mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-18 12:52:42 +01:00
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
This commit is contained in:
parent
b6c65719e4
commit
1e8c314c81
23 changed files with 781 additions and 57 deletions
11
resources/sql/autopatches/20141008.phortunerefund.sql
Normal file
11
resources/sql/autopatches/20141008.phortunerefund.sql
Normal file
|
@ -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);
|
|
@ -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',
|
||||
|
|
|
@ -115,4 +115,11 @@ final class FundBackerProduct extends PhortuneProductImplementation {
|
|||
return;
|
||||
}
|
||||
|
||||
public function didRefundProduct(
|
||||
PhortuneProduct $product,
|
||||
PhortunePurchase $purchase) {
|
||||
$viewer = $this->getViewer();
|
||||
// TODO: Undonate.
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication {
|
|||
'cart/(?P<id>\d+)/' => array(
|
||||
'' => 'PhortuneCartViewController',
|
||||
'checkout/' => 'PhortuneCartCheckoutController',
|
||||
'(?P<action>cancel|refund)/' => 'PhortuneCartCancelController',
|
||||
),
|
||||
'account/' => array(
|
||||
'' => 'PhortuneAccountListController',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -48,14 +48,12 @@ 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}'!");
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
|
|
@ -28,4 +28,10 @@ abstract class PhortuneProductImplementation {
|
|||
return;
|
||||
}
|
||||
|
||||
public function didRefundProduct(
|
||||
PhortuneProduct $product,
|
||||
PhortunePurchase $purchase) {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 )--------------------------------------------- */
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
$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()
|
||||
|
|
|
@ -56,6 +56,12 @@ final class PhortuneTestPaymentProvider extends PhortunePaymentProvider {
|
|||
return;
|
||||
}
|
||||
|
||||
protected function executeRefund(
|
||||
PhortuneCharge $charge,
|
||||
PhortuneCharge $refund) {
|
||||
return;
|
||||
}
|
||||
|
||||
public function getAllConfigurableProperties() {
|
||||
return array();
|
||||
}
|
||||
|
|
|
@ -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 )-------------------------------------------------- */
|
||||
|
||||
|
|
|
@ -106,6 +106,13 @@ final class PhortuneAccount extends PhortuneDAO
|
|||
}
|
||||
|
||||
public function getPolicy($capability) {
|
||||
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;
|
||||
|
@ -113,6 +120,7 @@ final class PhortuneAccount extends PhortuneDAO
|
|||
return PhabricatorPolicies::POLICY_NOONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
|
||||
$members = array_fuse($this->getMemberPHIDs());
|
||||
|
|
|
@ -98,14 +98,16 @@ final class PhortuneCart extends PhortuneDAO
|
|||
if ($copy->getStatus() !== self::STATUS_READY) {
|
||||
throw new Exception(
|
||||
pht(
|
||||
'Cart has wrong status ("%s") to call willApplyCharge(), expected '.
|
||||
'"%s".',
|
||||
'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;
|
||||
|
@ -123,14 +125,16 @@ final class PhortuneCart extends PhortuneDAO
|
|||
if ($copy->getStatus() !== self::STATUS_PURCHASING) {
|
||||
throw new Exception(
|
||||
pht(
|
||||
'Cart has wrong status ("%s") to call didApplyCharge(), expected '.
|
||||
'"%s".',
|
||||
'Cart has wrong status ("%s") to call didApplyCharge(), '.
|
||||
'expected "%s".',
|
||||
$copy->getStatus(),
|
||||
self::STATUS_PURCHASING));
|
||||
}
|
||||
|
||||
$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.'),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 )----------------------------------------- */
|
||||
|
||||
|
|
|
@ -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 )----------------------------------------- */
|
||||
|
||||
|
|
Loading…
Reference in a new issue