Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Commit

Permalink
Implement offline mode for credit card form
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed Sep 11, 2017
1 parent f1fdd4b commit 66e056e
Show file tree
Hide file tree
Showing 13 changed files with 180 additions and 44 deletions.
5 changes: 4 additions & 1 deletion .travis.yml
Expand Up @@ -44,7 +44,10 @@ before_script:
- echo "EMAIL_QUEUE_LOG_METRICS_EVERY=0" >> local.env

- psql -U postgres -c 'CREATE DATABASE "gratipay";'
- if [ "${TRAVIS_BRANCH}" = "master" -a "${TRAVIS_PULL_REQUEST}" = "false" ]; then rm -rfv tests/py/fixtures; fi
- if [ "${TRAVIS_BRANCH}" = "master" -a "${TRAVIS_PULL_REQUEST}" = "false" ]; then
rm -rfv tests/py/fixtures;
echo "LOAD_BRAINTREE_FORM_ON_HOMEPAGE=yes" >> local.env;
fi
script: LD_LIBRARY_PATH=/usr/local/lib xvfb-run make test-schema bgrun test doc
notifications:
email: false
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Expand Up @@ -6,7 +6,7 @@ bin_dir := $(shell $(python) -c 'import sys; bin = "Scripts" if sys.platform ==
env_bin := env/$(bin_dir)
venv := "./vendor/virtualenv-15.1.0.py"
doc_env_files := defaults.env,docs/doc.env,docs/local.env
test_env_files := defaults.env,tests/defaults.env,tests/local.env
test_env_files := defaults.env,local.env,tests/defaults.env,tests/local.env
pip := $(env_bin)/pip
honcho := $(env_bin)/honcho
honcho_run := $(honcho) run -e defaults.env,local.env
Expand Down
2 changes: 2 additions & 0 deletions defaults.env
Expand Up @@ -106,3 +106,5 @@ PROJECT_REVIEW_USERNAME=
PROJECT_REVIEW_TOKEN=

RAISE_SIGNIN_NOTIFICATIONS=no

LOAD_BRAINTREE_FORM_ON_HOMEPAGE=no
2 changes: 2 additions & 0 deletions gratipay/application.py
Expand Up @@ -6,6 +6,7 @@
from . import email, sync_npm, utils
from .cron import Cron
from .models import GratipayDB
from .card_charger import CardCharger
from .payday_runner import PaydayRunner
from .project_review_process import ProjectReviewProcess
from .website import Website
Expand Down Expand Up @@ -47,6 +48,7 @@ def __init__(self):
self.website = website
self.payday_runner = PaydayRunner(self)
self.project_review_process = ProjectReviewProcess(env, db, self.email_queue)
self.pfos_card_charger = CardCharger(online=env.load_braintree_form_on_homepage)


def install_periodic_jobs(self, website, env, db):
Expand Down
74 changes: 74 additions & 0 deletions gratipay/card_charger.py
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

import braintree
from uuid import uuid4
from decimal import Decimal as D


class CardCharger(object):

def __init__(self, online=False):
self.implementation = Braintree() if online else FakeBraintree()

def charge(self, params):
return self.implementation.charge(params)


# Online
# ======

class Braintree(object):
"""Sends data to Braintree.
"""

def charge(self, params):
"""Charge using the Braintree APi, returning a result.
"""
return braintree.Transaction.sale(params)


# Offline
# =======

class FakeTransaction(object):
def __init__(self):
self.id = uuid4().hex

class FakeSuccessResult(object):
def __init__(self):
self.is_success = True
self.transaction = FakeTransaction()

class FakeFailureResult(object):
def __init__(self):
self.is_success = False
self.message = 'Not a success.'
self.transaction = FakeTransaction()

class FakeErrorResult(object):
def __init__(self):
self.is_success = False
self.message = 'Not even a success.'
self.transaction = None


class FakeBraintree(object):
"""For offline use.
"""

def charge(self, params):
"""Return a fake result. Partially implements Braintree's testing logic:
- fake-valid-nonce returns a success result
- amount >= 2000 returns a failure result
- otherwise return an error result
https://developers.braintreepayments.com/reference/general/testing/python
"""
if params['payment_method_nonce'] == 'fake-valid-nonce':
if D(params['amount']) < 2000:
return FakeSuccessResult()
return FakeFailureResult()
return FakeErrorResult()
17 changes: 8 additions & 9 deletions gratipay/homepage.py
Expand Up @@ -3,7 +3,6 @@
"""
from __future__ import absolute_import, division, print_function, unicode_literals

import braintree
from gratipay import utils
from gratipay.models.payment_for_open_source import PaymentForOpenSource

Expand Down Expand Up @@ -84,13 +83,13 @@ def _store(parsed):
return PaymentForOpenSource.insert(**parsed)


def _charge(pfos, payment_method_nonce):
charge = braintree.Transaction.sale
result = charge({ 'amount': pfos.amount
, 'payment_method_nonce': payment_method_nonce
, 'options': {'submit_for_settlement': True}
, 'custom_fields': {'pfos_uuid': pfos.uuid}
})
def _charge(app, pfos, nonce):
params = { 'amount': pfos.amount
, 'payment_method_nonce': nonce
, 'options': {'submit_for_settlement': True}
, 'custom_fields': {'pfos_uuid': pfos.uuid}
}
result = app.pfos_card_charger.charge(params)
pfos.process_result(result)


Expand All @@ -109,7 +108,7 @@ def pay_for_open_source(app, raw):
if not errors:
payment_method_nonce = parsed.pop('payment_method_nonce')
pfos = _store(parsed)
_charge(pfos, payment_method_nonce)
_charge(app, pfos, payment_method_nonce)
if pfos.succeeded:
out['receipt_url'] = pfos.receipt_url
if pfos.email_address:
Expand Down
1 change: 1 addition & 0 deletions gratipay/wireup.py
Expand Up @@ -395,6 +395,7 @@ def env():
PROJECT_REVIEW_USERNAME = unicode,
PROJECT_REVIEW_TOKEN = unicode,
RAISE_SIGNIN_NOTIFICATIONS = is_yesish,
LOAD_BRAINTREE_FORM_ON_HOMEPAGE = is_yesish,
GUNICORN_OPTS = unicode,
)

Expand Down
44 changes: 33 additions & 11 deletions js/gratipay/homepage.js
Expand Up @@ -2,21 +2,44 @@ Gratipay.homepage = {}

Gratipay.homepage.initForm = function(clientAuthorization) {
$form = $('#homepage #content form');
$submit = $form.find('button[type=submit]');

if (clientAuthorization === undefined) { // Offline mode

$('#braintree-container').addClass('offline').html(Gratipay.jsonml(['div',
['div', {'class': 'field amount'},
['label', {'for': 'nonce'}, 'Nonce'],
['input', {'id': 'nonce', 'value': 'fake-valid-nonce', 'required': true}, 'Nonce'],
],
['p', {'class': 'fine-print'}, "If you're seeing this on gratipay.com, we screwed up."]
]));

function callback(createErr, instance) {
$submit = $form.find('button[type=submit]');
$submit.click(function(e) {
e.preventDefault();
instance.requestPaymentMethod(function(requestPaymentMethodErr, payload) {
Gratipay.homepage.submitFormWithNonce(payload.nonce);
});
nonce = $('#braintree-container input').val();
Gratipay.homepage.submitFormWithNonce(nonce);
});
}

braintree.dropin.create({
authorization: clientAuthorization,
container: '#braintree-container'
}, callback);
} else { // Online mode (sandbox or production)

function braintreeInitCallback(createErr, instance) {
if (createErr) {
$('#braintree-container').addClass('failed').text('Failed to load Braintree.');
} else {
$submit.click(function(e) {
e.preventDefault();
instance.requestPaymentMethod(function(requestPaymentMethodErr, payload) {
Gratipay.homepage.submitFormWithNonce(payload.nonce);
});
});
}
}

braintree.dropin.create({
authorization: clientAuthorization,
container: '#braintree-container'
}, braintreeInitCallback);
}
};


Expand All @@ -36,7 +59,6 @@ Gratipay.homepage.submitFormWithNonce = function(nonce) {
contentType: false,
dataType: 'json',
success: function(data) {
console.log(data);
// Due to Aspen limitations we use 200 for both success and failure. :/
if (data.errors.length > 0) {
$submit.prop('disable', false);
Expand Down
36 changes: 28 additions & 8 deletions scss/pages/homepage.scss
Expand Up @@ -23,6 +23,34 @@
#content {
text-align: center;

.payment-complete {
.fine-print {
padding: 0;
}
.twitter-container {
padding-top: 20px;
}
}

#braintree-container {
min-height: 260px;
&.failed {
background: red;
color: white;
font: normal 24px/24px $Mono;
padding-top: 118px;
}
&.offline {
background: orange;
color: white;
padding-top: 78px;

.fine-print {
color: blue;
}
}
}

fieldset {
margin: 3em 0 0.5em;
border: 1px solid $light-brown;
Expand Down Expand Up @@ -171,13 +199,5 @@
}
}
}
.payment-complete {
.fine-print {
padding: 0;
}
.twitter-container {
padding-top: 20px;
}
}
}
}
2 changes: 1 addition & 1 deletion tests/py/test_payments_for_open_source.py
Expand Up @@ -22,7 +22,7 @@ def test_can_update(self):
assert pfos.braintree_result_message is None
assert not pfos.succeeded

_charge(pfos, 'fake-valid-nonce')
_charge(self.app, pfos, 'fake-valid-nonce')

assert pfos.braintree_transaction_id is not None
assert pfos.braintree_result_message == ''
Expand Down
4 changes: 2 additions & 2 deletions tests/py/test_www_homepage.py
Expand Up @@ -103,14 +103,14 @@ class GoodCharge(Harness):

def test_bad_nonce_fails(self):
pfos = self.make_payment_for_open_source()
_charge(pfos, 'deadbeef')
_charge(self.app, pfos, 'deadbeef')
assert not pfos.succeeded

class BadCharge(Harness):

def test_good_nonce_succeeds(self):
pfos = self.make_payment_for_open_source()
_charge(pfos, 'fake-valid-nonce')
_charge(self.app, pfos, 'fake-valid-nonce')
assert pfos.succeeded


Expand Down
27 changes: 18 additions & 9 deletions tests/ttw/test_homepage.py
Expand Up @@ -9,17 +9,26 @@ def fetch(self):
return self.db.one('SELECT pfos.*::payments_for_open_source '
'FROM payments_for_open_source pfos')


def fill_cc(self, credit_card_number, expiration, cvv):
if self.app.env.load_braintree_form_on_homepage:
self.wait_for('.braintree-form-number')
with self.get_iframe('braintree-hosted-field-number') as iframe:
iframe.fill('credit-card-number', credit_card_number)
with self.get_iframe('braintree-hosted-field-expirationDate') as iframe:
iframe.fill('expiration', expiration)
with self.get_iframe('braintree-hosted-field-cvv') as iframe:
iframe.fill('cvv', cvv)
else:
# The field should already have "fake-valid-nonce" for a value.
self.wait_for('#braintree-container input')


def fill_form(self, amount, credit_card_number, expiration, cvv,
name='', email_address='',
promotion_name='', promotion_url='', promotion_twitter='', promotion_message=''):
self.wait_for('.braintree-form-number')
self.fill('amount', amount)
with self.get_iframe('braintree-hosted-field-number') as iframe:
iframe.fill('credit-card-number', credit_card_number)
with self.get_iframe('braintree-hosted-field-expirationDate') as iframe:
iframe.fill('expiration', expiration)
with self.get_iframe('braintree-hosted-field-cvv') as iframe:
iframe.fill('cvv', cvv)
self.fill_cc(credit_card_number, expiration, cvv)
if name: self.fill('name', name)
if email_address: self.fill('email_address', email_address)
if promotion_name: self.fill('promotion_name', promotion_name)
Expand All @@ -41,8 +50,8 @@ def test_redirects_for_authed_exclamation_point(self):

def submit_succeeds(self):
self.css('fieldset.submit button').click()
self.wait_for('.payment-complete', 10)
told_them = self.css('.payment-complete .description').text == 'Payment complete.'
self.wait_for('.payment-complete', 4)
told_them = self.css('.payment-complete .description').text.startswith('Payment complete!')
return self.fetch().succeeded and told_them

def test_anon_can_post(self):
Expand Down
8 changes: 6 additions & 2 deletions www/index.spt
Expand Up @@ -36,7 +36,11 @@ result
{% block scripts %}
<script>
$(document).ready(function() {
{% if website.env.load_braintree_form_on_homepage %}
Gratipay.homepage.initForm('{{ website.env.braintree_client_authorization }}');
{% else %}
Gratipay.homepage.initForm();
{% endif %}
});
</script>
{{ super() }}
Expand All @@ -46,10 +50,10 @@ $(document).ready(function() {

<div class="payment-complete" style="display: none;">
<div class="description important-thing-at-the-top">
{{ _("Payment complete.") }}
{{ _("Payment complete!") }} &#x1f483;
</div>
<p class="instructions">
{{ _("Thank you for your purchase!") }}
{{ _("Thank you for your payment!") }}
</p>
<div class="important-button" class="important-button">
<a class="button large selected receipt" href="">{{ _("View Receipt") }}</a>
Expand Down

0 comments on commit 66e056e

Please sign in to comment.