1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-01 19:22:42 +01:00

Kind of generate a bill for users

Summary:
Ref T6881. This generates a product, purchase and invoice for users, and there's sort of some UI for them. Stuff it doesn't do yet:

  - Try to autobill when we have a CC;
  - actually tell the user they should pay it;
  - ask the application for anything like "how much should we charge", or tell the application anything like "the user paid".

However, these work:

  - You can //technically// pay the invoices.
  - You can see the invoices you paid in the past.

Test Plan: Used `bin/phriction invoice` to double-bill myself over and over again. Paid one of the invoices.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T6881

Differential Revision: https://secure.phabricator.com/D11580
This commit is contained in:
epriestley 2015-01-30 11:52:50 -08:00
parent bdb3adeee4
commit d1e793a292
12 changed files with 380 additions and 32 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE {$NAMESPACE}_phortune.phortune_cart
ADD subscriptionPHID VARBINARY(64);

View file

@ -0,0 +1,2 @@
ALTER TABLE {$NAMESPACE}_phortune.phortune_cart
ADD KEY `key_subscription` (subscriptionPHID);

View file

@ -2811,9 +2811,11 @@ phutil_register_library_map(array(
'PhortuneSchemaSpec' => 'applications/phortune/storage/PhortuneSchemaSpec.php', 'PhortuneSchemaSpec' => 'applications/phortune/storage/PhortuneSchemaSpec.php',
'PhortuneStripePaymentProvider' => 'applications/phortune/provider/PhortuneStripePaymentProvider.php', 'PhortuneStripePaymentProvider' => 'applications/phortune/provider/PhortuneStripePaymentProvider.php',
'PhortuneSubscription' => 'applications/phortune/storage/PhortuneSubscription.php', 'PhortuneSubscription' => 'applications/phortune/storage/PhortuneSubscription.php',
'PhortuneSubscriptionCart' => 'applications/phortune/cart/PhortuneSubscriptionCart.php',
'PhortuneSubscriptionImplementation' => 'applications/phortune/subscription/PhortuneSubscriptionImplementation.php', 'PhortuneSubscriptionImplementation' => 'applications/phortune/subscription/PhortuneSubscriptionImplementation.php',
'PhortuneSubscriptionListController' => 'applications/phortune/controller/PhortuneSubscriptionListController.php', 'PhortuneSubscriptionListController' => 'applications/phortune/controller/PhortuneSubscriptionListController.php',
'PhortuneSubscriptionPHIDType' => 'applications/phortune/phid/PhortuneSubscriptionPHIDType.php', 'PhortuneSubscriptionPHIDType' => 'applications/phortune/phid/PhortuneSubscriptionPHIDType.php',
'PhortuneSubscriptionProduct' => 'applications/phortune/product/PhortuneSubscriptionProduct.php',
'PhortuneSubscriptionQuery' => 'applications/phortune/query/PhortuneSubscriptionQuery.php', 'PhortuneSubscriptionQuery' => 'applications/phortune/query/PhortuneSubscriptionQuery.php',
'PhortuneSubscriptionSearchEngine' => 'applications/phortune/query/PhortuneSubscriptionSearchEngine.php', 'PhortuneSubscriptionSearchEngine' => 'applications/phortune/query/PhortuneSubscriptionSearchEngine.php',
'PhortuneSubscriptionTableView' => 'applications/phortune/view/PhortuneSubscriptionTableView.php', 'PhortuneSubscriptionTableView' => 'applications/phortune/view/PhortuneSubscriptionTableView.php',
@ -6169,8 +6171,10 @@ phutil_register_library_map(array(
'PhortuneDAO', 'PhortuneDAO',
'PhabricatorPolicyInterface', 'PhabricatorPolicyInterface',
), ),
'PhortuneSubscriptionCart' => 'PhortuneCartImplementation',
'PhortuneSubscriptionListController' => 'PhortuneController', 'PhortuneSubscriptionListController' => 'PhortuneController',
'PhortuneSubscriptionPHIDType' => 'PhabricatorPHIDType', 'PhortuneSubscriptionPHIDType' => 'PhabricatorPHIDType',
'PhortuneSubscriptionProduct' => 'PhortuneProductImplementation',
'PhortuneSubscriptionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhortuneSubscriptionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'PhortuneSubscriptionSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhortuneSubscriptionSearchEngine' => 'PhabricatorApplicationSearchEngine',
'PhortuneSubscriptionTableView' => 'AphrontView', 'PhortuneSubscriptionTableView' => 'AphrontView',

View file

@ -50,6 +50,8 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication {
=> 'PhortuneSubscriptionListController', => 'PhortuneSubscriptionListController',
'view/(?P<id>\d+)/' 'view/(?P<id>\d+)/'
=> 'PhortuneSubscriptionViewController', => 'PhortuneSubscriptionViewController',
'order/(?P<subscriptionID>\d+)/'
=> 'PhortuneCartListController',
), ),
'charge/(?:query/(?P<queryKey>[^/]+)/)?' 'charge/(?:query/(?P<queryKey>[^/]+)/)?'
=> 'PhortuneChargeListController', => 'PhortuneChargeListController',
@ -90,6 +92,8 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication {
=> 'PhortuneSubscriptionListController', => 'PhortuneSubscriptionListController',
'view/(?P<id>\d+)/' 'view/(?P<id>\d+)/'
=> 'PhortuneSubscriptionViewController', => 'PhortuneSubscriptionViewController',
'order/(?P<subscriptionID>\d+)/'
=> 'PhortuneCartListController',
), ),
'(?P<id>\d+)/' => 'PhortuneMerchantViewController', '(?P<id>\d+)/' => 'PhortuneMerchantViewController',
), ),

View file

@ -0,0 +1,89 @@
<?php
final class PhortuneSubscriptionCart
extends PhortuneCartImplementation {
private $subscriptionPHID;
private $subscription;
public function setSubscriptionPHID($subscription_phid) {
$this->subscriptionPHID = $subscription_phid;
return $this;
}
public function getSubscriptionPHID() {
return $this->subscriptionPHID;
}
public function setSubscription(PhortuneSubscription $subscription) {
$this->subscription = $subscription;
return $this;
}
public function getSubscription() {
return $this->subscription;
}
public function getName(PhortuneCart $cart) {
return pht('Subscription');
}
public function willCreateCart(
PhabricatorUser $viewer,
PhortuneCart $cart) {
$subscription = $this->getSubscription();
if (!$subscription) {
throw new Exception(
pht('Call setSubscription() before building a cart!'));
}
$cart->setMetadataValue('subscriptionPHID', $subscription->getPHID());
}
public function loadImplementationsForCarts(
PhabricatorUser $viewer,
array $carts) {
$phids = array();
foreach ($carts as $cart) {
$phids[] = $cart->getMetadataValue('subscriptionPHID');
}
$subscriptions = id(new PhortuneSubscriptionQuery())
->setViewer($viewer)
->withPHIDs($phids)
->execute();
$subscriptions = mpull($subscriptions, null, 'getPHID');
$objects = array();
foreach ($carts as $key => $cart) {
$subscription_phid = $cart->getMetadataValue('subscriptionPHID');
$subscription = idx($subscriptions, $subscription_phid);
if (!$subscription) {
continue;
}
$object = id(new PhortuneSubscriptionCart())
->setSubscriptionPHID($subscription_phid)
->setSubscription($subscription);
$objects[$key] = $object;
}
return $objects;
}
public function getCancelURI(PhortuneCart $cart) {
return $this->getSubscription()->getURI();
}
public function getDoneURI(PhortuneCart $cart) {
return $this->getSubscription()->getURI();
}
public function getDoneActionName(PhortuneCart $cart) {
return pht('Return to Subscription');
}
}

View file

@ -3,29 +3,35 @@
final class PhortuneCartListController final class PhortuneCartListController
extends PhortuneController { extends PhortuneController {
private $accountID;
private $merchantID;
private $queryKey;
private $merchant; private $merchant;
private $account; private $account;
private $subscription;
public function willProcessRequest(array $data) { public function handleRequest(AphrontRequest $request) {
$this->merchantID = idx($data, 'merchantID'); $viewer = $this->getViewer();
$this->accountID = idx($data, 'accountID');
$this->queryKey = idx($data, 'queryKey');
}
public function processRequest() { $merchant_id = $request->getURIData('merchantID');
$request = $this->getRequest(); $account_id = $request->getURIData('accountID');
$viewer = $request->getUser(); $subscription_id = $request->getURIData('subscriptionID');
$engine = new PhortuneCartSearchEngine(); $engine = new PhortuneCartSearchEngine();
if ($this->merchantID) { if ($subscription_id) {
$subscription = id(new PhortuneSubscriptionQuery())
->setViewer($viewer)
->withIDs(array($subscription_id))
->executeOne();
if (!$subscription) {
return new Aphront404Response();
}
$this->subscription = $subscription;
$engine->setSubscription($subscription);
}
if ($merchant_id) {
$merchant = id(new PhortuneMerchantQuery()) $merchant = id(new PhortuneMerchantQuery())
->setViewer($viewer) ->setViewer($viewer)
->withIDs(array($this->merchantID)) ->withIDs(array($merchant_id))
->requireCapabilities( ->requireCapabilities(
array( array(
PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_VIEW,
@ -37,10 +43,10 @@ final class PhortuneCartListController
} }
$this->merchant = $merchant; $this->merchant = $merchant;
$engine->setMerchant($merchant); $engine->setMerchant($merchant);
} else if ($this->accountID) { } else if ($account_id) {
$account = id(new PhortuneAccountQuery()) $account = id(new PhortuneAccountQuery())
->setViewer($viewer) ->setViewer($viewer)
->withIDs(array($this->accountID)) ->withIDs(array($account_id))
->requireCapabilities( ->requireCapabilities(
array( array(
PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_VIEW,
@ -57,7 +63,7 @@ final class PhortuneCartListController
} }
$controller = id(new PhabricatorApplicationSearchController()) $controller = id(new PhabricatorApplicationSearchController())
->setQueryKey($this->queryKey) ->setQueryKey($request->getURIData('queryKey'))
->setSearchEngine($engine) ->setSearchEngine($engine)
->setNavigation($this->buildSideNavView()); ->setNavigation($this->buildSideNavView());
@ -82,27 +88,40 @@ final class PhortuneCartListController
protected function buildApplicationCrumbs() { protected function buildApplicationCrumbs() {
$crumbs = parent::buildApplicationCrumbs(); $crumbs = parent::buildApplicationCrumbs();
$subscription = $this->subscription;
$merchant = $this->merchant; $merchant = $this->merchant;
if ($merchant) { if ($merchant) {
$id = $merchant->getID(); $id = $merchant->getID();
$crumbs->addTextCrumb( $this->addMerchantCrumb($crumbs, $merchant);
$merchant->getName(), if (!$subscription) {
$this->getApplicationURI("merchant/{$id}/"));
$crumbs->addTextCrumb( $crumbs->addTextCrumb(
pht('Orders'), pht('Orders'),
$this->getApplicationURI("merchant/orders/{$id}/")); $this->getApplicationURI("merchant/orders/{$id}/"));
} }
}
$account = $this->account; $account = $this->account;
if ($account) { if ($account) {
$id = $account->getID(); $id = $account->getID();
$crumbs->addTextCrumb( $this->addAccountCrumb($crumbs, $account);
$account->getName(), if (!$subscription) {
$this->getApplicationURI("{$id}/"));
$crumbs->addTextCrumb( $crumbs->addTextCrumb(
pht('Orders'), pht('Orders'),
$this->getApplicationURI("{$id}/order/")); $this->getApplicationURI("{$id}/order/"));
} }
}
if ($subscription) {
if ($merchant) {
$subscription_uri = $subscription->getMerchantURI();
} else {
$subscription_uri = $subscription->getURI();
}
$crumbs->addTextCrumb(
$subscription->getSubscriptionName(),
$subscription_uri);
}
return $crumbs; return $crumbs;
} }

View file

@ -15,6 +15,8 @@ final class PhortuneSubscriptionViewController extends PhortuneController {
} }
$is_merchant = (bool)$request->getURIData('merchantID'); $is_merchant = (bool)$request->getURIData('merchantID');
$merchant = $subscription->getMerchant();
$account = $subscription->getAccount();
$title = pht('Subscription: %s', $subscription->getSubscriptionName()); $title = pht('Subscription: %s', $subscription->getSubscriptionName());
@ -27,9 +29,9 @@ final class PhortuneSubscriptionViewController extends PhortuneController {
$crumbs = $this->buildApplicationCrumbs(); $crumbs = $this->buildApplicationCrumbs();
if ($is_merchant) { if ($is_merchant) {
$this->addMerchantCrumb($crumbs, $subscription->getMerchant()); $this->addMerchantCrumb($crumbs, $merchant);
} else { } else {
$this->addAccountCrumb($crumbs, $subscription->getAccount()); $this->addAccountCrumb($crumbs, $account);
} }
$crumbs->addTextCrumb(pht('Subscription %d', $subscription->getID())); $crumbs->addTextCrumb(pht('Subscription %d', $subscription->getID()));
@ -46,10 +48,66 @@ final class PhortuneSubscriptionViewController extends PhortuneController {
->setHeader($header) ->setHeader($header)
->addPropertyList($properties); ->addPropertyList($properties);
$carts = id(new PhortuneCartQuery())
->setViewer($viewer)
->withSubscriptionPHIDs(array($subscription->getPHID()))
->needPurchases(true)
->withStatuses(
array(
PhortuneCart::STATUS_PURCHASING,
PhortuneCart::STATUS_CHARGED,
PhortuneCart::STATUS_HOLD,
PhortuneCart::STATUS_REVIEW,
PhortuneCart::STATUS_PURCHASED,
))
->execute();
$phids = array();
foreach ($carts as $cart) {
$phids[] = $cart->getPHID();
foreach ($cart->getPurchases() as $purchase) {
$phids[] = $purchase->getPHID();
}
}
$handles = $this->loadViewerHandles($phids);
$invoice_table = id(new PhortuneOrderTableView())
->setUser($viewer)
->setCarts($carts)
->setHandles($handles);
$account_id = $account->getID();
$merchant_id = $merchant->getID();
$subscription_id = $subscription->getID();
if ($is_merchant) {
$invoices_uri = $this->getApplicationURI(
"merchant/{$merchant_id}/subscription/order/{$subscription_id}/");
} else {
$invoices_uri = $this->getApplicationURI(
"{$account_id}/subscription/order/{$subscription_id}/");
}
$invoice_header = id(new PHUIHeaderView())
->setHeader(pht('Recent Invoices'))
->addActionLink(
id(new PHUIButtonView())
->setTag('a')
->setIcon(
id(new PHUIIconView())
->setIconFont('fa-list'))
->setHref($invoices_uri)
->setText(pht('View All Invoices')));
$invoice_box = id(new PHUIObjectBoxView())
->setHeader($invoice_header)
->appendChild($invoice_table);
return $this->buildApplicationPage( return $this->buildApplicationPage(
array( array(
$crumbs, $crumbs,
$object_box, $object_box,
$invoice_box,
), ),
array( array(
'title' => $title, 'title' => $title,

View file

@ -0,0 +1,90 @@
<?php
final class PhortuneSubscriptionProduct
extends PhortuneProductImplementation {
private $viewer;
private $subscriptionPHID;
private $subscription;
public function setSubscriptionPHID($subscription_phid) {
$this->subscriptionPHID = $subscription_phid;
return $this;
}
public function getSubscriptionPHID() {
return $this->subscriptionPHID;
}
public function setSubscription(PhortuneSubscription $subscription) {
$this->subscription = $subscription;
return $this;
}
public function getSubscription() {
return $this->subscription;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function getRef() {
return $this->getSubscriptionPHID();
}
public function getName(PhortuneProduct $product) {
return $this->getSubscription()->getSubscriptionName();
}
public function getPriceAsCurrency(PhortuneProduct $product) {
return PhortuneCurrency::newEmptyCurrency();
}
public function didPurchaseProduct(
PhortuneProduct $product,
PhortunePurchase $purchase) {
// TODO: Callback the subscription.
return;
}
public function didRefundProduct(
PhortuneProduct $product,
PhortunePurchase $purchase,
PhortuneCurrency $amount) {
// TODO: Callback the subscription.
return;
}
public function loadImplementationsForRefs(
PhabricatorUser $viewer,
array $refs) {
$subscriptions = id(new PhortuneSubscriptionQuery())
->setViewer($viewer)
->withPHIDs($refs)
->execute();
$subscriptions = mpull($subscriptions, null, 'getPHID');
$objects = array();
foreach ($refs as $ref) {
$subscription = idx($subscriptions, $ref);
if (!$subscription) {
continue;
}
$objects[] = id(new PhortuneSubscriptionProduct())
->setViewer($viewer)
->setSubscriptionPHID($ref)
->setSubscription($subscription);
}
return $objects;
}
}

View file

@ -7,6 +7,7 @@ final class PhortuneCartQuery
private $phids; private $phids;
private $accountPHIDs; private $accountPHIDs;
private $merchantPHIDs; private $merchantPHIDs;
private $subscriptionPHIDs;
private $statuses; private $statuses;
private $needPurchases; private $needPurchases;
@ -31,6 +32,11 @@ final class PhortuneCartQuery
return $this; return $this;
} }
public function withSubscriptionPHIDs(array $subscription_phids) {
$this->subscriptionPHIDs = $subscription_phids;
return $this;
}
public function withStatuses(array $statuses) { public function withStatuses(array $statuses) {
$this->statuses = $statuses; $this->statuses = $statuses;
return $this; return $this;
@ -158,6 +164,13 @@ final class PhortuneCartQuery
$this->merchantPHIDs); $this->merchantPHIDs);
} }
if ($this->subscriptionPHIDs !== null) {
$where[] = qsprintf(
$conn,
'cart.subscriptionPHID IN (%Ls)',
$this->subscriptionPHIDs);
}
if ($this->statuses !== null) { if ($this->statuses !== null) {
$where[] = qsprintf( $where[] = qsprintf(
$conn, $conn,

View file

@ -5,6 +5,7 @@ final class PhortuneCartSearchEngine
private $merchant; private $merchant;
private $account; private $account;
private $subscription;
public function setAccount(PhortuneAccount $account) { public function setAccount(PhortuneAccount $account) {
$this->account = $account; $this->account = $account;
@ -24,6 +25,15 @@ final class PhortuneCartSearchEngine
return $this->merchant; return $this->merchant;
} }
public function setSubscription(PhortuneSubscription $subscription) {
$this->subscription = $subscription;
return $this;
}
public function getSubscription() {
return $this->subscription;
}
public function getResultTypeDescription() { public function getResultTypeDescription() {
return pht('Phortune Orders'); return pht('Phortune Orders');
} }
@ -83,6 +93,11 @@ final class PhortuneCartSearchEngine
} }
} }
$subscription = $this->getSubscription();
if ($subscription) {
$query->withSubscriptionPHIDs(array($subscription->getPHID()));
}
return $query; return $query;
} }

View file

@ -16,6 +16,7 @@ final class PhortuneCart extends PhortuneDAO
protected $accountPHID; protected $accountPHID;
protected $authorPHID; protected $authorPHID;
protected $merchantPHID; protected $merchantPHID;
protected $subscriptionPHID;
protected $cartClass; protected $cartClass;
protected $status; protected $status;
protected $metadata = array(); protected $metadata = array();
@ -518,6 +519,7 @@ final class PhortuneCart extends PhortuneDAO
'status' => 'text32', 'status' => 'text32',
'cartClass' => 'text128', 'cartClass' => 'text128',
'mailKey' => 'bytes20', 'mailKey' => 'bytes20',
'subscriptionPHID' => 'phid?',
), ),
self::CONFIG_KEY_SCHEMA => array( self::CONFIG_KEY_SCHEMA => array(
'key_account' => array( 'key_account' => array(
@ -526,6 +528,9 @@ final class PhortuneCart extends PhortuneDAO
'key_merchant' => array( 'key_merchant' => array(
'columns' => array('merchantPHID'), 'columns' => array('merchantPHID'),
), ),
'key_subscription' => array(
'columns' => array('subscriptionPHID'),
),
), ),
) + parent::getConfiguration(); ) + parent::getConfiguration();
} }

View file

@ -8,8 +8,55 @@ final class PhortuneSubscriptionWorker extends PhabricatorWorker {
$range = $this->getBillingPeriodRange($subscription); $range = $this->getBillingPeriodRange($subscription);
list($last_epoch, $next_epoch) = $range; list($last_epoch, $next_epoch) = $range;
// TODO: Actual billing. $account = $subscription->getAccount();
echo "Bill from {$last_epoch} to {$next_epoch}.\n"; $merchant = $subscription->getMerchant();
$viewer = PhabricatorUser::getOmnipotentUser();
$product = id(new PhortuneProductQuery())
->setViewer($viewer)
->withClassAndRef('PhortuneSubscriptionProduct', $subscription->getPHID())
->executeOne();
$cart_implementation = id(new PhortuneSubscriptionCart())
->setSubscription($subscription);
// TODO: This isn't really ideal. It would be better to use an application
// actor than the original author of the subscription. In particular, if
// someone initiates a subscription, adds some other account managers, and
// later leaves the company, they'll continue "acting" here indefinitely.
// However, for now, some of the stuff later in the pipeline requires a
// valid actor with a real PHID. The subscription should eventually be
// able to create these invoices "as" the application it is acting on
// behalf of.
$actor = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withPHIDs(array($subscription->getAuthorPHID()))
->executeOne();
if (!$actor) {
throw new Exception(pht('Failed to load actor to bill subscription!'));
}
$cart = $account->newCart($actor, $cart_implementation, $merchant);
$purchase = $cart->newPurchase($actor, $product);
// TODO: Consider allowing subscriptions to cost an amount other than one
// dollar and twenty-three cents.
$currency = PhortuneCurrency::newFromUserInput($actor, '1.23 USD');
$purchase
->setBasePriceAsCurrency($currency)
->setMetadataValue('subscriptionPHID', $subscription->getPHID())
->save();
$cart->setSubscriptionPHID($subscription->getPHID());
$cart->activateCart();
// TODO: Autocharge this, etc.; this is still mostly faked up.
echo 'Okay, made a cart here: ';
echo $cart->getCheckoutURI()."\n\n";
} }