From d1e793a292a1a765b85d39e29247ccd1b13b3cc4 Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 30 Jan 2015 11:52:50 -0800 Subject: [PATCH] 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 --- .../20150130.phortune.1.subphid.sql | 2 + .../20150130.phortune.2.subkey.sql | 2 + src/__phutil_library_map__.php | 4 + .../PhabricatorPhortuneApplication.php | 4 + .../cart/PhortuneSubscriptionCart.php | 89 ++++++++++++++++++ .../controller/PhortuneCartListController.php | 75 ++++++++++------ .../PhortuneSubscriptionViewController.php | 62 ++++++++++++- .../product/PhortuneSubscriptionProduct.php | 90 +++++++++++++++++++ .../phortune/query/PhortuneCartQuery.php | 13 +++ .../query/PhortuneCartSearchEngine.php | 15 ++++ .../phortune/storage/PhortuneCart.php | 5 ++ .../worker/PhortuneSubscriptionWorker.php | 51 ++++++++++- 12 files changed, 380 insertions(+), 32 deletions(-) create mode 100644 resources/sql/autopatches/20150130.phortune.1.subphid.sql create mode 100644 resources/sql/autopatches/20150130.phortune.2.subkey.sql create mode 100644 src/applications/phortune/cart/PhortuneSubscriptionCart.php create mode 100644 src/applications/phortune/product/PhortuneSubscriptionProduct.php diff --git a/resources/sql/autopatches/20150130.phortune.1.subphid.sql b/resources/sql/autopatches/20150130.phortune.1.subphid.sql new file mode 100644 index 0000000000..f2eae091da --- /dev/null +++ b/resources/sql/autopatches/20150130.phortune.1.subphid.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_phortune.phortune_cart + ADD subscriptionPHID VARBINARY(64); diff --git a/resources/sql/autopatches/20150130.phortune.2.subkey.sql b/resources/sql/autopatches/20150130.phortune.2.subkey.sql new file mode 100644 index 0000000000..10d0225afc --- /dev/null +++ b/resources/sql/autopatches/20150130.phortune.2.subkey.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_phortune.phortune_cart + ADD KEY `key_subscription` (subscriptionPHID); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index d461388aef..4a646fc968 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2811,9 +2811,11 @@ phutil_register_library_map(array( 'PhortuneSchemaSpec' => 'applications/phortune/storage/PhortuneSchemaSpec.php', 'PhortuneStripePaymentProvider' => 'applications/phortune/provider/PhortuneStripePaymentProvider.php', 'PhortuneSubscription' => 'applications/phortune/storage/PhortuneSubscription.php', + 'PhortuneSubscriptionCart' => 'applications/phortune/cart/PhortuneSubscriptionCart.php', 'PhortuneSubscriptionImplementation' => 'applications/phortune/subscription/PhortuneSubscriptionImplementation.php', 'PhortuneSubscriptionListController' => 'applications/phortune/controller/PhortuneSubscriptionListController.php', 'PhortuneSubscriptionPHIDType' => 'applications/phortune/phid/PhortuneSubscriptionPHIDType.php', + 'PhortuneSubscriptionProduct' => 'applications/phortune/product/PhortuneSubscriptionProduct.php', 'PhortuneSubscriptionQuery' => 'applications/phortune/query/PhortuneSubscriptionQuery.php', 'PhortuneSubscriptionSearchEngine' => 'applications/phortune/query/PhortuneSubscriptionSearchEngine.php', 'PhortuneSubscriptionTableView' => 'applications/phortune/view/PhortuneSubscriptionTableView.php', @@ -6169,8 +6171,10 @@ phutil_register_library_map(array( 'PhortuneDAO', 'PhabricatorPolicyInterface', ), + 'PhortuneSubscriptionCart' => 'PhortuneCartImplementation', 'PhortuneSubscriptionListController' => 'PhortuneController', 'PhortuneSubscriptionPHIDType' => 'PhabricatorPHIDType', + 'PhortuneSubscriptionProduct' => 'PhortuneProductImplementation', 'PhortuneSubscriptionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhortuneSubscriptionSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhortuneSubscriptionTableView' => 'AphrontView', diff --git a/src/applications/phortune/application/PhabricatorPhortuneApplication.php b/src/applications/phortune/application/PhabricatorPhortuneApplication.php index cf0f502ec6..23da65f4fc 100644 --- a/src/applications/phortune/application/PhabricatorPhortuneApplication.php +++ b/src/applications/phortune/application/PhabricatorPhortuneApplication.php @@ -50,6 +50,8 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication { => 'PhortuneSubscriptionListController', 'view/(?P\d+)/' => 'PhortuneSubscriptionViewController', + 'order/(?P\d+)/' + => 'PhortuneCartListController', ), 'charge/(?:query/(?P[^/]+)/)?' => 'PhortuneChargeListController', @@ -90,6 +92,8 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication { => 'PhortuneSubscriptionListController', 'view/(?P\d+)/' => 'PhortuneSubscriptionViewController', + 'order/(?P\d+)/' + => 'PhortuneCartListController', ), '(?P\d+)/' => 'PhortuneMerchantViewController', ), diff --git a/src/applications/phortune/cart/PhortuneSubscriptionCart.php b/src/applications/phortune/cart/PhortuneSubscriptionCart.php new file mode 100644 index 0000000000..f913974a6c --- /dev/null +++ b/src/applications/phortune/cart/PhortuneSubscriptionCart.php @@ -0,0 +1,89 @@ +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'); + } + +} diff --git a/src/applications/phortune/controller/PhortuneCartListController.php b/src/applications/phortune/controller/PhortuneCartListController.php index 7197db8047..bc73ecfd1a 100644 --- a/src/applications/phortune/controller/PhortuneCartListController.php +++ b/src/applications/phortune/controller/PhortuneCartListController.php @@ -3,29 +3,35 @@ final class PhortuneCartListController extends PhortuneController { - private $accountID; - private $merchantID; - private $queryKey; - private $merchant; private $account; + private $subscription; - public function willProcessRequest(array $data) { - $this->merchantID = idx($data, 'merchantID'); - $this->accountID = idx($data, 'accountID'); - $this->queryKey = idx($data, 'queryKey'); - } + public function handleRequest(AphrontRequest $request) { + $viewer = $this->getViewer(); - public function processRequest() { - $request = $this->getRequest(); - $viewer = $request->getUser(); + $merchant_id = $request->getURIData('merchantID'); + $account_id = $request->getURIData('accountID'); + $subscription_id = $request->getURIData('subscriptionID'); $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()) ->setViewer($viewer) - ->withIDs(array($this->merchantID)) + ->withIDs(array($merchant_id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, @@ -37,10 +43,10 @@ final class PhortuneCartListController } $this->merchant = $merchant; $engine->setMerchant($merchant); - } else if ($this->accountID) { + } else if ($account_id) { $account = id(new PhortuneAccountQuery()) ->setViewer($viewer) - ->withIDs(array($this->accountID)) + ->withIDs(array($account_id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, @@ -57,7 +63,7 @@ final class PhortuneCartListController } $controller = id(new PhabricatorApplicationSearchController()) - ->setQueryKey($this->queryKey) + ->setQueryKey($request->getURIData('queryKey')) ->setSearchEngine($engine) ->setNavigation($this->buildSideNavView()); @@ -82,26 +88,39 @@ final class PhortuneCartListController protected function buildApplicationCrumbs() { $crumbs = parent::buildApplicationCrumbs(); + $subscription = $this->subscription; + $merchant = $this->merchant; if ($merchant) { $id = $merchant->getID(); - $crumbs->addTextCrumb( - $merchant->getName(), - $this->getApplicationURI("merchant/{$id}/")); - $crumbs->addTextCrumb( - pht('Orders'), - $this->getApplicationURI("merchant/orders/{$id}/")); + $this->addMerchantCrumb($crumbs, $merchant); + if (!$subscription) { + $crumbs->addTextCrumb( + pht('Orders'), + $this->getApplicationURI("merchant/orders/{$id}/")); + } } $account = $this->account; if ($account) { $id = $account->getID(); + $this->addAccountCrumb($crumbs, $account); + if (!$subscription) { + $crumbs->addTextCrumb( + pht('Orders'), + $this->getApplicationURI("{$id}/order/")); + } + } + + if ($subscription) { + if ($merchant) { + $subscription_uri = $subscription->getMerchantURI(); + } else { + $subscription_uri = $subscription->getURI(); + } $crumbs->addTextCrumb( - $account->getName(), - $this->getApplicationURI("{$id}/")); - $crumbs->addTextCrumb( - pht('Orders'), - $this->getApplicationURI("{$id}/order/")); + $subscription->getSubscriptionName(), + $subscription_uri); } return $crumbs; diff --git a/src/applications/phortune/controller/PhortuneSubscriptionViewController.php b/src/applications/phortune/controller/PhortuneSubscriptionViewController.php index e396eb9339..aed8e77a9f 100644 --- a/src/applications/phortune/controller/PhortuneSubscriptionViewController.php +++ b/src/applications/phortune/controller/PhortuneSubscriptionViewController.php @@ -15,6 +15,8 @@ final class PhortuneSubscriptionViewController extends PhortuneController { } $is_merchant = (bool)$request->getURIData('merchantID'); + $merchant = $subscription->getMerchant(); + $account = $subscription->getAccount(); $title = pht('Subscription: %s', $subscription->getSubscriptionName()); @@ -27,9 +29,9 @@ final class PhortuneSubscriptionViewController extends PhortuneController { $crumbs = $this->buildApplicationCrumbs(); if ($is_merchant) { - $this->addMerchantCrumb($crumbs, $subscription->getMerchant()); + $this->addMerchantCrumb($crumbs, $merchant); } else { - $this->addAccountCrumb($crumbs, $subscription->getAccount()); + $this->addAccountCrumb($crumbs, $account); } $crumbs->addTextCrumb(pht('Subscription %d', $subscription->getID())); @@ -46,10 +48,66 @@ final class PhortuneSubscriptionViewController extends PhortuneController { ->setHeader($header) ->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( array( $crumbs, $object_box, + $invoice_box, ), array( 'title' => $title, diff --git a/src/applications/phortune/product/PhortuneSubscriptionProduct.php b/src/applications/phortune/product/PhortuneSubscriptionProduct.php new file mode 100644 index 0000000000..dc1aed421b --- /dev/null +++ b/src/applications/phortune/product/PhortuneSubscriptionProduct.php @@ -0,0 +1,90 @@ +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; + } + +} diff --git a/src/applications/phortune/query/PhortuneCartQuery.php b/src/applications/phortune/query/PhortuneCartQuery.php index fd37591257..6c7aaf77a3 100644 --- a/src/applications/phortune/query/PhortuneCartQuery.php +++ b/src/applications/phortune/query/PhortuneCartQuery.php @@ -7,6 +7,7 @@ final class PhortuneCartQuery private $phids; private $accountPHIDs; private $merchantPHIDs; + private $subscriptionPHIDs; private $statuses; private $needPurchases; @@ -31,6 +32,11 @@ final class PhortuneCartQuery return $this; } + public function withSubscriptionPHIDs(array $subscription_phids) { + $this->subscriptionPHIDs = $subscription_phids; + return $this; + } + public function withStatuses(array $statuses) { $this->statuses = $statuses; return $this; @@ -158,6 +164,13 @@ final class PhortuneCartQuery $this->merchantPHIDs); } + if ($this->subscriptionPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'cart.subscriptionPHID IN (%Ls)', + $this->subscriptionPHIDs); + } + if ($this->statuses !== null) { $where[] = qsprintf( $conn, diff --git a/src/applications/phortune/query/PhortuneCartSearchEngine.php b/src/applications/phortune/query/PhortuneCartSearchEngine.php index a04bd03072..fca225a612 100644 --- a/src/applications/phortune/query/PhortuneCartSearchEngine.php +++ b/src/applications/phortune/query/PhortuneCartSearchEngine.php @@ -5,6 +5,7 @@ final class PhortuneCartSearchEngine private $merchant; private $account; + private $subscription; public function setAccount(PhortuneAccount $account) { $this->account = $account; @@ -24,6 +25,15 @@ final class PhortuneCartSearchEngine return $this->merchant; } + public function setSubscription(PhortuneSubscription $subscription) { + $this->subscription = $subscription; + return $this; + } + + public function getSubscription() { + return $this->subscription; + } + public function getResultTypeDescription() { return pht('Phortune Orders'); } @@ -83,6 +93,11 @@ final class PhortuneCartSearchEngine } } + $subscription = $this->getSubscription(); + if ($subscription) { + $query->withSubscriptionPHIDs(array($subscription->getPHID())); + } + return $query; } diff --git a/src/applications/phortune/storage/PhortuneCart.php b/src/applications/phortune/storage/PhortuneCart.php index f1867985e3..cb2524815b 100644 --- a/src/applications/phortune/storage/PhortuneCart.php +++ b/src/applications/phortune/storage/PhortuneCart.php @@ -16,6 +16,7 @@ final class PhortuneCart extends PhortuneDAO protected $accountPHID; protected $authorPHID; protected $merchantPHID; + protected $subscriptionPHID; protected $cartClass; protected $status; protected $metadata = array(); @@ -518,6 +519,7 @@ final class PhortuneCart extends PhortuneDAO 'status' => 'text32', 'cartClass' => 'text128', 'mailKey' => 'bytes20', + 'subscriptionPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_account' => array( @@ -526,6 +528,9 @@ final class PhortuneCart extends PhortuneDAO 'key_merchant' => array( 'columns' => array('merchantPHID'), ), + 'key_subscription' => array( + 'columns' => array('subscriptionPHID'), + ), ), ) + parent::getConfiguration(); } diff --git a/src/applications/phortune/worker/PhortuneSubscriptionWorker.php b/src/applications/phortune/worker/PhortuneSubscriptionWorker.php index 3b5921fc7c..8c9809042f 100644 --- a/src/applications/phortune/worker/PhortuneSubscriptionWorker.php +++ b/src/applications/phortune/worker/PhortuneSubscriptionWorker.php @@ -8,8 +8,55 @@ final class PhortuneSubscriptionWorker extends PhabricatorWorker { $range = $this->getBillingPeriodRange($subscription); list($last_epoch, $next_epoch) = $range; - // TODO: Actual billing. - echo "Bill from {$last_epoch} to {$next_epoch}.\n"; + $account = $subscription->getAccount(); + $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"; }