@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Automatically bill subscriptions when a payment method is available

Summary:
Ref T6881.

- Allow users to set a default payment method for a subscription, which we'll try to autobill (not all payment methods are autobillable, so we can't require this in the general case, and a charge might fail anyway).
- If a subscription has an autopay method, try to automatically bill it.
- Otherwise, we'll send them an email like "hey here's a bill, it couldn't autopay for some reasons, go pay it and fix those if you want".
- (That email doesn't exist yet but there's a comment about it.)
- Also some UI cleanup.

Test Plan:
- Used `bin/phortune invoice` to autobill myself some fake test money.

{F279416}

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T6881

Differential Revision: https://secure.phabricator.com/D11596

+327 -24
+2
resources/sql/autopatches/20150131.phortune.1.defaultpayment.sql
··· 1 + ALTER TABLE {$NAMESPACE}_phortune.phortune_subscription 2 + ADD defaultPaymentMethodPHID VARBINARY(64);
+2
src/__phutil_library_map__.php
··· 2812 2812 'PhortuneStripePaymentProvider' => 'applications/phortune/provider/PhortuneStripePaymentProvider.php', 2813 2813 'PhortuneSubscription' => 'applications/phortune/storage/PhortuneSubscription.php', 2814 2814 'PhortuneSubscriptionCart' => 'applications/phortune/cart/PhortuneSubscriptionCart.php', 2815 + 'PhortuneSubscriptionEditController' => 'applications/phortune/controller/PhortuneSubscriptionEditController.php', 2815 2816 'PhortuneSubscriptionImplementation' => 'applications/phortune/subscription/PhortuneSubscriptionImplementation.php', 2816 2817 'PhortuneSubscriptionListController' => 'applications/phortune/controller/PhortuneSubscriptionListController.php', 2817 2818 'PhortuneSubscriptionPHIDType' => 'applications/phortune/phid/PhortuneSubscriptionPHIDType.php', ··· 6172 6173 'PhabricatorPolicyInterface', 6173 6174 ), 6174 6175 'PhortuneSubscriptionCart' => 'PhortuneCartImplementation', 6176 + 'PhortuneSubscriptionEditController' => 'PhortuneController', 6175 6177 'PhortuneSubscriptionListController' => 'PhortuneController', 6176 6178 'PhortuneSubscriptionPHIDType' => 'PhabricatorPHIDType', 6177 6179 'PhortuneSubscriptionProduct' => 'PhortuneProductImplementation',
+2
src/applications/phortune/application/PhabricatorPhortuneApplication.php
··· 46 46 => 'PhortuneSubscriptionListController', 47 47 'view/(?P<id>\d+)/' 48 48 => 'PhortuneSubscriptionViewController', 49 + 'edit/(?P<id>\d+)/' 50 + => 'PhortuneSubscriptionEditController', 49 51 'order/(?P<subscriptionID>\d+)/' 50 52 => 'PhortuneCartListController', 51 53 ),
+132
src/applications/phortune/controller/PhortuneSubscriptionEditController.php
··· 1 + <?php 2 + 3 + final class PhortuneSubscriptionEditController extends PhortuneController { 4 + 5 + public function handleRequest(AphrontRequest $request) { 6 + $viewer = $this->getViewer(); 7 + 8 + $subscription = id(new PhortuneSubscriptionQuery()) 9 + ->setViewer($viewer) 10 + ->withIDs(array($request->getURIData('id'))) 11 + ->requireCapabilities( 12 + array( 13 + PhabricatorPolicyCapability::CAN_VIEW, 14 + PhabricatorPolicyCapability::CAN_EDIT, 15 + )) 16 + ->executeOne(); 17 + if (!$subscription) { 18 + return new Aphront404Response(); 19 + } 20 + 21 + $merchant = $subscription->getMerchant(); 22 + $account = $subscription->getAccount(); 23 + 24 + $title = pht('Subscription: %s', $subscription->getSubscriptionName()); 25 + 26 + $header = id(new PHUIHeaderView()) 27 + ->setHeader($subscription->getSubscriptionName()); 28 + 29 + $view_uri = $subscription->getURI(); 30 + 31 + $valid_methods = id(new PhortunePaymentMethodQuery()) 32 + ->setViewer($viewer) 33 + ->withAccountPHIDs(array($account->getPHID())) 34 + ->requireCapabilities( 35 + array( 36 + PhabricatorPolicyCapability::CAN_VIEW, 37 + PhabricatorPolicyCapability::CAN_EDIT, 38 + )) 39 + ->execute(); 40 + $valid_methods = mpull($valid_methods, null, 'getPHID'); 41 + 42 + $current_phid = $subscription->getDefaultPaymentMethodPHID(); 43 + 44 + $errors = array(); 45 + if ($request->isFormPost()) { 46 + 47 + $default_method_phid = $request->getStr('defaultPaymentMethodPHID'); 48 + if (!$default_method_phid) { 49 + $default_method_phid = null; 50 + $e_method = null; 51 + } else if ($default_method_phid == $current_phid) { 52 + // If you have an invalid setting already, it's OK to retain it. 53 + $e_method = null; 54 + } else { 55 + if (empty($valid_methods[$default_method_phid])) { 56 + $e_method = pht('Invalid'); 57 + $errors[] = pht('You must select a valid default payment method.'); 58 + } 59 + } 60 + 61 + // TODO: We should use transactions here, and move the validation logic 62 + // inside the Editor. 63 + 64 + if (!$errors) { 65 + $subscription->setDefaultPaymentMethodPHID($default_method_phid); 66 + $subscription->save(); 67 + 68 + return id(new AphrontRedirectResponse()) 69 + ->setURI($view_uri); 70 + } 71 + } 72 + 73 + // Add the option to disable autopay. 74 + $disable_options = array( 75 + '' => pht('(Disable Autopay)'), 76 + ); 77 + 78 + // Don't require the user to make a valid selection if the current method 79 + // has become invalid. 80 + // TODO: This should probably have a note about why this is bogus. 81 + if ($current_phid && empty($valid_methods[$current_phid])) { 82 + $handles = $this->loadViewerHandles(array($current_phid)); 83 + $current_options = array( 84 + $current_phid => $handles[$current_phid]->getName(), 85 + ); 86 + } else { 87 + $current_options = array(); 88 + } 89 + 90 + // Add any available options. 91 + $valid_options = mpull($valid_methods, 'getFullDisplayName', 'getPHID'); 92 + 93 + $options = $disable_options + $current_options + $valid_options; 94 + 95 + $crumbs = $this->buildApplicationCrumbs(); 96 + $this->addAccountCrumb($crumbs, $account); 97 + $crumbs->addTextCrumb( 98 + pht('Subscription %d', $subscription->getID()), 99 + $view_uri); 100 + $crumbs->addTextCrumb(pht('Edit')); 101 + 102 + $form = id(new AphrontFormView()) 103 + ->setUser($viewer) 104 + ->appendChild( 105 + id(new AphrontFormSelectControl()) 106 + ->setName('defaultPaymentMethodPHID') 107 + ->setLabel(pht('Autopay With')) 108 + ->setValue($current_phid) 109 + ->setOptions($options)) 110 + ->appendChild( 111 + id(new AphrontFormSubmitControl()) 112 + ->setValue(pht('Save Changes')) 113 + ->addCancelButton($view_uri)); 114 + 115 + $box = id(new PHUIObjectBoxView()) 116 + ->setUser($viewer) 117 + ->setHeaderText(pht('Edit %s', $subscription->getSubscriptionName())) 118 + ->setFormErrors($errors) 119 + ->appendChild($form); 120 + 121 + return $this->buildApplicationPage( 122 + array( 123 + $crumbs, 124 + $box, 125 + ), 126 + array( 127 + 'title' => $title, 128 + )); 129 + } 130 + 131 + 132 + }
+103 -17
src/applications/phortune/controller/PhortuneSubscriptionViewController.php
··· 14 14 return new Aphront404Response(); 15 15 } 16 16 17 + $can_edit = PhabricatorPolicyFilter::hasCapability( 18 + $viewer, 19 + $subscription, 20 + PhabricatorPolicyCapability::CAN_EDIT); 21 + 17 22 $is_merchant = (bool)$request->getURIData('merchantID'); 18 23 $merchant = $subscription->getMerchant(); 19 24 $account = $subscription->getAccount(); 25 + 26 + $account_id = $account->getID(); 27 + $subscription_id = $subscription->getID(); 20 28 21 29 $title = pht('Subscription: %s', $subscription->getSubscriptionName()); 22 30 ··· 27 35 ->setUser($viewer) 28 36 ->setObjectURI($request->getRequestURI()); 29 37 38 + $edit_uri = $this->getApplicationURI( 39 + "{$account_id}/subscription/edit/{$subscription_id}/"); 40 + 41 + $actions->addAction( 42 + id(new PhabricatorActionView()) 43 + ->setIcon('fa-pencil') 44 + ->setName(pht('Edit Subscription')) 45 + ->setHref($edit_uri) 46 + ->setDisabled(!$can_edit) 47 + ->setWorkflow(!$can_edit)); 48 + 49 + 30 50 $crumbs = $this->buildApplicationCrumbs(); 31 51 if ($is_merchant) { 32 52 $this->addMerchantCrumb($crumbs, $merchant); ··· 44 64 pht('Next Invoice'), 45 65 phabricator_datetime($next_invoice, $viewer)); 46 66 67 + $default_method = $subscription->getDefaultPaymentMethodPHID(); 68 + if ($default_method) { 69 + $handles = $this->loadViewerHandles(array($default_method)); 70 + $autopay_method = $handles[$default_method]->renderLink(); 71 + } else { 72 + $autopay_method = phutil_tag( 73 + 'em', 74 + array(), 75 + pht('No Autopay Method Configured')); 76 + } 77 + 78 + $properties->addProperty( 79 + pht('Autopay With'), 80 + $autopay_method); 81 + 47 82 $object_box = id(new PHUIObjectBoxView()) 48 83 ->setHeader($header) 49 84 ->addPropertyList($properties); 50 85 51 - $carts = id(new PhortuneCartQuery()) 86 + $due_box = $this->buildDueInvoices($subscription, $is_merchant); 87 + $invoice_box = $this->buildPastInvoices($subscription, $is_merchant); 88 + 89 + return $this->buildApplicationPage( 90 + array( 91 + $crumbs, 92 + $object_box, 93 + $due_box, 94 + $invoice_box, 95 + ), 96 + array( 97 + 'title' => $title, 98 + )); 99 + } 100 + 101 + private function buildDueInvoices( 102 + PhortuneSubscription $subscription, 103 + $is_merchant) { 104 + $viewer = $this->getViewer(); 105 + 106 + $invoices = id(new PhortuneCartQuery()) 107 + ->setViewer($viewer) 108 + ->withSubscriptionPHIDs(array($subscription->getPHID())) 109 + ->needPurchases(true) 110 + ->withInvoices(true) 111 + ->execute(); 112 + 113 + $phids = array(); 114 + foreach ($invoices as $invoice) { 115 + $phids[] = $invoice->getPHID(); 116 + $phids[] = $invoice->getMerchantPHID(); 117 + foreach ($invoice->getPurchases() as $purchase) { 118 + $phids[] = $purchase->getPHID(); 119 + } 120 + } 121 + $handles = $this->loadViewerHandles($phids); 122 + 123 + $invoice_table = id(new PhortuneOrderTableView()) 124 + ->setUser($viewer) 125 + ->setCarts($invoices) 126 + ->setIsInvoices(true) 127 + ->setIsMerchantView($is_merchant) 128 + ->setHandles($handles); 129 + 130 + $invoice_header = id(new PHUIHeaderView()) 131 + ->setHeader(pht('Invoices Due')); 132 + 133 + return id(new PHUIObjectBoxView()) 134 + ->setHeader($invoice_header) 135 + ->appendChild($invoice_table); 136 + } 137 + 138 + private function buildPastInvoices( 139 + PhortuneSubscription $subscription, 140 + $is_merchant) { 141 + $viewer = $this->getViewer(); 142 + 143 + $invoices = id(new PhortuneCartQuery()) 52 144 ->setViewer($viewer) 53 145 ->withSubscriptionPHIDs(array($subscription->getPHID())) 54 146 ->needPurchases(true) ··· 60 152 PhortuneCart::STATUS_REVIEW, 61 153 PhortuneCart::STATUS_PURCHASED, 62 154 )) 155 + ->setLimit(50) 63 156 ->execute(); 64 157 65 158 $phids = array(); 66 - foreach ($carts as $cart) { 67 - $phids[] = $cart->getPHID(); 68 - foreach ($cart->getPurchases() as $purchase) { 159 + foreach ($invoices as $invoice) { 160 + $phids[] = $invoice->getPHID(); 161 + foreach ($invoice->getPurchases() as $purchase) { 69 162 $phids[] = $purchase->getPHID(); 70 163 } 71 164 } ··· 73 166 74 167 $invoice_table = id(new PhortuneOrderTableView()) 75 168 ->setUser($viewer) 76 - ->setCarts($carts) 169 + ->setCarts($invoices) 77 170 ->setHandles($handles); 78 171 172 + $account = $subscription->getAccount(); 173 + $merchant = $subscription->getMerchant(); 174 + 79 175 $account_id = $account->getID(); 80 176 $merchant_id = $merchant->getID(); 81 177 $subscription_id = $subscription->getID(); ··· 89 185 } 90 186 91 187 $invoice_header = id(new PHUIHeaderView()) 92 - ->setHeader(pht('Recent Invoices')) 188 + ->setHeader(pht('Past Invoices')) 93 189 ->addActionLink( 94 190 id(new PHUIButtonView()) 95 191 ->setTag('a') ··· 99 195 ->setHref($invoices_uri) 100 196 ->setText(pht('View All Invoices'))); 101 197 102 - $invoice_box = id(new PHUIObjectBoxView()) 198 + return id(new PHUIObjectBoxView()) 103 199 ->setHeader($invoice_header) 104 200 ->appendChild($invoice_table); 105 - 106 - return $this->buildApplicationPage( 107 - array( 108 - $crumbs, 109 - $object_box, 110 - $invoice_box, 111 - ), 112 - array( 113 - 'title' => $title, 114 - )); 115 201 } 116 202 117 203 }
+3 -1
src/applications/phortune/storage/PhortuneCart.php
··· 35 35 ->setAuthorPHID($actor->getPHID()) 36 36 ->setStatus(self::STATUS_BUILDING) 37 37 ->setAccountPHID($account->getPHID()) 38 - ->setMerchantPHID($merchant->getPHID()); 38 + ->attachAccount($account) 39 + ->setMerchantPHID($merchant->getPHID()) 40 + ->attachMerchant($merchant); 39 41 40 42 $cart->account = $account; 41 43 $cart->purchases = array();
+1
src/applications/phortune/storage/PhortunePurchase.php
··· 31 31 return id(new PhortunePurchase()) 32 32 ->setAuthorPHID($actor->getPHID()) 33 33 ->setProductPHID($product->getPHID()) 34 + ->attachProduct($product) 34 35 ->setQuantity(1) 35 36 ->setStatus(self::STATUS_PENDING) 36 37 ->setBasePriceAsCurrency($product->getPriceAsCurrency());
+3 -1
src/applications/phortune/storage/PhortuneSubscription.php
··· 13 13 protected $merchantPHID; 14 14 protected $triggerPHID; 15 15 protected $authorPHID; 16 + protected $defaultPaymentMethodPHID; 16 17 protected $subscriptionClassKey; 17 18 protected $subscriptionClass; 18 19 protected $subscriptionRefKey; ··· 32 33 'metadata' => self::SERIALIZATION_JSON, 33 34 ), 34 35 self::CONFIG_COLUMN_SCHEMA => array( 36 + 'defaultPaymentMethodPHID' => 'phid?', 35 37 'subscriptionClassKey' => 'bytes12', 36 38 'subscriptionClass' => 'text128', 37 39 'subscriptionRefKey' => 'bytes12', ··· 125 127 $this->subscriptionRefKey = PhabricatorHash::digestForIndex( 126 128 $this->subscriptionRef); 127 129 128 - $trigger = $this->getTrigger(); 129 130 $is_new = (!$this->getID()); 130 131 131 132 $this->openTransaction(); ··· 153 154 ), 154 155 )); 155 156 157 + $trigger = $this->getTrigger(); 156 158 $trigger->setPHID($trigger_phid); 157 159 $trigger->setAction($trigger_action); 158 160 $trigger->save();
+16 -2
src/applications/phortune/view/PhortuneOrderTableView.php
··· 6 6 private $handles; 7 7 private $noDataString; 8 8 private $isInvoices; 9 + private $isMerchantView; 9 10 10 11 public function setHandles(array $handles) { 11 12 $this->handles = $handles; ··· 43 44 return $this->noDataString; 44 45 } 45 46 47 + public function setIsMerchantView($is_merchant_view) { 48 + $this->isMerchantView = $is_merchant_view; 49 + return $this; 50 + } 51 + 52 + public function getIsMerchantView() { 53 + return $this->isMerchantView; 54 + } 55 + 46 56 public function render() { 47 57 $carts = $this->getCarts(); 48 58 $handles = $this->getHandles(); 49 59 $viewer = $this->getUser(); 50 60 51 61 $is_invoices = $this->getIsInvoices(); 62 + $is_merchant = $this->getIsMerchantView(); 52 63 53 64 $rows = array(); 54 65 $rowc = array(); ··· 138 149 '', 139 150 'right', 140 151 'right', 141 - '', 152 + 'action', 142 153 )) 143 154 ->setColumnVisibility( 144 155 array( ··· 150 161 !$is_invoices, 151 162 !$is_invoices, 152 163 $is_invoices, 153 - $is_invoices, 164 + 165 + // We show "Pay Now" for due invoices, but not if the viewer is the 166 + // merchant, since it doesn't make sense for them to pay. 167 + ($is_invoices && !$is_merchant), 154 168 )); 155 169 156 170 return $table;
+63 -3
src/applications/phortune/worker/PhortuneSubscriptionWorker.php
··· 54 54 $cart->setSubscriptionPHID($subscription->getPHID()); 55 55 $cart->activateCart(); 56 56 57 - // TODO: Autocharge this, etc.; this is still mostly faked up. 58 - echo 'Okay, made a cart here: '; 59 - echo $cart->getCheckoutURI()."\n\n"; 57 + try { 58 + $issues = $this->chargeSubscription($actor, $subscription, $cart); 59 + } catch (Exception $ex) { 60 + $issues = array( 61 + pht( 62 + 'There was a technical error while trying to automatically bill '. 63 + 'this subscription: %s', 64 + $ex), 65 + ); 66 + } 67 + 68 + if (!$issues) { 69 + // We're all done; charging the cart sends a billing email as a side 70 + // effect. 71 + return; 72 + } 73 + 74 + // TODO: Send an email telling the user that we weren't able to autopay 75 + // so they need to pay this manually. 76 + throw new Exception(implode("\n", $issues)); 77 + } 78 + 79 + 80 + private function chargeSubscription( 81 + PhabricatorUser $viewer, 82 + PhortuneSubscription $subscription, 83 + PhortuneCart $cart) { 84 + 85 + $issues = array(); 86 + if (!$subscription->getDefaultPaymentMethodPHID()) { 87 + $issues[] = pht( 88 + 'There is no payment method associated with this subscription, so '. 89 + 'it could not be billed automatically. Add a default payment method '. 90 + 'to enable automatic billing.'); 91 + return $issues; 92 + } 93 + 94 + $method = id(new PhortunePaymentMethodQuery()) 95 + ->setViewer($viewer) 96 + ->withPHIDs(array($subscription->getDefaultPaymentMethodPHID())) 97 + ->executeOne(); 98 + if (!$method) { 99 + $issues[] = pht( 100 + 'The payment method associated with this subscription is invalid '. 101 + 'or out of date, so it could not be automatically billed. Update '. 102 + 'the default payment method to enable automatic billing.'); 103 + return $issues; 104 + } 105 + 106 + $provider = $method->buildPaymentProvider(); 107 + $charge = $cart->willApplyCharge($viewer, $provider, $method); 108 + 109 + try { 110 + $provider->applyCharge($method, $charge); 111 + } catch (Exception $ex) { 112 + $cart->didFailCharge($charge); 113 + $issues[] = pht( 114 + 'Automatic billing failed: %s', 115 + $ex->getMessage()); 116 + return $issues; 117 + } 118 + 119 + $cart->didApplyCharge($charge); 60 120 } 61 121 62 122