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

Mostly implement order refunds and cancellations

Summary:
Ref T2787. This has some rough edges but basically works.

- Users can cancel orders that are in incomplete states (or in complete states, if the application allows them to -- for example, some future application might allow cancellation of billed-but-not-shipped orders).
- Merchant controllers can partially or fully refund orders from any state after payment.

Test Plan: This is still rough around the edges, but issued Stripe and WePay refunds.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: chad, epriestley

Maniphest Tasks: T2787

Differential Revision: https://secure.phabricator.com/D10664

+781 -57
+11
resources/sql/autopatches/20141008.phortunerefund.sql
··· 1 + ALTER TABLE {$NAMESPACE}_phortune.phortune_charge 2 + ADD amountRefundedAsCurrency VARCHAR(64) NOT NULL COLLATE utf8_bin; 3 + 4 + UPDATE {$NAMESPACE}_phortune.phortune_charge 5 + SET amountRefundedAsCurrency = '0.00 USD'; 6 + 7 + ALTER TABLE {$NAMESPACE}_phortune.phortune_charge 8 + ADD refundingPHID VARBINARY(64); 9 + 10 + ALTER TABLE {$NAMESPACE}_phortune.phortune_charge 11 + ADD refundedChargePHID VARBINARY(64);
+2
src/__phutil_library_map__.php
··· 2554 2554 'PhortuneAccountViewController' => 'applications/phortune/controller/PhortuneAccountViewController.php', 2555 2555 'PhortuneBalancedPaymentProvider' => 'applications/phortune/provider/PhortuneBalancedPaymentProvider.php', 2556 2556 'PhortuneCart' => 'applications/phortune/storage/PhortuneCart.php', 2557 + 'PhortuneCartCancelController' => 'applications/phortune/controller/PhortuneCartCancelController.php', 2557 2558 'PhortuneCartCheckoutController' => 'applications/phortune/controller/PhortuneCartCheckoutController.php', 2558 2559 'PhortuneCartController' => 'applications/phortune/controller/PhortuneCartController.php', 2559 2560 'PhortuneCartImplementation' => 'applications/phortune/cart/PhortuneCartImplementation.php', ··· 5608 5609 'PhortuneDAO', 5609 5610 'PhabricatorPolicyInterface', 5610 5611 ), 5612 + 'PhortuneCartCancelController' => 'PhortuneCartController', 5611 5613 'PhortuneCartCheckoutController' => 'PhortuneCartController', 5612 5614 'PhortuneCartController' => 'PhortuneController', 5613 5615 'PhortuneCartListController' => 'PhortuneController',
+7
src/applications/fund/phortune/FundBackerProduct.php
··· 115 115 return; 116 116 } 117 117 118 + public function didRefundProduct( 119 + PhortuneProduct $product, 120 + PhortunePurchase $purchase) { 121 + $viewer = $this->getViewer(); 122 + // TODO: Undonate. 123 + } 124 + 118 125 }
+1
src/applications/phortune/application/PhabricatorPhortuneApplication.php
··· 48 48 'cart/(?P<id>\d+)/' => array( 49 49 '' => 'PhortuneCartViewController', 50 50 'checkout/' => 'PhortuneCartCheckoutController', 51 + '(?P<action>cancel|refund)/' => 'PhortuneCartCancelController', 51 52 ), 52 53 'account/' => array( 53 54 '' => 'PhortuneAccountListController',
+15
src/applications/phortune/cart/PhortuneCartImplementation.php
··· 16 16 abstract public function getCancelURI(PhortuneCart $cart); 17 17 abstract public function getDoneURI(PhortuneCart $cart); 18 18 19 + public function assertCanCancelOrder(PhortuneCart $cart) { 20 + switch ($cart->getStatus()) { 21 + case PhortuneCart::STATUS_PURCHASED: 22 + throw new Exception( 23 + pht( 24 + 'This order can not be cancelled because it has already been '. 25 + 'completed.')); 26 + break; 27 + } 28 + } 29 + 30 + public function assertCanRefundOrder(PhortuneCart $cart) { 31 + return; 32 + } 33 + 19 34 abstract public function willCreateCart( 20 35 PhabricatorUser $viewer, 21 36 PhortuneCart $cart);
+11
src/applications/phortune/controller/PhortuneAccountViewController.php
··· 12 12 $request = $this->getRequest(); 13 13 $user = $request->getUser(); 14 14 15 + // TODO: Currently, you must be able to edit an account to view the detail 16 + // page, because the account must be broadly visible so merchants can 17 + // process orders but merchants should not be able to see all the details 18 + // of an account. Ideally this page should be visible to merchants, too, 19 + // just with less information. 20 + 15 21 $account = id(new PhortuneAccountQuery()) 16 22 ->setViewer($user) 17 23 ->withIDs(array($this->accountID)) 24 + ->requireCapabilities( 25 + array( 26 + PhabricatorPolicyCapability::CAN_VIEW, 27 + PhabricatorPolicyCapability::CAN_EDIT, 28 + )) 18 29 ->executeOne(); 19 30 20 31 if (!$account) {
+195
src/applications/phortune/controller/PhortuneCartCancelController.php
··· 1 + <?php 2 + 3 + final class PhortuneCartCancelController 4 + extends PhortuneCartController { 5 + 6 + private $id; 7 + private $action; 8 + 9 + public function willProcessRequest(array $data) { 10 + $this->id = $data['id']; 11 + $this->action = $data['action']; 12 + } 13 + 14 + public function processRequest() { 15 + $request = $this->getRequest(); 16 + $viewer = $request->getUser(); 17 + 18 + $cart = id(new PhortuneCartQuery()) 19 + ->setViewer($viewer) 20 + ->withIDs(array($this->id)) 21 + ->needPurchases(true) 22 + ->executeOne(); 23 + if (!$cart) { 24 + return new Aphront404Response(); 25 + } 26 + 27 + switch ($this->action) { 28 + case 'cancel': 29 + // You must be able to edit the account to cancel an order. 30 + PhabricatorPolicyFilter::requireCapability( 31 + $viewer, 32 + $cart->getAccount(), 33 + PhabricatorPolicyCapability::CAN_EDIT); 34 + $is_refund = false; 35 + break; 36 + case 'refund': 37 + // You must be able to control the merchant to refund an order. 38 + PhabricatorPolicyFilter::requireCapability( 39 + $viewer, 40 + $cart->getMerchant(), 41 + PhabricatorPolicyCapability::CAN_EDIT); 42 + $is_refund = true; 43 + break; 44 + default: 45 + return new Aphront404Response(); 46 + } 47 + 48 + $cancel_uri = $cart->getDetailURI(); 49 + $merchant = $cart->getMerchant(); 50 + 51 + try { 52 + if ($is_refund) { 53 + $title = pht('Unable to Refund Order'); 54 + $cart->assertCanRefundOrder(); 55 + } else { 56 + $title = pht('Unable to Cancel Order'); 57 + $cart->assertCanCancelOrder(); 58 + } 59 + } catch (Exception $ex) { 60 + return $this->newDialog() 61 + ->setTitle($title) 62 + ->appendChild($ex->getMessage()) 63 + ->addCancelButton($cancel_uri); 64 + } 65 + 66 + $charges = id(new PhortuneChargeQuery()) 67 + ->setViewer($viewer) 68 + ->withCartPHIDs(array($cart->getPHID())) 69 + ->withStatuses( 70 + array( 71 + PhortuneCharge::STATUS_CHARGED, 72 + )) 73 + ->execute(); 74 + 75 + $amounts = mpull($charges, 'getAmountAsCurrency'); 76 + $maximum = PhortuneCurrency::newFromList($amounts); 77 + $v_refund = $maximum->formatForDisplay(); 78 + 79 + $errors = array(); 80 + $e_refund = true; 81 + if ($request->isFormPost()) { 82 + if ($is_refund) { 83 + try { 84 + $refund = PhortuneCurrency::newFromUserInput( 85 + $viewer, 86 + $request->getStr('refund')); 87 + $refund->assertInRange('0.00 USD', $maximum->formatForDisplay()); 88 + } catch (Exception $ex) { 89 + $errors[] = $ex; 90 + $e_refund = pht('Invalid'); 91 + } 92 + } else { 93 + $refund = $maximum; 94 + } 95 + 96 + if (!$errors) { 97 + $charges = msort($charges, 'getID'); 98 + $charges = array_reverse($charges); 99 + 100 + if ($charges) { 101 + $providers = id(new PhortunePaymentProviderConfigQuery()) 102 + ->setViewer($viewer) 103 + ->withPHIDs(mpull($charges, 'getProviderPHID')) 104 + ->execute(); 105 + $providers = mpull($providers, null, 'getPHID'); 106 + } else { 107 + $providers = array(); 108 + } 109 + 110 + foreach ($charges as $charge) { 111 + $refundable = $charge->getAmountRefundableAsCurrency(); 112 + if (!$refundable->isPositive()) { 113 + // This charge is a refund, or has already been fully refunded. 114 + continue; 115 + } 116 + 117 + if ($refund->isGreaterThan($refundable)) { 118 + $refund_amount = $refundable; 119 + } else { 120 + $refund_amount = $refund; 121 + } 122 + 123 + $provider_config = idx($providers, $charge->getProviderPHID()); 124 + if (!$provider_config) { 125 + throw new Exception(pht('Unable to load provider for charge!')); 126 + } 127 + 128 + $provider = $provider_config->buildProvider(); 129 + 130 + $refund_charge = $cart->willRefundCharge( 131 + $viewer, 132 + $provider, 133 + $charge, 134 + $refund_amount); 135 + 136 + $refunded = false; 137 + try { 138 + $provider->refundCharge($charge, $refund_charge); 139 + $refunded = true; 140 + } catch (Exception $ex) { 141 + phlog($ex); 142 + $cart->didFailRefund($charge, $refund_charge); 143 + } 144 + 145 + if ($refunded) { 146 + $cart->didRefundCharge($charge, $refund_charge); 147 + $refund = $refund->subtract($refund_amount); 148 + } 149 + 150 + if (!$refund->isPositive()) { 151 + break; 152 + } 153 + } 154 + 155 + if ($refund->isPositive()) { 156 + throw new Exception(pht('Unable to refund some charges!')); 157 + } 158 + 159 + return id(new AphrontRedirectResponse())->setURI($cancel_uri); 160 + } 161 + } 162 + 163 + if ($is_refund) { 164 + $title = pht('Refund Order?'); 165 + $body = pht( 166 + 'Really refund this order?'); 167 + $button = pht('Refund Order'); 168 + 169 + $form = id(new AphrontFormView()) 170 + ->setUser($viewer) 171 + ->appendChild( 172 + id(new AphrontFormTextControl()) 173 + ->setName('refund') 174 + ->setLabel(pht('Amount')) 175 + ->setError($e_refund) 176 + ->setValue($v_refund)); 177 + 178 + $form = $form->buildLayoutView(); 179 + } else { 180 + $title = pht('Cancel Order?'); 181 + $body = pht( 182 + 'Really cancel this order? Any payment will be refunded.'); 183 + $button = pht('Cancel Order'); 184 + 185 + $form = null; 186 + } 187 + 188 + return $this->newDialog() 189 + ->setTitle($title) 190 + ->appendChild($body) 191 + ->appendChild($form) 192 + ->addSubmitButton($button) 193 + ->addCancelButton($cancel_uri); 194 + } 195 + }
+6 -2
src/applications/phortune/controller/PhortuneCartCheckoutController.php
··· 119 119 } 120 120 } 121 121 122 - $cart_box = $this->buildCartContents($cart); 123 - $cart_box->setFormErrors($errors); 122 + $cart_table = $this->buildCartContentTable($cart); 123 + 124 + $cart_box = id(new PHUIObjectBoxView()) 125 + ->setFormErrors($errors) 126 + ->setHeaderText(pht('Cart Contents')) 127 + ->appendChild($cart_table); 124 128 125 129 $title = pht('Buy Stuff'); 126 130
+2 -4
src/applications/phortune/controller/PhortuneCartController.php
··· 3 3 abstract class PhortuneCartController 4 4 extends PhortuneController { 5 5 6 - protected function buildCartContents(PhortuneCart $cart) { 6 + protected function buildCartContentTable(PhortuneCart $cart) { 7 7 8 8 $rows = array(); 9 9 foreach ($cart->getPurchases() as $purchase) { ··· 39 39 'right', 40 40 )); 41 41 42 - return id(new PHUIObjectBoxView()) 43 - ->setHeaderText(pht('Cart Contents')) 44 - ->appendChild($table); 42 + return $table; 45 43 } 46 44 47 45 }
+96 -1
src/applications/phortune/controller/PhortuneCartViewController.php
··· 22 22 return new Aphront404Response(); 23 23 } 24 24 25 - $cart_box = $this->buildCartContents($cart); 25 + $can_admin = PhabricatorPolicyFilter::hasCapability( 26 + $viewer, 27 + $cart->getMerchant(), 28 + PhabricatorPolicyCapability::CAN_EDIT); 29 + 30 + $cart_table = $this->buildCartContentTable($cart); 31 + 32 + $properties = $this->buildPropertyListView($cart); 33 + $actions = $this->buildActionListView($cart, $can_admin); 34 + $properties->setActionList($actions); 35 + 36 + $header = id(new PHUIHeaderView()) 37 + ->setUser($viewer) 38 + ->setHeader(pht('Order Detail')) 39 + ->setPolicyObject($cart); 40 + 41 + $cart_box = id(new PHUIObjectBoxView()) 42 + ->setHeader($header) 43 + ->appendChild($properties) 44 + ->appendChild($cart_table); 26 45 27 46 $charges = id(new PhortuneChargeQuery()) 28 47 ->setViewer($viewer) ··· 49 68 )); 50 69 51 70 } 71 + 72 + private function buildPropertyListView(PhortuneCart $cart) { 73 + 74 + $viewer = $this->getRequest()->getUser(); 75 + 76 + $view = id(new PHUIPropertyListView()) 77 + ->setUser($viewer) 78 + ->setObject($cart); 79 + 80 + $handles = $this->loadViewerHandles( 81 + array( 82 + $cart->getAccountPHID(), 83 + $cart->getAuthorPHID(), 84 + $cart->getMerchantPHID(), 85 + )); 86 + 87 + $view->addProperty( 88 + pht('Order Name'), 89 + $cart->getName()); 90 + $view->addProperty( 91 + pht('Account'), 92 + $handles[$cart->getAccountPHID()]->renderLink()); 93 + $view->addProperty( 94 + pht('Authorized By'), 95 + $handles[$cart->getAuthorPHID()]->renderLink()); 96 + $view->addProperty( 97 + pht('Merchant'), 98 + $handles[$cart->getMerchantPHID()]->renderLink()); 99 + $view->addProperty( 100 + pht('Status'), 101 + PhortuneCart::getNameForStatus($cart->getStatus())); 102 + $view->addProperty( 103 + pht('Updated'), 104 + phabricator_datetime($cart->getDateModified(), $viewer)); 105 + 106 + return $view; 107 + } 108 + 109 + private function buildActionListView(PhortuneCart $cart, $can_admin) { 110 + $viewer = $this->getRequest()->getUser(); 111 + $id = $cart->getID(); 112 + 113 + $can_edit = PhabricatorPolicyFilter::hasCapability( 114 + $viewer, 115 + $cart, 116 + PhabricatorPolicyCapability::CAN_EDIT); 117 + 118 + $view = id(new PhabricatorActionListView()) 119 + ->setUser($viewer) 120 + ->setObject($cart); 121 + 122 + $can_cancel = ($can_edit && $cart->canCancelOrder()); 123 + 124 + $cancel_uri = $this->getApplicationURI("cart/{$id}/cancel/"); 125 + $refund_uri = $this->getApplicationURI("cart/{$id}/refund/"); 126 + 127 + $view->addAction( 128 + id(new PhabricatorActionView()) 129 + ->setName(pht('Cancel Order')) 130 + ->setIcon('fa-times') 131 + ->setDisabled(!$can_cancel) 132 + ->setWorkflow(true) 133 + ->setHref($cancel_uri)); 134 + 135 + if ($can_admin) { 136 + $view->addAction( 137 + id(new PhabricatorActionView()) 138 + ->setName(pht('Refund Order')) 139 + ->setIcon('fa-reply') 140 + ->setWorkflow(true) 141 + ->setHref($refund_uri)); 142 + } 143 + 144 + return $view; 145 + } 146 + 52 147 }
+4 -2
src/applications/phortune/controller/PhortuneController.php
··· 28 28 $charge->getID(), 29 29 $handles[$charge->getCartPHID()]->renderLink(), 30 30 $handles[$charge->getProviderPHID()]->renderLink(), 31 - $handles[$charge->getPaymentMethodPHID()]->renderLink(), 31 + $charge->getPaymentMethodPHID() 32 + ? $handles[$charge->getPaymentMethodPHID()]->renderLink() 33 + : null, 32 34 $handles[$charge->getMerchantPHID()]->renderLink(), 33 35 $charge->getAmountAsCurrency()->formatForDisplay(), 34 - PhortuneCharge::getNameForStatus($charge->getStatus()), 36 + $charge->getStatusForDisplay(), 35 37 phabricator_datetime($charge->getDateCreated(), $viewer), 36 38 ); 37 39 }
+54 -8
src/applications/phortune/currency/PhortuneCurrency.php
··· 48 48 $value = (int)round(100 * $value); 49 49 50 50 $currency = idx($matches, 2, $default); 51 - if ($currency) { 52 - switch ($currency) { 53 - case 'USD': 54 - break; 55 - default: 56 - throw new Exception("Unsupported currency '{$currency}'!"); 57 - } 51 + switch ($currency) { 52 + case 'USD': 53 + break; 54 + default: 55 + throw new Exception("Unsupported currency '{$currency}'!"); 58 56 } 59 57 60 58 return self::newFromValueAndCurrency($value, $currency); ··· 126 124 throw new Exception("Invalid currency format ('{$string}')."); 127 125 } 128 126 127 + private function throwUnlikeCurrenciesException(PhortuneCurrency $other) { 128 + throw new Exception( 129 + pht( 130 + 'Trying to operate on unlike currencies ("%s" and "%s")!', 131 + $this->currency, 132 + $other->currency)); 133 + } 134 + 129 135 public function add(PhortuneCurrency $other) { 130 136 if ($this->currency !== $other->currency) { 131 - throw new Exception(pht('Trying to add unlike currencies!')); 137 + $this->throwUnlikeCurrenciesException($other); 132 138 } 133 139 134 140 $currency = new PhortuneCurrency(); ··· 138 144 $currency->currency = $this->currency; 139 145 140 146 return $currency; 147 + } 148 + 149 + public function subtract(PhortuneCurrency $other) { 150 + if ($this->currency !== $other->currency) { 151 + $this->throwUnlikeCurrenciesException($other); 152 + } 153 + 154 + $currency = new PhortuneCurrency(); 155 + 156 + // TODO: This should check for integer overflows, etc. 157 + $currency->value = $this->value - $other->value; 158 + $currency->currency = $this->currency; 159 + 160 + return $currency; 161 + } 162 + 163 + public function isEqualTo(PhortuneCurrency $other) { 164 + if ($this->currency !== $other->currency) { 165 + $this->throwUnlikeCurrenciesException($other); 166 + } 167 + 168 + return ($this->value === $other->value); 169 + } 170 + 171 + public function negate() { 172 + $currency = new PhortuneCurrency(); 173 + $currency->value = -$this->value; 174 + $currency->currency = $this->currency; 175 + return $currency; 176 + } 177 + 178 + public function isPositive() { 179 + return ($this->value > 0); 180 + } 181 + 182 + public function isGreaterThan(PhortuneCurrency $other) { 183 + if ($this->currency !== $other->currency) { 184 + $this->throwUnlikeCurrenciesException($other); 185 + } 186 + return $this->value > $other->value; 141 187 } 142 188 143 189 /**
+6
src/applications/phortune/product/PhortuneProductImplementation.php
··· 28 28 return; 29 29 } 30 30 31 + public function didRefundProduct( 32 + PhortuneProduct $product, 33 + PhortunePurchase $purchase) { 34 + return; 35 + } 36 + 31 37 }
+8 -1
src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php
··· 179 179 $charge->save(); 180 180 } 181 181 182 + protected function executeRefund( 183 + PhortuneCharge $charge, 184 + PhortuneCharge $refund) { 185 + // TODO: Implement. 186 + throw new PhortuneNotImplementedException($this); 187 + } 188 + 182 189 private function getMarketplaceID() { 183 190 return $this 184 191 ->getProviderConfig() ··· 192 199 } 193 200 194 201 private function getMarketplaceURI() { 195 - return '/v1/marketplace/'.$this->getMarketplaceID(); 202 + return '/v1/marketplaces/'.$this->getMarketplaceID(); 196 203 } 197 204 198 205
+7
src/applications/phortune/provider/PhortunePayPalPaymentProvider.php
··· 167 167 throw new Exception('!'); 168 168 } 169 169 170 + protected function executeRefund( 171 + PhortuneCharge $charge, 172 + PhortuneCharge $refund) { 173 + // TODO: Implement. 174 + throw new PhortuneNotImplementedException($this); 175 + } 176 + 170 177 private function getPaypalAPIUsername() { 171 178 return $this 172 179 ->getProviderConfig()
+10
src/applications/phortune/provider/PhortunePaymentProvider.php
··· 137 137 $this->executeCharge($payment_method, $charge); 138 138 } 139 139 140 + final public function refundCharge( 141 + PhortuneCharge $charge, 142 + PhortuneCharge $refund) { 143 + $this->executeRefund($charge, $refund); 144 + } 145 + 140 146 abstract protected function executeCharge( 141 147 PhortunePaymentMethod $payment_method, 148 + PhortuneCharge $charge); 149 + 150 + abstract protected function executeRefund( 151 + PhortuneCharge $charge, 142 152 PhortuneCharge $charge); 143 153 144 154
+36 -6
src/applications/phortune/provider/PhortuneStripePaymentProvider.php
··· 146 146 'capture' => true, 147 147 ); 148 148 149 - try { 150 - $stripe_charge = Stripe_Charge::create($params, $secret_key); 151 - } catch (Stripe_CardError $ex) { 152 - // TODO: Fail charge explicitly. 153 - throw $ex; 154 - } 149 + $stripe_charge = Stripe_Charge::create($params, $secret_key); 155 150 156 151 $id = $stripe_charge->id; 157 152 if (!$id) { ··· 159 154 } 160 155 161 156 $charge->setMetadataValue('stripe.chargeID', $id); 157 + $charge->save(); 158 + } 159 + 160 + protected function executeRefund( 161 + PhortuneCharge $charge, 162 + PhortuneCharge $refund) { 163 + 164 + $charge_id = $charge->getMetadataValue('stripe.chargeID'); 165 + if (!$charge_id) { 166 + throw new Exception( 167 + pht('Unable to refund charge; no Stripe chargeID!')); 168 + } 169 + 170 + $root = dirname(phutil_get_library_root('phabricator')); 171 + require_once $root.'/externals/stripe-php/lib/Stripe.php'; 172 + 173 + $refund_cents = $refund 174 + ->getAmountAsCurrency() 175 + ->negate() 176 + ->getValueInUSDCents(); 177 + 178 + $secret_key = $this->getSecretKey(); 179 + $params = array( 180 + 'amount' => $refund_cents, 181 + ); 182 + 183 + $stripe_charge = Stripe_Charge::retrieve($charge_id, $secret_key); 184 + $stripe_refund = $stripe_charge->refunds->create($params); 185 + 186 + $id = $stripe_refund->id; 187 + if (!$id) { 188 + throw new Exception(pht('Stripe refund call did not return an ID!')); 189 + } 190 + 191 + $charge->setMetadataValue('stripe.refundID', $id); 162 192 $charge->save(); 163 193 } 164 194
+6
src/applications/phortune/provider/PhortuneTestPaymentProvider.php
··· 56 56 return; 57 57 } 58 58 59 + protected function executeRefund( 60 + PhortuneCharge $charge, 61 + PhortuneCharge $refund) { 62 + return; 63 + } 64 + 59 65 public function getAllConfigurableProperties() { 60 66 return array(); 61 67 }
+23
src/applications/phortune/provider/PhortuneWePayPaymentProvider.php
··· 186 186 ->getMetadataValue(self::WEPAY_ACCOUNT_ID); 187 187 } 188 188 189 + protected function executeRefund( 190 + PhortuneCharge $charge, 191 + PhortuneCharge $refund) { 192 + 193 + $root = dirname(phutil_get_library_root('phabricator')); 194 + require_once $root.'/externals/wepay/wepay.php'; 195 + 196 + WePay::useStaging( 197 + $this->getWePayClientID(), 198 + $this->getWePayClientSecret()); 199 + 200 + $wepay = new WePay($this->getWePayAccessToken()); 201 + 202 + $charge_id = $charge->getMetadataValue('wepay.checkoutID'); 203 + 204 + $params = array( 205 + 'checkout_id' => $charge_id, 206 + 'refund_reason' => pht('Refund'), 207 + 'amount' => $refund->getAmountAsCurrency()->negate()->formatBareValue(), 208 + ); 209 + 210 + $wepay->request('checkout/refund', $params); 211 + } 189 212 190 213 /* -( One-Time Payments )-------------------------------------------------- */ 191 214
+13 -5
src/applications/phortune/storage/PhortuneAccount.php
··· 106 106 } 107 107 108 108 public function getPolicy($capability) { 109 - if ($this->getPHID() === null) { 110 - // Allow a user to create an account for themselves. 111 - return PhabricatorPolicies::POLICY_USER; 112 - } else { 113 - return PhabricatorPolicies::POLICY_NOONE; 109 + switch ($capability) { 110 + case PhabricatorPolicyCapability::CAN_VIEW: 111 + // Accounts are technically visible to all users, because merchant 112 + // controllers need to be able to see accounts in order to process 113 + // orders. We lock things down more tightly at the application level. 114 + return PhabricatorPolicies::POLICY_USER; 115 + case PhabricatorPolicyCapability::CAN_EDIT: 116 + if ($this->getPHID() === null) { 117 + // Allow a user to create an account for themselves. 118 + return PhabricatorPolicies::POLICY_USER; 119 + } else { 120 + return PhabricatorPolicies::POLICY_NOONE; 121 + } 114 122 } 115 123 } 116 124
+220 -26
src/applications/phortune/storage/PhortuneCart.php
··· 92 92 $this->openTransaction(); 93 93 $this->beginReadLocking(); 94 94 95 - $copy = clone $this; 96 - $copy->reload(); 95 + $copy = clone $this; 96 + $copy->reload(); 97 97 98 - if ($copy->getStatus() !== self::STATUS_READY) { 99 - throw new Exception( 100 - pht( 101 - 'Cart has wrong status ("%s") to call willApplyCharge(), expected '. 102 - '"%s".', 103 - $copy->getStatus(), 104 - self::STATUS_READY)); 105 - } 98 + if ($copy->getStatus() !== self::STATUS_READY) { 99 + throw new Exception( 100 + pht( 101 + 'Cart has wrong status ("%s") to call willApplyCharge(), '. 102 + 'expected "%s".', 103 + $copy->getStatus(), 104 + self::STATUS_READY)); 105 + } 106 + 107 + $charge->save(); 108 + $this->setStatus(PhortuneCart::STATUS_PURCHASING)->save(); 106 109 107 - $charge->save(); 108 - $this->setStatus(PhortuneCart::STATUS_PURCHASING)->save(); 110 + $this->endReadLocking(); 109 111 $this->saveTransaction(); 110 112 111 113 return $charge; ··· 117 119 $this->openTransaction(); 118 120 $this->beginReadLocking(); 119 121 120 - $copy = clone $this; 121 - $copy->reload(); 122 + $copy = clone $this; 123 + $copy->reload(); 122 124 123 - if ($copy->getStatus() !== self::STATUS_PURCHASING) { 124 - throw new Exception( 125 - pht( 126 - 'Cart has wrong status ("%s") to call didApplyCharge(), expected '. 127 - '"%s".', 128 - $copy->getStatus(), 129 - self::STATUS_PURCHASING)); 130 - } 125 + if ($copy->getStatus() !== self::STATUS_PURCHASING) { 126 + throw new Exception( 127 + pht( 128 + 'Cart has wrong status ("%s") to call didApplyCharge(), '. 129 + 'expected "%s".', 130 + $copy->getStatus(), 131 + self::STATUS_PURCHASING)); 132 + } 133 + 134 + $charge->save(); 135 + $this->setStatus(self::STATUS_CHARGED)->save(); 131 136 132 - $charge->save(); 133 - $this->setStatus(self::STATUS_CHARGED)->save(); 137 + $this->endReadLocking(); 134 138 $this->saveTransaction(); 135 139 136 140 foreach ($this->purchases as $purchase) { ··· 142 146 return $this; 143 147 } 144 148 149 + public function willRefundCharge( 150 + PhabricatorUser $actor, 151 + PhortunePaymentProvider $provider, 152 + PhortuneCharge $charge, 153 + PhortuneCurrency $amount) { 154 + 155 + if (!$amount->isPositive()) { 156 + throw new Exception( 157 + pht('Trying to refund nonpositive amount of money!')); 158 + } 159 + 160 + if ($amount->isGreaterThan($charge->getAmountRefundableAsCurrency())) { 161 + throw new Exception( 162 + pht('Trying to refund more money than remaining on charge!')); 163 + } 164 + 165 + if ($charge->getRefundedChargePHID()) { 166 + throw new Exception( 167 + pht('Trying to refund a refund!')); 168 + } 169 + 170 + if ($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) { 171 + throw new Exception( 172 + pht('Trying to refund an uncharged charge!')); 173 + } 174 + 175 + $refund_charge = PhortuneCharge::initializeNewCharge() 176 + ->setAccountPHID($this->getAccount()->getPHID()) 177 + ->setCartPHID($this->getPHID()) 178 + ->setAuthorPHID($actor->getPHID()) 179 + ->setMerchantPHID($this->getMerchant()->getPHID()) 180 + ->setProviderPHID($provider->getProviderConfig()->getPHID()) 181 + ->setPaymentMethodPHID($charge->getPaymentMethodPHID()) 182 + ->setRefundedChargePHID($charge->getPHID()) 183 + ->setAmountAsCurrency($amount->negate()); 184 + 185 + $charge->openTransaction(); 186 + $charge->beginReadLocking(); 187 + 188 + $copy = clone $charge; 189 + $copy->reload(); 190 + 191 + if ($copy->getRefundingPHID() !== null) { 192 + throw new Exception( 193 + pht('Trying to refund a charge which is already refunding!')); 194 + } 195 + 196 + $refund_charge->save(); 197 + $charge->setRefundingPHID($refund_charge->getPHID()); 198 + $charge->save(); 199 + 200 + $charge->endReadLocking(); 201 + $charge->saveTransaction(); 202 + 203 + return $refund_charge; 204 + } 205 + 206 + public function didRefundCharge( 207 + PhortuneCharge $charge, 208 + PhortuneCharge $refund) { 209 + 210 + $refund->setStatus(PhortuneCharge::STATUS_CHARGED); 211 + 212 + $this->openTransaction(); 213 + $this->beginReadLocking(); 214 + 215 + $copy = clone $charge; 216 + $copy->reload(); 217 + 218 + if ($charge->getRefundingPHID() !== $refund->getPHID()) { 219 + throw new Exception( 220 + pht('Charge is in the wrong refunding state!')); 221 + } 222 + 223 + $charge->setRefundingPHID(null); 224 + 225 + // NOTE: There's some trickiness here to get the signs right. Both 226 + // these values are positive but the refund has a negative value. 227 + $total_refunded = $charge 228 + ->getAmountRefundedAsCurrency() 229 + ->add($refund->getAmountAsCurrency()->negate()); 230 + 231 + $charge->setAmountRefundedAsCurrency($total_refunded); 232 + $charge->save(); 233 + $refund->save(); 234 + 235 + $this->endReadLocking(); 236 + $this->saveTransaction(); 237 + 238 + foreach ($this->purchases as $purchase) { 239 + $purchase->getProduct()->didRefundProduct($purchase); 240 + } 241 + 242 + return $this; 243 + } 244 + 245 + public function didFailRefund( 246 + PhortuneCharge $charge, 247 + PhortuneCharge $refund) { 248 + 249 + $refund->setStatus(PhortuneCharge::STATUS_FAILED); 250 + 251 + $this->openTransaction(); 252 + $this->beginReadLocking(); 253 + 254 + $copy = clone $charge; 255 + $copy->reload(); 256 + 257 + if ($charge->getRefundingPHID() !== $refund->getPHID()) { 258 + throw new Exception( 259 + pht('Charge is in the wrong refunding state!')); 260 + } 261 + 262 + $charge->setRefundingPHID(null); 263 + $charge->save(); 264 + $refund->save(); 265 + 266 + $this->endReadLocking(); 267 + $this->saveTransaction(); 268 + } 269 + 145 270 public function getName() { 146 271 return $this->getImplementation()->getName($this); 147 272 } ··· 162 287 return '/phortune/cart/'.$this->getID().'/checkout/'; 163 288 } 164 289 290 + public function canCancelOrder() { 291 + try { 292 + $this->assertCanCancelOrder(); 293 + return true; 294 + } catch (Exception $ex) { 295 + return false; 296 + } 297 + } 298 + 299 + public function canRefundOrder() { 300 + try { 301 + $this->assertCanRefundOrder(); 302 + return true; 303 + } catch (Exception $ex) { 304 + return false; 305 + } 306 + } 307 + 308 + public function assertCanCancelOrder() { 309 + switch ($this->getStatus()) { 310 + case self::STATUS_BUILDING: 311 + throw new Exception( 312 + pht( 313 + 'This order can not be cancelled because the application has not '. 314 + 'finished building it yet.')); 315 + case self::STATUS_READY: 316 + throw new Exception( 317 + pht( 318 + 'This order can not be cancelled because it has not been placed.')); 319 + } 320 + 321 + return $this->getImplementation()->assertCanCancelOrder($this); 322 + } 323 + 324 + public function assertCanRefundOrder() { 325 + switch ($this->getStatus()) { 326 + case self::STATUS_BUILDING: 327 + throw new Exception( 328 + pht( 329 + 'This order can not be refunded because the application has not '. 330 + 'finished building it yet.')); 331 + case self::STATUS_READY: 332 + throw new Exception( 333 + pht( 334 + 'This order can not be refunded because it has not been placed.')); 335 + } 336 + 337 + return $this->getImplementation()->assertCanRefundOrder($this); 338 + } 339 + 165 340 public function getConfiguration() { 166 341 return array( 167 342 self::CONFIG_AUX_PHID => true, ··· 260 435 } 261 436 262 437 public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { 263 - return $this->getAccount()->hasAutomaticCapability($capability, $viewer); 438 + if ($this->getAccount()->hasAutomaticCapability($capability, $viewer)) { 439 + return true; 440 + } 441 + 442 + // If the viewer controls the merchant this order was placed with, they 443 + // can view the order. 444 + if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { 445 + $can_admin = PhabricatorPolicyFilter::hasCapability( 446 + $viewer, 447 + $this->getMerchant(), 448 + PhabricatorPolicyCapability::CAN_EDIT); 449 + if ($can_admin) { 450 + return true; 451 + } 452 + } 453 + 454 + return false; 264 455 } 265 456 266 457 public function describeAutomaticCapability($capability) { 267 - return pht('Carts inherit the policies of the associated account.'); 458 + return array( 459 + pht('Orders inherit the policies of the associated account.'), 460 + pht('The merchant you placed an order with can review and manage it.'), 461 + ); 268 462 } 269 463 270 464 }
+44 -2
src/applications/phortune/storage/PhortuneCharge.php
··· 20 20 protected $merchantPHID; 21 21 protected $paymentMethodPHID; 22 22 protected $amountAsCurrency; 23 + protected $amountRefundedAsCurrency; 24 + protected $refundedChargePHID; 25 + protected $refundingPHID; 23 26 protected $status; 24 27 protected $metadata = array(); 25 28 ··· 28 31 29 32 public static function initializeNewCharge() { 30 33 return id(new PhortuneCharge()) 31 - ->setStatus(self::STATUS_CHARGING); 34 + ->setStatus(self::STATUS_CHARGING) 35 + ->setAmountRefundedAsCurrency(PhortuneCurrency::newEmptyCurrency()); 32 36 } 33 37 34 38 public function getConfiguration() { ··· 39 43 ), 40 44 self::CONFIG_APPLICATION_SERIALIZERS => array( 41 45 'amountAsCurrency' => new PhortuneCurrencySerializer(), 46 + 'amountRefundedAsCurrency' => new PhortuneCurrencySerializer(), 42 47 ), 43 48 self::CONFIG_COLUMN_SCHEMA => array( 44 - 'paymentProviderKey' => 'text128', 45 49 'paymentMethodPHID' => 'phid?', 50 + 'refundedChargePHID' => 'phid?', 51 + 'refundingPHID' => 'phid?', 46 52 'amountAsCurrency' => 'text64', 53 + 'amountRefundedAsCurrency' => 'text64', 47 54 'status' => 'text32', 48 55 ), 49 56 self::CONFIG_KEY_SCHEMA => array( ··· 75 82 return idx(self::getStatusNameMap(), $status, pht('Unknown')); 76 83 } 77 84 85 + public function getStatusForDisplay() { 86 + if ($this->getStatus() == self::STATUS_CHARGED) { 87 + if ($this->getRefundedChargePHID()) { 88 + return pht('Refund'); 89 + } 90 + 91 + $refunded = $this->getAmountRefundedAsCurrency(); 92 + 93 + if ($refunded->isPositive()) { 94 + if ($refunded->isEqualTo($this->getAmountAsCurrency())) { 95 + return pht('Fully Refunded'); 96 + } else { 97 + return pht('%s Refunded', $refunded->formatForDisplay()); 98 + } 99 + } 100 + } 101 + 102 + return self::getNameForStatus($this->getStatus()); 103 + } 104 + 78 105 public function generatePHID() { 79 106 return PhabricatorPHID::generateNewPHID( 80 107 PhortuneChargePHIDType::TYPECONST); ··· 105 132 public function attachCart(PhortuneCart $cart = null) { 106 133 $this->cart = $cart; 107 134 return $this; 135 + } 136 + 137 + public function getAmountRefundableAsCurrency() { 138 + $amount = $this->getAmountAsCurrency(); 139 + $refunded = $this->getAmountRefundedAsCurrency(); 140 + 141 + // We can't refund negative amounts of money, since it does not make 142 + // sense and is not possible in the various payment APIs. 143 + 144 + $refundable = $amount->subtract($refunded); 145 + if ($refundable->isPositive()) { 146 + return $refundable; 147 + } else { 148 + return PhortuneCurrency::newEmptyCurrency(); 149 + } 108 150 } 109 151 110 152
+4
src/applications/phortune/storage/PhortuneProduct.php
··· 78 78 return $this->getImplementation()->didPurchaseProduct($this, $purchase); 79 79 } 80 80 81 + public function didRefundProduct(PhortunePurchase $purchase) { 82 + return $this->getImplementation()->didRefundProduct($this, $purchase); 83 + } 84 + 81 85 82 86 /* -( PhabricatorPolicyInterface )----------------------------------------- */ 83 87