Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F15336026
D10664.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
37 KB
Referenced Files
None
Subscribers
None
D10664.diff
View Options
diff --git a/resources/sql/autopatches/20141008.phortunerefund.sql b/resources/sql/autopatches/20141008.phortunerefund.sql
new file mode 100644
--- /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
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -2554,6 +2554,7 @@
'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 @@
'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
--- a/src/applications/fund/phortune/FundBackerProduct.php
+++ b/src/applications/fund/phortune/FundBackerProduct.php
@@ -115,4 +115,11 @@
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
--- a/src/applications/phortune/application/PhabricatorPhortuneApplication.php
+++ b/src/applications/phortune/application/PhabricatorPhortuneApplication.php
@@ -48,6 +48,7 @@
'cart/(?P<id>\d+)/' => array(
'' => 'PhortuneCartViewController',
'checkout/' => 'PhortuneCartCheckoutController',
+ '(?P<action>cancel|refund)/' => 'PhortuneCartCancelController',
),
'account/' => array(
'' => 'PhortuneAccountListController',
diff --git a/src/applications/phortune/cart/PhortuneCartImplementation.php b/src/applications/phortune/cart/PhortuneCartImplementation.php
--- a/src/applications/phortune/cart/PhortuneCartImplementation.php
+++ b/src/applications/phortune/cart/PhortuneCartImplementation.php
@@ -16,6 +16,21 @@
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
--- a/src/applications/phortune/controller/PhortuneAccountViewController.php
+++ b/src/applications/phortune/controller/PhortuneAccountViewController.php
@@ -12,9 +12,20 @@
$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
--- /dev/null
+++ b/src/applications/phortune/controller/PhortuneCartCancelController.php
@@ -0,0 +1,195 @@
+<?php
+
+final class PhortuneCartCancelController
+ extends PhortuneCartController {
+
+ private $id;
+ private $action;
+
+ public function willProcessRequest(array $data) {
+ $this->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
--- a/src/applications/phortune/controller/PhortuneCartCheckoutController.php
+++ b/src/applications/phortune/controller/PhortuneCartCheckoutController.php
@@ -119,8 +119,12 @@
}
}
- $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
--- 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 @@
'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
--- a/src/applications/phortune/controller/PhortuneCartViewController.php
+++ b/src/applications/phortune/controller/PhortuneCartViewController.php
@@ -22,7 +22,26 @@
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 @@
));
}
+
+ 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
--- a/src/applications/phortune/controller/PhortuneController.php
+++ b/src/applications/phortune/controller/PhortuneController.php
@@ -28,10 +28,12 @@
$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
--- a/src/applications/phortune/currency/PhortuneCurrency.php
+++ b/src/applications/phortune/currency/PhortuneCurrency.php
@@ -48,13 +48,11 @@
$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 @@
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 @@
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
--- a/src/applications/phortune/product/PhortuneProductImplementation.php
+++ b/src/applications/phortune/product/PhortuneProductImplementation.php
@@ -28,4 +28,10 @@
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
--- a/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php
+++ b/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php
@@ -179,6 +179,13 @@
$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 @@
}
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
--- a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php
+++ b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php
@@ -167,6 +167,13 @@
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
--- a/src/applications/phortune/provider/PhortunePaymentProvider.php
+++ b/src/applications/phortune/provider/PhortunePaymentProvider.php
@@ -137,10 +137,20 @@
$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
--- a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
+++ b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
@@ -146,12 +146,7 @@
'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 @@
$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
--- a/src/applications/phortune/provider/PhortuneTestPaymentProvider.php
+++ b/src/applications/phortune/provider/PhortuneTestPaymentProvider.php
@@ -56,6 +56,12 @@
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
--- a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php
+++ b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php
@@ -186,6 +186,29 @@
->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
--- a/src/applications/phortune/storage/PhortuneAccount.php
+++ b/src/applications/phortune/storage/PhortuneAccount.php
@@ -106,11 +106,19 @@
}
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
--- a/src/applications/phortune/storage/PhortuneCart.php
+++ b/src/applications/phortune/storage/PhortuneCart.php
@@ -92,20 +92,22 @@
$this->openTransaction();
$this->beginReadLocking();
- $copy = clone $this;
- $copy->reload();
-
- if ($copy->getStatus() !== self::STATUS_READY) {
- throw new Exception(
- pht(
- 'Cart has wrong status ("%s") to call willApplyCharge(), expected '.
- '"%s".',
- $copy->getStatus(),
- self::STATUS_READY));
- }
-
- $charge->save();
- $this->setStatus(PhortuneCart::STATUS_PURCHASING)->save();
+ $copy = clone $this;
+ $copy->reload();
+
+ if ($copy->getStatus() !== self::STATUS_READY) {
+ throw new Exception(
+ pht(
+ 'Cart has wrong status ("%s") to call willApplyCharge(), '.
+ 'expected "%s".',
+ $copy->getStatus(),
+ self::STATUS_READY));
+ }
+
+ $charge->save();
+ $this->setStatus(PhortuneCart::STATUS_PURCHASING)->save();
+
+ $this->endReadLocking();
$this->saveTransaction();
return $charge;
@@ -117,20 +119,22 @@
$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 @@
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 @@
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 @@
}
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
--- a/src/applications/phortune/storage/PhortuneCharge.php
+++ b/src/applications/phortune/storage/PhortuneCharge.php
@@ -20,6 +20,9 @@
protected $merchantPHID;
protected $paymentMethodPHID;
protected $amountAsCurrency;
+ protected $amountRefundedAsCurrency;
+ protected $refundedChargePHID;
+ protected $refundingPHID;
protected $status;
protected $metadata = array();
@@ -28,7 +31,8 @@
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 @@
),
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 @@
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 @@
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
--- a/src/applications/phortune/storage/PhortuneProduct.php
+++ b/src/applications/phortune/storage/PhortuneProduct.php
@@ -78,6 +78,10 @@
return $this->getImplementation()->didPurchaseProduct($this, $purchase);
}
+ public function didRefundProduct(PhortunePurchase $purchase) {
+ return $this->getImplementation()->didRefundProduct($this, $purchase);
+ }
+
/* -( PhabricatorPolicyInterface )----------------------------------------- */
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mar 9 2025, 6:08 PM (6 w, 3 d ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/6b/zo/j26agx5aabu3qb2t
Default Alt Text
D10664.diff (37 KB)
Attached To
Mode
D10664: Mostly implement order refunds and cancellations
Attached
Detach File
Event Timeline
Log In to Comment