diff --git a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php index efac0a652d..5a19c928d8 100644 --- a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php @@ -1,236 +1,244 @@ getPublishableKey() && $this->getSecretKey(); } public function getProviderType() { return 'stripe'; } public function getProviderDomain() { return 'stripe.com'; } public function getPaymentMethodDescription() { return pht('Add Credit or Debit Card (US and Canada)'); } public function getPaymentMethodIcon() { return celerity_get_resource_uri('/rsrc/image/phortune/stripe.png'); } public function getPaymentMethodProviderDescription() { return pht('Processed by Stripe'); } public function getDefaultPaymentMethodDisplayName( PhortunePaymentMethod $method) { return pht('Credit/Debit Card'); } public function canHandlePaymentMethod(PhortunePaymentMethod $method) { $type = $method->getMetadataValue('type'); return ($type === 'stripe.customer'); } /** * @phutil-external-symbol class Stripe_Charge + * @phutil-external-symbol class Stripe_CardError */ protected function executeCharge( PhortunePaymentMethod $method, PhortuneCharge $charge) { $root = dirname(phutil_get_library_root('phabricator')); require_once $root.'/externals/stripe-php/lib/Stripe.php'; $price = $charge->getAmountAsCurrency(); $secret_key = $this->getSecretKey(); $params = array( 'amount' => $price->getValue(), 'currency' => $price->getCurrency(), 'customer' => $method->getMetadataValue('stripe.customerID'), 'description' => $charge->getPHID(), 'capture' => true, ); - $stripe_charge = Stripe_Charge::create($params, $secret_key); + try { + $stripe_charge = Stripe_Charge::create($params, $secret_key); + } catch (Stripe_CardError $ex) { + // TODO: Fail charge explicitly. + throw $ex; + } + $id = $stripe_charge->id; if (!$id) { throw new Exception('Stripe charge call did not return an ID!'); } $charge->setMetadataValue('stripe.chargeID', $id); + $charge->save(); } private function getPublishableKey() { return PhabricatorEnv::getEnvConfig('phortune.stripe.publishable-key'); } private function getSecretKey() { return PhabricatorEnv::getEnvConfig('phortune.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) { $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; } } diff --git a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php index 0c11e4186b..0bfe334654 100644 --- a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php @@ -1,221 +1,222 @@ getWePayClientID() && $this->getWePayClientSecret() && $this->getWePayAccessToken() && $this->getWePayAccountID(); } public function getProviderType() { return 'wepay'; } public function getProviderDomain() { return 'wepay.com'; } public function getPaymentMethodDescription() { return pht('Credit Card or Bank Account'); } public function getPaymentMethodIcon() { return celerity_get_resource_uri('/rsrc/image/phortune/wepay.png'); } public function getPaymentMethodProviderDescription() { return 'WePay'; } public function canHandlePaymentMethod(PhortunePaymentMethod $method) { $type = $method->getMetadataValue('type'); return ($type == 'wepay'); } protected function executeCharge( PhortunePaymentMethod $payment_method, PhortuneCharge $charge) { throw new Exception('!'); } private function getWePayClientID() { return PhabricatorEnv::getEnvConfig('phortune.wepay.client-id'); } private function getWePayClientSecret() { return PhabricatorEnv::getEnvConfig('phortune.wepay.client-secret'); } private function getWePayAccessToken() { return PhabricatorEnv::getEnvConfig('phortune.wepay.access-token'); } private function getWePayAccountID() { return PhabricatorEnv::getEnvConfig('phortune.wepay.account-id'); } /* -( 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( PhortuneProviderController $controller, AphrontRequest $request) { $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 = id(new PhortuneChargeQuery()) ->setViewer($viewer) ->withCartPHIDs(array($cart->getPHID())) ->withStatuses( array( PhortuneCharge::STATUS_CHARGING, )) ->executeOne(); 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' => 'Services', // TODO 'type' => 'SERVICE', 'amount' => $price->formatBareValue(), 'long_description' => 'Services', // TODO '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', 'funding_sources' => 'bank,cc' ); - $cart->willApplyCharge($viewer, $this); - + $charge = $cart->willApplyCharge($viewer, $this); $result = $wepay->request('checkout/create', $params); $cart->setMetadataValue('provider.checkoutURI', $result->checkout_uri); - $cart->setMetadataValue('wepay.checkoutID', $result->checkout_id); $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': $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!')); } switch ($checkout->state) { case 'authorized': case 'reserved': case 'captured': break; default: throw new Exception( pht( 'Checkout is in bad state "%s"!', $result->state)); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $cart->didApplyCharge($charge); unset($unguarded); return id(new AphrontRedirectResponse()) ->setURI($cart->getDoneURI()); 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())); } }