diff --git a/src/applications/fund/phortune/FundBackerProduct.php b/src/applications/fund/phortune/FundBackerProduct.php index ebe9f31843..28f3d8c7cc 100644 --- a/src/applications/fund/phortune/FundBackerProduct.php +++ b/src/applications/fund/phortune/FundBackerProduct.php @@ -1,125 +1,127 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function getRef() { return $this->getInitiativePHID(); } public function getName(PhortuneProduct $product) { $initiative = $this->getInitiative(); return pht( 'Fund %s %s', $initiative->getMonogram(), $initiative->getName()); } public function getPriceAsCurrency(PhortuneProduct $product) { return PhortuneCurrency::newEmptyCurrency(); } public function setInitiativePHID($initiative_phid) { $this->initiativePHID = $initiative_phid; return $this; } public function getInitiativePHID() { return $this->initiativePHID; } public function setInitiative(FundInitiative $initiative) { $this->initiative = $initiative; return $this; } public function getInitiative() { return $this->initiative; } public function loadImplementationsForRefs( PhabricatorUser $viewer, array $refs) { $initiatives = id(new FundInitiativeQuery()) ->setViewer($viewer) ->withPHIDs($refs) ->execute(); $initiatives = mpull($initiatives, null, 'getPHID'); $objects = array(); foreach ($refs as $ref) { $object = id(new FundBackerProduct()) ->setViewer($viewer) ->setInitiativePHID($ref); $initiative = idx($initiatives, $ref); if ($initiative) { $object->setInitiative($initiative); } $objects[] = $object; } return $objects; } public function didPurchaseProduct( PhortuneProduct $product, PhortunePurchase $purchase) { + // TODO: This viewer may be wrong if the purchase completes after a hold + // we should load the backer explicitly. $viewer = $this->getViewer(); $backer = id(new FundBackerQuery()) ->setViewer($viewer) ->withPHIDs(array($purchase->getMetadataValue('backerPHID'))) ->executeOne(); if (!$backer) { throw new Exception(pht('Unable to load FundBacker!')); } $xactions = array(); $xactions[] = id(new FundBackerTransaction()) ->setTransactionType(FundBackerTransaction::TYPE_STATUS) ->setNewValue(FundBacker::STATUS_PURCHASED); $editor = id(new FundBackerEditor()) ->setActor($viewer) ->setContentSource($this->getContentSource()); $editor->applyTransactions($backer, $xactions); $xactions = array(); $xactions[] = id(new FundInitiativeTransaction()) ->setTransactionType(FundInitiativeTransaction::TYPE_BACKER) ->setNewValue($backer->getPHID()); $editor = id(new FundInitiativeEditor()) ->setActor($viewer) ->setContentSource($this->getContentSource()); $editor->applyTransactions($this->getInitiative(), $xactions); return; } public function didRefundProduct( PhortuneProduct $product, PhortunePurchase $purchase) { $viewer = $this->getViewer(); // TODO: Undonate. } } diff --git a/src/applications/phortune/controller/PhortuneAccountViewController.php b/src/applications/phortune/controller/PhortuneAccountViewController.php index 89d6a84149..40da7157ce 100644 --- a/src/applications/phortune/controller/PhortuneAccountViewController.php +++ b/src/applications/phortune/controller/PhortuneAccountViewController.php @@ -1,285 +1,294 @@ accountID = $data['accountID']; } public function processRequest() { $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) { return new Aphront404Response(); } $title = $account->getName(); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Account'), $request->getRequestURI()); $header = id(new PHUIHeaderView()) ->setHeader($title); $actions = id(new PhabricatorActionListView()) ->setUser($user) ->setObjectURI($request->getRequestURI()) ->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Account')) ->setIcon('fa-pencil') ->setHref('#') ->setDisabled(true)) ->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Members')) ->setIcon('fa-users') ->setHref('#') ->setDisabled(true)); $crumbs->setActionList($actions); $properties = id(new PHUIPropertyListView()) ->setObject($account) ->setUser($user); $properties->setActionList($actions); $payment_methods = $this->buildPaymentMethodsSection($account); $purchase_history = $this->buildPurchaseHistorySection($account); $charge_history = $this->buildChargeHistorySection($account); $account_history = $this->buildAccountHistorySection($account); $object_box = id(new PHUIObjectBoxView()) ->setHeader($header) ->addPropertyList($properties); return $this->buildApplicationPage( array( $crumbs, $object_box, $payment_methods, $purchase_history, $charge_history, $account_history, ), array( 'title' => $title, )); } private function buildPaymentMethodsSection(PhortuneAccount $account) { $request = $this->getRequest(); $viewer = $request->getUser(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $account, PhabricatorPolicyCapability::CAN_EDIT); $id = $account->getID(); $header = id(new PHUIHeaderView()) ->setHeader(pht('Payment Methods')); $list = id(new PHUIObjectItemListView()) ->setUser($viewer) ->setNoDataString( pht('No payment methods associated with this account.')); $methods = id(new PhortunePaymentMethodQuery()) ->setViewer($viewer) ->withAccountPHIDs(array($account->getPHID())) ->execute(); if ($methods) { $this->loadHandles(mpull($methods, 'getAuthorPHID')); } foreach ($methods as $method) { $id = $method->getID(); $item = new PHUIObjectItemView(); $item->setHeader($method->getFullDisplayName()); switch ($method->getStatus()) { case PhortunePaymentMethod::STATUS_ACTIVE: $item->setBarColor('green'); $disable_uri = $this->getApplicationURI('card/'.$id.'/disable/'); $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-times') ->setHref($disable_uri) ->setDisabled(!$can_edit) ->setWorkflow(true)); break; case PhortunePaymentMethod::STATUS_DISABLED: $item->setDisabled(true); break; } $provider = $method->buildPaymentProvider(); $item->addAttribute($provider->getPaymentMethodProviderDescription()); $edit_uri = $this->getApplicationURI('card/'.$id.'/edit/'); $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-pencil') ->setHref($edit_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $list->addItem($item); } return id(new PHUIObjectBoxView()) ->setHeader($header) ->appendChild($list); } private function buildPurchaseHistorySection(PhortuneAccount $account) { $request = $this->getRequest(); $viewer = $request->getUser(); $carts = id(new PhortuneCartQuery()) ->setViewer($viewer) ->withAccountPHIDs(array($account->getPHID())) ->needPurchases(true) ->withStatuses( array( PhortuneCart::STATUS_PURCHASING, + PhortuneCart::STATUS_CHARGED, + PhortuneCart::STATUS_HOLD, PhortuneCart::STATUS_PURCHASED, )) ->execute(); $phids = array(); foreach ($carts as $cart) { $phids[] = $cart->getPHID(); foreach ($cart->getPurchases() as $purchase) { $phids[] = $purchase->getPHID(); } } $handles = $this->loadViewerHandles($phids); $rows = array(); $rowc = array(); foreach ($carts as $cart) { $cart_link = $handles[$cart->getPHID()]->renderLink(); $purchases = $cart->getPurchases(); if (count($purchases) == 1) { $purchase_name = $handles[$purchase->getPHID()]->renderLink(); $purchases = array(); } else { $purchase_name = ''; } $rowc[] = ''; $rows[] = array( + $cart->getID(), phutil_tag( 'strong', array(), $cart_link), $purchase_name, phutil_tag( 'strong', array(), $cart->getTotalPriceAsCurrency()->formatForDisplay()), + PhortuneCart::getNameForStatus($cart->getStatus()), phabricator_datetime($cart->getDateModified(), $viewer), ); foreach ($purchases as $purchase) { $id = $purchase->getID(); $price = $purchase->getTotalPriceAsCurrency()->formatForDisplay(); $rowc[] = ''; $rows[] = array( '', $handles[$purchase->getPHID()]->renderLink(), $price, '', + '', ); } } $table = id(new AphrontTableView($rows)) ->setRowClasses($rowc) ->setHeaders( array( - pht('Cart'), + pht('ID'), + pht('Order'), pht('Purchase'), pht('Amount'), + pht('Status'), pht('Updated'), )) ->setColumnClasses( array( + '', '', 'wide', 'right', + '', 'right', )); $header = id(new PHUIHeaderView()) - ->setHeader(pht('Purchase History')); + ->setHeader(pht('Order History')); return id(new PHUIObjectBoxView()) ->setHeader($header) ->appendChild($table); } private function buildChargeHistorySection(PhortuneAccount $account) { $request = $this->getRequest(); $viewer = $request->getUser(); $charges = id(new PhortuneChargeQuery()) ->setViewer($viewer) ->withAccountPHIDs(array($account->getPHID())) ->needCarts(true) ->execute(); return $this->buildChargesTable($charges); } private function buildAccountHistorySection(PhortuneAccount $account) { $request = $this->getRequest(); $user = $request->getUser(); $xactions = id(new PhortuneAccountTransactionQuery()) ->setViewer($user) ->withObjectPHIDs(array($account->getPHID())) ->execute(); $engine = id(new PhabricatorMarkupEngine()) ->setViewer($user); $xaction_view = id(new PhabricatorApplicationTransactionView()) ->setUser($user) ->setObjectPHID($account->getPHID()) ->setTransactions($xactions) ->setMarkupEngine($engine); return $xaction_view; } } diff --git a/src/applications/phortune/controller/PhortuneCartUpdateController.php b/src/applications/phortune/controller/PhortuneCartUpdateController.php index 8bbd6eaa95..b9477fc68b 100644 --- a/src/applications/phortune/controller/PhortuneCartUpdateController.php +++ b/src/applications/phortune/controller/PhortuneCartUpdateController.php @@ -1,31 +1,65 @@ id = $data['id']; } 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(); } - // TODO: This obviously doesn't do anything for now. + $charges = id(new PhortuneChargeQuery()) + ->setViewer($viewer) + ->withCartPHIDs(array($cart->getPHID())) + ->needCarts(true) + ->withStatuses( + array( + PhortuneCharge::STATUS_HOLD, + PhortuneCharge::STATUS_CHARGED, + )) + ->execute(); + + 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) { + if ($charge->isRefund()) { + // Don't update refunds. + continue; + } + + $provider_config = idx($providers, $charge->getProviderPHID()); + if (!$provider_config) { + throw new Exception(pht('Unable to load provider for charge!')); + } + + $provider = $provider_config->buildProvider(); + $provider->updateCharge($charge); + } return id(new AphrontRedirectResponse()) ->setURI($cart->getDetailURI()); } } diff --git a/src/applications/phortune/controller/PhortuneCartViewController.php b/src/applications/phortune/controller/PhortuneCartViewController.php index 8f2b54c248..b909a996f5 100644 --- a/src/applications/phortune/controller/PhortuneCartViewController.php +++ b/src/applications/phortune/controller/PhortuneCartViewController.php @@ -1,211 +1,210 @@ id = $data['id']; } 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(); } $can_admin = PhabricatorPolicyFilter::hasCapability( $viewer, $cart->getMerchant(), PhabricatorPolicyCapability::CAN_EDIT); $cart_table = $this->buildCartContentTable($cart); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $cart, PhabricatorPolicyCapability::CAN_EDIT); $errors = array(); $resume_uri = null; switch ($cart->getStatus()) { case PhortuneCart::STATUS_PURCHASING: if ($can_edit) { $resume_uri = $cart->getMetadataValue('provider.checkoutURI'); if ($resume_uri) { $errors[] = pht( 'The checkout process has been started, but not yet completed. '. 'You can continue checking out by clicking %s, or cancel the '. 'order, or contact the merchant for assistance.', phutil_tag('strong', array(), pht('Continue Checkout'))); } else { $errors[] = pht( 'The checkout process has been started, but an error occurred. '. 'You can cancel the order or contact the merchant for '. 'assistance.'); } } break; case PhortuneCart::STATUS_CHARGED: if ($can_edit) { $errors[] = pht( 'You have been charged, but processing could not be completed. '. 'You can cancel your order, or contact the merchant for '. 'assistance.'); } break; case PhortuneCart::STATUS_HOLD: if ($can_edit) { $errors[] = pht( 'Payment for this order is on hold. You can click %s to check '. 'for updates, cancel the order, or contact the merchant for '. 'assistance.', phutil_tag('strong', array(), pht('Update Status'))); } break; } $properties = $this->buildPropertyListView($cart); $actions = $this->buildActionListView( $cart, $can_edit, $can_admin, $resume_uri); $properties->setActionList($actions); $header = id(new PHUIHeaderView()) ->setUser($viewer) - ->setHeader(pht('Order Detail')) - ->setPolicyObject($cart); + ->setHeader(pht('Order Detail')); $cart_box = id(new PHUIObjectBoxView()) ->setHeader($header) ->setFormErrors($errors) ->appendChild($properties) ->appendChild($cart_table); $charges = id(new PhortuneChargeQuery()) ->setViewer($viewer) ->withCartPHIDs(array($cart->getPHID())) ->needCarts(true) ->execute(); $charges_table = $this->buildChargesTable($charges, false); $account = $cart->getAccount(); $crumbs = $this->buildApplicationCrumbs(); $this->addAccountCrumb($crumbs, $cart->getAccount()); $crumbs->addTextCrumb(pht('Cart %d', $cart->getID())); return $this->buildApplicationPage( array( $crumbs, $cart_box, $charges_table, ), array( 'title' => pht('Cart'), )); } 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_edit, $can_admin, $resume_uri) { $viewer = $this->getRequest()->getUser(); $id = $cart->getID(); $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/"); $update_uri = $this->getApplicationURI("cart/{$id}/update/"); $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)); } $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Update Status')) ->setIcon('fa-refresh') ->setHref($update_uri)); if ($can_edit && $resume_uri) { $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Continue Checkout')) ->setIcon('fa-shopping-cart') ->setHref($resume_uri)); } return $view; } } diff --git a/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php b/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php index 92d1d64fb0..87ab157716 100644 --- a/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneBalancedPaymentProvider.php @@ -1,360 +1,370 @@ getSecretKey()); } public function getName() { return pht('Balanced Payments'); } public function getConfigureName() { return pht('Add Balanced Payments Account'); } public function getConfigureDescription() { return pht( 'Allows you to accept credit or debit card payments with a '. 'balancedpayments.com account.'); } public function getConfigureProvidesDescription() { return pht( 'This merchant accepts credit and debit cards via Balanced Payments.'); } public function getConfigureInstructions() { return pht( "To configure Balacned, register or log in to an existing account on ". "[[https://balancedpayments.com | balancedpayments.com]]. Once logged ". "in:\n\n". " - Choose a marketplace.\n". " - Find the **Marketplace ID** in {nav My Marketplace > Settings} and ". " copy it into the field above.\n". " - On the same screen, under **API keys**, choose **Add a key**, then ". " **Show key secret**. Copy the value into the field above.\n\n". "You can either use a test marketplace to add this provider in test ". "mode, or use a live marketplace to accept live payments."); } public function getAllConfigurableProperties() { return array( self::BALANCED_MARKETPLACE_ID, self::BALANCED_SECRET_KEY, ); } public function getAllConfigurableSecretProperties() { return array( self::BALANCED_SECRET_KEY, ); } public function processEditForm( AphrontRequest $request, array $values) { $errors = array(); $issues = array(); if (!strlen($values[self::BALANCED_MARKETPLACE_ID])) { $errors[] = pht('Balanced Marketplace ID is required.'); $issues[self::BALANCED_MARKETPLACE_ID] = pht('Required'); } if (!strlen($values[self::BALANCED_SECRET_KEY])) { $errors[] = pht('Balanced Secret Key is required.'); $issues[self::BALANCED_SECRET_KEY] = pht('Required'); } return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $form ->appendChild( id(new AphrontFormTextControl()) ->setName(self::BALANCED_MARKETPLACE_ID) ->setValue($values[self::BALANCED_MARKETPLACE_ID]) ->setError(idx($issues, self::BALANCED_MARKETPLACE_ID, true)) ->setLabel(pht('Balanced Marketplace ID'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::BALANCED_SECRET_KEY) ->setValue($values[self::BALANCED_SECRET_KEY]) ->setError(idx($issues, self::BALANCED_SECRET_KEY, true)) ->setLabel(pht('Balanced Secret Key'))); } public function canRunConfigurationTest() { return true; } public function runConfigurationTest() { - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/httpful/bootstrap.php'; - require_once $root.'/externals/restful/bootstrap.php'; - require_once $root.'/externals/balanced-php/bootstrap.php'; + $this->loadBalancedAPILibraries(); // TODO: This only tests that the secret key is correct. It's not clear // how to test that the marketplace is correct. try { Balanced\Settings::$api_key = $this->getSecretKey(); Balanced\APIKey::query()->first(); } catch (RESTful\Exceptions\HTTPError $error) { // NOTE: This exception doesn't print anything meaningful if it escapes // to top level. Replace it with something slightly readable. throw new Exception($error->response->body->description); } } public function getPaymentMethodDescription() { return pht('Add Credit or Debit Card'); } public function getPaymentMethodIcon() { return 'Balanced'; } public function getPaymentMethodProviderDescription() { return pht('Processed by Balanced'); } public function getDefaultPaymentMethodDisplayName( PhortunePaymentMethod $method) { return pht('Credit/Debit Card'); } protected function executeCharge( PhortunePaymentMethod $method, PhortuneCharge $charge) { - - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/httpful/bootstrap.php'; - require_once $root.'/externals/restful/bootstrap.php'; - require_once $root.'/externals/balanced-php/bootstrap.php'; + $this->loadBalancedAPILibraries(); $price = $charge->getAmountAsCurrency(); // Build the string which will appear on the credit card statement. $charge_as = new PhutilURI(PhabricatorEnv::getProductionURI('/')); $charge_as = $charge_as->getDomain(); $charge_as = id(new PhutilUTF8StringTruncator()) ->setMaximumBytes(22) ->setTerminator('') ->truncateString($charge_as); try { Balanced\Settings::$api_key = $this->getSecretKey(); $card = Balanced\Card::get($method->getMetadataValue('balanced.cardURI')); $debit = $card->debit($price->getValueInUSDCents(), $charge_as); } catch (RESTful\Exceptions\HTTPError $error) { // NOTE: This exception doesn't print anything meaningful if it escapes // to top level. Replace it with something slightly readable. throw new Exception($error->response->body->description); } $expect_status = 'succeeded'; if ($debit->status !== $expect_status) { throw new Exception( pht( 'Debit failed, expected "%s", got "%s".', $expect_status, $debit->status)); } $charge->setMetadataValue('balanced.debitURI', $debit->uri); $charge->save(); } protected function executeRefund( PhortuneCharge $charge, PhortuneCharge $refund) { - - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/httpful/bootstrap.php'; - require_once $root.'/externals/restful/bootstrap.php'; - require_once $root.'/externals/balanced-php/bootstrap.php'; + $this->loadBalancedAPILibraries(); $debit_uri = $charge->getMetadataValue('balanced.debitURI'); if (!$debit_uri) { throw new Exception(pht('No Balanced debit URI!')); } $refund_cents = $refund ->getAmountAsCurrency() ->negate() ->getValueInUSDCents(); $params = array( 'amount' => $refund_cents, ); try { Balanced\Settings::$api_key = $this->getSecretKey(); $balanced_debit = Balanced\Debit::get($debit_uri); $balanced_refund = $balanced_debit->refunds->create($params); } catch (RESTful\Exceptions\HTTPError $error) { throw new Exception($error->response->body->description); } $refund->setMetadataValue('balanced.refundURI', $balanced_refund->uri); $refund->save(); } + public function updateCharge(PhortuneCharge $charge) { + $this->loadBalancedAPILibraries(); + + $debit_uri = $charge->getMetadataValue('balanced.debitURI'); + if (!$debit_uri) { + throw new Exception(pht('No Balanced debit URI!')); + } + + try { + Balanced\Settings::$api_key = $this->getSecretKey(); + $balanced_debit = Balanced\Debit::get($debit_uri); + } catch (RESTful\Exceptions\HTTPError $error) { + throw new Exception($error->response->body->description); + } + + // TODO: Deal with disputes / chargebacks / surprising refunds. + } + private function getMarketplaceID() { return $this ->getProviderConfig() ->getMetadataValue(self::BALANCED_MARKETPLACE_ID); } private function getSecretKey() { return $this ->getProviderConfig() ->getMetadataValue(self::BALANCED_SECRET_KEY); } private function getMarketplaceURI() { return '/v1/marketplaces/'.$this->getMarketplaceID(); } /* -( Adding Payment Methods )--------------------------------------------- */ public function canCreatePaymentMethods() { return true; } public function validateCreatePaymentMethodToken(array $token) { return isset($token['balancedMarketplaceURI']); } /** * @phutil-external-symbol class Balanced\Card * @phutil-external-symbol class Balanced\Debit * @phutil-external-symbol class Balanced\Settings * @phutil-external-symbol class Balanced\Marketplace * @phutil-external-symbol class Balanced\APIKey * @phutil-external-symbol class RESTful\Exceptions\HTTPError */ public function createPaymentMethodFromRequest( AphrontRequest $request, PhortunePaymentMethod $method, array $token) { + $this->loadBalancedAPILibraries(); $errors = array(); - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/httpful/bootstrap.php'; - require_once $root.'/externals/restful/bootstrap.php'; - require_once $root.'/externals/balanced-php/bootstrap.php'; - $account_phid = $method->getAccountPHID(); $author_phid = $method->getAuthorPHID(); $description = $account_phid.':'.$author_phid; try { Balanced\Settings::$api_key = $this->getSecretKey(); $card = Balanced\Card::get($token['balancedMarketplaceURI']); $buyer = Balanced\Marketplace::mine()->createBuyer( null, $card->uri, array( 'description' => $description, )); } catch (RESTful\Exceptions\HTTPError $error) { // NOTE: This exception doesn't print anything meaningful if it escapes // to top level. Replace it with something slightly readable. throw new Exception($error->response->body->description); } $method ->setBrand($card->brand) ->setLastFourDigits($card->last_four) ->setExpires($card->expiration_year, $card->expiration_month) ->setMetadata( array( 'type' => 'balanced.account', 'balanced.accountURI' => $buyer->uri, 'balanced.cardURI' => $card->uri, )); return $errors; } public function renderCreatePaymentMethodForm( AphrontRequest $request, array $errors) { $ccform = id(new PhortuneCreditCardForm()) ->setUser($request->getUser()) ->setErrors($errors) ->addScript('https://js.balancedpayments.com/v1/balanced.js'); Javelin::initBehavior( 'balanced-payment-form', array( 'balancedMarketplaceURI' => $this->getMarketplaceURI(), 'formID' => $ccform->getFormID(), )); return $ccform->buildForm(); } private function getBalancedShortErrorCode($error_code) { $prefix = 'cc:balanced:'; if (strncmp($error_code, $prefix, strlen($prefix))) { return null; } return substr($error_code, strlen($prefix)); } public function translateCreatePaymentMethodErrorCode($error_code) { $short_code = $this->getBalancedShortErrorCode($error_code); if ($short_code) { static $map = array( ); if (isset($map[$short_code])) { return $map[$short_code]; } } return $error_code; } public function getCreatePaymentMethodErrorMessage($error_code) { $short_code = $this->getBalancedShortErrorCode($error_code); if (!$short_code) { return null; } switch ($short_code) { default: break; } return null; } + private function loadBalancedAPILibraries() { + $root = dirname(phutil_get_library_root('phabricator')); + require_once $root.'/externals/httpful/bootstrap.php'; + require_once $root.'/externals/restful/bootstrap.php'; + require_once $root.'/externals/balanced-php/bootstrap.php'; + } + } diff --git a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php index 300f5de4f2..258d8875f2 100644 --- a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php +++ b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php @@ -1,449 +1,506 @@ getProviderConfig()->getMetadataValue(self::PAYPAL_MODE); return ($mode === 'live'); } public function getName() { return pht('PayPal'); } public function getConfigureName() { return pht('Add PayPal Payments Account'); } public function getConfigureDescription() { return pht( 'Allows you to accept various payment instruments with a paypal.com '. 'account.'); } public function getConfigureProvidesDescription() { return pht( 'This merchant accepts payments via PayPal.'); } public function getConfigureInstructions() { return pht( "To configure PayPal, register or log into an existing account on ". "[[https://paypal.com | paypal.com]] (for live payments) or ". "[[https://sandbox.paypal.com | sandbox.paypal.com]] (for test ". "payments). Once logged in:\n\n". " - Navigate to {nav Tools > API Access}.\n". " - Choose **View API Signature**.\n". " - Copy the **API Username**, **API Password** and **Signature** ". " into the fields above.\n\n". "You can select whether the provider operates in test mode or ". "accepts live payments using the **Mode** dropdown above.\n\n". "You can either use `sandbox.paypal.com` to retrieve live credentials, ". "or `paypal.com` to retrieve live credentials."); } public function getAllConfigurableProperties() { return array( self::PAYPAL_API_USERNAME, self::PAYPAL_API_PASSWORD, self::PAYPAL_API_SIGNATURE, self::PAYPAL_MODE, ); } public function getAllConfigurableSecretProperties() { return array( self::PAYPAL_API_PASSWORD, self::PAYPAL_API_SIGNATURE, ); } public function processEditForm( AphrontRequest $request, array $values) { $errors = array(); $issues = array(); if (!strlen($values[self::PAYPAL_API_USERNAME])) { $errors[] = pht('PayPal API Username is required.'); $issues[self::PAYPAL_API_USERNAME] = pht('Required'); } if (!strlen($values[self::PAYPAL_API_PASSWORD])) { $errors[] = pht('PayPal API Password is required.'); $issues[self::PAYPAL_API_PASSWORD] = pht('Required'); } if (!strlen($values[self::PAYPAL_API_SIGNATURE])) { $errors[] = pht('PayPal API Signature is required.'); $issues[self::PAYPAL_API_SIGNATURE] = pht('Required'); } if (!strlen($values[self::PAYPAL_MODE])) { $errors[] = pht('Mode is required.'); $issues[self::PAYPAL_MODE] = pht('Required'); } return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $form ->appendChild( id(new AphrontFormTextControl()) ->setName(self::PAYPAL_API_USERNAME) ->setValue($values[self::PAYPAL_API_USERNAME]) ->setError(idx($issues, self::PAYPAL_API_USERNAME, true)) ->setLabel(pht('Paypal API Username'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::PAYPAL_API_PASSWORD) ->setValue($values[self::PAYPAL_API_PASSWORD]) ->setError(idx($issues, self::PAYPAL_API_PASSWORD, true)) ->setLabel(pht('Paypal API Password'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::PAYPAL_API_SIGNATURE) ->setValue($values[self::PAYPAL_API_SIGNATURE]) ->setError(idx($issues, self::PAYPAL_API_SIGNATURE, true)) ->setLabel(pht('Paypal API Signature'))) ->appendChild( id(new AphrontFormSelectControl()) ->setName(self::PAYPAL_MODE) ->setValue($values[self::PAYPAL_MODE]) ->setError(idx($issues, self::PAYPAL_MODE)) ->setLabel(pht('Mode')) ->setOptions( array( 'test' => pht('Test Mode'), 'live' => pht('Live Mode'), ))); return; } public function canRunConfigurationTest() { return true; } public function runConfigurationTest() { $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('GetBalance', array()) ->resolve(); } public function getPaymentMethodDescription() { return pht('Credit Card or PayPal Account'); } public function getPaymentMethodIcon() { return 'PayPal'; } public function getPaymentMethodProviderDescription() { return 'PayPal'; } protected function executeCharge( PhortunePaymentMethod $payment_method, PhortuneCharge $charge) { throw new Exception('!'); } protected function executeRefund( PhortuneCharge $charge, PhortuneCharge $refund) { $transaction_id = $charge->getMetadataValue('paypal.transactionID'); if (!$transaction_id) { throw new Exception(pht('Charge has no transaction ID!')); } $refund_amount = $refund->getAmountAsCurrency()->negate(); $refund_currency = $refund_amount->getCurrency(); $refund_value = $refund_amount->formatBareValue(); $params = array( 'TRANSACTIONID' => $transaction_id, 'REFUNDTYPE' => 'Partial', 'AMT' => $refund_value, 'CURRENCYCODE' => $refund_currency, ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('RefundTransaction', $params) ->resolve(); $charge->setMetadataValue( 'paypal.refundID', $result['REFUNDTRANSACTIONID']); } + public function updateCharge(PhortuneCharge $charge) { + $transaction_id = $charge->getMetadataValue('paypal.transactionID'); + if (!$transaction_id) { + throw new Exception(pht('Charge has no transaction ID!')); + } + + $params = array( + 'TRANSACTIONID' => $transaction_id, + ); + + $result = $this + ->newPaypalAPICall() + ->setRawPayPalQuery('GetTransactionDetails', $params) + ->resolve(); + + $is_charge = false; + $is_fail = false; + switch ($result['PAYMENTSTATUS']) { + case 'Processed': + case 'Completed': + case 'Completed-Funds-Held': + $is_charge = true; + break; + case 'Partially-Refunded': + case 'Refunded': + case 'Reversed': + case 'Canceled-Reversal': + // TODO: Handle these. + return; + case 'In-Progress': + case 'Pending': + // TODO: Also handle these better? + return; + case 'Denied': + case 'Expired': + case 'Failed': + case 'None': + case 'Voided': + default: + $is_fail = true; + break; + } + + if ($charge->getStatus() == PhortuneCharge::STATUS_HOLD) { + $cart = $charge->getCart(); + + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + if ($is_charge) { + $cart->didApplyCharge($charge); + } else if ($is_fail) { + $cart->didFailCharge($charge); + } + unset($unguarded); + } + } + private function getPaypalAPIUsername() { return $this ->getProviderConfig() ->getMetadataValue(self::PAYPAL_API_USERNAME); } private function getPaypalAPIPassword() { return $this ->getProviderConfig() ->getMetadataValue(self::PAYPAL_API_PASSWORD); } private function getPaypalAPISignature() { return $this ->getProviderConfig() ->getMetadataValue(self::PAYPAL_API_SIGNATURE); } /* -( One-Time Payments )-------------------------------------------------- */ public function canProcessOneTimePayments() { return true; } /* -( Controllers )-------------------------------------------------------- */ public function canRespondToControllerAction($action) { switch ($action) { case 'checkout': case 'charge': case 'cancel': return true; } return parent::canRespondToControllerAction(); } public function processControllerRequest( PhortuneProviderActionController $controller, AphrontRequest $request) { $viewer = $request->getUser(); $cart = $controller->loadCart($request->getInt('cartID')); if (!$cart) { return new Aphront404Response(); } $charge = $controller->loadActiveCharge($cart); switch ($controller->getAction()) { case 'checkout': if ($charge) { throw new Exception(pht('Cart is already charging!')); } break; case 'charge': case 'cancel': if (!$charge) { throw new Exception(pht('Cart is not charging yet!')); } break; } switch ($controller->getAction()) { case 'checkout': $return_uri = $this->getControllerURI( 'charge', array( 'cartID' => $cart->getID(), )); $cancel_uri = $this->getControllerURI( 'cancel', array( 'cartID' => $cart->getID(), )); $price = $cart->getTotalPriceAsCurrency(); $charge = $cart->willApplyCharge($viewer, $this); $params = array( 'PAYMENTREQUEST_0_AMT' => $price->formatBareValue(), 'PAYMENTREQUEST_0_CURRENCYCODE' => $price->getCurrency(), 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', 'PAYMENTREQUEST_0_CUSTOM' => $charge->getPHID(), + 'PAYMENTREQUEST_0_DESC' => $cart->getName(), 'RETURNURL' => $return_uri, 'CANCELURL' => $cancel_uri, // TODO: This should be cart-dependent if we eventually support // physical goods. 'NOSHIPPING' => '1', ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('SetExpressCheckout', $params) ->resolve(); $uri = new PhutilURI('https://www.sandbox.paypal.com/cgi-bin/webscr'); $uri->setQueryParams( array( 'cmd' => '_express-checkout', 'token' => $result['TOKEN'], )); $cart->setMetadataValue('provider.checkoutURI', (string)$uri); $cart->save(); $charge->setMetadataValue('paypal.token', $result['TOKEN']); $charge->save(); return id(new AphrontRedirectResponse()) ->setIsExternal(true) ->setURI($uri); case 'charge': if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } $token = $request->getStr('token'); $params = array( 'TOKEN' => $token, ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('GetExpressCheckoutDetails', $params) ->resolve(); if ($result['CUSTOM'] !== $charge->getPHID()) { throw new Exception( pht('Paypal checkout does not match Phortune charge!')); } if ($result['CHECKOUTSTATUS'] !== 'PaymentActionNotInitiated') { return $controller->newDialog() ->setTitle(pht('Payment Already Processed')) ->appendParagraph( pht( 'The payment response for this charge attempt has already '. 'been processed.')) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); } $price = $cart->getTotalPriceAsCurrency(); $params = array( 'TOKEN' => $token, 'PAYERID' => $result['PAYERID'], 'PAYMENTREQUEST_0_AMT' => $price->formatBareValue(), 'PAYMENTREQUEST_0_CURRENCYCODE' => $price->getCurrency(), 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('DoExpressCheckoutPayment', $params) ->resolve(); $transaction_id = $result['PAYMENTINFO_0_TRANSACTIONID']; $success = false; $hold = false; switch ($result['PAYMENTINFO_0_PAYMENTSTATUS']) { case 'Processed': case 'Completed': case 'Completed-Funds-Held': $success = true; break; case 'In-Progress': case 'Pending': // TODO: We can capture more information about this stuff. $hold = true; break; case 'Denied': case 'Expired': case 'Failed': case 'Partially-Refunded': case 'Canceled-Reversal': case 'None': case 'Refunded': case 'Reversed': case 'Voided': default: // These are all failure states. break; } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $charge->setMetadataValue('paypal.transactionID', $transaction_id); $charge->save(); if ($success) { $cart->didApplyCharge($charge); $response = id(new AphrontRedirectResponse())->setURI( $cart->getDoneURI()); } else if ($hold) { $cart->didHoldCharge($charge); $response = $controller ->newDialog() ->setTitle(pht('Charge On Hold')) ->appendParagraph( pht('Your charge is on hold, for reasons?')) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); } else { $cart->didFailCharge($charge); $response = $controller ->newDialog() ->setTitle(pht('Charge Failed')) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); } unset($unguarded); return $response; case 'cancel': if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); // TODO: Since the user cancelled this, we could conceivably just // throw it away or make it more clear that it's a user cancel. $cart->didFailCharge($charge); unset($unguarded); } return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } throw new Exception( pht('Unsupported action "%s".', $controller->getAction())); } private function newPaypalAPICall() { if ($this->isAcceptingLivePayments()) { $host = 'https://api-3t.paypal.com/nvp'; } else { $host = 'https://api-3t.sandbox.paypal.com/nvp'; } return id(new PhutilPayPalAPIFuture()) ->setHost($host) ->setAPIUsername($this->getPaypalAPIUsername()) ->setAPIPassword($this->getPaypalAPIPassword()) ->setAPISignature($this->getPaypalAPISignature()); } } diff --git a/src/applications/phortune/provider/PhortunePaymentProvider.php b/src/applications/phortune/provider/PhortunePaymentProvider.php index ab31dd04cc..49c80dcd72 100644 --- a/src/applications/phortune/provider/PhortunePaymentProvider.php +++ b/src/applications/phortune/provider/PhortunePaymentProvider.php @@ -1,294 +1,296 @@ providerConfig = $provider_config; return $this; } public function getProviderConfig() { return $this->providerConfig; } /** * Return a short name which identifies this provider. */ abstract public function getName(); /* -( Configuring Providers )---------------------------------------------- */ /** * Return a human-readable provider name for use on the merchant workflow * where a merchant owner adds providers. */ abstract public function getConfigureName(); /** * Return a human-readable provider description for use on the merchant * workflow where a merchant owner adds providers. */ abstract public function getConfigureDescription(); abstract public function getConfigureInstructions(); abstract public function getConfigureProvidesDescription(); abstract public function getAllConfigurableProperties(); abstract public function getAllConfigurableSecretProperties(); /** * Read a dictionary of properties from the provider's configuration for * use when editing the provider. */ public function readEditFormValuesFromProviderConfig() { $properties = $this->getAllConfigurableProperties(); $config = $this->getProviderConfig(); $secrets = $this->getAllConfigurableSecretProperties(); $secrets = array_fuse($secrets); $map = array(); foreach ($properties as $property) { $map[$property] = $config->getMetadataValue($property); if (isset($secrets[$property])) { $map[$property] = $this->renderConfigurationSecret($map[$property]); } } return $map; } /** * Read a dictionary of properties from a request for use when editing the * provider. */ public function readEditFormValuesFromRequest(AphrontRequest $request) { $properties = $this->getAllConfigurableProperties(); $map = array(); foreach ($properties as $property) { $map[$property] = $request->getStr($property); } return $map; } abstract public function processEditForm( AphrontRequest $request, array $values); abstract public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues); protected function renderConfigurationSecret($value) { if (strlen($value)) { return str_repeat('*', strlen($value)); } return ''; } public function isConfigurationSecret($value) { return preg_match('/^\*+\z/', trim($value)); } abstract public function canRunConfigurationTest(); public function runConfigurationTest() { throw new PhortuneNotImplementedException($this); } /* -( Selecting Providers )------------------------------------------------ */ public static function getAllProviders() { return id(new PhutilSymbolLoader()) ->setAncestorClass('PhortunePaymentProvider') ->loadObjects(); } public function isEnabled() { return $this->getProviderConfig()->getIsEnabled(); } abstract public function isAcceptingLivePayments(); abstract public function getPaymentMethodDescription(); abstract public function getPaymentMethodIcon(); abstract public function getPaymentMethodProviderDescription(); final public function applyCharge( PhortunePaymentMethod $payment_method, PhortuneCharge $charge) { $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); + PhortuneCharge $refund); + + abstract public function updateCharge(PhortuneCharge $charge); /* -( Adding Payment Methods )--------------------------------------------- */ /** * @task addmethod */ public function canCreatePaymentMethods() { return false; } /** * @task addmethod */ public function translateCreatePaymentMethodErrorCode($error_code) { throw new PhortuneNotImplementedException($this); } /** * @task addmethod */ public function getCreatePaymentMethodErrorMessage($error_code) { throw new PhortuneNotImplementedException($this); } /** * @task addmethod */ public function validateCreatePaymentMethodToken(array $token) { throw new PhortuneNotImplementedException($this); } /** * @task addmethod */ public function createPaymentMethodFromRequest( AphrontRequest $request, PhortunePaymentMethod $method, array $token) { throw new PhortuneNotImplementedException($this); } /** * @task addmethod */ public function renderCreatePaymentMethodForm( AphrontRequest $request, array $errors) { throw new PhortuneNotImplementedException($this); } public function getDefaultPaymentMethodDisplayName( PhortunePaymentMethod $method) { throw new PhortuneNotImplementedException($this); } /* -( One-Time Payments )-------------------------------------------------- */ public function canProcessOneTimePayments() { return false; } public function renderOneTimePaymentButton( PhortuneAccount $account, PhortuneCart $cart, PhabricatorUser $user) { require_celerity_resource('phortune-css'); $description = $this->getPaymentMethodProviderDescription(); $details = $this->getPaymentMethodDescription(); $icon = id(new PHUIIconView()) ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN) ->setSpriteIcon($this->getPaymentMethodIcon()); $button = id(new PHUIButtonView()) ->setSize(PHUIButtonView::BIG) ->setColor(PHUIButtonView::GREY) ->setIcon($icon) ->setText($description) ->setSubtext($details); // NOTE: We generate a local URI to make sure the form picks up CSRF tokens. $uri = $this->getControllerURI( 'checkout', array( 'cartID' => $cart->getID(), ), $local = true); return phabricator_form( $user, array( 'action' => $uri, 'method' => 'POST', ), $button); } /* -( Controllers )-------------------------------------------------------- */ final public function getControllerURI( $action, array $params = array(), $local = false) { $id = $this->getProviderConfig()->getID(); $app = PhabricatorApplication::getByClass('PhabricatorPhortuneApplication'); $path = $app->getBaseURI().'provider/'.$id.'/'.$action.'/'; $uri = new PhutilURI($path); $uri->setQueryParams($params); if ($local) { return $uri; } else { return PhabricatorEnv::getURI((string)$uri); } } public function canRespondToControllerAction($action) { return false; } public function processControllerRequest( PhortuneProviderActionController $controller, AphrontRequest $request) { throw new PhortuneNotImplementedException($this); } } diff --git a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php index 62b17ed906..d0ed2b0b00 100644 --- a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php @@ -1,365 +1,379 @@ getPublishableKey()); } public function getName() { return pht('Stripe'); } public function getConfigureName() { return pht('Add Stripe Payments Account'); } public function getConfigureDescription() { return pht( 'Allows you to accept credit or debit card payments with a '. 'stripe.com account.'); } public function getConfigureProvidesDescription() { return pht( 'This merchant accepts credit and debit cards via Stripe.'); } public function getPaymentMethodDescription() { return pht('Add Credit or Debit Card (US and Canada)'); } public function getPaymentMethodIcon() { return 'Stripe'; } public function getPaymentMethodProviderDescription() { return pht('Processed by Stripe'); } public function getDefaultPaymentMethodDisplayName( PhortunePaymentMethod $method) { return pht('Credit/Debit Card'); } public function getAllConfigurableProperties() { return array( self::STRIPE_PUBLISHABLE_KEY, self::STRIPE_SECRET_KEY, ); } public function getAllConfigurableSecretProperties() { return array( self::STRIPE_SECRET_KEY, ); } public function processEditForm( AphrontRequest $request, array $values) { $errors = array(); $issues = array(); if (!strlen($values[self::STRIPE_SECRET_KEY])) { $errors[] = pht('Stripe Secret Key is required.'); $issues[self::STRIPE_SECRET_KEY] = pht('Required'); } if (!strlen($values[self::STRIPE_PUBLISHABLE_KEY])) { $errors[] = pht('Stripe Publishable Key is required.'); $issues[self::STRIPE_PUBLISHABLE_KEY] = pht('Required'); } return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $form ->appendChild( id(new AphrontFormTextControl()) ->setName(self::STRIPE_SECRET_KEY) ->setValue($values[self::STRIPE_SECRET_KEY]) ->setError(idx($issues, self::STRIPE_SECRET_KEY, true)) ->setLabel(pht('Stripe Secret Key'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::STRIPE_PUBLISHABLE_KEY) ->setValue($values[self::STRIPE_PUBLISHABLE_KEY]) ->setError(idx($issues, self::STRIPE_PUBLISHABLE_KEY, true)) ->setLabel(pht('Stripe Publishable Key'))); } public function getConfigureInstructions() { return pht( "To configure Stripe, register or log in to an existing account on ". "[[https://stripe.com | stripe.com]]. Once logged in:\n\n". " - Go to {nav icon=user, name=Your Account > Account Settings ". "> API Keys}\n". " - Copy the **Secret Key** and **Publishable Key** into the fields ". "above.\n\n". "You can either use the test keys to add this provider in test mode, ". "or the live keys to accept live payments."); } public function canRunConfigurationTest() { return true; } public function runConfigurationTest() { - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/stripe-php/lib/Stripe.php'; + $this->loadStripeAPILibraries(); $secret_key = $this->getSecretKey(); $account = Stripe_Account::retrieve($secret_key); } /** * @phutil-external-symbol class Stripe_Charge * @phutil-external-symbol class Stripe_CardError * @phutil-external-symbol class Stripe_Account */ protected function executeCharge( PhortunePaymentMethod $method, PhortuneCharge $charge) { - - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/stripe-php/lib/Stripe.php'; + $this->loadStripeAPILibraries(); $price = $charge->getAmountAsCurrency(); $secret_key = $this->getSecretKey(); $params = array( 'amount' => $price->getValueInUSDCents(), 'currency' => $price->getCurrency(), 'customer' => $method->getMetadataValue('stripe.customerID'), 'description' => $charge->getPHID(), 'capture' => true, ); $stripe_charge = Stripe_Charge::create($params, $secret_key); $id = $stripe_charge->id; if (!$id) { throw new Exception('Stripe charge call did not return an ID!'); } $charge->setMetadataValue('stripe.chargeID', $id); $charge->save(); } protected function executeRefund( PhortuneCharge $charge, PhortuneCharge $refund) { + $this->loadStripeAPILibraries(); $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(); } + public function updateCharge(PhortuneCharge $charge) { + $this->loadStripeAPILibraries(); + + $charge_id = $charge->getMetadataValue('stripe.chargeID'); + if (!$charge_id) { + throw new Exception( + pht('Unable to update charge; no Stripe chargeID!')); + } + + $secret_key = $this->getSecretKey(); + $stripe_charge = Stripe_Charge::retrieve($charge_id, $secret_key); + + // TODO: Deal with disputes / chargebacks / surprising refunds. + + } + private function getPublishableKey() { return $this ->getProviderConfig() ->getMetadataValue(self::STRIPE_PUBLISHABLE_KEY); } private function getSecretKey() { return $this ->getProviderConfig() ->getMetadataValue(self::STRIPE_SECRET_KEY); } /* -( Adding Payment Methods )--------------------------------------------- */ public function canCreatePaymentMethods() { return true; } /** * @phutil-external-symbol class Stripe_Token * @phutil-external-symbol class Stripe_Customer */ public function createPaymentMethodFromRequest( AphrontRequest $request, PhortunePaymentMethod $method, array $token) { + $this->loadStripeAPILibraries(); $errors = array(); - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/stripe-php/lib/Stripe.php'; - $secret_key = $this->getSecretKey(); $stripe_token = $token['stripeCardToken']; // First, make sure the token is valid. $info = id(new Stripe_Token())->retrieve($stripe_token, $secret_key); $account_phid = $method->getAccountPHID(); $author_phid = $method->getAuthorPHID(); $params = array( 'card' => $stripe_token, 'description' => $account_phid.':'.$author_phid, ); // Then, we need to create a Customer in order to be able to charge // the card more than once. We create one Customer for each card; // they do not map to PhortuneAccounts because we allow an account to // have more than one active card. $customer = Stripe_Customer::create($params, $secret_key); $card = $info->card; $method ->setBrand($card->brand) ->setLastFourDigits($card->last4) ->setExpires($card->exp_year, $card->exp_month) ->setMetadata( array( 'type' => 'stripe.customer', 'stripe.customerID' => $customer->id, 'stripe.cardToken' => $stripe_token, )); return $errors; } public function renderCreatePaymentMethodForm( AphrontRequest $request, array $errors) { $ccform = id(new PhortuneCreditCardForm()) ->setUser($request->getUser()) ->setErrors($errors) ->addScript('https://js.stripe.com/v2/'); Javelin::initBehavior( 'stripe-payment-form', array( 'stripePublishableKey' => $this->getPublishableKey(), 'formID' => $ccform->getFormID(), )); return $ccform->buildForm(); } private function getStripeShortErrorCode($error_code) { $prefix = 'cc:stripe:'; if (strncmp($error_code, $prefix, strlen($prefix))) { return null; } return substr($error_code, strlen($prefix)); } public function validateCreatePaymentMethodToken(array $token) { return isset($token['stripeCardToken']); } public function translateCreatePaymentMethodErrorCode($error_code) { $short_code = $this->getStripeShortErrorCode($error_code); if ($short_code) { static $map = array( 'error:invalid_number' => PhortuneErrCode::ERR_CC_INVALID_NUMBER, 'error:invalid_cvc' => PhortuneErrCode::ERR_CC_INVALID_CVC, 'error:invalid_expiry_month' => PhortuneErrCode::ERR_CC_INVALID_EXPIRY, 'error:invalid_expiry_year' => PhortuneErrCode::ERR_CC_INVALID_EXPIRY, ); if (isset($map[$short_code])) { return $map[$short_code]; } } return $error_code; } /** * See https://stripe.com/docs/api#errors for more information on possible * errors. */ public function getCreatePaymentMethodErrorMessage($error_code) { $short_code = $this->getStripeShortErrorCode($error_code); if (!$short_code) { return null; } switch ($short_code) { case 'error:incorrect_number': $error_key = 'number'; $message = pht('Invalid or incorrect credit card number.'); break; case 'error:incorrect_cvc': $error_key = 'cvc'; $message = pht('Card CVC is invalid or incorrect.'); break; $error_key = 'exp'; $message = pht('Card expiration date is invalid or incorrect.'); break; case 'error:invalid_expiry_month': case 'error:invalid_expiry_year': case 'error:invalid_cvc': case 'error:invalid_number': // NOTE: These should be translated into Phortune error codes earlier, // so we don't expect to receive them here. They are listed for clarity // and completeness. If we encounter one, we treat it as an unknown // error. break; case 'error:invalid_amount': case 'error:missing': case 'error:card_declined': case 'error:expired_card': case 'error:duplicate_transaction': case 'error:processing_error': default: // NOTE: These errors currently don't recevive a detailed message. // NOTE: We can also end up here with "http:nnn" messages. // TODO: At least some of these should have a better message, or be // translated into common errors above. break; } return null; } + private function loadStripeAPILibraries() { + $root = dirname(phutil_get_library_root('phabricator')); + require_once $root.'/externals/stripe-php/lib/Stripe.php'; + } + } diff --git a/src/applications/phortune/provider/PhortuneTestPaymentProvider.php b/src/applications/phortune/provider/PhortuneTestPaymentProvider.php index c485e8b65d..3139d2692e 100644 --- a/src/applications/phortune/provider/PhortuneTestPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneTestPaymentProvider.php @@ -1,154 +1,158 @@ setExpires('2050', '01') ->setBrand('FreeMoney') ->setLastFourDigits('9999') ->setMetadata( array( 'type' => 'test.wealth', )); return array(); } /** * @task addmethod */ public function renderCreatePaymentMethodForm( AphrontRequest $request, array $errors) { $ccform = id(new PhortuneCreditCardForm()) ->setUser($request->getUser()) ->setErrors($errors); Javelin::initBehavior( 'test-payment-form', array( 'formID' => $ccform->getFormID(), )); return $ccform->buildForm(); } } diff --git a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php index c4d927f8da..0bc54fe193 100644 --- a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php @@ -1,392 +1,405 @@ getWePayAccessToken()); } public function getName() { return pht('WePay'); } public function getConfigureName() { return pht('Add WePay Payments Account'); } public function getConfigureDescription() { return pht( 'Allows you to accept credit or debit card payments with a '. 'wepay.com account.'); } public function getConfigureProvidesDescription() { return pht('This merchant accepts credit and debit cards via WePay.'); } public function getConfigureInstructions() { return pht( "To configure WePay, register or log in to an existing account on ". "[[https://wepay.com | wepay.com]] (for live payments) or ". "[[https://stage.wepay.com | stage.wepay.com]] (for testing). ". "Once logged in:\n\n". " - Create an API application if you don't already have one.\n". " - Click the API application name to go to the detail page.\n". " - Copy **Client ID**, **Client Secret**, **Access Token** and ". " **AccountID** from that page to the fields above.\n\n". "You can either use `stage.wepay.com` to retrieve test credentials, ". "or `wepay.com` to retrieve live credentials for accepting live ". "payments."); } public function canRunConfigurationTest() { return true; } public function runConfigurationTest() { - $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/externals/wepay/wepay.php'; + $this->loadWePayAPILibraries(); WePay::useStaging( $this->getWePayClientID(), $this->getWePayClientSecret()); $wepay = new WePay($this->getWePayAccessToken()); $params = array( 'client_id' => $this->getWePayClientID(), 'client_secret' => $this->getWePayClientSecret(), ); $wepay->request('app', $params); } public function getAllConfigurableProperties() { return array( self::WEPAY_CLIENT_ID, self::WEPAY_CLIENT_SECRET, self::WEPAY_ACCESS_TOKEN, self::WEPAY_ACCOUNT_ID, ); } public function getAllConfigurableSecretProperties() { return array( self::WEPAY_CLIENT_SECRET, ); } public function processEditForm( AphrontRequest $request, array $values) { $errors = array(); $issues = array(); if (!strlen($values[self::WEPAY_CLIENT_ID])) { $errors[] = pht('WePay Client ID is required.'); $issues[self::WEPAY_CLIENT_ID] = pht('Required'); } if (!strlen($values[self::WEPAY_CLIENT_SECRET])) { $errors[] = pht('WePay Client Secret is required.'); $issues[self::WEPAY_CLIENT_SECRET] = pht('Required'); } if (!strlen($values[self::WEPAY_ACCESS_TOKEN])) { $errors[] = pht('WePay Access Token is required.'); $issues[self::WEPAY_ACCESS_TOKEN] = pht('Required'); } if (!strlen($values[self::WEPAY_ACCOUNT_ID])) { $errors[] = pht('WePay Account ID is required.'); $issues[self::WEPAY_ACCOUNT_ID] = pht('Required'); } return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $form ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_CLIENT_ID) ->setValue($values[self::WEPAY_CLIENT_ID]) ->setError(idx($issues, self::WEPAY_CLIENT_ID, true)) ->setLabel(pht('WePay Client ID'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_CLIENT_SECRET) ->setValue($values[self::WEPAY_CLIENT_SECRET]) ->setError(idx($issues, self::WEPAY_CLIENT_SECRET, true)) ->setLabel(pht('WePay Client Secret'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_ACCESS_TOKEN) ->setValue($values[self::WEPAY_ACCESS_TOKEN]) ->setError(idx($issues, self::WEPAY_ACCESS_TOKEN, true)) ->setLabel(pht('WePay Access Token'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_ACCOUNT_ID) ->setValue($values[self::WEPAY_ACCOUNT_ID]) ->setError(idx($issues, self::WEPAY_ACCOUNT_ID, true)) ->setLabel(pht('WePay Account ID'))); } public function getPaymentMethodDescription() { return pht('Credit or Debit Card'); } public function getPaymentMethodIcon() { return 'WePay'; } public function getPaymentMethodProviderDescription() { return 'WePay'; } protected function executeCharge( PhortunePaymentMethod $payment_method, PhortuneCharge $charge) { throw new Exception('!'); } private function getWePayClientID() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_CLIENT_ID); } private function getWePayClientSecret() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_CLIENT_SECRET); } private function getWePayAccessToken() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_ACCESS_TOKEN); } private function getWePayAccountID() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_ACCOUNT_ID); } protected function executeRefund( PhortuneCharge $charge, PhortuneCharge $refund) { + $wepay = $this->loadWePayAPILibraries(); - $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'); + $checkout_id = $this->getWePayCheckoutID($charge); $params = array( - 'checkout_id' => $charge_id, + 'checkout_id' => $checkout_id, 'refund_reason' => pht('Refund'), 'amount' => $refund->getAmountAsCurrency()->negate()->formatBareValue(), ); $wepay->request('checkout/refund', $params); } + public function updateCharge(PhortuneCharge $charge) { + $wepay = $this->loadWePayAPILibraries(); + + $params = array( + 'checkout_id' => $this->getWePayCheckoutID($charge), + ); + $wepay_checkout = $wepay->request('checkout', $params); + + // TODO: Deal with disputes / chargebacks / surprising refunds. + } + + /* -( One-Time Payments )-------------------------------------------------- */ public function canProcessOneTimePayments() { return true; } /* -( Controllers )-------------------------------------------------------- */ public function canRespondToControllerAction($action) { switch ($action) { case 'checkout': case 'charge': case 'cancel': return true; } return parent::canRespondToControllerAction(); } /** * @phutil-external-symbol class WePay */ public function processControllerRequest( PhortuneProviderActionController $controller, AphrontRequest $request) { + $wepay = $this->loadWePayAPILibraries(); $viewer = $request->getUser(); $cart = $controller->loadCart($request->getInt('cartID')); if (!$cart) { return new Aphront404Response(); } - $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 = $controller->loadActiveCharge($cart); switch ($controller->getAction()) { case 'checkout': if ($charge) { throw new Exception(pht('Cart is already charging!')); } break; case 'charge': case 'cancel': if (!$charge) { throw new Exception(pht('Cart is not charging yet!')); } break; } switch ($controller->getAction()) { case 'checkout': $return_uri = $this->getControllerURI( 'charge', array( 'cartID' => $cart->getID(), )); $cancel_uri = $this->getControllerURI( 'cancel', array( 'cartID' => $cart->getID(), )); $price = $cart->getTotalPriceAsCurrency(); $params = array( 'account_id' => $this->getWePayAccountID(), 'short_description' => $cart->getName(), 'type' => 'SERVICE', 'amount' => $price->formatBareValue(), 'long_description' => $cart->getName(), 'reference_id' => $cart->getPHID(), 'app_fee' => 0, 'fee_payer' => 'Payee', 'redirect_uri' => $return_uri, 'fallback_uri' => $cancel_uri, // NOTE: If we don't `auto_capture`, we might get a result back in // either an "authorized" or a "reserved" state. We can't capture // an "authorized" result, so just autocapture. 'auto_capture' => true, 'require_shipping' => 0, 'shipping_fee' => 0, 'charge_tax' => 0, 'mode' => 'regular', // TODO: We could accept bank accounts but the hold/capture rules // are not quite clear. Just accept credit cards for now. 'funding_sources' => 'cc', ); $charge = $cart->willApplyCharge($viewer, $this); $result = $wepay->request('checkout/create', $params); $cart->setMetadataValue('provider.checkoutURI', $result->checkout_uri); $cart->save(); $charge->setMetadataValue('wepay.checkoutID', $result->checkout_id); $charge->save(); $uri = new PhutilURI($result->checkout_uri); return id(new AphrontRedirectResponse()) ->setIsExternal(true) ->setURI($uri); case 'charge': if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } $checkout_id = $request->getInt('checkout_id'); $params = array( 'checkout_id' => $checkout_id, ); $checkout = $wepay->request('checkout', $params); if ($checkout->reference_id != $cart->getPHID()) { throw new Exception( pht('Checkout reference ID does not match cart PHID!')); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); switch ($checkout->state) { case 'authorized': case 'reserved': case 'captured': // TODO: Are these all really "done" states, and not "hold" // states? Cards and bank accounts both come back as "authorized" // on the staging environment. Figure out what happens in // production? $cart->didApplyCharge($charge); $response = id(new AphrontRedirectResponse())->setURI( $cart->getDoneURI()); break; default: // It's not clear if we can ever get here on the web workflow, // WePay doesn't seem to return back to us after a failure (the // workflow dead-ends instead). $cart->didFailCharge($charge); $response = $controller ->newDialog() ->setTitle(pht('Charge Failed')) ->appendParagraph( pht( 'Unable to make payment (checkout state is "%s").', $checkout->state)) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); break; } unset($unguarded); return $response; case 'cancel': // TODO: I don't know how it's possible to cancel out of a WePay // charge workflow. throw new Exception( pht('How did you get here? WePay has no cancel flow in its UI...?')); break; } throw new Exception( pht('Unsupported action "%s".', $controller->getAction())); } + private function loadWePayAPILibraries() { + $root = dirname(phutil_get_library_root('phabricator')); + require_once $root.'/externals/wepay/wepay.php'; + + WePay::useStaging( + $this->getWePayClientID(), + $this->getWePayClientSecret()); + + return new WePay($this->getWePayAccessToken()); + } + + private function getWePayCheckoutID(PhortuneCharge $charge) { + $checkout_id = $charge->getMetadataValue('wepay.checkoutID'); + if ($checkout_id === null) { + throw new Exception(pht('No WePay Checkout ID present on charge!')); + } + return $checkout_id; + } } diff --git a/src/applications/phortune/storage/PhortuneCart.php b/src/applications/phortune/storage/PhortuneCart.php index 0f5159648d..4b0225c68c 100644 --- a/src/applications/phortune/storage/PhortuneCart.php +++ b/src/applications/phortune/storage/PhortuneCart.php @@ -1,527 +1,525 @@ setAuthorPHID($actor->getPHID()) ->setStatus(self::STATUS_BUILDING) ->setAccountPHID($account->getPHID()) ->setMerchantPHID($merchant->getPHID()); $cart->account = $account; $cart->purchases = array(); return $cart; } public function newPurchase( PhabricatorUser $actor, PhortuneProduct $product) { $purchase = PhortunePurchase::initializeNewPurchase($actor, $product) ->setAccountPHID($this->getAccount()->getPHID()) ->setCartPHID($this->getPHID()) ->save(); $this->purchases[] = $purchase; return $purchase; } public static function getStatusNameMap() { return array( self::STATUS_BUILDING => pht('Building'), self::STATUS_READY => pht('Ready'), self::STATUS_PURCHASING => pht('Purchasing'), self::STATUS_CHARGED => pht('Charged'), self::STATUS_HOLD => pht('Hold'), self::STATUS_PURCHASED => pht('Purchased'), ); } public static function getNameForStatus($status) { return idx(self::getStatusNameMap(), $status, $status); } public function activateCart() { $this->setStatus(self::STATUS_READY)->save(); return $this; } public function willApplyCharge( PhabricatorUser $actor, PhortunePaymentProvider $provider, PhortunePaymentMethod $method = null) { $account = $this->getAccount(); $charge = PhortuneCharge::initializeNewCharge() ->setAccountPHID($account->getPHID()) ->setCartPHID($this->getPHID()) ->setAuthorPHID($actor->getPHID()) ->setMerchantPHID($this->getMerchant()->getPHID()) ->setProviderPHID($provider->getProviderConfig()->getPHID()) ->setAmountAsCurrency($this->getTotalPriceAsCurrency()); if ($method) { $charge->setPaymentMethodPHID($method->getPHID()); } $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(); $this->endReadLocking(); $this->saveTransaction(); return $charge; } public function didHoldCharge(PhortuneCharge $charge) { $charge->setStatus(PhortuneCharge::STATUS_HOLD); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); if ($copy->getStatus() !== self::STATUS_PURCHASING) { throw new Exception( pht( 'Cart has wrong status ("%s") to call didHoldCharge(), '. 'expected "%s".', $copy->getStatus(), self::STATUS_PURCHASING)); } $charge->save(); $this->setStatus(self::STATUS_HOLD)->save(); $this->endReadLocking(); $this->saveTransaction(); } public function didApplyCharge(PhortuneCharge $charge) { $charge->setStatus(PhortuneCharge::STATUS_CHARGED); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); - if ($copy->getStatus() !== self::STATUS_PURCHASING) { + if (($copy->getStatus() !== self::STATUS_PURCHASING) && + ($copy->getStatus() !== self::STATUS_HOLD)) { throw new Exception( pht( - 'Cart has wrong status ("%s") to call didApplyCharge(), '. - 'expected "%s".', - $copy->getStatus(), - self::STATUS_PURCHASING)); + 'Cart has wrong status ("%s") to call didApplyCharge().', + $copy->getStatus())); } $charge->save(); $this->setStatus(self::STATUS_CHARGED)->save(); $this->endReadLocking(); $this->saveTransaction(); foreach ($this->purchases as $purchase) { $purchase->getProduct()->didPurchaseProduct($purchase); } $this->setStatus(self::STATUS_PURCHASED)->save(); return $this; } public function didFailCharge(PhortuneCharge $charge) { $charge->setStatus(PhortuneCharge::STATUS_FAILED); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); - if ($copy->getStatus() !== self::STATUS_PURCHASING) { + if (($copy->getStatus() !== self::STATUS_PURCHASING) && + ($copy->getStatus() !== self::STATUS_HOLD)) { throw new Exception( pht( - 'Cart has wrong status ("%s") to call didFailCharge(), '. - 'expected "%s".', - $copy->getStatus(), - self::STATUS_PURCHASING)); + 'Cart has wrong status ("%s") to call didFailCharge().', + $copy->getStatus())); } $charge->save(); // Move the cart back into STATUS_READY so the user can try // making the purchase again. $this->setStatus(self::STATUS_READY)->save(); $this->endReadLocking(); $this->saveTransaction(); 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) && ($charge->getStatus() !== PhortuneCharge::STATUS_HOLD)) { 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); } public function getDoneURI() { return $this->getImplementation()->getDoneURI($this); } public function getCancelURI() { return $this->getImplementation()->getCancelURI($this); } public function getDetailURI() { return '/phortune/cart/'.$this->getID().'/'; } public function getCheckoutURI() { 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, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'status' => 'text32', 'cartClass' => 'text128', ), self::CONFIG_KEY_SCHEMA => array( 'key_account' => array( 'columns' => array('accountPHID'), ), 'key_merchant' => array( 'columns' => array('merchantPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhortuneCartPHIDType::TYPECONST); } public function attachPurchases(array $purchases) { assert_instances_of($purchases, 'PhortunePurchase'); $this->purchases = $purchases; return $this; } public function getPurchases() { return $this->assertAttached($this->purchases); } public function attachAccount(PhortuneAccount $account) { $this->account = $account; return $this; } public function getAccount() { return $this->assertAttached($this->account); } public function attachMerchant(PhortuneMerchant $merchant) { $this->merchant = $merchant; return $this; } public function getMerchant() { return $this->assertAttached($this->merchant); } public function attachImplementation( PhortuneCartImplementation $implementation) { $this->implementation = $implementation; return $this; } public function getImplementation() { return $this->assertAttached($this->implementation); } public function getTotalPriceAsCurrency() { $prices = array(); foreach ($this->getPurchases() as $purchase) { $prices[] = $purchase->getTotalPriceAsCurrency(); } return PhortuneCurrency::newFromList($prices); } public function setMetadataValue($key, $value) { $this->metadata[$key] = $value; return $this; } public function getMetadataValue($key, $default = null) { return idx($this->metadata, $key, $default); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { // NOTE: Both view and edit use the account's edit policy. We punch a hole // through this for merchants, below. return $this ->getAccount() ->getPolicy(PhabricatorPolicyCapability::CAN_EDIT); } public function hasAutomaticCapability($capability, PhabricatorUser $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 array( pht('Orders inherit the policies of the associated account.'), pht('The merchant you placed an order with can review and manage it.'), ); } } diff --git a/src/applications/phortune/storage/PhortuneCharge.php b/src/applications/phortune/storage/PhortuneCharge.php index 1b3390fef8..48fb5bc09b 100644 --- a/src/applications/phortune/storage/PhortuneCharge.php +++ b/src/applications/phortune/storage/PhortuneCharge.php @@ -1,176 +1,180 @@ setStatus(self::STATUS_CHARGING) ->setAmountRefundedAsCurrency(PhortuneCurrency::newEmptyCurrency()); } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_APPLICATION_SERIALIZERS => array( 'amountAsCurrency' => new PhortuneCurrencySerializer(), 'amountRefundedAsCurrency' => new PhortuneCurrencySerializer(), ), self::CONFIG_COLUMN_SCHEMA => array( 'paymentMethodPHID' => 'phid?', 'refundedChargePHID' => 'phid?', 'refundingPHID' => 'phid?', 'amountAsCurrency' => 'text64', 'amountRefundedAsCurrency' => 'text64', 'status' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'key_cart' => array( 'columns' => array('cartPHID'), ), 'key_account' => array( 'columns' => array('accountPHID'), ), 'key_merchant' => array( 'columns' => array('merchantPHID'), ), 'key_provider' => array( 'columns' => array('providerPHID'), ), ), ) + parent::getConfiguration(); } public static function getStatusNameMap() { return array( self::STATUS_CHARGING => pht('Charging'), self::STATUS_CHARGED => pht('Charged'), self::STATUS_HOLD => pht('Hold'), self::STATUS_FAILED => pht('Failed'), ); } public static function getNameForStatus($status) { return idx(self::getStatusNameMap(), $status, pht('Unknown')); } + public function isRefund() { + return $this->getAmountAsCurrency()->negate()->isPositive(); + } + 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); } public function getMetadataValue($key, $default = null) { return idx($this->metadata, $key, $default); } public function setMetadataValue($key, $value) { $this->metadata[$key] = $value; return $this; } public function getAccount() { return $this->assertAttached($this->account); } public function attachAccount(PhortuneAccount $account) { $this->account = $account; return $this; } public function getCart() { return $this->assertAttached($this->cart); } public function attachCart(PhortuneCart $cart = null) { $this->cart = $cart; 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 )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return $this->getAccount()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getAccount()->hasAutomaticCapability($capability, $viewer); } public function describeAutomaticCapability($capability) { return pht('Charges inherit the policies of the associated account.'); } }