1
0
Fork 0
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:
epriestley 2015-02-01 12:31:46 -08:00
parent 87deb72cdb
commit 77db15c47b
10 changed files with 327 additions and 24 deletions

View file

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

View file

@ -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',

View file

@ -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',
),

View file

@ -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,
));
}
}

View file

@ -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,
));
}
}

View file

@ -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();

View file

@ -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());

View file

@ -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();

View file

@ -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;

View file

@ -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);
}