diff --git a/resources/sql/autopatches/20150131.phortune.1.defaultpayment.sql b/resources/sql/autopatches/20150131.phortune.1.defaultpayment.sql new file mode 100644 index 0000000000..aa990fe0b9 --- /dev/null +++ b/resources/sql/autopatches/20150131.phortune.1.defaultpayment.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_phortune.phortune_subscription + ADD defaultPaymentMethodPHID VARBINARY(64); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 4a646fc968..ac1565dab7 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2812,6 +2812,7 @@ phutil_register_library_map(array( 'PhortuneStripePaymentProvider' => 'applications/phortune/provider/PhortuneStripePaymentProvider.php', 'PhortuneSubscription' => 'applications/phortune/storage/PhortuneSubscription.php', 'PhortuneSubscriptionCart' => 'applications/phortune/cart/PhortuneSubscriptionCart.php', + 'PhortuneSubscriptionEditController' => 'applications/phortune/controller/PhortuneSubscriptionEditController.php', 'PhortuneSubscriptionImplementation' => 'applications/phortune/subscription/PhortuneSubscriptionImplementation.php', 'PhortuneSubscriptionListController' => 'applications/phortune/controller/PhortuneSubscriptionListController.php', 'PhortuneSubscriptionPHIDType' => 'applications/phortune/phid/PhortuneSubscriptionPHIDType.php', @@ -6172,6 +6173,7 @@ phutil_register_library_map(array( 'PhabricatorPolicyInterface', ), 'PhortuneSubscriptionCart' => 'PhortuneCartImplementation', + 'PhortuneSubscriptionEditController' => 'PhortuneController', 'PhortuneSubscriptionListController' => 'PhortuneController', 'PhortuneSubscriptionPHIDType' => 'PhabricatorPHIDType', 'PhortuneSubscriptionProduct' => 'PhortuneProductImplementation', diff --git a/src/applications/phortune/application/PhabricatorPhortuneApplication.php b/src/applications/phortune/application/PhabricatorPhortuneApplication.php index 8eac0d16b7..ee573e2664 100644 --- a/src/applications/phortune/application/PhabricatorPhortuneApplication.php +++ b/src/applications/phortune/application/PhabricatorPhortuneApplication.php @@ -46,6 +46,8 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication { => 'PhortuneSubscriptionListController', 'view/(?P\d+)/' => 'PhortuneSubscriptionViewController', + 'edit/(?P\d+)/' + => 'PhortuneSubscriptionEditController', 'order/(?P\d+)/' => 'PhortuneCartListController', ), diff --git a/src/applications/phortune/controller/PhortuneSubscriptionEditController.php b/src/applications/phortune/controller/PhortuneSubscriptionEditController.php new file mode 100644 index 0000000000..d7f2ac23a3 --- /dev/null +++ b/src/applications/phortune/controller/PhortuneSubscriptionEditController.php @@ -0,0 +1,132 @@ +getViewer(); + + $subscription = id(new PhortuneSubscriptionQuery()) + ->setViewer($viewer) + ->withIDs(array($request->getURIData('id'))) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$subscription) { + return new Aphront404Response(); + } + + $merchant = $subscription->getMerchant(); + $account = $subscription->getAccount(); + + $title = pht('Subscription: %s', $subscription->getSubscriptionName()); + + $header = id(new PHUIHeaderView()) + ->setHeader($subscription->getSubscriptionName()); + + $view_uri = $subscription->getURI(); + + $valid_methods = id(new PhortunePaymentMethodQuery()) + ->setViewer($viewer) + ->withAccountPHIDs(array($account->getPHID())) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->execute(); + $valid_methods = mpull($valid_methods, null, 'getPHID'); + + $current_phid = $subscription->getDefaultPaymentMethodPHID(); + + $errors = array(); + if ($request->isFormPost()) { + + $default_method_phid = $request->getStr('defaultPaymentMethodPHID'); + if (!$default_method_phid) { + $default_method_phid = null; + $e_method = null; + } else if ($default_method_phid == $current_phid) { + // If you have an invalid setting already, it's OK to retain it. + $e_method = null; + } else { + if (empty($valid_methods[$default_method_phid])) { + $e_method = pht('Invalid'); + $errors[] = pht('You must select a valid default payment method.'); + } + } + + // TODO: We should use transactions here, and move the validation logic + // inside the Editor. + + if (!$errors) { + $subscription->setDefaultPaymentMethodPHID($default_method_phid); + $subscription->save(); + + return id(new AphrontRedirectResponse()) + ->setURI($view_uri); + } + } + + // Add the option to disable autopay. + $disable_options = array( + '' => pht('(Disable Autopay)'), + ); + + // Don't require the user to make a valid selection if the current method + // has become invalid. + // TODO: This should probably have a note about why this is bogus. + if ($current_phid && empty($valid_methods[$current_phid])) { + $handles = $this->loadViewerHandles(array($current_phid)); + $current_options = array( + $current_phid => $handles[$current_phid]->getName(), + ); + } else { + $current_options = array(); + } + + // Add any available options. + $valid_options = mpull($valid_methods, 'getFullDisplayName', 'getPHID'); + + $options = $disable_options + $current_options + $valid_options; + + $crumbs = $this->buildApplicationCrumbs(); + $this->addAccountCrumb($crumbs, $account); + $crumbs->addTextCrumb( + pht('Subscription %d', $subscription->getID()), + $view_uri); + $crumbs->addTextCrumb(pht('Edit')); + + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->appendChild( + id(new AphrontFormSelectControl()) + ->setName('defaultPaymentMethodPHID') + ->setLabel(pht('Autopay With')) + ->setValue($current_phid) + ->setOptions($options)) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue(pht('Save Changes')) + ->addCancelButton($view_uri)); + + $box = id(new PHUIObjectBoxView()) + ->setUser($viewer) + ->setHeaderText(pht('Edit %s', $subscription->getSubscriptionName())) + ->setFormErrors($errors) + ->appendChild($form); + + return $this->buildApplicationPage( + array( + $crumbs, + $box, + ), + array( + 'title' => $title, + )); + } + + +} diff --git a/src/applications/phortune/controller/PhortuneSubscriptionViewController.php b/src/applications/phortune/controller/PhortuneSubscriptionViewController.php index aed8e77a9f..4fbfac3bd1 100644 --- a/src/applications/phortune/controller/PhortuneSubscriptionViewController.php +++ b/src/applications/phortune/controller/PhortuneSubscriptionViewController.php @@ -14,10 +14,18 @@ final class PhortuneSubscriptionViewController extends PhortuneController { return new Aphront404Response(); } + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $subscription, + PhabricatorPolicyCapability::CAN_EDIT); + $is_merchant = (bool)$request->getURIData('merchantID'); $merchant = $subscription->getMerchant(); $account = $subscription->getAccount(); + $account_id = $account->getID(); + $subscription_id = $subscription->getID(); + $title = pht('Subscription: %s', $subscription->getSubscriptionName()); $header = id(new PHUIHeaderView()) @@ -27,6 +35,18 @@ final class PhortuneSubscriptionViewController extends PhortuneController { ->setUser($viewer) ->setObjectURI($request->getRequestURI()); + $edit_uri = $this->getApplicationURI( + "{$account_id}/subscription/edit/{$subscription_id}/"); + + $actions->addAction( + id(new PhabricatorActionView()) + ->setIcon('fa-pencil') + ->setName(pht('Edit Subscription')) + ->setHref($edit_uri) + ->setDisabled(!$can_edit) + ->setWorkflow(!$can_edit)); + + $crumbs = $this->buildApplicationCrumbs(); if ($is_merchant) { $this->addMerchantCrumb($crumbs, $merchant); @@ -44,11 +64,83 @@ final class PhortuneSubscriptionViewController extends PhortuneController { pht('Next Invoice'), phabricator_datetime($next_invoice, $viewer)); + $default_method = $subscription->getDefaultPaymentMethodPHID(); + if ($default_method) { + $handles = $this->loadViewerHandles(array($default_method)); + $autopay_method = $handles[$default_method]->renderLink(); + } else { + $autopay_method = phutil_tag( + 'em', + array(), + pht('No Autopay Method Configured')); + } + + $properties->addProperty( + pht('Autopay With'), + $autopay_method); + $object_box = id(new PHUIObjectBoxView()) ->setHeader($header) ->addPropertyList($properties); - $carts = id(new PhortuneCartQuery()) + $due_box = $this->buildDueInvoices($subscription, $is_merchant); + $invoice_box = $this->buildPastInvoices($subscription, $is_merchant); + + return $this->buildApplicationPage( + array( + $crumbs, + $object_box, + $due_box, + $invoice_box, + ), + array( + 'title' => $title, + )); + } + + private function buildDueInvoices( + PhortuneSubscription $subscription, + $is_merchant) { + $viewer = $this->getViewer(); + + $invoices = id(new PhortuneCartQuery()) + ->setViewer($viewer) + ->withSubscriptionPHIDs(array($subscription->getPHID())) + ->needPurchases(true) + ->withInvoices(true) + ->execute(); + + $phids = array(); + foreach ($invoices as $invoice) { + $phids[] = $invoice->getPHID(); + $phids[] = $invoice->getMerchantPHID(); + foreach ($invoice->getPurchases() as $purchase) { + $phids[] = $purchase->getPHID(); + } + } + $handles = $this->loadViewerHandles($phids); + + $invoice_table = id(new PhortuneOrderTableView()) + ->setUser($viewer) + ->setCarts($invoices) + ->setIsInvoices(true) + ->setIsMerchantView($is_merchant) + ->setHandles($handles); + + $invoice_header = id(new PHUIHeaderView()) + ->setHeader(pht('Invoices Due')); + + return id(new PHUIObjectBoxView()) + ->setHeader($invoice_header) + ->appendChild($invoice_table); + } + + private function buildPastInvoices( + PhortuneSubscription $subscription, + $is_merchant) { + $viewer = $this->getViewer(); + + $invoices = id(new PhortuneCartQuery()) ->setViewer($viewer) ->withSubscriptionPHIDs(array($subscription->getPHID())) ->needPurchases(true) @@ -60,12 +152,13 @@ final class PhortuneSubscriptionViewController extends PhortuneController { PhortuneCart::STATUS_REVIEW, PhortuneCart::STATUS_PURCHASED, )) + ->setLimit(50) ->execute(); $phids = array(); - foreach ($carts as $cart) { - $phids[] = $cart->getPHID(); - foreach ($cart->getPurchases() as $purchase) { + foreach ($invoices as $invoice) { + $phids[] = $invoice->getPHID(); + foreach ($invoice->getPurchases() as $purchase) { $phids[] = $purchase->getPHID(); } } @@ -73,9 +166,12 @@ final class PhortuneSubscriptionViewController extends PhortuneController { $invoice_table = id(new PhortuneOrderTableView()) ->setUser($viewer) - ->setCarts($carts) + ->setCarts($invoices) ->setHandles($handles); + $account = $subscription->getAccount(); + $merchant = $subscription->getMerchant(); + $account_id = $account->getID(); $merchant_id = $merchant->getID(); $subscription_id = $subscription->getID(); @@ -89,7 +185,7 @@ final class PhortuneSubscriptionViewController extends PhortuneController { } $invoice_header = id(new PHUIHeaderView()) - ->setHeader(pht('Recent Invoices')) + ->setHeader(pht('Past Invoices')) ->addActionLink( id(new PHUIButtonView()) ->setTag('a') @@ -99,19 +195,9 @@ final class PhortuneSubscriptionViewController extends PhortuneController { ->setHref($invoices_uri) ->setText(pht('View All Invoices'))); - $invoice_box = id(new PHUIObjectBoxView()) + return 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/storage/PhortuneCart.php b/src/applications/phortune/storage/PhortuneCart.php index cb2524815b..dc24c3f132 100644 --- a/src/applications/phortune/storage/PhortuneCart.php +++ b/src/applications/phortune/storage/PhortuneCart.php @@ -35,7 +35,9 @@ final class PhortuneCart extends PhortuneDAO ->setAuthorPHID($actor->getPHID()) ->setStatus(self::STATUS_BUILDING) ->setAccountPHID($account->getPHID()) - ->setMerchantPHID($merchant->getPHID()); + ->attachAccount($account) + ->setMerchantPHID($merchant->getPHID()) + ->attachMerchant($merchant); $cart->account = $account; $cart->purchases = array(); diff --git a/src/applications/phortune/storage/PhortunePurchase.php b/src/applications/phortune/storage/PhortunePurchase.php index 2c218ff296..67a1cf0117 100644 --- a/src/applications/phortune/storage/PhortunePurchase.php +++ b/src/applications/phortune/storage/PhortunePurchase.php @@ -31,6 +31,7 @@ final class PhortunePurchase extends PhortuneDAO return id(new PhortunePurchase()) ->setAuthorPHID($actor->getPHID()) ->setProductPHID($product->getPHID()) + ->attachProduct($product) ->setQuantity(1) ->setStatus(self::STATUS_PENDING) ->setBasePriceAsCurrency($product->getPriceAsCurrency()); diff --git a/src/applications/phortune/storage/PhortuneSubscription.php b/src/applications/phortune/storage/PhortuneSubscription.php index dedc0010fc..036a31e0f6 100644 --- a/src/applications/phortune/storage/PhortuneSubscription.php +++ b/src/applications/phortune/storage/PhortuneSubscription.php @@ -13,6 +13,7 @@ final class PhortuneSubscription extends PhortuneDAO protected $merchantPHID; protected $triggerPHID; protected $authorPHID; + protected $defaultPaymentMethodPHID; protected $subscriptionClassKey; protected $subscriptionClass; protected $subscriptionRefKey; @@ -32,6 +33,7 @@ final class PhortuneSubscription extends PhortuneDAO 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( + 'defaultPaymentMethodPHID' => 'phid?', 'subscriptionClassKey' => 'bytes12', 'subscriptionClass' => 'text128', 'subscriptionRefKey' => 'bytes12', @@ -125,7 +127,6 @@ final class PhortuneSubscription extends PhortuneDAO $this->subscriptionRefKey = PhabricatorHash::digestForIndex( $this->subscriptionRef); - $trigger = $this->getTrigger(); $is_new = (!$this->getID()); $this->openTransaction(); @@ -153,6 +154,7 @@ final class PhortuneSubscription extends PhortuneDAO ), )); + $trigger = $this->getTrigger(); $trigger->setPHID($trigger_phid); $trigger->setAction($trigger_action); $trigger->save(); diff --git a/src/applications/phortune/view/PhortuneOrderTableView.php b/src/applications/phortune/view/PhortuneOrderTableView.php index 1abf128361..a2e492c5be 100644 --- a/src/applications/phortune/view/PhortuneOrderTableView.php +++ b/src/applications/phortune/view/PhortuneOrderTableView.php @@ -6,6 +6,7 @@ final class PhortuneOrderTableView extends AphrontView { private $handles; private $noDataString; private $isInvoices; + private $isMerchantView; public function setHandles(array $handles) { $this->handles = $handles; @@ -43,12 +44,22 @@ final class PhortuneOrderTableView extends AphrontView { return $this->noDataString; } + public function setIsMerchantView($is_merchant_view) { + $this->isMerchantView = $is_merchant_view; + return $this; + } + + public function getIsMerchantView() { + return $this->isMerchantView; + } + public function render() { $carts = $this->getCarts(); $handles = $this->getHandles(); $viewer = $this->getUser(); $is_invoices = $this->getIsInvoices(); + $is_merchant = $this->getIsMerchantView(); $rows = array(); $rowc = array(); @@ -138,7 +149,7 @@ final class PhortuneOrderTableView extends AphrontView { '', 'right', 'right', - '', + 'action', )) ->setColumnVisibility( array( @@ -150,7 +161,10 @@ final class PhortuneOrderTableView extends AphrontView { !$is_invoices, !$is_invoices, $is_invoices, - $is_invoices, + + // We show "Pay Now" for due invoices, but not if the viewer is the + // merchant, since it doesn't make sense for them to pay. + ($is_invoices && !$is_merchant), )); return $table; diff --git a/src/applications/phortune/worker/PhortuneSubscriptionWorker.php b/src/applications/phortune/worker/PhortuneSubscriptionWorker.php index c59bef0422..b872b6c83d 100644 --- a/src/applications/phortune/worker/PhortuneSubscriptionWorker.php +++ b/src/applications/phortune/worker/PhortuneSubscriptionWorker.php @@ -54,9 +54,69 @@ final class PhortuneSubscriptionWorker extends PhabricatorWorker { $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"; + try { + $issues = $this->chargeSubscription($actor, $subscription, $cart); + } catch (Exception $ex) { + $issues = array( + pht( + 'There was a technical error while trying to automatically bill '. + 'this subscription: %s', + $ex), + ); + } + + if (!$issues) { + // We're all done; charging the cart sends a billing email as a side + // effect. + return; + } + + // TODO: Send an email telling the user that we weren't able to autopay + // so they need to pay this manually. + throw new Exception(implode("\n", $issues)); + } + + + private function chargeSubscription( + PhabricatorUser $viewer, + PhortuneSubscription $subscription, + PhortuneCart $cart) { + + $issues = array(); + if (!$subscription->getDefaultPaymentMethodPHID()) { + $issues[] = pht( + 'There is no payment method associated with this subscription, so '. + 'it could not be billed automatically. Add a default payment method '. + 'to enable automatic billing.'); + return $issues; + } + + $method = id(new PhortunePaymentMethodQuery()) + ->setViewer($viewer) + ->withPHIDs(array($subscription->getDefaultPaymentMethodPHID())) + ->executeOne(); + if (!$method) { + $issues[] = pht( + 'The payment method associated with this subscription is invalid '. + 'or out of date, so it could not be automatically billed. Update '. + 'the default payment method to enable automatic billing.'); + return $issues; + } + + $provider = $method->buildPaymentProvider(); + $charge = $cart->willApplyCharge($viewer, $provider, $method); + + try { + $provider->applyCharge($method, $charge); + } catch (Exception $ex) { + $cart->didFailCharge($charge); + $issues[] = pht( + 'Automatic billing failed: %s', + $ex->getMessage()); + return $issues; + } + + $cart->didApplyCharge($charge); }