@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+246 -67
+2
src/__phutil_library_map__.php
··· 2563 2563 'PhortuneCartPHIDType' => 'applications/phortune/phid/PhortuneCartPHIDType.php', 2564 2564 'PhortuneCartQuery' => 'applications/phortune/query/PhortuneCartQuery.php', 2565 2565 'PhortuneCartSearchEngine' => 'applications/phortune/query/PhortuneCartSearchEngine.php', 2566 + 'PhortuneCartUpdateController' => 'applications/phortune/controller/PhortuneCartUpdateController.php', 2566 2567 'PhortuneCartViewController' => 'applications/phortune/controller/PhortuneCartViewController.php', 2567 2568 'PhortuneCharge' => 'applications/phortune/storage/PhortuneCharge.php', 2568 2569 'PhortuneChargePHIDType' => 'applications/phortune/phid/PhortuneChargePHIDType.php', ··· 5618 5619 'PhortuneCartPHIDType' => 'PhabricatorPHIDType', 5619 5620 'PhortuneCartQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 5620 5621 'PhortuneCartSearchEngine' => 'PhabricatorApplicationSearchEngine', 5622 + 'PhortuneCartUpdateController' => 'PhortuneCartController', 5621 5623 'PhortuneCartViewController' => 'PhortuneCartController', 5622 5624 'PhortuneCharge' => array( 5623 5625 'PhortuneDAO',
+1
src/applications/phortune/application/PhabricatorPhortuneApplication.php
··· 49 49 '' => 'PhortuneCartViewController', 50 50 'checkout/' => 'PhortuneCartCheckoutController', 51 51 '(?P<action>cancel|refund)/' => 'PhortuneCartCancelController', 52 + 'update/' => 'PhortuneCartUpdateController', 52 53 ), 53 54 'account/' => array( 54 55 '' => 'PhortuneAccountListController',
+10 -1
src/applications/phortune/controller/PhortuneCartCancelController.php
··· 68 68 ->withCartPHIDs(array($cart->getPHID())) 69 69 ->withStatuses( 70 70 array( 71 + PhortuneCharge::STATUS_HOLD, 71 72 PhortuneCharge::STATUS_CHARGED, 72 73 )) 73 74 ->execute(); ··· 156 157 throw new Exception(pht('Unable to refund some charges!')); 157 158 } 158 159 160 + // TODO: If every HOLD and CHARGING transaction has been fully refunded 161 + // and we're in a HOLD, PURCHASING or CHARGED cart state we probably 162 + // need to kick the cart back to READY here? 163 + 159 164 return id(new AphrontRedirectResponse())->setURI($cancel_uri); 160 165 } 161 166 } ··· 182 187 'Really cancel this order? Any payment will be refunded.'); 183 188 $button = pht('Cancel Order'); 184 189 190 + // Don't give the user a "Cancel" button in response to a "Cancel?" 191 + // prompt, as it's confusing. 192 + $cancel_text = pht('Do Not Cancel Order'); 193 + 185 194 $form = null; 186 195 } 187 196 ··· 190 199 ->appendChild($body) 191 200 ->appendChild($form) 192 201 ->addSubmitButton($button) 193 - ->addCancelButton($cancel_uri); 202 + ->addCancelButton($cancel_uri, $cancel_text); 194 203 } 195 204 }
+4 -29
src/applications/phortune/controller/PhortuneCartCheckoutController.php
··· 39 39 // This is the expected, normal state for a cart that's ready for 40 40 // checkout. 41 41 break; 42 + case PhortuneCart::STATUS_CHARGED: 42 43 case PhortuneCart::STATUS_PURCHASING: 43 - // We've started the purchase workflow for this cart, but were not able 44 - // to complete it. If the workflow is on an external site, this could 45 - // happen because the user abandoned the workflow. Just return them to 46 - // the right place so they can resume where they left off. 47 - $uri = $cart->getMetadataValue('provider.checkoutURI'); 48 - if ($uri !== null) { 49 - return id(new AphrontRedirectResponse()) 50 - ->setIsExternal(true) 51 - ->setURI($uri); 52 - } 53 - 54 - return $this->newDialog() 55 - ->setTitle(pht('Charge Failed')) 56 - ->appendParagraph( 57 - pht( 58 - 'Failed to charge this cart.')) 59 - ->addCancelButton($cancel_uri); 60 - break; 61 - case PhortuneCart::STATUS_CHARGED: 62 - // TODO: This is really bad (we took your money and at least partially 63 - // failed to fulfill your order) and should have better steps forward. 64 - 65 - return $this->newDialog() 66 - ->setTitle(pht('Purchase Failed')) 67 - ->appendParagraph( 68 - pht( 69 - 'This cart was charged but the purchase could not be '. 70 - 'completed.')) 71 - ->addCancelButton($cancel_uri); 44 + case PhortuneCart::STATUS_HOLD: 72 45 case PhortuneCart::STATUS_PURCHASED: 46 + // For these states, kick the user to the order page to give them 47 + // information and options. 73 48 return id(new AphrontRedirectResponse())->setURI($cart->getDetailURI()); 74 49 default: 75 50 throw new Exception(
+31
src/applications/phortune/controller/PhortuneCartUpdateController.php
··· 1 + <?php 2 + 3 + final class PhortuneCartUpdateController 4 + extends PhortuneCartController { 5 + 6 + private $id; 7 + 8 + public function willProcessRequest(array $data) { 9 + $this->id = $data['id']; 10 + } 11 + 12 + public function processRequest() { 13 + $request = $this->getRequest(); 14 + $viewer = $request->getUser(); 15 + 16 + $cart = id(new PhortuneCartQuery()) 17 + ->setViewer($viewer) 18 + ->withIDs(array($this->id)) 19 + ->needPurchases(true) 20 + ->executeOne(); 21 + if (!$cart) { 22 + return new Aphront404Response(); 23 + } 24 + 25 + // TODO: This obviously doesn't do anything for now. 26 + 27 + return id(new AphrontRedirectResponse()) 28 + ->setURI($cart->getDetailURI()); 29 + } 30 + 31 + }
+71 -7
src/applications/phortune/controller/PhortuneCartViewController.php
··· 29 29 30 30 $cart_table = $this->buildCartContentTable($cart); 31 31 32 + $can_edit = PhabricatorPolicyFilter::hasCapability( 33 + $viewer, 34 + $cart, 35 + PhabricatorPolicyCapability::CAN_EDIT); 36 + 37 + $errors = array(); 38 + $resume_uri = null; 39 + switch ($cart->getStatus()) { 40 + case PhortuneCart::STATUS_PURCHASING: 41 + if ($can_edit) { 42 + $resume_uri = $cart->getMetadataValue('provider.checkoutURI'); 43 + if ($resume_uri) { 44 + $errors[] = pht( 45 + 'The checkout process has been started, but not yet completed. '. 46 + 'You can continue checking out by clicking %s, or cancel the '. 47 + 'order, or contact the merchant for assistance.', 48 + phutil_tag('strong', array(), pht('Continue Checkout'))); 49 + } else { 50 + $errors[] = pht( 51 + 'The checkout process has been started, but an error occurred. '. 52 + 'You can cancel the order or contact the merchant for '. 53 + 'assistance.'); 54 + } 55 + } 56 + break; 57 + case PhortuneCart::STATUS_CHARGED: 58 + if ($can_edit) { 59 + $errors[] = pht( 60 + 'You have been charged, but processing could not be completed. '. 61 + 'You can cancel your order, or contact the merchant for '. 62 + 'assistance.'); 63 + } 64 + break; 65 + case PhortuneCart::STATUS_HOLD: 66 + if ($can_edit) { 67 + $errors[] = pht( 68 + 'Payment for this order is on hold. You can click %s to check '. 69 + 'for updates, cancel the order, or contact the merchant for '. 70 + 'assistance.', 71 + phutil_tag('strong', array(), pht('Update Status'))); 72 + } 73 + break; 74 + } 75 + 32 76 $properties = $this->buildPropertyListView($cart); 33 - $actions = $this->buildActionListView($cart, $can_admin); 77 + $actions = $this->buildActionListView( 78 + $cart, 79 + $can_edit, 80 + $can_admin, 81 + $resume_uri); 34 82 $properties->setActionList($actions); 35 83 36 84 $header = id(new PHUIHeaderView()) ··· 40 88 41 89 $cart_box = id(new PHUIObjectBoxView()) 42 90 ->setHeader($header) 91 + ->setFormErrors($errors) 43 92 ->appendChild($properties) 44 93 ->appendChild($cart_table); 45 94 ··· 106 155 return $view; 107 156 } 108 157 109 - private function buildActionListView(PhortuneCart $cart, $can_admin) { 158 + private function buildActionListView( 159 + PhortuneCart $cart, 160 + $can_edit, 161 + $can_admin, 162 + $resume_uri) { 163 + 110 164 $viewer = $this->getRequest()->getUser(); 111 165 $id = $cart->getID(); 112 166 113 - $can_edit = PhabricatorPolicyFilter::hasCapability( 114 - $viewer, 115 - $cart, 116 - PhabricatorPolicyCapability::CAN_EDIT); 117 - 118 167 $view = id(new PhabricatorActionListView()) 119 168 ->setUser($viewer) 120 169 ->setObject($cart); ··· 123 172 124 173 $cancel_uri = $this->getApplicationURI("cart/{$id}/cancel/"); 125 174 $refund_uri = $this->getApplicationURI("cart/{$id}/refund/"); 175 + $update_uri = $this->getApplicationURI("cart/{$id}/update/"); 126 176 127 177 $view->addAction( 128 178 id(new PhabricatorActionView()) ··· 139 189 ->setIcon('fa-reply') 140 190 ->setWorkflow(true) 141 191 ->setHref($refund_uri)); 192 + } 193 + 194 + $view->addAction( 195 + id(new PhabricatorActionView()) 196 + ->setName(pht('Update Status')) 197 + ->setIcon('fa-refresh') 198 + ->setHref($update_uri)); 199 + 200 + if ($can_edit && $resume_uri) { 201 + $view->addAction( 202 + id(new PhabricatorActionView()) 203 + ->setName(pht('Continue Checkout')) 204 + ->setIcon('fa-shopping-cart') 205 + ->setHref($resume_uri)); 142 206 } 143 207 144 208 return $view;
+2 -1
src/applications/phortune/controller/PhortuneProviderActionController.php
··· 1 1 <?php 2 2 3 - final class PhortuneProviderActionController extends PhortuneController { 3 + final class PhortuneProviderActionController 4 + extends PhortuneController { 4 5 5 6 private $id; 6 7 private $action;
+89 -27
src/applications/phortune/provider/PhortunePayPalPaymentProvider.php
··· 7 7 const PAYPAL_API_SIGNATURE = 'paypal.api-signature'; 8 8 const PAYPAL_MODE = 'paypal.mode'; 9 9 10 - public function isEnabled() { 11 - // TODO: See note in processControllerRequest(). 12 - return false; 13 - } 14 - 15 10 public function isAcceptingLivePayments() { 16 11 $mode = $this->getProviderConfig()->getMetadataValue(self::PAYPAL_MODE); 17 12 return ($mode === 'live'); ··· 170 165 protected function executeRefund( 171 166 PhortuneCharge $charge, 172 167 PhortuneCharge $refund) { 173 - // TODO: Implement. 174 - throw new PhortuneNotImplementedException($this); 168 + 169 + $transaction_id = $charge->getMetadataValue('paypal.transactionID'); 170 + if (!$transaction_id) { 171 + throw new Exception(pht('Charge has no transaction ID!')); 172 + } 173 + 174 + $refund_amount = $refund->getAmountAsCurrency()->negate(); 175 + $refund_currency = $refund_amount->getCurrency(); 176 + $refund_value = $refund_amount->formatBareValue(); 177 + 178 + $params = array( 179 + 'TRANSACTIONID' => $transaction_id, 180 + 'REFUNDTYPE' => 'Partial', 181 + 'AMT' => $refund_value, 182 + 'CURRENCYCODE' => $refund_currency, 183 + ); 184 + 185 + $result = $this 186 + ->newPaypalAPICall() 187 + ->setRawPayPalQuery('RefundTransaction', $params) 188 + ->resolve(); 189 + 190 + $charge->setMetadataValue( 191 + 'paypal.refundID', 192 + $result['REFUNDTRANSACTIONID']); 175 193 } 176 194 177 195 private function getPaypalAPIUsername() { ··· 281 299 'token' => $result['TOKEN'], 282 300 )); 283 301 284 - $cart->setMetadataValue('provider.checkoutURI', $uri); 302 + $cart->setMetadataValue('provider.checkoutURI', (string)$uri); 285 303 $cart->save(); 286 304 287 305 $charge->setMetadataValue('paypal.token', $result['TOKEN']); ··· 291 309 ->setIsExternal(true) 292 310 ->setURI($uri); 293 311 case 'charge': 312 + if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { 313 + return id(new AphrontRedirectResponse()) 314 + ->setURI($cart->getCheckoutURI()); 315 + } 316 + 294 317 $token = $request->getStr('token'); 295 318 296 319 $params = array( ··· 302 325 ->setRawPayPalQuery('GetExpressCheckoutDetails', $params) 303 326 ->resolve(); 304 327 305 - var_dump($result); 306 - 307 328 if ($result['CUSTOM'] !== $charge->getPHID()) { 308 329 throw new Exception( 309 330 pht('Paypal checkout does not match Phortune charge!')); 310 331 } 311 332 312 333 if ($result['CHECKOUTSTATUS'] !== 'PaymentActionNotInitiated') { 313 - throw new Exception( 314 - pht( 315 - 'Expected status "%s", got "%s".', 316 - 'PaymentActionNotInitiated', 317 - $result['CHECKOUTSTATUS'])); 334 + return $controller->newDialog() 335 + ->setTitle(pht('Payment Already Processed')) 336 + ->appendParagraph( 337 + pht( 338 + 'The payment response for this charge attempt has already '. 339 + 'been processed.')) 340 + ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); 318 341 } 319 342 320 343 $price = $cart->getTotalPriceAsCurrency(); ··· 333 356 ->setRawPayPalQuery('DoExpressCheckoutPayment', $params) 334 357 ->resolve(); 335 358 336 - // TODO: Paypal can send requests back in "PaymentReview" status, 337 - // and does this for test transactions. We're supposed to hold 338 - // the transaction and poll the API every 6 hours. This is unreasonably 339 - // difficult for now and we can't reasonably just fail these charges. 340 - 341 - var_dump($result); 342 - die(); 359 + $transaction_id = $result['PAYMENTINFO_0_TRANSACTIONID']; 343 360 344 - $success = false; // TODO: <---- 361 + $success = false; 362 + $hold = false; 363 + switch ($result['PAYMENTINFO_0_PAYMENTSTATUS']) { 364 + case 'Processed': 365 + case 'Completed': 366 + case 'Completed-Funds-Held': 367 + $success = true; 368 + break; 369 + case 'In-Progress': 370 + case 'Pending': 371 + // TODO: We can capture more information about this stuff. 372 + $hold = true; 373 + break; 374 + case 'Denied': 375 + case 'Expired': 376 + case 'Failed': 377 + case 'Partially-Refunded': 378 + case 'Canceled-Reversal': 379 + case 'None': 380 + case 'Refunded': 381 + case 'Reversed': 382 + case 'Voided': 383 + default: 384 + // These are all failure states. 385 + break; 386 + } 345 387 346 - // TODO: Clean this up once that mess up there ^^^^^ gets cleaned up. 347 388 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 389 + 390 + $charge->setMetadataValue('paypal.transactionID', $transaction_id); 391 + $charge->save(); 392 + 348 393 if ($success) { 349 394 $cart->didApplyCharge($charge); 350 395 $response = id(new AphrontRedirectResponse())->setURI( 351 - $cart->getDoneURI()); 396 + $cart->getDoneURI()); 397 + } else if ($hold) { 398 + $cart->didHoldCharge($charge); 399 + 400 + $response = $controller 401 + ->newDialog() 402 + ->setTitle(pht('Charge On Hold')) 403 + ->appendParagraph( 404 + pht('Your charge is on hold, for reasons?')) 405 + ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); 352 406 } else { 353 407 $cart->didFailCharge($charge); 354 408 ··· 361 415 362 416 return $response; 363 417 case 'cancel': 364 - var_dump($_REQUEST); 365 - break; 418 + if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { 419 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 420 + // TODO: Since the user cancelled this, we could conceivably just 421 + // throw it away or make it more clear that it's a user cancel. 422 + $cart->didFailCharge($charge); 423 + unset($unguarded); 424 + } 425 + 426 + return id(new AphrontRedirectResponse()) 427 + ->setURI($cart->getCheckoutURI()); 366 428 } 367 429 368 430 throw new Exception(
+34 -2
src/applications/phortune/storage/PhortuneCart.php
··· 7 7 const STATUS_READY = 'cart:ready'; 8 8 const STATUS_PURCHASING = 'cart:purchasing'; 9 9 const STATUS_CHARGED = 'cart:charged'; 10 + const STATUS_HOLD = 'cart:hold'; 10 11 const STATUS_PURCHASED = 'cart:purchased'; 11 12 12 13 protected $accountPHID; ··· 57 58 self::STATUS_READY => pht('Ready'), 58 59 self::STATUS_PURCHASING => pht('Purchasing'), 59 60 self::STATUS_CHARGED => pht('Charged'), 61 + self::STATUS_HOLD => pht('Hold'), 60 62 self::STATUS_PURCHASED => pht('Purchased'), 61 63 ); 62 64 } ··· 113 115 return $charge; 114 116 } 115 117 118 + public function didHoldCharge(PhortuneCharge $charge) { 119 + $charge->setStatus(PhortuneCharge::STATUS_HOLD); 120 + 121 + $this->openTransaction(); 122 + $this->beginReadLocking(); 123 + 124 + $copy = clone $this; 125 + $copy->reload(); 126 + 127 + if ($copy->getStatus() !== self::STATUS_PURCHASING) { 128 + throw new Exception( 129 + pht( 130 + 'Cart has wrong status ("%s") to call didHoldCharge(), '. 131 + 'expected "%s".', 132 + $copy->getStatus(), 133 + self::STATUS_PURCHASING)); 134 + } 135 + 136 + $charge->save(); 137 + $this->setStatus(self::STATUS_HOLD)->save(); 138 + 139 + $this->endReadLocking(); 140 + $this->saveTransaction(); 141 + } 142 + 116 143 public function didApplyCharge(PhortuneCharge $charge) { 117 144 $charge->setStatus(PhortuneCharge::STATUS_CHARGED); 118 145 ··· 198 225 pht('Trying to refund a refund!')); 199 226 } 200 227 201 - if ($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) { 228 + if (($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) && 229 + ($charge->getStatus() !== PhortuneCharge::STATUS_HOLD)) { 202 230 throw new Exception( 203 231 pht('Trying to refund an uncharged charge!')); 204 232 } ··· 462 490 } 463 491 464 492 public function getPolicy($capability) { 465 - return $this->getAccount()->getPolicy($capability); 493 + // NOTE: Both view and edit use the account's edit policy. We punch a hole 494 + // through this for merchants, below. 495 + return $this 496 + ->getAccount() 497 + ->getPolicy(PhabricatorPolicyCapability::CAN_EDIT); 466 498 } 467 499 468 500 public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
+2
src/applications/phortune/storage/PhortuneCharge.php
··· 11 11 12 12 const STATUS_CHARGING = 'charge:charging'; 13 13 const STATUS_CHARGED = 'charge:charged'; 14 + const STATUS_HOLD = 'charge:hold'; 14 15 const STATUS_FAILED = 'charge:failed'; 15 16 16 17 protected $accountPHID; ··· 74 75 return array( 75 76 self::STATUS_CHARGING => pht('Charging'), 76 77 self::STATUS_CHARGED => pht('Charged'), 78 + self::STATUS_HOLD => pht('Hold'), 77 79 self::STATUS_FAILED => pht('Failed'), 78 80 ); 79 81 }