Page MenuHomePhabricator

D10664.diff
No OneTemporary

D10664.diff

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

Mime Type
text/plain
Expires
Mon, Apr 14, 3:23 PM (1 w, 1 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)

Event Timeline