1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2025-01-22 12:41:19 +01:00

Add initial cut of PayPal and pay-once-at-checkout providers to Phortune

Summary:
Paypal doesn't let us capture cards in a PCI-free way like Stripe and Balanced do, but we can provide a "pay with paypal" option at checkout. (For subscriptions, we'll have to invoice monthly to retain control over billing, but this doesn't seem wildly unreasonable.) The bitcoin provider MtGox works in a similar way, as do some other providers we might some day want to implement.

This adds:

  - Hooks to providers so they can offer "pay once at checkout" workflows.
  - Hooks so providers can have controllers, for redirect-based third-party workflows.
  - Basic Paypal integration using the "Express Checkout Merchant API", which seems like the best fit for our use case. This only goes as far as shoving the user through the payment flow; we don't actually capture payments yet (paypal has around 35 different APIs, but this one seems to be the only PCI-free one which wouldn't give users an awful experience).

This diff is fairly checkpointey, but Phortune doesn't really bill anything yet anyway. Ref T2787.

Test Plan: Ran through Paypal sandbox workflow; "paid" for stuff.

Reviewers: btrahan

Reviewed By: btrahan

CC: aran

Maniphest Tasks: T2787

Differential Revision: https://secure.phabricator.com/D5834
This commit is contained in:
epriestley 2013-05-06 11:44:24 -07:00
parent 67459092d7
commit b51117790e
9 changed files with 369 additions and 16 deletions

View file

@ -1604,6 +1604,7 @@ phutil_register_library_map(array(
'PhortunePaymentMethodViewController' => 'applications/phortune/controller/PhortunePaymentMethodViewController.php',
'PhortunePaymentProvider' => 'applications/phortune/provider/PhortunePaymentProvider.php',
'PhortunePaymentProviderTestCase' => 'applications/phortune/provider/__tests__/PhortunePaymentProviderTestCase.php',
'PhortunePaypalPaymentProvider' => 'applications/phortune/provider/PhortunePaypalPaymentProvider.php',
'PhortuneProduct' => 'applications/phortune/storage/PhortuneProduct.php',
'PhortuneProductEditController' => 'applications/phortune/controller/PhortuneProductEditController.php',
'PhortuneProductEditor' => 'applications/phortune/editor/PhortuneProductEditor.php',
@ -1612,6 +1613,7 @@ phutil_register_library_map(array(
'PhortuneProductTransaction' => 'applications/phortune/storage/PhortuneProductTransaction.php',
'PhortuneProductTransactionQuery' => 'applications/phortune/query/PhortuneProductTransactionQuery.php',
'PhortuneProductViewController' => 'applications/phortune/controller/PhortuneProductViewController.php',
'PhortuneProviderController' => 'applications/phortune/controller/PhortuneProviderController.php',
'PhortunePurchase' => 'applications/phortune/storage/PhortunePurchase.php',
'PhortuneStripePaymentProvider' => 'applications/phortune/provider/PhortuneStripePaymentProvider.php',
'PhortuneTestExtraPaymentProvider' => 'applications/phortune/provider/__tests__/PhortuneTestExtraPaymentProvider.php',
@ -3335,6 +3337,7 @@ phutil_register_library_map(array(
'PhortunePaymentMethodQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'PhortunePaymentMethodViewController' => 'PhabricatorController',
'PhortunePaymentProviderTestCase' => 'PhabricatorTestCase',
'PhortunePaypalPaymentProvider' => 'PhortunePaymentProvider',
'PhortuneProduct' =>
array(
0 => 'PhortuneDAO',
@ -3347,6 +3350,7 @@ phutil_register_library_map(array(
'PhortuneProductTransaction' => 'PhabricatorApplicationTransaction',
'PhortuneProductTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
'PhortuneProductViewController' => 'PhortuneController',
'PhortuneProviderController' => 'PhortuneController',
'PhortunePurchase' => 'PhortuneDAO',
'PhortuneStripePaymentProvider' => 'PhortunePaymentProvider',
'PhortuneTestExtraPaymentProvider' => 'PhortunePaymentProvider',

View file

@ -49,6 +49,8 @@ final class PhabricatorApplicationPhortune extends PhabricatorApplication {
'view/(?P<id>\d+)/' => 'PhortuneProductViewController',
'edit/(?:(?P<id>\d+)/)?' => 'PhortuneProductEditController',
),
'provider/(?P<digest>[^/]+)/(?P<action>[^/]+)/'
=> 'PhortuneProviderController',
),
);
}

View file

@ -110,7 +110,7 @@ final class PhortuneAccountBuyController
foreach ($methods as $method) {
$method_control->addButton(
$method->getID(),
$method->getName(),
$method->getBrand().' / '.$method->getLastFourDigits(),
$method->getDescription());
}
}
@ -118,28 +118,60 @@ final class PhortuneAccountBuyController
$payment_method_uri = $this->getApplicationURI(
$account->getID().'/paymentmethod/edit/');
$new_method = phutil_tag(
'a',
array(
'href' => $payment_method_uri,
'sigil' => 'workflow',
),
pht('Add New Payment Method'));
$form = id(new AphrontFormView())
->setUser($user)
->appendChild($method_control)
->appendChild(
->appendChild($method_control);
$add_providers = PhortunePaymentProvider::getProvidersForAddPaymentMethod();
if ($add_providers) {
$new_method = phutil_tag(
'a',
array(
'class' => 'button grey',
'href' => $payment_method_uri,
'sigil' => 'workflow',
),
pht('Add New Payment Method'));
$form->appendChild(
id(new AphrontFormMarkupControl())
->setValue($new_method))
->appendChild(
id(new AphrontFormSubmitControl())
->setValue(pht("Dolla Dolla Bill Y'all")));
->setValue($new_method));
}
if ($methods || $add_providers) {
$form
->appendChild(
id(new AphrontFormSubmitControl())
->setValue(pht("Submit Payment"))
->setDisabled(!$methods));
}
$provider_form = null;
$pay_providers = PhortunePaymentProvider::getProvidersForOneTimePayment();
if ($pay_providers) {
$one_time_options = array();
foreach ($pay_providers as $provider) {
$one_time_options[] = $provider->renderOneTimePaymentButton(
$account,
$cart,
$user);
}
$provider_form = id(new AphrontFormLayoutView())
->setPadded(true)
->setBackgroundShading(true);
$provider_form->appendChild(
id(new AphrontFormMarkupControl())
->setLabel('Pay With')
->setValue($one_time_options));
}
return $this->buildApplicationPage(
array(
$panel,
$form,
phutil_tag('br', array()),
$provider_form,
),
array(
'title' => $title,

View file

@ -0,0 +1,64 @@
<?php
final class PhortuneProviderController extends PhortuneController {
private $digest;
private $action;
public function willProcessRequest(array $data) {
$this->digest = $data['digest'];
$this->setAction($data['action']);
}
public function setAction($action) {
$this->action = $action;
return $this;
}
public function getAction() {
return $this->action;
}
public function processRequest() {
$request = $this->getRequest();
$user = $request->getUser();
// NOTE: This use of digests to identify payment providers is because
// payment provider keys don't necessarily have restrictions on what they
// contain (so they might have stuff that's not safe to put in URIs), and
// using digests prevents errors with URI encoding.
$provider = PhortunePaymentProvider::getProviderByDigest($this->digest);
if (!$provider) {
throw new Exception("Invalid payment provider digest!");
}
if (!$provider->canRespondToControllerAction($this->getAction())) {
return new Aphront404Response();
}
$response = $provider->processControllerRequest($this, $request);
if ($response instanceof AphrontResponse) {
return $response;
}
$title = 'Phortune';
return $this->buildApplicationPage(
$response,
array(
'title' => $title,
'device' => true,
'dust' => true,
));
}
public function loadCart($id) {
return id(new PhortuneCart());
}
}

View file

@ -38,8 +38,19 @@ final class PhabricatorPhortuneConfigOptions
"NOTE: Enabling this provider gives all users infinite free ".
"money! You should enable it **ONLY** for testing and ".
"development."))
->setLocked(true),
$this->newOption('phortune.paypal.api-username', 'string', null)
->setLocked(true)
->setDescription(
pht('PayPal API username.')),
$this->newOption('phortune.paypal.api-password', 'string', null)
->setHidden(true)
->setDescription(
pht('PayPal API password.')),
$this->newOption('phortune.paypal.api-signature', 'string', null)
->setHidden(true)
->setDescription(
pht('PayPal API signature.')),
);
}

View file

@ -37,6 +37,27 @@ abstract class PhortunePaymentProvider {
return $providers;
}
public static function getProvidersForOneTimePayment() {
$providers = self::getEnabledProviders();
foreach ($providers as $key => $provider) {
if (!$provider->canProcessOneTimePayments()) {
unset($providers[$key]);
}
}
return $providers;
}
public static function getProviderByDigest($digest) {
$providers = self::getEnabledProviders();
foreach ($providers as $key => $provider) {
$provider_digest = PhabricatorHash::digestForIndex($key);
if ($provider_digest == $digest) {
return $provider;
}
}
return null;
}
abstract public function isEnabled();
final public function getProviderKey() {
@ -137,4 +158,47 @@ abstract class PhortunePaymentProvider {
}
/* -( One-Time Payments )-------------------------------------------------- */
public function canProcessOneTimePayments() {
return false;
}
public function renderOneTimePaymentButton(
PhortuneAccount $account,
PhortuneCart $cart,
PhabricatorUser $user) {
throw new PhortuneNotImplementedException($this);
}
/* -( Controllers )-------------------------------------------------------- */
final public function getControllerURI(
$action,
array $params = array()) {
$digest = PhabricatorHash::digestForIndex($this->getProviderKey());
$app = PhabricatorApplication::getByClass('PhabricatorApplicationPhortune');
$path = $app->getBaseURI().'provider/'.$digest.'/'.$action.'/';
$uri = new PhutilURI($path);
$uri->setQueryParams($params);
return PhabricatorEnv::getURI((string)$uri);
}
public function canRespondToControllerAction($action) {
return false;
}
public function processControllerRequest(
PhortuneProviderController $controller,
AphrontRequest $request) {
throw new PhortuneNotImplementedException($this);
}
}

View file

@ -0,0 +1,168 @@
<?php
final class PhortunePaypalPaymentProvider extends PhortunePaymentProvider {
public function isEnabled() {
return $this->getPaypalAPIUsername() &&
$this->getPaypalAPIPassword() &&
$this->getPaypalAPISignature();
}
public function getProviderType() {
return 'paypal';
}
public function getProviderDomain() {
return 'paypal.com';
}
public function getPaymentMethodDescription() {
return 'Paypal Account';
}
public function getPaymentMethodIcon() {
return 'rsrc/phortune/paypal.png';
}
public function getPaymentMethodProviderDescription() {
return "Paypal";
}
public function canHandlePaymentMethod(PhortunePaymentMethod $method) {
$type = $method->getMetadataValue('type');
return ($type == 'paypal');
}
protected function executeCharge(
PhortunePaymentMethod $payment_method,
PhortuneCharge $charge) {
throw new Exception("!");
}
private function getPaypalAPIUsername() {
return PhabricatorEnv::getEnvConfig('phortune.paypal.api-username');
}
private function getPaypalAPIPassword() {
return PhabricatorEnv::getEnvConfig('phortune.paypal.api-password');
}
private function getPaypalAPISignature() {
return PhabricatorEnv::getEnvConfig('phortune.paypal.api-signature');
}
/* -( One-Time Payments )-------------------------------------------------- */
public function canProcessOneTimePayments() {
return true;
}
public function renderOneTimePaymentButton(
PhortuneAccount $account,
PhortuneCart $cart,
PhabricatorUser $user) {
$uri = $this->getControllerURI(
'checkout',
array(
'cartID' => $cart->getID(),
));
return phabricator_form(
$user,
array(
'action' => $uri,
'method' => 'POST',
),
phutil_tag(
'button',
array(
'class' => 'green',
'type' => 'submit',
),
pht('Pay with Paypal')));
}
/* -( Controllers )-------------------------------------------------------- */
public function canRespondToControllerAction($action) {
switch ($action) {
case 'checkout':
case 'charge':
case 'cancel':
return true;
}
return parent::canRespondToControllerAction();
}
public function processControllerRequest(
PhortuneProviderController $controller,
AphrontRequest $request) {
$cart = $controller->loadCart($request->getInt('cartID'));
if (!$cart) {
return new Aphront404Response();
}
switch ($controller->getAction()) {
case 'checkout':
$return_uri = $this->getControllerURI(
'charge',
array(
'cartID' => $cart->getID(),
));
$cancel_uri = $this->getControllerURI(
'cancel',
array(
'cartID' => $cart->getID(),
));
$total_in_cents = $cart->getTotalInCents();
$price = PhortuneUtil::formatBareCurrency($total_in_cents);
$result = $this
->newPaypalAPICall()
->setRawPayPalQuery(
'SetExpressCheckout',
array(
'PAYMENTREQUEST_0_AMT' => $price,
'PAYMENTREQUEST_0_CURRENCYCODE' => 'USD',
'RETURNURL' => $return_uri,
'CANCELURL' => $cancel_uri,
'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale',
))
->resolve();
$uri = new PhutilURI('https://www.sandbox.paypal.com/cgi-bin/webscr');
$uri->setQueryParams(
array(
'cmd' => '_express-checkout',
'token' => $result['TOKEN'],
));
return id(new AphrontRedirectResponse())->setURI($uri);
case 'charge':
var_dump($_REQUEST);
break;
case 'cancel':
var_dump($_REQUEST);
break;
}
throw new Exception("The rest of this isn't implemented yet.");
}
private function newPaypalAPICall() {
return id(new PhutilPayPalAPIFuture())
->setHost('https://api-3t.sandbox.paypal.com/nvp')
->setAPIUsername($this->getPaypalAPIUsername())
->setAPIPassword($this->getPaypalAPIPassword())
->setAPISignature($this->getPaypalAPISignature());
}
}

View file

@ -28,6 +28,10 @@ final class PhortuneCart extends PhortuneDAO {
return $this;
}
public function getTotalInCents() {
return 123;
}
public function getPurchases() {
if ($this->purchases === null) {
throw new Exception("Purchases not attached to cart!");

View file

@ -31,4 +31,8 @@ final class PhortuneUtil {
return $display_value;
}
public static function formatBareCurrency($price_in_cents) {
return str_replace('$', '', self::formatCurrency($price_in_cents));
}
}