1
0
Fork 0
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:
epriestley 2014-10-08 15:33:25 -07:00
parent b6c65719e4
commit 1e8c314c81
23 changed files with 781 additions and 57 deletions

View 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);

View file

@ -2554,6 +2554,7 @@ phutil_register_library_map(array(
'PhortuneAccountViewController' => 'applications/phortune/controller/PhortuneAccountViewController.php', 'PhortuneAccountViewController' => 'applications/phortune/controller/PhortuneAccountViewController.php',
'PhortuneBalancedPaymentProvider' => 'applications/phortune/provider/PhortuneBalancedPaymentProvider.php', 'PhortuneBalancedPaymentProvider' => 'applications/phortune/provider/PhortuneBalancedPaymentProvider.php',
'PhortuneCart' => 'applications/phortune/storage/PhortuneCart.php', 'PhortuneCart' => 'applications/phortune/storage/PhortuneCart.php',
'PhortuneCartCancelController' => 'applications/phortune/controller/PhortuneCartCancelController.php',
'PhortuneCartCheckoutController' => 'applications/phortune/controller/PhortuneCartCheckoutController.php', 'PhortuneCartCheckoutController' => 'applications/phortune/controller/PhortuneCartCheckoutController.php',
'PhortuneCartController' => 'applications/phortune/controller/PhortuneCartController.php', 'PhortuneCartController' => 'applications/phortune/controller/PhortuneCartController.php',
'PhortuneCartImplementation' => 'applications/phortune/cart/PhortuneCartImplementation.php', 'PhortuneCartImplementation' => 'applications/phortune/cart/PhortuneCartImplementation.php',
@ -5608,6 +5609,7 @@ phutil_register_library_map(array(
'PhortuneDAO', 'PhortuneDAO',
'PhabricatorPolicyInterface', 'PhabricatorPolicyInterface',
), ),
'PhortuneCartCancelController' => 'PhortuneCartController',
'PhortuneCartCheckoutController' => 'PhortuneCartController', 'PhortuneCartCheckoutController' => 'PhortuneCartController',
'PhortuneCartController' => 'PhortuneController', 'PhortuneCartController' => 'PhortuneController',
'PhortuneCartListController' => 'PhortuneController', 'PhortuneCartListController' => 'PhortuneController',

View file

@ -115,4 +115,11 @@ final class FundBackerProduct extends PhortuneProductImplementation {
return; return;
} }
public function didRefundProduct(
PhortuneProduct $product,
PhortunePurchase $purchase) {
$viewer = $this->getViewer();
// TODO: Undonate.
}
} }

View file

@ -48,6 +48,7 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication {
'cart/(?P<id>\d+)/' => array( 'cart/(?P<id>\d+)/' => array(
'' => 'PhortuneCartViewController', '' => 'PhortuneCartViewController',
'checkout/' => 'PhortuneCartCheckoutController', 'checkout/' => 'PhortuneCartCheckoutController',
'(?P<action>cancel|refund)/' => 'PhortuneCartCancelController',
), ),
'account/' => array( 'account/' => array(
'' => 'PhortuneAccountListController', '' => 'PhortuneAccountListController',

View file

@ -16,6 +16,21 @@ abstract class PhortuneCartImplementation {
abstract public function getCancelURI(PhortuneCart $cart); abstract public function getCancelURI(PhortuneCart $cart);
abstract public function getDoneURI(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( abstract public function willCreateCart(
PhabricatorUser $viewer, PhabricatorUser $viewer,
PhortuneCart $cart); PhortuneCart $cart);

View file

@ -12,9 +12,20 @@ final class PhortuneAccountViewController extends PhortuneController {
$request = $this->getRequest(); $request = $this->getRequest();
$user = $request->getUser(); $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()) $account = id(new PhortuneAccountQuery())
->setViewer($user) ->setViewer($user)
->withIDs(array($this->accountID)) ->withIDs(array($this->accountID))
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->executeOne(); ->executeOne();
if (!$account) { if (!$account) {

View file

@ -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);
}
}

View file

@ -119,8 +119,12 @@ final class PhortuneCartCheckoutController
} }
} }
$cart_box = $this->buildCartContents($cart); $cart_table = $this->buildCartContentTable($cart);
$cart_box->setFormErrors($errors);
$cart_box = id(new PHUIObjectBoxView())
->setFormErrors($errors)
->setHeaderText(pht('Cart Contents'))
->appendChild($cart_table);
$title = pht('Buy Stuff'); $title = pht('Buy Stuff');

View file

@ -3,7 +3,7 @@
abstract class PhortuneCartController abstract class PhortuneCartController
extends PhortuneController { extends PhortuneController {
protected function buildCartContents(PhortuneCart $cart) { protected function buildCartContentTable(PhortuneCart $cart) {
$rows = array(); $rows = array();
foreach ($cart->getPurchases() as $purchase) { foreach ($cart->getPurchases() as $purchase) {
@ -39,9 +39,7 @@ abstract class PhortuneCartController
'right', 'right',
)); ));
return id(new PHUIObjectBoxView()) return $table;
->setHeaderText(pht('Cart Contents'))
->appendChild($table);
} }
} }

View file

@ -22,7 +22,26 @@ final class PhortuneCartViewController
return new Aphront404Response(); 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()) $charges = id(new PhortuneChargeQuery())
->setViewer($viewer) ->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;
}
} }

View file

@ -28,10 +28,12 @@ abstract class PhortuneController extends PhabricatorController {
$charge->getID(), $charge->getID(),
$handles[$charge->getCartPHID()]->renderLink(), $handles[$charge->getCartPHID()]->renderLink(),
$handles[$charge->getProviderPHID()]->renderLink(), $handles[$charge->getProviderPHID()]->renderLink(),
$handles[$charge->getPaymentMethodPHID()]->renderLink(), $charge->getPaymentMethodPHID()
? $handles[$charge->getPaymentMethodPHID()]->renderLink()
: null,
$handles[$charge->getMerchantPHID()]->renderLink(), $handles[$charge->getMerchantPHID()]->renderLink(),
$charge->getAmountAsCurrency()->formatForDisplay(), $charge->getAmountAsCurrency()->formatForDisplay(),
PhortuneCharge::getNameForStatus($charge->getStatus()), $charge->getStatusForDisplay(),
phabricator_datetime($charge->getDateCreated(), $viewer), phabricator_datetime($charge->getDateCreated(), $viewer),
); );
} }

View file

@ -48,14 +48,12 @@ final class PhortuneCurrency extends Phobject {
$value = (int)round(100 * $value); $value = (int)round(100 * $value);
$currency = idx($matches, 2, $default); $currency = idx($matches, 2, $default);
if ($currency) {
switch ($currency) { switch ($currency) {
case 'USD': case 'USD':
break; break;
default: default:
throw new Exception("Unsupported currency '{$currency}'!"); throw new Exception("Unsupported currency '{$currency}'!");
} }
}
return self::newFromValueAndCurrency($value, $currency); return self::newFromValueAndCurrency($value, $currency);
} }
@ -126,9 +124,17 @@ final class PhortuneCurrency extends Phobject {
throw new Exception("Invalid currency format ('{$string}')."); 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) { public function add(PhortuneCurrency $other) {
if ($this->currency !== $other->currency) { if ($this->currency !== $other->currency) {
throw new Exception(pht('Trying to add unlike currencies!')); $this->throwUnlikeCurrenciesException($other);
} }
$currency = new PhortuneCurrency(); $currency = new PhortuneCurrency();
@ -140,6 +146,46 @@ final class PhortuneCurrency extends Phobject {
return $currency; 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. * Assert that a currency value lies within a range.
* *

View file

@ -28,4 +28,10 @@ abstract class PhortuneProductImplementation {
return; return;
} }
public function didRefundProduct(
PhortuneProduct $product,
PhortunePurchase $purchase) {
return;
}
} }

View file

@ -179,6 +179,13 @@ final class PhortuneBalancedPaymentProvider extends PhortunePaymentProvider {
$charge->save(); $charge->save();
} }
protected function executeRefund(
PhortuneCharge $charge,
PhortuneCharge $refund) {
// TODO: Implement.
throw new PhortuneNotImplementedException($this);
}
private function getMarketplaceID() { private function getMarketplaceID() {
return $this return $this
->getProviderConfig() ->getProviderConfig()
@ -192,7 +199,7 @@ final class PhortuneBalancedPaymentProvider extends PhortunePaymentProvider {
} }
private function getMarketplaceURI() { private function getMarketplaceURI() {
return '/v1/marketplace/'.$this->getMarketplaceID(); return '/v1/marketplaces/'.$this->getMarketplaceID();
} }

View file

@ -167,6 +167,13 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
throw new Exception('!'); throw new Exception('!');
} }
protected function executeRefund(
PhortuneCharge $charge,
PhortuneCharge $refund) {
// TODO: Implement.
throw new PhortuneNotImplementedException($this);
}
private function getPaypalAPIUsername() { private function getPaypalAPIUsername() {
return $this return $this
->getProviderConfig() ->getProviderConfig()

View file

@ -137,10 +137,20 @@ abstract class PhortunePaymentProvider {
$this->executeCharge($payment_method, $charge); $this->executeCharge($payment_method, $charge);
} }
final public function refundCharge(
PhortuneCharge $charge,
PhortuneCharge $refund) {
$this->executeRefund($charge, $refund);
}
abstract protected function executeCharge( abstract protected function executeCharge(
PhortunePaymentMethod $payment_method, PhortunePaymentMethod $payment_method,
PhortuneCharge $charge); PhortuneCharge $charge);
abstract protected function executeRefund(
PhortuneCharge $charge,
PhortuneCharge $charge);
/* -( Adding Payment Methods )--------------------------------------------- */ /* -( Adding Payment Methods )--------------------------------------------- */

View file

@ -146,12 +146,7 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider {
'capture' => true, 'capture' => true,
); );
try {
$stripe_charge = Stripe_Charge::create($params, $secret_key); $stripe_charge = Stripe_Charge::create($params, $secret_key);
} catch (Stripe_CardError $ex) {
// TODO: Fail charge explicitly.
throw $ex;
}
$id = $stripe_charge->id; $id = $stripe_charge->id;
if (!$id) { if (!$id) {
@ -162,6 +157,41 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider {
$charge->save(); $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() { private function getPublishableKey() {
return $this return $this
->getProviderConfig() ->getProviderConfig()

View file

@ -56,6 +56,12 @@ final class PhortuneTestPaymentProvider extends PhortunePaymentProvider {
return; return;
} }
protected function executeRefund(
PhortuneCharge $charge,
PhortuneCharge $refund) {
return;
}
public function getAllConfigurableProperties() { public function getAllConfigurableProperties() {
return array(); return array();
} }

View file

@ -186,6 +186,29 @@ final class PhortuneWePayPaymentProvider extends PhortunePaymentProvider {
->getMetadataValue(self::WEPAY_ACCOUNT_ID); ->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 )-------------------------------------------------- */ /* -( One-Time Payments )-------------------------------------------------- */

View file

@ -106,6 +106,13 @@ final class PhortuneAccount extends PhortuneDAO
} }
public function getPolicy($capability) { 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) { if ($this->getPHID() === null) {
// Allow a user to create an account for themselves. // Allow a user to create an account for themselves.
return PhabricatorPolicies::POLICY_USER; return PhabricatorPolicies::POLICY_USER;
@ -113,6 +120,7 @@ final class PhortuneAccount extends PhortuneDAO
return PhabricatorPolicies::POLICY_NOONE; return PhabricatorPolicies::POLICY_NOONE;
} }
} }
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
$members = array_fuse($this->getMemberPHIDs()); $members = array_fuse($this->getMemberPHIDs());

View file

@ -98,14 +98,16 @@ final class PhortuneCart extends PhortuneDAO
if ($copy->getStatus() !== self::STATUS_READY) { if ($copy->getStatus() !== self::STATUS_READY) {
throw new Exception( throw new Exception(
pht( pht(
'Cart has wrong status ("%s") to call willApplyCharge(), expected '. 'Cart has wrong status ("%s") to call willApplyCharge(), '.
'"%s".', 'expected "%s".',
$copy->getStatus(), $copy->getStatus(),
self::STATUS_READY)); self::STATUS_READY));
} }
$charge->save(); $charge->save();
$this->setStatus(PhortuneCart::STATUS_PURCHASING)->save(); $this->setStatus(PhortuneCart::STATUS_PURCHASING)->save();
$this->endReadLocking();
$this->saveTransaction(); $this->saveTransaction();
return $charge; return $charge;
@ -123,14 +125,16 @@ final class PhortuneCart extends PhortuneDAO
if ($copy->getStatus() !== self::STATUS_PURCHASING) { if ($copy->getStatus() !== self::STATUS_PURCHASING) {
throw new Exception( throw new Exception(
pht( pht(
'Cart has wrong status ("%s") to call didApplyCharge(), expected '. 'Cart has wrong status ("%s") to call didApplyCharge(), '.
'"%s".', 'expected "%s".',
$copy->getStatus(), $copy->getStatus(),
self::STATUS_PURCHASING)); self::STATUS_PURCHASING));
} }
$charge->save(); $charge->save();
$this->setStatus(self::STATUS_CHARGED)->save(); $this->setStatus(self::STATUS_CHARGED)->save();
$this->endReadLocking();
$this->saveTransaction(); $this->saveTransaction();
foreach ($this->purchases as $purchase) { foreach ($this->purchases as $purchase) {
@ -142,6 +146,127 @@ final class PhortuneCart extends PhortuneDAO
return $this; 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() { public function getName() {
return $this->getImplementation()->getName($this); return $this->getImplementation()->getName($this);
} }
@ -162,6 +287,56 @@ final class PhortuneCart extends PhortuneDAO
return '/phortune/cart/'.$this->getID().'/checkout/'; 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() { public function getConfiguration() {
return array( return array(
self::CONFIG_AUX_PHID => true, self::CONFIG_AUX_PHID => true,
@ -260,11 +435,30 @@ final class PhortuneCart extends PhortuneDAO
} }
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { 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) { 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.'),
);
} }
} }

View file

@ -20,6 +20,9 @@ final class PhortuneCharge extends PhortuneDAO
protected $merchantPHID; protected $merchantPHID;
protected $paymentMethodPHID; protected $paymentMethodPHID;
protected $amountAsCurrency; protected $amountAsCurrency;
protected $amountRefundedAsCurrency;
protected $refundedChargePHID;
protected $refundingPHID;
protected $status; protected $status;
protected $metadata = array(); protected $metadata = array();
@ -28,7 +31,8 @@ final class PhortuneCharge extends PhortuneDAO
public static function initializeNewCharge() { public static function initializeNewCharge() {
return id(new PhortuneCharge()) return id(new PhortuneCharge())
->setStatus(self::STATUS_CHARGING); ->setStatus(self::STATUS_CHARGING)
->setAmountRefundedAsCurrency(PhortuneCurrency::newEmptyCurrency());
} }
public function getConfiguration() { public function getConfiguration() {
@ -39,11 +43,14 @@ final class PhortuneCharge extends PhortuneDAO
), ),
self::CONFIG_APPLICATION_SERIALIZERS => array( self::CONFIG_APPLICATION_SERIALIZERS => array(
'amountAsCurrency' => new PhortuneCurrencySerializer(), 'amountAsCurrency' => new PhortuneCurrencySerializer(),
'amountRefundedAsCurrency' => new PhortuneCurrencySerializer(),
), ),
self::CONFIG_COLUMN_SCHEMA => array( self::CONFIG_COLUMN_SCHEMA => array(
'paymentProviderKey' => 'text128',
'paymentMethodPHID' => 'phid?', 'paymentMethodPHID' => 'phid?',
'refundedChargePHID' => 'phid?',
'refundingPHID' => 'phid?',
'amountAsCurrency' => 'text64', 'amountAsCurrency' => 'text64',
'amountRefundedAsCurrency' => 'text64',
'status' => 'text32', 'status' => 'text32',
), ),
self::CONFIG_KEY_SCHEMA => array( self::CONFIG_KEY_SCHEMA => array(
@ -75,6 +82,26 @@ final class PhortuneCharge extends PhortuneDAO
return idx(self::getStatusNameMap(), $status, pht('Unknown')); 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() { public function generatePHID() {
return PhabricatorPHID::generateNewPHID( return PhabricatorPHID::generateNewPHID(
PhortuneChargePHIDType::TYPECONST); PhortuneChargePHIDType::TYPECONST);
@ -107,6 +134,21 @@ final class PhortuneCharge extends PhortuneDAO
return $this; 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 )----------------------------------------- */ /* -( PhabricatorPolicyInterface )----------------------------------------- */

View file

@ -78,6 +78,10 @@ final class PhortuneProduct extends PhortuneDAO
return $this->getImplementation()->didPurchaseProduct($this, $purchase); return $this->getImplementation()->didPurchaseProduct($this, $purchase);
} }
public function didRefundProduct(PhortunePurchase $purchase) {
return $this->getImplementation()->didRefundProduct($this, $purchase);
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */ /* -( PhabricatorPolicyInterface )----------------------------------------- */