1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-23 14:00:56 +01:00

Add a HOLD state to Phortune and handle unusual states better

Summary:
Ref T2787. When Paypal comes back to us with funds on hold, dead-end the transaction but handle it properly.

Generally, smooth out the user interaction on weird states.

Implement refudnds/cancels for Paypal.

Test Plan: {F215230}

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T2787

Differential Revision: https://secure.phabricator.com/D10667
This commit is contained in:
epriestley 2014-10-09 04:30:47 -07:00
parent 45bb77531c
commit 2a2fb62229
10 changed files with 246 additions and 67 deletions

View file

@ -2563,6 +2563,7 @@ phutil_register_library_map(array(
'PhortuneCartPHIDType' => 'applications/phortune/phid/PhortuneCartPHIDType.php', 'PhortuneCartPHIDType' => 'applications/phortune/phid/PhortuneCartPHIDType.php',
'PhortuneCartQuery' => 'applications/phortune/query/PhortuneCartQuery.php', 'PhortuneCartQuery' => 'applications/phortune/query/PhortuneCartQuery.php',
'PhortuneCartSearchEngine' => 'applications/phortune/query/PhortuneCartSearchEngine.php', 'PhortuneCartSearchEngine' => 'applications/phortune/query/PhortuneCartSearchEngine.php',
'PhortuneCartUpdateController' => 'applications/phortune/controller/PhortuneCartUpdateController.php',
'PhortuneCartViewController' => 'applications/phortune/controller/PhortuneCartViewController.php', 'PhortuneCartViewController' => 'applications/phortune/controller/PhortuneCartViewController.php',
'PhortuneCharge' => 'applications/phortune/storage/PhortuneCharge.php', 'PhortuneCharge' => 'applications/phortune/storage/PhortuneCharge.php',
'PhortuneChargePHIDType' => 'applications/phortune/phid/PhortuneChargePHIDType.php', 'PhortuneChargePHIDType' => 'applications/phortune/phid/PhortuneChargePHIDType.php',
@ -5618,6 +5619,7 @@ phutil_register_library_map(array(
'PhortuneCartPHIDType' => 'PhabricatorPHIDType', 'PhortuneCartPHIDType' => 'PhabricatorPHIDType',
'PhortuneCartQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhortuneCartQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'PhortuneCartSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhortuneCartSearchEngine' => 'PhabricatorApplicationSearchEngine',
'PhortuneCartUpdateController' => 'PhortuneCartController',
'PhortuneCartViewController' => 'PhortuneCartController', 'PhortuneCartViewController' => 'PhortuneCartController',
'PhortuneCharge' => array( 'PhortuneCharge' => array(
'PhortuneDAO', 'PhortuneDAO',

View file

@ -49,6 +49,7 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication {
'' => 'PhortuneCartViewController', '' => 'PhortuneCartViewController',
'checkout/' => 'PhortuneCartCheckoutController', 'checkout/' => 'PhortuneCartCheckoutController',
'(?P<action>cancel|refund)/' => 'PhortuneCartCancelController', '(?P<action>cancel|refund)/' => 'PhortuneCartCancelController',
'update/' => 'PhortuneCartUpdateController',
), ),
'account/' => array( 'account/' => array(
'' => 'PhortuneAccountListController', '' => 'PhortuneAccountListController',

View file

@ -68,6 +68,7 @@ final class PhortuneCartCancelController
->withCartPHIDs(array($cart->getPHID())) ->withCartPHIDs(array($cart->getPHID()))
->withStatuses( ->withStatuses(
array( array(
PhortuneCharge::STATUS_HOLD,
PhortuneCharge::STATUS_CHARGED, PhortuneCharge::STATUS_CHARGED,
)) ))
->execute(); ->execute();
@ -156,6 +157,10 @@ final class PhortuneCartCancelController
throw new Exception(pht('Unable to refund some charges!')); throw new Exception(pht('Unable to refund some charges!'));
} }
// TODO: If every HOLD and CHARGING transaction has been fully refunded
// and we're in a HOLD, PURCHASING or CHARGED cart state we probably
// need to kick the cart back to READY here?
return id(new AphrontRedirectResponse())->setURI($cancel_uri); return id(new AphrontRedirectResponse())->setURI($cancel_uri);
} }
} }
@ -182,6 +187,10 @@ final class PhortuneCartCancelController
'Really cancel this order? Any payment will be refunded.'); 'Really cancel this order? Any payment will be refunded.');
$button = pht('Cancel Order'); $button = pht('Cancel Order');
// Don't give the user a "Cancel" button in response to a "Cancel?"
// prompt, as it's confusing.
$cancel_text = pht('Do Not Cancel Order');
$form = null; $form = null;
} }
@ -190,6 +199,6 @@ final class PhortuneCartCancelController
->appendChild($body) ->appendChild($body)
->appendChild($form) ->appendChild($form)
->addSubmitButton($button) ->addSubmitButton($button)
->addCancelButton($cancel_uri); ->addCancelButton($cancel_uri, $cancel_text);
} }
} }

View file

@ -39,37 +39,12 @@ 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 case PhortuneCart::STATUS_PURCHASING:
// failed to fulfill your order) and should have better steps forward. case PhortuneCart::STATUS_HOLD:
return $this->newDialog()
->setTitle(pht('Purchase Failed'))
->appendParagraph(
pht(
'This cart was charged but the purchase could not be '.
'completed.'))
->addCancelButton($cancel_uri);
case PhortuneCart::STATUS_PURCHASED: case PhortuneCart::STATUS_PURCHASED:
// For these states, kick the user to the order page to give them
// information and options.
return id(new AphrontRedirectResponse())->setURI($cart->getDetailURI()); return id(new AphrontRedirectResponse())->setURI($cart->getDetailURI());
default: default:
throw new Exception( throw new Exception(

View file

@ -0,0 +1,31 @@
<?php
final class PhortuneCartUpdateController
extends PhortuneCartController {
private $id;
public function willProcessRequest(array $data) {
$this->id = $data['id'];
}
public function processRequest() {
$request = $this->getRequest();
$viewer = $request->getUser();
$cart = id(new PhortuneCartQuery())
->setViewer($viewer)
->withIDs(array($this->id))
->needPurchases(true)
->executeOne();
if (!$cart) {
return new Aphront404Response();
}
// TODO: This obviously doesn't do anything for now.
return id(new AphrontRedirectResponse())
->setURI($cart->getDetailURI());
}
}

View file

@ -29,8 +29,56 @@ final class PhortuneCartViewController
$cart_table = $this->buildCartContentTable($cart); $cart_table = $this->buildCartContentTable($cart);
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$cart,
PhabricatorPolicyCapability::CAN_EDIT);
$errors = array();
$resume_uri = null;
switch ($cart->getStatus()) {
case PhortuneCart::STATUS_PURCHASING:
if ($can_edit) {
$resume_uri = $cart->getMetadataValue('provider.checkoutURI');
if ($resume_uri) {
$errors[] = pht(
'The checkout process has been started, but not yet completed. '.
'You can continue checking out by clicking %s, or cancel the '.
'order, or contact the merchant for assistance.',
phutil_tag('strong', array(), pht('Continue Checkout')));
} else {
$errors[] = pht(
'The checkout process has been started, but an error occurred. '.
'You can cancel the order or contact the merchant for '.
'assistance.');
}
}
break;
case PhortuneCart::STATUS_CHARGED:
if ($can_edit) {
$errors[] = pht(
'You have been charged, but processing could not be completed. '.
'You can cancel your order, or contact the merchant for '.
'assistance.');
}
break;
case PhortuneCart::STATUS_HOLD:
if ($can_edit) {
$errors[] = pht(
'Payment for this order is on hold. You can click %s to check '.
'for updates, cancel the order, or contact the merchant for '.
'assistance.',
phutil_tag('strong', array(), pht('Update Status')));
}
break;
}
$properties = $this->buildPropertyListView($cart); $properties = $this->buildPropertyListView($cart);
$actions = $this->buildActionListView($cart, $can_admin); $actions = $this->buildActionListView(
$cart,
$can_edit,
$can_admin,
$resume_uri);
$properties->setActionList($actions); $properties->setActionList($actions);
$header = id(new PHUIHeaderView()) $header = id(new PHUIHeaderView())
@ -40,6 +88,7 @@ final class PhortuneCartViewController
$cart_box = id(new PHUIObjectBoxView()) $cart_box = id(new PHUIObjectBoxView())
->setHeader($header) ->setHeader($header)
->setFormErrors($errors)
->appendChild($properties) ->appendChild($properties)
->appendChild($cart_table); ->appendChild($cart_table);
@ -106,15 +155,15 @@ final class PhortuneCartViewController
return $view; return $view;
} }
private function buildActionListView(PhortuneCart $cart, $can_admin) { private function buildActionListView(
PhortuneCart $cart,
$can_edit,
$can_admin,
$resume_uri) {
$viewer = $this->getRequest()->getUser(); $viewer = $this->getRequest()->getUser();
$id = $cart->getID(); $id = $cart->getID();
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$cart,
PhabricatorPolicyCapability::CAN_EDIT);
$view = id(new PhabricatorActionListView()) $view = id(new PhabricatorActionListView())
->setUser($viewer) ->setUser($viewer)
->setObject($cart); ->setObject($cart);
@ -123,6 +172,7 @@ final class PhortuneCartViewController
$cancel_uri = $this->getApplicationURI("cart/{$id}/cancel/"); $cancel_uri = $this->getApplicationURI("cart/{$id}/cancel/");
$refund_uri = $this->getApplicationURI("cart/{$id}/refund/"); $refund_uri = $this->getApplicationURI("cart/{$id}/refund/");
$update_uri = $this->getApplicationURI("cart/{$id}/update/");
$view->addAction( $view->addAction(
id(new PhabricatorActionView()) id(new PhabricatorActionView())
@ -141,6 +191,20 @@ final class PhortuneCartViewController
->setHref($refund_uri)); ->setHref($refund_uri));
} }
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('Update Status'))
->setIcon('fa-refresh')
->setHref($update_uri));
if ($can_edit && $resume_uri) {
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('Continue Checkout'))
->setIcon('fa-shopping-cart')
->setHref($resume_uri));
}
return $view; return $view;
} }

View file

@ -1,6 +1,7 @@
<?php <?php
final class PhortuneProviderActionController extends PhortuneController { final class PhortuneProviderActionController
extends PhortuneController {
private $id; private $id;
private $action; private $action;

View file

@ -7,11 +7,6 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
const PAYPAL_API_SIGNATURE = 'paypal.api-signature'; const PAYPAL_API_SIGNATURE = 'paypal.api-signature';
const PAYPAL_MODE = 'paypal.mode'; const PAYPAL_MODE = 'paypal.mode';
public function isEnabled() {
// TODO: See note in processControllerRequest().
return false;
}
public function isAcceptingLivePayments() { public function isAcceptingLivePayments() {
$mode = $this->getProviderConfig()->getMetadataValue(self::PAYPAL_MODE); $mode = $this->getProviderConfig()->getMetadataValue(self::PAYPAL_MODE);
return ($mode === 'live'); return ($mode === 'live');
@ -170,8 +165,31 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
protected function executeRefund( protected function executeRefund(
PhortuneCharge $charge, PhortuneCharge $charge,
PhortuneCharge $refund) { PhortuneCharge $refund) {
// TODO: Implement.
throw new PhortuneNotImplementedException($this); $transaction_id = $charge->getMetadataValue('paypal.transactionID');
if (!$transaction_id) {
throw new Exception(pht('Charge has no transaction ID!'));
}
$refund_amount = $refund->getAmountAsCurrency()->negate();
$refund_currency = $refund_amount->getCurrency();
$refund_value = $refund_amount->formatBareValue();
$params = array(
'TRANSACTIONID' => $transaction_id,
'REFUNDTYPE' => 'Partial',
'AMT' => $refund_value,
'CURRENCYCODE' => $refund_currency,
);
$result = $this
->newPaypalAPICall()
->setRawPayPalQuery('RefundTransaction', $params)
->resolve();
$charge->setMetadataValue(
'paypal.refundID',
$result['REFUNDTRANSACTIONID']);
} }
private function getPaypalAPIUsername() { private function getPaypalAPIUsername() {
@ -281,7 +299,7 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
'token' => $result['TOKEN'], 'token' => $result['TOKEN'],
)); ));
$cart->setMetadataValue('provider.checkoutURI', $uri); $cart->setMetadataValue('provider.checkoutURI', (string)$uri);
$cart->save(); $cart->save();
$charge->setMetadataValue('paypal.token', $result['TOKEN']); $charge->setMetadataValue('paypal.token', $result['TOKEN']);
@ -291,6 +309,11 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
->setIsExternal(true) ->setIsExternal(true)
->setURI($uri); ->setURI($uri);
case 'charge': case 'charge':
if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) {
return id(new AphrontRedirectResponse())
->setURI($cart->getCheckoutURI());
}
$token = $request->getStr('token'); $token = $request->getStr('token');
$params = array( $params = array(
@ -302,19 +325,19 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
->setRawPayPalQuery('GetExpressCheckoutDetails', $params) ->setRawPayPalQuery('GetExpressCheckoutDetails', $params)
->resolve(); ->resolve();
var_dump($result);
if ($result['CUSTOM'] !== $charge->getPHID()) { if ($result['CUSTOM'] !== $charge->getPHID()) {
throw new Exception( throw new Exception(
pht('Paypal checkout does not match Phortune charge!')); pht('Paypal checkout does not match Phortune charge!'));
} }
if ($result['CHECKOUTSTATUS'] !== 'PaymentActionNotInitiated') { if ($result['CHECKOUTSTATUS'] !== 'PaymentActionNotInitiated') {
throw new Exception( return $controller->newDialog()
->setTitle(pht('Payment Already Processed'))
->appendParagraph(
pht( pht(
'Expected status "%s", got "%s".', 'The payment response for this charge attempt has already '.
'PaymentActionNotInitiated', 'been processed.'))
$result['CHECKOUTSTATUS'])); ->addCancelButton($cart->getCheckoutURI(), pht('Continue'));
} }
$price = $cart->getTotalPriceAsCurrency(); $price = $cart->getTotalPriceAsCurrency();
@ -333,22 +356,53 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
->setRawPayPalQuery('DoExpressCheckoutPayment', $params) ->setRawPayPalQuery('DoExpressCheckoutPayment', $params)
->resolve(); ->resolve();
// TODO: Paypal can send requests back in "PaymentReview" status, $transaction_id = $result['PAYMENTINFO_0_TRANSACTIONID'];
// and does this for test transactions. We're supposed to hold
// the transaction and poll the API every 6 hours. This is unreasonably
// difficult for now and we can't reasonably just fail these charges.
var_dump($result); $success = false;
die(); $hold = false;
switch ($result['PAYMENTINFO_0_PAYMENTSTATUS']) {
case 'Processed':
case 'Completed':
case 'Completed-Funds-Held':
$success = true;
break;
case 'In-Progress':
case 'Pending':
// TODO: We can capture more information about this stuff.
$hold = true;
break;
case 'Denied':
case 'Expired':
case 'Failed':
case 'Partially-Refunded':
case 'Canceled-Reversal':
case 'None':
case 'Refunded':
case 'Reversed':
case 'Voided':
default:
// These are all failure states.
break;
}
$success = false; // TODO: <----
// TODO: Clean this up once that mess up there ^^^^^ gets cleaned up.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$charge->setMetadataValue('paypal.transactionID', $transaction_id);
$charge->save();
if ($success) { if ($success) {
$cart->didApplyCharge($charge); $cart->didApplyCharge($charge);
$response = id(new AphrontRedirectResponse())->setURI( $response = id(new AphrontRedirectResponse())->setURI(
$cart->getDoneURI()); $cart->getDoneURI());
} else if ($hold) {
$cart->didHoldCharge($charge);
$response = $controller
->newDialog()
->setTitle(pht('Charge On Hold'))
->appendParagraph(
pht('Your charge is on hold, for reasons?'))
->addCancelButton($cart->getCheckoutURI(), pht('Continue'));
} else { } else {
$cart->didFailCharge($charge); $cart->didFailCharge($charge);
@ -361,8 +415,16 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
return $response; return $response;
case 'cancel': case 'cancel':
var_dump($_REQUEST); if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) {
break; $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
// TODO: Since the user cancelled this, we could conceivably just
// throw it away or make it more clear that it's a user cancel.
$cart->didFailCharge($charge);
unset($unguarded);
}
return id(new AphrontRedirectResponse())
->setURI($cart->getCheckoutURI());
} }
throw new Exception( throw new Exception(

View file

@ -7,6 +7,7 @@ final class PhortuneCart extends PhortuneDAO
const STATUS_READY = 'cart:ready'; const STATUS_READY = 'cart:ready';
const STATUS_PURCHASING = 'cart:purchasing'; const STATUS_PURCHASING = 'cart:purchasing';
const STATUS_CHARGED = 'cart:charged'; const STATUS_CHARGED = 'cart:charged';
const STATUS_HOLD = 'cart:hold';
const STATUS_PURCHASED = 'cart:purchased'; const STATUS_PURCHASED = 'cart:purchased';
protected $accountPHID; protected $accountPHID;
@ -57,6 +58,7 @@ final class PhortuneCart extends PhortuneDAO
self::STATUS_READY => pht('Ready'), self::STATUS_READY => pht('Ready'),
self::STATUS_PURCHASING => pht('Purchasing'), self::STATUS_PURCHASING => pht('Purchasing'),
self::STATUS_CHARGED => pht('Charged'), self::STATUS_CHARGED => pht('Charged'),
self::STATUS_HOLD => pht('Hold'),
self::STATUS_PURCHASED => pht('Purchased'), self::STATUS_PURCHASED => pht('Purchased'),
); );
} }
@ -113,6 +115,31 @@ final class PhortuneCart extends PhortuneDAO
return $charge; return $charge;
} }
public function didHoldCharge(PhortuneCharge $charge) {
$charge->setStatus(PhortuneCharge::STATUS_HOLD);
$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 didHoldCharge(), '.
'expected "%s".',
$copy->getStatus(),
self::STATUS_PURCHASING));
}
$charge->save();
$this->setStatus(self::STATUS_HOLD)->save();
$this->endReadLocking();
$this->saveTransaction();
}
public function didApplyCharge(PhortuneCharge $charge) { public function didApplyCharge(PhortuneCharge $charge) {
$charge->setStatus(PhortuneCharge::STATUS_CHARGED); $charge->setStatus(PhortuneCharge::STATUS_CHARGED);
@ -198,7 +225,8 @@ final class PhortuneCart extends PhortuneDAO
pht('Trying to refund a refund!')); pht('Trying to refund a refund!'));
} }
if ($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) { if (($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) &&
($charge->getStatus() !== PhortuneCharge::STATUS_HOLD)) {
throw new Exception( throw new Exception(
pht('Trying to refund an uncharged charge!')); pht('Trying to refund an uncharged charge!'));
} }
@ -462,7 +490,11 @@ final class PhortuneCart extends PhortuneDAO
} }
public function getPolicy($capability) { public function getPolicy($capability) {
return $this->getAccount()->getPolicy($capability); // NOTE: Both view and edit use the account's edit policy. We punch a hole
// through this for merchants, below.
return $this
->getAccount()
->getPolicy(PhabricatorPolicyCapability::CAN_EDIT);
} }
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {

View file

@ -11,6 +11,7 @@ final class PhortuneCharge extends PhortuneDAO
const STATUS_CHARGING = 'charge:charging'; const STATUS_CHARGING = 'charge:charging';
const STATUS_CHARGED = 'charge:charged'; const STATUS_CHARGED = 'charge:charged';
const STATUS_HOLD = 'charge:hold';
const STATUS_FAILED = 'charge:failed'; const STATUS_FAILED = 'charge:failed';
protected $accountPHID; protected $accountPHID;
@ -74,6 +75,7 @@ final class PhortuneCharge extends PhortuneDAO
return array( return array(
self::STATUS_CHARGING => pht('Charging'), self::STATUS_CHARGING => pht('Charging'),
self::STATUS_CHARGED => pht('Charged'), self::STATUS_CHARGED => pht('Charged'),
self::STATUS_HOLD => pht('Hold'),
self::STATUS_FAILED => pht('Failed'), self::STATUS_FAILED => pht('Failed'),
); );
} }