From 1e8c314c81829a2ed568e9deb0c188835da7f625 Mon Sep 17 00:00:00 2001 From: epriestley Date: Wed, 8 Oct 2014 15:33:25 -0700 Subject: [PATCH] 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 --- .../autopatches/20141008.phortunerefund.sql | 11 + src/__phutil_library_map__.php | 2 + .../fund/phortune/FundBackerProduct.php | 7 + .../PhabricatorPhortuneApplication.php | 1 + .../cart/PhortuneCartImplementation.php | 15 ++ .../PhortuneAccountViewController.php | 11 + .../PhortuneCartCancelController.php | 195 ++++++++++++++ .../PhortuneCartCheckoutController.php | 8 +- .../controller/PhortuneCartController.php | 6 +- .../controller/PhortuneCartViewController.php | 97 ++++++- .../controller/PhortuneController.php | 6 +- .../phortune/currency/PhortuneCurrency.php | 62 ++++- .../product/PhortuneProductImplementation.php | 6 + .../PhortuneBalancedPaymentProvider.php | 9 +- .../PhortunePayPalPaymentProvider.php | 7 + .../provider/PhortunePaymentProvider.php | 10 + .../PhortuneStripePaymentProvider.php | 42 ++- .../provider/PhortuneTestPaymentProvider.php | 6 + .../provider/PhortuneWePayPaymentProvider.php | 23 ++ .../phortune/storage/PhortuneAccount.php | 18 +- .../phortune/storage/PhortuneCart.php | 246 ++++++++++++++++-- .../phortune/storage/PhortuneCharge.php | 46 +++- .../phortune/storage/PhortuneProduct.php | 4 + 23 files changed, 781 insertions(+), 57 deletions(-) create mode 100644 resources/sql/autopatches/20141008.phortunerefund.sql create mode 100644 src/applications/phortune/controller/PhortuneCartCancelController.php diff --git a/resources/sql/autopatches/20141008.phortunerefund.sql b/resources/sql/autopatches/20141008.phortunerefund.sql new file mode 100644 index 0000000000..225008cf72 --- /dev/null +++ b/resources/sql/autopatches/20141008.phortunerefund.sql @@ -0,0 +1,11 @@ +ALTER TABLE {$NAMESPACE}_phortune.phortune_charge + ADD amountRefundedAsCurrency VARCHAR(64) NOT NULL COLLATE utf8_bin; + +UPDATE {$NAMESPACE}_phortune.phortune_charge + SET amountRefundedAsCurrency = '0.00 USD'; + +ALTER TABLE {$NAMESPACE}_phortune.phortune_charge + ADD refundingPHID VARBINARY(64); + +ALTER TABLE {$NAMESPACE}_phortune.phortune_charge + ADD refundedChargePHID VARBINARY(64); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 89efdefeb2..8604e87930 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2554,6 +2554,7 @@ phutil_register_library_map(array( 'PhortuneAccountViewController' => 'applications/phortune/controller/PhortuneAccountViewController.php', 'PhortuneBalancedPaymentProvider' => 'applications/phortune/provider/PhortuneBalancedPaymentProvider.php', 'PhortuneCart' => 'applications/phortune/storage/PhortuneCart.php', + 'PhortuneCartCancelController' => 'applications/phortune/controller/PhortuneCartCancelController.php', 'PhortuneCartCheckoutController' => 'applications/phortune/controller/PhortuneCartCheckoutController.php', 'PhortuneCartController' => 'applications/phortune/controller/PhortuneCartController.php', 'PhortuneCartImplementation' => 'applications/phortune/cart/PhortuneCartImplementation.php', @@ -5608,6 +5609,7 @@ phutil_register_library_map(array( 'PhortuneDAO', 'PhabricatorPolicyInterface', ), + 'PhortuneCartCancelController' => 'PhortuneCartController', 'PhortuneCartCheckoutController' => 'PhortuneCartController', 'PhortuneCartController' => 'PhortuneController', 'PhortuneCartListController' => 'PhortuneController', diff --git a/src/applications/fund/phortune/FundBackerProduct.php b/src/applications/fund/phortune/FundBackerProduct.php index a543ecb055..ebe9f31843 100644 --- a/src/applications/fund/phortune/FundBackerProduct.php +++ b/src/applications/fund/phortune/FundBackerProduct.php @@ -115,4 +115,11 @@ final class FundBackerProduct extends PhortuneProductImplementation { return; } + public function didRefundProduct( + PhortuneProduct $product, + PhortunePurchase $purchase) { + $viewer = $this->getViewer(); + // TODO: Undonate. + } + } diff --git a/src/applications/phortune/application/PhabricatorPhortuneApplication.php b/src/applications/phortune/application/PhabricatorPhortuneApplication.php index f4b468b30f..5e83945445 100644 --- a/src/applications/phortune/application/PhabricatorPhortuneApplication.php +++ b/src/applications/phortune/application/PhabricatorPhortuneApplication.php @@ -48,6 +48,7 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication { 'cart/(?P\d+)/' => array( '' => 'PhortuneCartViewController', 'checkout/' => 'PhortuneCartCheckoutController', + '(?Pcancel|refund)/' => 'PhortuneCartCancelController', ), 'account/' => array( '' => 'PhortuneAccountListController', diff --git a/src/applications/phortune/cart/PhortuneCartImplementation.php b/src/applications/phortune/cart/PhortuneCartImplementation.php index 337c66a136..cf2cf28e09 100644 --- a/src/applications/phortune/cart/PhortuneCartImplementation.php +++ b/src/applications/phortune/cart/PhortuneCartImplementation.php @@ -16,6 +16,21 @@ abstract class PhortuneCartImplementation { abstract public function getCancelURI(PhortuneCart $cart); abstract public function getDoneURI(PhortuneCart $cart); + public function assertCanCancelOrder(PhortuneCart $cart) { + switch ($cart->getStatus()) { + case PhortuneCart::STATUS_PURCHASED: + throw new Exception( + pht( + 'This order can not be cancelled because it has already been '. + 'completed.')); + break; + } + } + + public function assertCanRefundOrder(PhortuneCart $cart) { + return; + } + abstract public function willCreateCart( PhabricatorUser $viewer, PhortuneCart $cart); diff --git a/src/applications/phortune/controller/PhortuneAccountViewController.php b/src/applications/phortune/controller/PhortuneAccountViewController.php index f20c5ef878..89d6a84149 100644 --- a/src/applications/phortune/controller/PhortuneAccountViewController.php +++ b/src/applications/phortune/controller/PhortuneAccountViewController.php @@ -12,9 +12,20 @@ final class PhortuneAccountViewController extends PhortuneController { $request = $this->getRequest(); $user = $request->getUser(); + // TODO: Currently, you must be able to edit an account to view the detail + // page, because the account must be broadly visible so merchants can + // process orders but merchants should not be able to see all the details + // of an account. Ideally this page should be visible to merchants, too, + // just with less information. + $account = id(new PhortuneAccountQuery()) ->setViewer($user) ->withIDs(array($this->accountID)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) ->executeOne(); if (!$account) { diff --git a/src/applications/phortune/controller/PhortuneCartCancelController.php b/src/applications/phortune/controller/PhortuneCartCancelController.php new file mode 100644 index 0000000000..df7b1678e2 --- /dev/null +++ b/src/applications/phortune/controller/PhortuneCartCancelController.php @@ -0,0 +1,195 @@ +id = $data['id']; + $this->action = $data['action']; + } + + 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(); + } + + switch ($this->action) { + case 'cancel': + // You must be able to edit the account to cancel an order. + PhabricatorPolicyFilter::requireCapability( + $viewer, + $cart->getAccount(), + PhabricatorPolicyCapability::CAN_EDIT); + $is_refund = false; + break; + case 'refund': + // You must be able to control the merchant to refund an order. + PhabricatorPolicyFilter::requireCapability( + $viewer, + $cart->getMerchant(), + PhabricatorPolicyCapability::CAN_EDIT); + $is_refund = true; + break; + default: + return new Aphront404Response(); + } + + $cancel_uri = $cart->getDetailURI(); + $merchant = $cart->getMerchant(); + + try { + if ($is_refund) { + $title = pht('Unable to Refund Order'); + $cart->assertCanRefundOrder(); + } else { + $title = pht('Unable to Cancel Order'); + $cart->assertCanCancelOrder(); + } + } catch (Exception $ex) { + return $this->newDialog() + ->setTitle($title) + ->appendChild($ex->getMessage()) + ->addCancelButton($cancel_uri); + } + + $charges = id(new PhortuneChargeQuery()) + ->setViewer($viewer) + ->withCartPHIDs(array($cart->getPHID())) + ->withStatuses( + array( + PhortuneCharge::STATUS_CHARGED, + )) + ->execute(); + + $amounts = mpull($charges, 'getAmountAsCurrency'); + $maximum = PhortuneCurrency::newFromList($amounts); + $v_refund = $maximum->formatForDisplay(); + + $errors = array(); + $e_refund = true; + if ($request->isFormPost()) { + if ($is_refund) { + try { + $refund = PhortuneCurrency::newFromUserInput( + $viewer, + $request->getStr('refund')); + $refund->assertInRange('0.00 USD', $maximum->formatForDisplay()); + } catch (Exception $ex) { + $errors[] = $ex; + $e_refund = pht('Invalid'); + } + } else { + $refund = $maximum; + } + + if (!$errors) { + $charges = msort($charges, 'getID'); + $charges = array_reverse($charges); + + if ($charges) { + $providers = id(new PhortunePaymentProviderConfigQuery()) + ->setViewer($viewer) + ->withPHIDs(mpull($charges, 'getProviderPHID')) + ->execute(); + $providers = mpull($providers, null, 'getPHID'); + } else { + $providers = array(); + } + + foreach ($charges as $charge) { + $refundable = $charge->getAmountRefundableAsCurrency(); + if (!$refundable->isPositive()) { + // This charge is a refund, or has already been fully refunded. + continue; + } + + if ($refund->isGreaterThan($refundable)) { + $refund_amount = $refundable; + } else { + $refund_amount = $refund; + } + + $provider_config = idx($providers, $charge->getProviderPHID()); + if (!$provider_config) { + throw new Exception(pht('Unable to load provider for charge!')); + } + + $provider = $provider_config->buildProvider(); + + $refund_charge = $cart->willRefundCharge( + $viewer, + $provider, + $charge, + $refund_amount); + + $refunded = false; + try { + $provider->refundCharge($charge, $refund_charge); + $refunded = true; + } catch (Exception $ex) { + phlog($ex); + $cart->didFailRefund($charge, $refund_charge); + } + + if ($refunded) { + $cart->didRefundCharge($charge, $refund_charge); + $refund = $refund->subtract($refund_amount); + } + + if (!$refund->isPositive()) { + break; + } + } + + if ($refund->isPositive()) { + throw new Exception(pht('Unable to refund some charges!')); + } + + return id(new AphrontRedirectResponse())->setURI($cancel_uri); + } + } + + if ($is_refund) { + $title = pht('Refund Order?'); + $body = pht( + 'Really refund this order?'); + $button = pht('Refund Order'); + + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->appendChild( + id(new AphrontFormTextControl()) + ->setName('refund') + ->setLabel(pht('Amount')) + ->setError($e_refund) + ->setValue($v_refund)); + + $form = $form->buildLayoutView(); + } else { + $title = pht('Cancel Order?'); + $body = pht( + 'Really cancel this order? Any payment will be refunded.'); + $button = pht('Cancel Order'); + + $form = null; + } + + return $this->newDialog() + ->setTitle($title) + ->appendChild($body) + ->appendChild($form) + ->addSubmitButton($button) + ->addCancelButton($cancel_uri); + } +} diff --git a/src/applications/phortune/controller/PhortuneCartCheckoutController.php b/src/applications/phortune/controller/PhortuneCartCheckoutController.php index fae092ece2..8e1d240a56 100644 --- a/src/applications/phortune/controller/PhortuneCartCheckoutController.php +++ b/src/applications/phortune/controller/PhortuneCartCheckoutController.php @@ -119,8 +119,12 @@ final class PhortuneCartCheckoutController } } - $cart_box = $this->buildCartContents($cart); - $cart_box->setFormErrors($errors); + $cart_table = $this->buildCartContentTable($cart); + + $cart_box = id(new PHUIObjectBoxView()) + ->setFormErrors($errors) + ->setHeaderText(pht('Cart Contents')) + ->appendChild($cart_table); $title = pht('Buy Stuff'); diff --git a/src/applications/phortune/controller/PhortuneCartController.php b/src/applications/phortune/controller/PhortuneCartController.php index 7be11d36f7..75269586b7 100644 --- a/src/applications/phortune/controller/PhortuneCartController.php +++ b/src/applications/phortune/controller/PhortuneCartController.php @@ -3,7 +3,7 @@ abstract class PhortuneCartController extends PhortuneController { - protected function buildCartContents(PhortuneCart $cart) { + protected function buildCartContentTable(PhortuneCart $cart) { $rows = array(); foreach ($cart->getPurchases() as $purchase) { @@ -39,9 +39,7 @@ abstract class PhortuneCartController 'right', )); - return id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Cart Contents')) - ->appendChild($table); + return $table; } } diff --git a/src/applications/phortune/controller/PhortuneCartViewController.php b/src/applications/phortune/controller/PhortuneCartViewController.php index 20df09723c..7e5ad6c979 100644 --- a/src/applications/phortune/controller/PhortuneCartViewController.php +++ b/src/applications/phortune/controller/PhortuneCartViewController.php @@ -22,7 +22,26 @@ final class PhortuneCartViewController return new Aphront404Response(); } - $cart_box = $this->buildCartContents($cart); + $can_admin = PhabricatorPolicyFilter::hasCapability( + $viewer, + $cart->getMerchant(), + PhabricatorPolicyCapability::CAN_EDIT); + + $cart_table = $this->buildCartContentTable($cart); + + $properties = $this->buildPropertyListView($cart); + $actions = $this->buildActionListView($cart, $can_admin); + $properties->setActionList($actions); + + $header = id(new PHUIHeaderView()) + ->setUser($viewer) + ->setHeader(pht('Order Detail')) + ->setPolicyObject($cart); + + $cart_box = id(new PHUIObjectBoxView()) + ->setHeader($header) + ->appendChild($properties) + ->appendChild($cart_table); $charges = id(new PhortuneChargeQuery()) ->setViewer($viewer) @@ -49,4 +68,80 @@ final class PhortuneCartViewController )); } + + private function buildPropertyListView(PhortuneCart $cart) { + + $viewer = $this->getRequest()->getUser(); + + $view = id(new PHUIPropertyListView()) + ->setUser($viewer) + ->setObject($cart); + + $handles = $this->loadViewerHandles( + array( + $cart->getAccountPHID(), + $cart->getAuthorPHID(), + $cart->getMerchantPHID(), + )); + + $view->addProperty( + pht('Order Name'), + $cart->getName()); + $view->addProperty( + pht('Account'), + $handles[$cart->getAccountPHID()]->renderLink()); + $view->addProperty( + pht('Authorized By'), + $handles[$cart->getAuthorPHID()]->renderLink()); + $view->addProperty( + pht('Merchant'), + $handles[$cart->getMerchantPHID()]->renderLink()); + $view->addProperty( + pht('Status'), + PhortuneCart::getNameForStatus($cart->getStatus())); + $view->addProperty( + pht('Updated'), + phabricator_datetime($cart->getDateModified(), $viewer)); + + return $view; + } + + private function buildActionListView(PhortuneCart $cart, $can_admin) { + $viewer = $this->getRequest()->getUser(); + $id = $cart->getID(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $cart, + PhabricatorPolicyCapability::CAN_EDIT); + + $view = id(new PhabricatorActionListView()) + ->setUser($viewer) + ->setObject($cart); + + $can_cancel = ($can_edit && $cart->canCancelOrder()); + + $cancel_uri = $this->getApplicationURI("cart/{$id}/cancel/"); + $refund_uri = $this->getApplicationURI("cart/{$id}/refund/"); + + $view->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Cancel Order')) + ->setIcon('fa-times') + ->setDisabled(!$can_cancel) + ->setWorkflow(true) + ->setHref($cancel_uri)); + + if ($can_admin) { + $view->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Refund Order')) + ->setIcon('fa-reply') + ->setWorkflow(true) + ->setHref($refund_uri)); + } + + return $view; + } + } diff --git a/src/applications/phortune/controller/PhortuneController.php b/src/applications/phortune/controller/PhortuneController.php index 21b23e2c7b..ae15fb495e 100644 --- a/src/applications/phortune/controller/PhortuneController.php +++ b/src/applications/phortune/controller/PhortuneController.php @@ -28,10 +28,12 @@ abstract class PhortuneController extends PhabricatorController { $charge->getID(), $handles[$charge->getCartPHID()]->renderLink(), $handles[$charge->getProviderPHID()]->renderLink(), - $handles[$charge->getPaymentMethodPHID()]->renderLink(), + $charge->getPaymentMethodPHID() + ? $handles[$charge->getPaymentMethodPHID()]->renderLink() + : null, $handles[$charge->getMerchantPHID()]->renderLink(), $charge->getAmountAsCurrency()->formatForDisplay(), - PhortuneCharge::getNameForStatus($charge->getStatus()), + $charge->getStatusForDisplay(), phabricator_datetime($charge->getDateCreated(), $viewer), ); } diff --git a/src/applications/phortune/currency/PhortuneCurrency.php b/src/applications/phortune/currency/PhortuneCurrency.php index 573532fb73..a473738ed6 100644 --- a/src/applications/phortune/currency/PhortuneCurrency.php +++ b/src/applications/phortune/currency/PhortuneCurrency.php @@ -48,13 +48,11 @@ final class PhortuneCurrency extends Phobject { $value = (int)round(100 * $value); $currency = idx($matches, 2, $default); - if ($currency) { - switch ($currency) { - case 'USD': - break; - default: - throw new Exception("Unsupported currency '{$currency}'!"); - } + switch ($currency) { + case 'USD': + break; + default: + throw new Exception("Unsupported currency '{$currency}'!"); } return self::newFromValueAndCurrency($value, $currency); @@ -126,9 +124,17 @@ final class PhortuneCurrency extends Phobject { throw new Exception("Invalid currency format ('{$string}')."); } + private function throwUnlikeCurrenciesException(PhortuneCurrency $other) { + throw new Exception( + pht( + 'Trying to operate on unlike currencies ("%s" and "%s")!', + $this->currency, + $other->currency)); + } + public function add(PhortuneCurrency $other) { if ($this->currency !== $other->currency) { - throw new Exception(pht('Trying to add unlike currencies!')); + $this->throwUnlikeCurrenciesException($other); } $currency = new PhortuneCurrency(); @@ -140,6 +146,46 @@ final class PhortuneCurrency extends Phobject { return $currency; } + public function subtract(PhortuneCurrency $other) { + if ($this->currency !== $other->currency) { + $this->throwUnlikeCurrenciesException($other); + } + + $currency = new PhortuneCurrency(); + + // TODO: This should check for integer overflows, etc. + $currency->value = $this->value - $other->value; + $currency->currency = $this->currency; + + return $currency; + } + + public function isEqualTo(PhortuneCurrency $other) { + if ($this->currency !== $other->currency) { + $this->throwUnlikeCurrenciesException($other); + } + + return ($this->value === $other->value); + } + + public function negate() { + $currency = new PhortuneCurrency(); + $currency->value = -$this->value; + $currency->currency = $this->currency; + return $currency; + } + + public function isPositive() { + return ($this->value > 0); + } + + public function isGreaterThan(PhortuneCurrency $other) { + if ($this->currency !== $other->currency) { + $this->throwUnlikeCurrenciesException($other); + } + return $this->value > $other->value; + } + /** * Assert that a currency value lies within a range. * diff --git a/src/applications/phortune/product/PhortuneProductImplementation.php b/src/applications/phortune/product/PhortuneProductImplementation.php index 8419d4b04d..14aa4df398 100644 --- a/src/applications/phortune/product/PhortuneProductImplementation.php +++ b/src/applications/phortune/product/PhortuneProductImplementation.php @@ -28,4 +28,10 @@ abstract class PhortuneProductImplementation { return; } + public function didRefundProduct( + PhortuneProduct $product, + PhortunePurchase $purchase) { + return; + } + } diff --git a/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php b/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php index 00c461a1d6..a79e5c28aa 100644 --- a/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php @@ -179,6 +179,13 @@ final class PhortuneBalancedPaymentProvider extends PhortunePaymentProvider { $charge->save(); } + protected function executeRefund( + PhortuneCharge $charge, + PhortuneCharge $refund) { + // TODO: Implement. + throw new PhortuneNotImplementedException($this); + } + private function getMarketplaceID() { return $this ->getProviderConfig() @@ -192,7 +199,7 @@ final class PhortuneBalancedPaymentProvider extends PhortunePaymentProvider { } private function getMarketplaceURI() { - return '/v1/marketplace/'.$this->getMarketplaceID(); + return '/v1/marketplaces/'.$this->getMarketplaceID(); } diff --git a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php index c54d71830a..d4755d2ffc 100644 --- a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php +++ b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php @@ -167,6 +167,13 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider { throw new Exception('!'); } + protected function executeRefund( + PhortuneCharge $charge, + PhortuneCharge $refund) { + // TODO: Implement. + throw new PhortuneNotImplementedException($this); + } + private function getPaypalAPIUsername() { return $this ->getProviderConfig() diff --git a/src/applications/phortune/provider/PhortunePaymentProvider.php b/src/applications/phortune/provider/PhortunePaymentProvider.php index 1cc1ca1dcb..ab31dd04cc 100644 --- a/src/applications/phortune/provider/PhortunePaymentProvider.php +++ b/src/applications/phortune/provider/PhortunePaymentProvider.php @@ -137,10 +137,20 @@ abstract class PhortunePaymentProvider { $this->executeCharge($payment_method, $charge); } + final public function refundCharge( + PhortuneCharge $charge, + PhortuneCharge $refund) { + $this->executeRefund($charge, $refund); + } + abstract protected function executeCharge( PhortunePaymentMethod $payment_method, PhortuneCharge $charge); + abstract protected function executeRefund( + PhortuneCharge $charge, + PhortuneCharge $charge); + /* -( Adding Payment Methods )--------------------------------------------- */ diff --git a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php index 25b14a18ba..62b17ed906 100644 --- a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php @@ -146,12 +146,7 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider { 'capture' => true, ); - try { - $stripe_charge = Stripe_Charge::create($params, $secret_key); - } catch (Stripe_CardError $ex) { - // TODO: Fail charge explicitly. - throw $ex; - } + $stripe_charge = Stripe_Charge::create($params, $secret_key); $id = $stripe_charge->id; if (!$id) { @@ -162,6 +157,41 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider { $charge->save(); } + protected function executeRefund( + PhortuneCharge $charge, + PhortuneCharge $refund) { + + $charge_id = $charge->getMetadataValue('stripe.chargeID'); + if (!$charge_id) { + throw new Exception( + pht('Unable to refund charge; no Stripe chargeID!')); + } + + $root = dirname(phutil_get_library_root('phabricator')); + require_once $root.'/externals/stripe-php/lib/Stripe.php'; + + $refund_cents = $refund + ->getAmountAsCurrency() + ->negate() + ->getValueInUSDCents(); + + $secret_key = $this->getSecretKey(); + $params = array( + 'amount' => $refund_cents, + ); + + $stripe_charge = Stripe_Charge::retrieve($charge_id, $secret_key); + $stripe_refund = $stripe_charge->refunds->create($params); + + $id = $stripe_refund->id; + if (!$id) { + throw new Exception(pht('Stripe refund call did not return an ID!')); + } + + $charge->setMetadataValue('stripe.refundID', $id); + $charge->save(); + } + private function getPublishableKey() { return $this ->getProviderConfig() diff --git a/src/applications/phortune/provider/PhortuneTestPaymentProvider.php b/src/applications/phortune/provider/PhortuneTestPaymentProvider.php index 7e22b36a22..c485e8b65d 100644 --- a/src/applications/phortune/provider/PhortuneTestPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneTestPaymentProvider.php @@ -56,6 +56,12 @@ final class PhortuneTestPaymentProvider extends PhortunePaymentProvider { return; } + protected function executeRefund( + PhortuneCharge $charge, + PhortuneCharge $refund) { + return; + } + public function getAllConfigurableProperties() { return array(); } diff --git a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php index 1be47021dd..705c6c08aa 100644 --- a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php @@ -186,6 +186,29 @@ final class PhortuneWePayPaymentProvider extends PhortunePaymentProvider { ->getMetadataValue(self::WEPAY_ACCOUNT_ID); } + protected function executeRefund( + PhortuneCharge $charge, + PhortuneCharge $refund) { + + $root = dirname(phutil_get_library_root('phabricator')); + require_once $root.'/externals/wepay/wepay.php'; + + WePay::useStaging( + $this->getWePayClientID(), + $this->getWePayClientSecret()); + + $wepay = new WePay($this->getWePayAccessToken()); + + $charge_id = $charge->getMetadataValue('wepay.checkoutID'); + + $params = array( + 'checkout_id' => $charge_id, + 'refund_reason' => pht('Refund'), + 'amount' => $refund->getAmountAsCurrency()->negate()->formatBareValue(), + ); + + $wepay->request('checkout/refund', $params); + } /* -( One-Time Payments )-------------------------------------------------- */ diff --git a/src/applications/phortune/storage/PhortuneAccount.php b/src/applications/phortune/storage/PhortuneAccount.php index c22fa90db7..c74a1a22ea 100644 --- a/src/applications/phortune/storage/PhortuneAccount.php +++ b/src/applications/phortune/storage/PhortuneAccount.php @@ -106,11 +106,19 @@ final class PhortuneAccount extends PhortuneDAO } public function getPolicy($capability) { - if ($this->getPHID() === null) { - // Allow a user to create an account for themselves. - return PhabricatorPolicies::POLICY_USER; - } else { - return PhabricatorPolicies::POLICY_NOONE; + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + // Accounts are technically visible to all users, because merchant + // controllers need to be able to see accounts in order to process + // orders. We lock things down more tightly at the application level. + return PhabricatorPolicies::POLICY_USER; + case PhabricatorPolicyCapability::CAN_EDIT: + if ($this->getPHID() === null) { + // Allow a user to create an account for themselves. + return PhabricatorPolicies::POLICY_USER; + } else { + return PhabricatorPolicies::POLICY_NOONE; + } } } diff --git a/src/applications/phortune/storage/PhortuneCart.php b/src/applications/phortune/storage/PhortuneCart.php index 5ca4401998..c78d332c43 100644 --- a/src/applications/phortune/storage/PhortuneCart.php +++ b/src/applications/phortune/storage/PhortuneCart.php @@ -92,20 +92,22 @@ final class PhortuneCart extends PhortuneDAO $this->openTransaction(); $this->beginReadLocking(); - $copy = clone $this; - $copy->reload(); + $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)); - } + 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(); + $charge->save(); + $this->setStatus(PhortuneCart::STATUS_PURCHASING)->save(); + + $this->endReadLocking(); $this->saveTransaction(); return $charge; @@ -117,20 +119,22 @@ final class PhortuneCart extends PhortuneDAO $this->openTransaction(); $this->beginReadLocking(); - $copy = clone $this; - $copy->reload(); + $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)); - } + 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(); + $charge->save(); + $this->setStatus(self::STATUS_CHARGED)->save(); + + $this->endReadLocking(); $this->saveTransaction(); foreach ($this->purchases as $purchase) { @@ -142,6 +146,127 @@ final class PhortuneCart extends PhortuneDAO return $this; } + public function willRefundCharge( + PhabricatorUser $actor, + PhortunePaymentProvider $provider, + PhortuneCharge $charge, + PhortuneCurrency $amount) { + + if (!$amount->isPositive()) { + throw new Exception( + pht('Trying to refund nonpositive amount of money!')); + } + + if ($amount->isGreaterThan($charge->getAmountRefundableAsCurrency())) { + throw new Exception( + pht('Trying to refund more money than remaining on charge!')); + } + + if ($charge->getRefundedChargePHID()) { + throw new Exception( + pht('Trying to refund a refund!')); + } + + if ($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) { + throw new Exception( + pht('Trying to refund an uncharged charge!')); + } + + $refund_charge = PhortuneCharge::initializeNewCharge() + ->setAccountPHID($this->getAccount()->getPHID()) + ->setCartPHID($this->getPHID()) + ->setAuthorPHID($actor->getPHID()) + ->setMerchantPHID($this->getMerchant()->getPHID()) + ->setProviderPHID($provider->getProviderConfig()->getPHID()) + ->setPaymentMethodPHID($charge->getPaymentMethodPHID()) + ->setRefundedChargePHID($charge->getPHID()) + ->setAmountAsCurrency($amount->negate()); + + $charge->openTransaction(); + $charge->beginReadLocking(); + + $copy = clone $charge; + $copy->reload(); + + if ($copy->getRefundingPHID() !== null) { + throw new Exception( + pht('Trying to refund a charge which is already refunding!')); + } + + $refund_charge->save(); + $charge->setRefundingPHID($refund_charge->getPHID()); + $charge->save(); + + $charge->endReadLocking(); + $charge->saveTransaction(); + + return $refund_charge; + } + + public function didRefundCharge( + PhortuneCharge $charge, + PhortuneCharge $refund) { + + $refund->setStatus(PhortuneCharge::STATUS_CHARGED); + + $this->openTransaction(); + $this->beginReadLocking(); + + $copy = clone $charge; + $copy->reload(); + + if ($charge->getRefundingPHID() !== $refund->getPHID()) { + throw new Exception( + pht('Charge is in the wrong refunding state!')); + } + + $charge->setRefundingPHID(null); + + // NOTE: There's some trickiness here to get the signs right. Both + // these values are positive but the refund has a negative value. + $total_refunded = $charge + ->getAmountRefundedAsCurrency() + ->add($refund->getAmountAsCurrency()->negate()); + + $charge->setAmountRefundedAsCurrency($total_refunded); + $charge->save(); + $refund->save(); + + $this->endReadLocking(); + $this->saveTransaction(); + + foreach ($this->purchases as $purchase) { + $purchase->getProduct()->didRefundProduct($purchase); + } + + return $this; + } + + public function didFailRefund( + PhortuneCharge $charge, + PhortuneCharge $refund) { + + $refund->setStatus(PhortuneCharge::STATUS_FAILED); + + $this->openTransaction(); + $this->beginReadLocking(); + + $copy = clone $charge; + $copy->reload(); + + if ($charge->getRefundingPHID() !== $refund->getPHID()) { + throw new Exception( + pht('Charge is in the wrong refunding state!')); + } + + $charge->setRefundingPHID(null); + $charge->save(); + $refund->save(); + + $this->endReadLocking(); + $this->saveTransaction(); + } + public function getName() { return $this->getImplementation()->getName($this); } @@ -162,6 +287,56 @@ final class PhortuneCart extends PhortuneDAO return '/phortune/cart/'.$this->getID().'/checkout/'; } + public function canCancelOrder() { + try { + $this->assertCanCancelOrder(); + return true; + } catch (Exception $ex) { + return false; + } + } + + public function canRefundOrder() { + try { + $this->assertCanRefundOrder(); + return true; + } catch (Exception $ex) { + return false; + } + } + + public function assertCanCancelOrder() { + switch ($this->getStatus()) { + case self::STATUS_BUILDING: + throw new Exception( + pht( + 'This order can not be cancelled because the application has not '. + 'finished building it yet.')); + case self::STATUS_READY: + throw new Exception( + pht( + 'This order can not be cancelled because it has not been placed.')); + } + + return $this->getImplementation()->assertCanCancelOrder($this); + } + + public function assertCanRefundOrder() { + switch ($this->getStatus()) { + case self::STATUS_BUILDING: + throw new Exception( + pht( + 'This order can not be refunded because the application has not '. + 'finished building it yet.')); + case self::STATUS_READY: + throw new Exception( + pht( + 'This order can not be refunded because it has not been placed.')); + } + + return $this->getImplementation()->assertCanRefundOrder($this); + } + public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, @@ -260,11 +435,30 @@ final class PhortuneCart extends PhortuneDAO } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { - return $this->getAccount()->hasAutomaticCapability($capability, $viewer); + if ($this->getAccount()->hasAutomaticCapability($capability, $viewer)) { + return true; + } + + // If the viewer controls the merchant this order was placed with, they + // can view the order. + if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { + $can_admin = PhabricatorPolicyFilter::hasCapability( + $viewer, + $this->getMerchant(), + PhabricatorPolicyCapability::CAN_EDIT); + if ($can_admin) { + return true; + } + } + + return false; } public function describeAutomaticCapability($capability) { - return pht('Carts inherit the policies of the associated account.'); + return array( + pht('Orders inherit the policies of the associated account.'), + pht('The merchant you placed an order with can review and manage it.'), + ); } } diff --git a/src/applications/phortune/storage/PhortuneCharge.php b/src/applications/phortune/storage/PhortuneCharge.php index 3d708416b6..7254e2f222 100644 --- a/src/applications/phortune/storage/PhortuneCharge.php +++ b/src/applications/phortune/storage/PhortuneCharge.php @@ -20,6 +20,9 @@ final class PhortuneCharge extends PhortuneDAO protected $merchantPHID; protected $paymentMethodPHID; protected $amountAsCurrency; + protected $amountRefundedAsCurrency; + protected $refundedChargePHID; + protected $refundingPHID; protected $status; protected $metadata = array(); @@ -28,7 +31,8 @@ final class PhortuneCharge extends PhortuneDAO public static function initializeNewCharge() { return id(new PhortuneCharge()) - ->setStatus(self::STATUS_CHARGING); + ->setStatus(self::STATUS_CHARGING) + ->setAmountRefundedAsCurrency(PhortuneCurrency::newEmptyCurrency()); } public function getConfiguration() { @@ -39,11 +43,14 @@ final class PhortuneCharge extends PhortuneDAO ), self::CONFIG_APPLICATION_SERIALIZERS => array( 'amountAsCurrency' => new PhortuneCurrencySerializer(), + 'amountRefundedAsCurrency' => new PhortuneCurrencySerializer(), ), self::CONFIG_COLUMN_SCHEMA => array( - 'paymentProviderKey' => 'text128', 'paymentMethodPHID' => 'phid?', + 'refundedChargePHID' => 'phid?', + 'refundingPHID' => 'phid?', 'amountAsCurrency' => 'text64', + 'amountRefundedAsCurrency' => 'text64', 'status' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( @@ -75,6 +82,26 @@ final class PhortuneCharge extends PhortuneDAO return idx(self::getStatusNameMap(), $status, pht('Unknown')); } + public function getStatusForDisplay() { + if ($this->getStatus() == self::STATUS_CHARGED) { + if ($this->getRefundedChargePHID()) { + return pht('Refund'); + } + + $refunded = $this->getAmountRefundedAsCurrency(); + + if ($refunded->isPositive()) { + if ($refunded->isEqualTo($this->getAmountAsCurrency())) { + return pht('Fully Refunded'); + } else { + return pht('%s Refunded', $refunded->formatForDisplay()); + } + } + } + + return self::getNameForStatus($this->getStatus()); + } + public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhortuneChargePHIDType::TYPECONST); @@ -107,6 +134,21 @@ final class PhortuneCharge extends PhortuneDAO return $this; } + public function getAmountRefundableAsCurrency() { + $amount = $this->getAmountAsCurrency(); + $refunded = $this->getAmountRefundedAsCurrency(); + + // We can't refund negative amounts of money, since it does not make + // sense and is not possible in the various payment APIs. + + $refundable = $amount->subtract($refunded); + if ($refundable->isPositive()) { + return $refundable; + } else { + return PhortuneCurrency::newEmptyCurrency(); + } + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ diff --git a/src/applications/phortune/storage/PhortuneProduct.php b/src/applications/phortune/storage/PhortuneProduct.php index 0496cb2103..c984cf86a7 100644 --- a/src/applications/phortune/storage/PhortuneProduct.php +++ b/src/applications/phortune/storage/PhortuneProduct.php @@ -78,6 +78,10 @@ final class PhortuneProduct extends PhortuneDAO return $this->getImplementation()->didPurchaseProduct($this, $purchase); } + public function didRefundProduct(PhortunePurchase $purchase) { + return $this->getImplementation()->didRefundProduct($this, $purchase); + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */