1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-22 13:30: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:
epriestley 2014-10-06 14:20:40 -07:00
parent 0beb8228da
commit 4ef547f8d6
6 changed files with 143 additions and 59 deletions

View file

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

View file

@ -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,7 +238,11 @@ abstract class PhortunePaymentProvider {
$uri = new PhutilURI($path); $uri = new PhutilURI($path);
$uri->setQueryParams($params); $uri->setQueryParams($params);
return PhabricatorEnv::getURI((string)$uri); if ($local) {
return $uri;
} else {
return PhabricatorEnv::getURI((string)$uri);
}
} }
public function canRespondToControllerAction($action) { public function canRespondToControllerAction($action) {

View file

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

View file

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

View file

@ -52,17 +52,67 @@ final class PhortuneCart extends PhortuneDAO
return $this; return $this;
} }
public function didApplyCharge(PhortuneCharge $charge) { public function willApplyCharge(
if ($this->getStatus() !== self::STATUS_PURCHASING) { PhabricatorUser $actor,
throw new Exception( PhortunePaymentProvider $provider,
pht( PhortunePaymentMethod $method = null) {
'Cart has wrong status ("%s") to call didApplyCharge(), expected '.
'"%s".', $account = $this->getAccount();
$this->getStatus(),
self::STATUS_PURCHASING)); $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->setStatus(self::STATUS_CHARGED)->save(); $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) {
$charge->setStatus(PhortuneCharge::STATUS_CHARGED);
$this->openTransaction();
$this->beginReadLocking();
$copy = clone $this;
$copy->reload();
if ($copy->getStatus() !== self::STATUS_PURCHASING) {
throw new Exception(
pht(
'Cart has wrong status ("%s") to call didApplyCharge(), expected '.
'"%s".',
$copy->getStatus(),
self::STATUS_PURCHASING));
}
$charge->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);

View file

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