mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-28 16:30:59 +01:00
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
This commit is contained in:
parent
87deb72cdb
commit
77db15c47b
10 changed files with 327 additions and 24 deletions
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE {$NAMESPACE}_phortune.phortune_subscription
|
||||
ADD defaultPaymentMethodPHID VARBINARY(64);
|
|
@ -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',
|
||||
|
|
|
@ -46,6 +46,8 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication {
|
|||
=> 'PhortuneSubscriptionListController',
|
||||
'view/(?P<id>\d+)/'
|
||||
=> 'PhortuneSubscriptionViewController',
|
||||
'edit/(?P<id>\d+)/'
|
||||
=> 'PhortuneSubscriptionEditController',
|
||||
'order/(?P<subscriptionID>\d+)/'
|
||||
=> 'PhortuneCartListController',
|
||||
),
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
final class PhortuneSubscriptionEditController extends PhortuneController {
|
||||
|
||||
public function handleRequest(AphrontRequest $request) {
|
||||
$viewer = $this->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,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue