mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-19 12:00:55 +01:00
Give WePay complete payment logic in Phortune
Summary: Ref T2787. This doesn't get all the edge cases quite correct, but is generally a safe, complete payment workflow: - Shares the actual charging state logic. - Makes it appropriately stateful with locking and transactions. - Gets the main flow correct. - Detects failure cases, just tends to blow up rather than help the user resolve them. Test Plan: - Charged with WePay. - Charged with Infinite Free Money. - Resumed an abandoned cart. - Hit all failure states where we just dead-end the cart. Not ideal, but (seemingly) complete/safe/correct. Reviewers: btrahan Reviewed By: btrahan Subscribers: epriestley Maniphest Tasks: T2787 Differential Revision: https://secure.phabricator.com/D10639
This commit is contained in:
parent
0beb8228da
commit
4ef547f8d6
6 changed files with 143 additions and 59 deletions
|
@ -38,6 +38,25 @@ final class PhortuneCartCheckoutController
|
||||||
// This is the expected, normal state for a cart that's ready for
|
// This is the expected, normal state for a cart that's ready for
|
||||||
// checkout.
|
// checkout.
|
||||||
break;
|
break;
|
||||||
|
case PhortuneCart::STATUS_PURCHASING:
|
||||||
|
// We've started the purchase workflow for this cart, but were not able
|
||||||
|
// to complete it. If the workflow is on an external site, this could
|
||||||
|
// happen because the user abandoned the workflow. Just return them to
|
||||||
|
// the right place so they can resume where they left off.
|
||||||
|
$uri = $cart->getMetadataValue('provider.checkoutURI');
|
||||||
|
if ($uri !== null) {
|
||||||
|
return id(new AphrontRedirectResponse())
|
||||||
|
->setIsExternal(true)
|
||||||
|
->setURI($uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->newDialog()
|
||||||
|
->setTitle(pht('Charge Failed'))
|
||||||
|
->appendParagraph(
|
||||||
|
pht(
|
||||||
|
'Failed to charge this cart.'))
|
||||||
|
->addCancelButton($cancel_uri);
|
||||||
|
break;
|
||||||
case PhortuneCart::STATUS_CHARGED:
|
case PhortuneCart::STATUS_CHARGED:
|
||||||
// TODO: This is really bad (we took your money and at least partially
|
// TODO: This is really bad (we took your money and at least partially
|
||||||
// failed to fulfill your order) and should have better steps forward.
|
// failed to fulfill your order) and should have better steps forward.
|
||||||
|
@ -89,24 +108,8 @@ final class PhortuneCartCheckoutController
|
||||||
if (!$errors) {
|
if (!$errors) {
|
||||||
$provider = $method->buildPaymentProvider();
|
$provider = $method->buildPaymentProvider();
|
||||||
|
|
||||||
$charge = id(new PhortuneCharge())
|
$charge = $cart->willApplyCharge($viewer, $provider, $method);
|
||||||
->setAccountPHID($account->getPHID())
|
|
||||||
->setCartPHID($cart->getPHID())
|
|
||||||
->setAuthorPHID($viewer->getPHID())
|
|
||||||
->setPaymentProviderKey($provider->getProviderKey())
|
|
||||||
->setPaymentMethodPHID($method->getPHID())
|
|
||||||
->setAmountAsCurrency($cart->getTotalPriceAsCurrency())
|
|
||||||
->setStatus(PhortuneCharge::STATUS_PENDING);
|
|
||||||
|
|
||||||
$charge->openTransaction();
|
|
||||||
$charge->save();
|
|
||||||
|
|
||||||
$cart->setStatus(PhortuneCart::STATUS_PURCHASING);
|
|
||||||
$cart->save();
|
|
||||||
$charge->saveTransaction();
|
|
||||||
|
|
||||||
$provider->applyCharge($method, $charge);
|
$provider->applyCharge($method, $charge);
|
||||||
|
|
||||||
$cart->didApplyCharge($charge);
|
$cart->didApplyCharge($charge);
|
||||||
|
|
||||||
$done_uri = $cart->getDoneURI();
|
$done_uri = $cart->getDoneURI();
|
||||||
|
|
|
@ -204,11 +204,13 @@ abstract class PhortunePaymentProvider {
|
||||||
->setText($description)
|
->setText($description)
|
||||||
->setSubtext($details);
|
->setSubtext($details);
|
||||||
|
|
||||||
|
// NOTE: We generate a local URI to make sure the form picks up CSRF tokens.
|
||||||
$uri = $this->getControllerURI(
|
$uri = $this->getControllerURI(
|
||||||
'checkout',
|
'checkout',
|
||||||
array(
|
array(
|
||||||
'cartID' => $cart->getID(),
|
'cartID' => $cart->getID(),
|
||||||
));
|
),
|
||||||
|
$local = true);
|
||||||
|
|
||||||
return phabricator_form(
|
return phabricator_form(
|
||||||
$user,
|
$user,
|
||||||
|
@ -225,7 +227,8 @@ abstract class PhortunePaymentProvider {
|
||||||
|
|
||||||
final public function getControllerURI(
|
final public function getControllerURI(
|
||||||
$action,
|
$action,
|
||||||
array $params = array()) {
|
array $params = array(),
|
||||||
|
$local = false) {
|
||||||
|
|
||||||
$digest = PhabricatorHash::digestForIndex($this->getProviderKey());
|
$digest = PhabricatorHash::digestForIndex($this->getProviderKey());
|
||||||
|
|
||||||
|
@ -235,8 +238,12 @@ abstract class PhortunePaymentProvider {
|
||||||
$uri = new PhutilURI($path);
|
$uri = new PhutilURI($path);
|
||||||
$uri->setQueryParams($params);
|
$uri->setQueryParams($params);
|
||||||
|
|
||||||
|
if ($local) {
|
||||||
|
return $uri;
|
||||||
|
} else {
|
||||||
return PhabricatorEnv::getURI((string)$uri);
|
return PhabricatorEnv::getURI((string)$uri);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function canRespondToControllerAction($action) {
|
public function canRespondToControllerAction($action) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -91,8 +91,6 @@ final class PhortuneWePayPaymentProvider extends PhortunePaymentProvider {
|
||||||
return new Aphront404Response();
|
return new Aphront404Response();
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart_uri = '/phortune/cart/'.$cart->getID().'/';
|
|
||||||
|
|
||||||
$root = dirname(phutil_get_library_root('phabricator'));
|
$root = dirname(phutil_get_library_root('phabricator'));
|
||||||
require_once $root.'/externals/wepay/wepay.php';
|
require_once $root.'/externals/wepay/wepay.php';
|
||||||
|
|
||||||
|
@ -102,6 +100,29 @@ final class PhortuneWePayPaymentProvider extends PhortunePaymentProvider {
|
||||||
|
|
||||||
$wepay = new WePay($this->getWePayAccessToken());
|
$wepay = new WePay($this->getWePayAccessToken());
|
||||||
|
|
||||||
|
$charge = id(new PhortuneChargeQuery())
|
||||||
|
->setViewer($viewer)
|
||||||
|
->withCartPHIDs(array($cart->getPHID()))
|
||||||
|
->withStatuses(
|
||||||
|
array(
|
||||||
|
PhortuneCharge::STATUS_CHARGING,
|
||||||
|
))
|
||||||
|
->executeOne();
|
||||||
|
|
||||||
|
switch ($controller->getAction()) {
|
||||||
|
case 'checkout':
|
||||||
|
if ($charge) {
|
||||||
|
throw new Exception(pht('Cart is already charging!'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'charge':
|
||||||
|
case 'cancel':
|
||||||
|
if (!$charge) {
|
||||||
|
throw new Exception(pht('Cart is not charging yet!'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
switch ($controller->getAction()) {
|
switch ($controller->getAction()) {
|
||||||
case 'checkout':
|
case 'checkout':
|
||||||
$return_uri = $this->getControllerURI(
|
$return_uri = $this->getControllerURI(
|
||||||
|
@ -142,10 +163,13 @@ final class PhortuneWePayPaymentProvider extends PhortunePaymentProvider {
|
||||||
'funding_sources' => 'bank,cc'
|
'funding_sources' => 'bank,cc'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$cart->willApplyCharge($viewer, $this);
|
||||||
|
|
||||||
$result = $wepay->request('checkout/create', $params);
|
$result = $wepay->request('checkout/create', $params);
|
||||||
|
|
||||||
// TODO: We must store "$result->checkout_id" on the Cart since the
|
$cart->setMetadataValue('provider.checkoutURI', $result->checkout_uri);
|
||||||
// user might not end up back here. Really this needs a bunch of junk.
|
$cart->setMetadataValue('wepay.checkoutID', $result->checkout_id);
|
||||||
|
$cart->save();
|
||||||
|
|
||||||
$uri = new PhutilURI($result->checkout_uri);
|
$uri = new PhutilURI($result->checkout_uri);
|
||||||
return id(new AphrontRedirectResponse())
|
return id(new AphrontRedirectResponse())
|
||||||
|
@ -175,38 +199,22 @@ final class PhortuneWePayPaymentProvider extends PhortunePaymentProvider {
|
||||||
$result->state));
|
$result->state));
|
||||||
}
|
}
|
||||||
|
|
||||||
$currency = PhortuneCurrency::newFromString($checkout->gross, 'USD');
|
|
||||||
|
|
||||||
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
|
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
|
||||||
|
$cart->didApplyCharge($charge);
|
||||||
$charge = id(new PhortuneCharge())
|
|
||||||
->setAmountAsCurrency($currency)
|
|
||||||
->setAccountPHID($cart->getAccount()->getPHID())
|
|
||||||
->setAuthorPHID($viewer->getPHID())
|
|
||||||
->setPaymentProviderKey($this->getProviderKey())
|
|
||||||
->setCartPHID($cart->getPHID())
|
|
||||||
->setStatus(PhortuneCharge::STATUS_CHARGING)
|
|
||||||
->save();
|
|
||||||
|
|
||||||
$cart->openTransaction();
|
|
||||||
$charge->setStatus(PhortuneCharge::STATUS_CHARGED);
|
|
||||||
$charge->save();
|
|
||||||
|
|
||||||
$cart->setStatus(PhortuneCart::STATUS_PURCHASED);
|
|
||||||
$cart->save();
|
|
||||||
$cart->saveTransaction();
|
|
||||||
|
|
||||||
unset($unguarded);
|
unset($unguarded);
|
||||||
|
|
||||||
return id(new AphrontRedirectResponse())
|
return id(new AphrontRedirectResponse())
|
||||||
->setIsExternal(true)
|
->setURI($cart->getDoneURI());
|
||||||
->setURI($cart_uri);
|
|
||||||
case 'cancel':
|
case 'cancel':
|
||||||
var_dump($_REQUEST);
|
// TODO: I don't know how it's possible to cancel out of a WePay
|
||||||
|
// charge workflow.
|
||||||
|
throw new Exception(
|
||||||
|
pht('How did you get here? WePay has no cancel flow in its UI...?'));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Exception("The rest of this isn't implemented yet.");
|
throw new Exception(
|
||||||
|
pht('Unsupported action "%s".', $controller->getAction()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ final class PhortuneChargeQuery
|
||||||
private $phids;
|
private $phids;
|
||||||
private $accountPHIDs;
|
private $accountPHIDs;
|
||||||
private $cartPHIDs;
|
private $cartPHIDs;
|
||||||
|
private $statuses;
|
||||||
|
|
||||||
private $needCarts;
|
private $needCarts;
|
||||||
|
|
||||||
|
@ -30,6 +31,11 @@ final class PhortuneChargeQuery
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withStatuses(array $statuses) {
|
||||||
|
$this->statuses = $statuses;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function needCarts($need_carts) {
|
public function needCarts($need_carts) {
|
||||||
$this->needCarts = $need_carts;
|
$this->needCarts = $need_carts;
|
||||||
return $this;
|
return $this;
|
||||||
|
@ -121,6 +127,13 @@ final class PhortuneChargeQuery
|
||||||
$this->cartPHIDs);
|
$this->cartPHIDs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->statuses !== null) {
|
||||||
|
$where[] = qsprintf(
|
||||||
|
$conn,
|
||||||
|
'charge.status IN (%Ls)',
|
||||||
|
$this->statuses);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->formatWhereClause($where);
|
return $this->formatWhereClause($where);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,17 +52,67 @@ final class PhortuneCart extends PhortuneDAO
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function willApplyCharge(
|
||||||
|
PhabricatorUser $actor,
|
||||||
|
PhortunePaymentProvider $provider,
|
||||||
|
PhortunePaymentMethod $method = null) {
|
||||||
|
|
||||||
|
$account = $this->getAccount();
|
||||||
|
|
||||||
|
$charge = PhortuneCharge::initializeNewCharge()
|
||||||
|
->setAccountPHID($account->getPHID())
|
||||||
|
->setCartPHID($this->getPHID())
|
||||||
|
->setAuthorPHID($actor->getPHID())
|
||||||
|
->setPaymentProviderKey($provider->getProviderKey())
|
||||||
|
->setAmountAsCurrency($this->getTotalPriceAsCurrency());
|
||||||
|
|
||||||
|
if ($method) {
|
||||||
|
$charge->setPaymentMethodPHID($method->getPHID());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->openTransaction();
|
||||||
|
$this->beginReadLocking();
|
||||||
|
|
||||||
|
$copy = clone $this;
|
||||||
|
$copy->reload();
|
||||||
|
|
||||||
|
if ($copy->getStatus() !== self::STATUS_READY) {
|
||||||
|
throw new Exception(
|
||||||
|
pht(
|
||||||
|
'Cart has wrong status ("%s") to call willApplyCharge(), expected '.
|
||||||
|
'"%s".',
|
||||||
|
$copy->getStatus(),
|
||||||
|
self::STATUS_READY));
|
||||||
|
}
|
||||||
|
|
||||||
|
$charge->save();
|
||||||
|
$this->setStatus(PhortuneCart::STATUS_PURCHASING)->save();
|
||||||
|
$this->saveTransaction();
|
||||||
|
|
||||||
|
return $charge;
|
||||||
|
}
|
||||||
|
|
||||||
public function didApplyCharge(PhortuneCharge $charge) {
|
public function didApplyCharge(PhortuneCharge $charge) {
|
||||||
if ($this->getStatus() !== self::STATUS_PURCHASING) {
|
$charge->setStatus(PhortuneCharge::STATUS_CHARGED);
|
||||||
|
|
||||||
|
$this->openTransaction();
|
||||||
|
$this->beginReadLocking();
|
||||||
|
|
||||||
|
$copy = clone $this;
|
||||||
|
$copy->reload();
|
||||||
|
|
||||||
|
if ($copy->getStatus() !== self::STATUS_PURCHASING) {
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
pht(
|
pht(
|
||||||
'Cart has wrong status ("%s") to call didApplyCharge(), expected '.
|
'Cart has wrong status ("%s") to call didApplyCharge(), expected '.
|
||||||
'"%s".',
|
'"%s".',
|
||||||
$this->getStatus(),
|
$copy->getStatus(),
|
||||||
self::STATUS_PURCHASING));
|
self::STATUS_PURCHASING));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$charge->save();
|
||||||
$this->setStatus(self::STATUS_CHARGED)->save();
|
$this->setStatus(self::STATUS_CHARGED)->save();
|
||||||
|
$this->saveTransaction();
|
||||||
|
|
||||||
foreach ($this->purchases as $purchase) {
|
foreach ($this->purchases as $purchase) {
|
||||||
$purchase->getProduct()->didPurchaseProduct($purchase);
|
$purchase->getProduct()->didPurchaseProduct($purchase);
|
||||||
|
|
|
@ -9,8 +9,6 @@
|
||||||
final class PhortuneCharge extends PhortuneDAO
|
final class PhortuneCharge extends PhortuneDAO
|
||||||
implements PhabricatorPolicyInterface {
|
implements PhabricatorPolicyInterface {
|
||||||
|
|
||||||
const STATUS_PENDING = 'charge:pending';
|
|
||||||
const STATUS_AUTHORIZED = 'charge:authorized';
|
|
||||||
const STATUS_CHARGING = 'charge:charging';
|
const STATUS_CHARGING = 'charge:charging';
|
||||||
const STATUS_CHARGED = 'charge:charged';
|
const STATUS_CHARGED = 'charge:charged';
|
||||||
const STATUS_FAILED = 'charge:failed';
|
const STATUS_FAILED = 'charge:failed';
|
||||||
|
@ -27,6 +25,11 @@ final class PhortuneCharge extends PhortuneDAO
|
||||||
private $account = self::ATTACHABLE;
|
private $account = self::ATTACHABLE;
|
||||||
private $cart = self::ATTACHABLE;
|
private $cart = self::ATTACHABLE;
|
||||||
|
|
||||||
|
public static function initializeNewCharge() {
|
||||||
|
return id(new PhortuneCharge())
|
||||||
|
->setStatus(self::STATUS_CHARGING);
|
||||||
|
}
|
||||||
|
|
||||||
public function getConfiguration() {
|
public function getConfiguration() {
|
||||||
return array(
|
return array(
|
||||||
self::CONFIG_AUX_PHID => true,
|
self::CONFIG_AUX_PHID => true,
|
||||||
|
|
Loading…
Reference in a new issue