diff --git a/.travis.yml b/.travis.yml index 840596d0f1..37b2c625cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/Makefile b/Makefile index ec91275920..ab710a47b1 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/defaults.env b/defaults.env index d90408ccb1..cad3021187 100644 --- a/defaults.env +++ b/defaults.env @@ -106,3 +106,5 @@ PROJECT_REVIEW_USERNAME= PROJECT_REVIEW_TOKEN= RAISE_SIGNIN_NOTIFICATIONS=no + +LOAD_BRAINTREE_FORM_ON_HOMEPAGE=no diff --git a/emails/paid-for-open-source.spt b/emails/paid-for-open-source.spt index 1ab6b73a48..5316449431 100644 --- a/emails/paid-for-open-source.spt +++ b/emails/paid-for-open-source.spt @@ -1,19 +1,18 @@ -{{ _("Payment for open source") }} +{{ _("Invoice from Gratipay") }} [---] text/html -{{ _( "Congratulations! You now have a moral license to use community-built open source software, because you paid {amount} to the open source community through Gratipay. Thank you for your purchase!" +{{ _( "Thank you for your payment of {amount} for open source!" , amount=format_currency(amount, 'USD') ) }}

-{{ _("View Receipt") }} +{{ _("View Invoice") }} [---] text/plain -{{ _( "Congratulations! You now have a moral license to use community-built open source software, because you paid {amount} to the open source community through Gratipay. Thank you for your purchase!" +{{ _( "Thank you for your payment of {amount} for open source!" , amount=format_currency(amount, 'USD') ) }} -{{ _("Follow this link to view your receipt:") }} +{{ _("Follow this link to view your invoice:") }} -{{ receipt_url }} +{{ invoice_url }} diff --git a/gratipay/application.py b/gratipay/application.py index 0f97ee8e27..da89211f60 100644 --- a/gratipay/application.py +++ b/gratipay/application.py @@ -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 @@ -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): diff --git a/gratipay/card_charger.py b/gratipay/card_charger.py new file mode 100644 index 0000000000..db6016dd04 --- /dev/null +++ b/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() diff --git a/gratipay/homepage.py b/gratipay/homepage.py index 66738c1782..698f66999e 100644 --- a/gratipay/homepage.py +++ b/gratipay/homepage.py @@ -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 @@ -29,8 +28,8 @@ def _parse(raw): # name name = x('name') - if len(name) > 256: - name = name[:256] + if len(name) > 255: + name = name[:255] errors.append('name') # email address @@ -52,8 +51,8 @@ def _parse(raw): promotion_url = x('promotion_url') is_link = lambda x: (x.startswith('http://') or x.startswith('https://')) and '.' in x - if len(promotion_url) > 256 or (promotion_url and not is_link(promotion_url)): - promotion_url = promotion_url[:256] + if len(promotion_url) > 255 or (promotion_url and not is_link(promotion_url)): + promotion_url = promotion_url[:255] errors.append('promotion_url') promotion_twitter = x('promotion_twitter') @@ -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) @@ -99,19 +98,19 @@ def _send(app, pfos): , template='paid-for-open-source' , email=pfos.email_address , amount=pfos.amount - , receipt_url=pfos.receipt_url + , invoice_url=pfos.invoice_url ) def pay_for_open_source(app, raw): parsed, errors = _parse(raw) - out = {'errors': errors, 'receipt_url': None} + out = {'errors': errors, 'invoice_url': None} 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 + out['invoice_url'] = pfos.invoice_url if pfos.email_address: _send(app, pfos) else: diff --git a/gratipay/models/payment_for_open_source.py b/gratipay/models/payment_for_open_source.py index bd95cc60f5..1c34baf981 100644 --- a/gratipay/models/payment_for_open_source.py +++ b/gratipay/models/payment_for_open_source.py @@ -20,10 +20,10 @@ def succeeded(self): @property - def receipt_url(self): + def invoice_url(self): if not self.succeeded: return None - return '{}/browse/payments/{}/receipt.html'.format(gratipay.base_url, self.uuid) + return '{}/browse/payments/{}/invoice.html'.format(gratipay.base_url, self.uuid) @classmethod diff --git a/gratipay/wireup.py b/gratipay/wireup.py index f902aa0514..7bf70cc698 100644 --- a/gratipay/wireup.py +++ b/gratipay/wireup.py @@ -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, ) diff --git a/js/gratipay/homepage.js b/js/gratipay/homepage.js index 42aa430077..43bb677eeb 100644 --- a/js/gratipay/homepage.js +++ b/js/gratipay/homepage.js @@ -1,50 +1,76 @@ Gratipay.homepage = {} Gratipay.homepage.initForm = function(clientAuthorization) { - $form = $('#homepage #content form'); + var self = this; + self.$form = $('#homepage #content form'); + self.$submit = self.$form.find('button[type=submit]'); - function callback(createErr, instance) { - $submit = $form.find('button[type=submit]'); - $submit.click(function(e) { + 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."] + ])); + + self.$submit.click(function(e) { e.preventDefault(); - instance.requestPaymentMethod(function(requestPaymentMethodErr, payload) { - Gratipay.homepage.submitFormWithNonce(payload.nonce); - }); + nonce = $('#braintree-container input').val(); + self.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 { + self.$submit.click(function(e) { + e.preventDefault(); + instance.requestPaymentMethod(function(requestPaymentMethodErr, payload) { + self.submitFormWithNonce(payload.nonce); + }); + }); + } + } + + braintree.dropin.create({ + authorization: clientAuthorization, + container: '#braintree-container' + }, braintreeInitCallback); + } }; Gratipay.homepage.submitFormWithNonce = function(nonce) { - $submit = $form.find('button[type=submit]'); - $form = $('#homepage #content form'); - var data = new FormData($form[0]); + var self = this; + var data = new FormData(self.$form[0]); data.set('payment_method_nonce', nonce); - $submit.prop('disable', true); + self.$submit.prop('disable', true); $.ajax({ - url: $form.attr('action'), + url: self.$form.attr('action'), type: 'POST', data: data, processData: false, 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); + self.$submit.prop('disable', false); Gratipay.notification(data.msg, 'error'); + for (var i=0, fieldName; fieldName=data.errors[i]; i++) { + $('.'+fieldName, self.$form).addClass('error'); + } } else { - $('.payment-complete a.receipt').attr('href', data.receipt_url); - $('.payment-complete').slideDown(200); - $('form').slideUp(500); + $('.payment-complete a.invoice').attr('href', data.invoice_url); + $('form').slideUp(500, function() { + $('.payment-complete').fadeIn(500); + }); } } }); diff --git a/scss/pages/homepage.scss b/scss/pages/homepage.scss index 64b1d81e99..602b17f798 100644 --- a/scss/pages/homepage.scss +++ b/scss/pages/homepage.scss @@ -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; @@ -95,13 +123,16 @@ width: 50%; text-align: center; } + .help { + text-align: center; + } } - &.email-address, &.follow-up { + &.email_address, &.follow_up { .fine-print { text-align: left; } } - &.follow-up { + &.follow_up { .fancy-radio { position: relative; display: inline-block; @@ -163,6 +194,20 @@ } } } + .help { + display: none; + color: red; + text-align: left; + } + &.error { + .help { + display: block; + } + input { + border-color: red; + background: pink; + } + } } &.optional { button { @@ -171,13 +216,5 @@ } } } - .payment-complete { - .fine-print { - padding: 0; - } - .twitter-container { - padding-top: 20px; - } - } } } diff --git a/tests/py/test_pages.py b/tests/py/test_pages.py index 1985ff7587..30f73a3724 100644 --- a/tests/py/test_pages.py +++ b/tests/py/test_pages.py @@ -24,6 +24,9 @@ def browse(self, setup=None, **kw): # for pricing page self.make_team('Gratipay') + # for the pfos invoice page + pfos_uuid = self.make_payment_for_open_source().uuid + # for the receipt page result = braintree.PaymentMethod.create({ "customer_id": alice.get_braintree_account().id, @@ -58,6 +61,7 @@ def browse(self, setup=None, **kw): .replace('/%exchange_id', '/%s' % exchange_id) \ .replace('/%redirect_to', '/giving') \ .replace('/%endpoint', '/public') \ + .replace('/payments/%uuid', '/payments/'+pfos_uuid) \ .replace('/about/me/%sub', '/about/me') assert '/%' not in url if 'index' in url.split('/')[-1]: diff --git a/tests/py/test_payments_for_open_source.py b/tests/py/test_payments_for_open_source.py index 90048e6787..f5b9f82618 100644 --- a/tests/py/test_payments_for_open_source.py +++ b/tests/py/test_payments_for_open_source.py @@ -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 == '' diff --git a/tests/py/test_www_homepage.py b/tests/py/test_www_homepage.py index f66027a01b..67c19d16d3 100644 --- a/tests/py/test_www_homepage.py +++ b/tests/py/test_www_homepage.py @@ -46,11 +46,11 @@ } SCRUBBED = { 'amount': '1000' , 'payment_method_nonce': '' - , 'name': 'Alice Liddell' * 19 + 'Alice Lid' + , 'name': 'Alice Liddell' * 19 + 'Alice Li' , 'email_address': 'alice' * 51 , 'follow_up': 'monthly' , 'promotion_name': 'WonderlandWonderlandWonderlandWo' - , 'promotion_url': 'http://www.example.com/' + 'cheese' * 38 + 'chees' + , 'promotion_url': 'http://www.example.com/' + 'cheese' * 38 + 'chee' , 'promotion_twitter': 'thebestbutterthebestbutterthebes' , 'promotion_message': 'Love me!' * 16 } @@ -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 @@ -126,14 +126,14 @@ def test_stores_info(self): class Send(QueuedEmailHarness): - def test_sends_receipt_link(self): + def test_sends_invoice_link(self): parsed, errors = _parse(GOOD) parsed.pop('payment_method_nonce') payment_for_open_source = _store(parsed) _send(self.app, payment_for_open_source) msg = self.get_last_email() assert msg['to'] == 'alice@example.com' - assert msg['subject'] == 'Payment for open source' + assert msg['subject'] == 'Invoice from Gratipay' class PayForOpenSource(PayForOpenSourceHarness): @@ -155,13 +155,13 @@ def test_pays_for_open_source(self): assert self.fetch() is None result = pay_for_open_source(self.app, self.good) assert not result['errors'] - assert result['receipt_url'].endswith('receipt.html') + assert result['invoice_url'].endswith('invoice.html') assert self.fetch().succeeded def test_flags_errors_and_doesnt_store(self): assert self.fetch() is None result = pay_for_open_source(self.app, self.bad) - assert result == {'errors': ALL, 'receipt_url': None} + assert result == {'errors': ALL, 'invoice_url': None} assert self.fetch() is None def test_flags_errors_with_no_transaction_id(self): @@ -189,7 +189,7 @@ def test_post_gets_json(self): assert response.headers['Content-Type'] == 'application/json' result = json.loads(response.body) assert not result['errors'] - assert result['receipt_url'].endswith('receipt.html') + assert result['invoice_url'].endswith('invoice.html') assert self.fetch().succeeded def test_bad_post_gets_400(self): @@ -213,5 +213,5 @@ def test_partial_post_is_fine(self): assert response.headers['Content-Type'] == 'application/json' result = json.loads(response.body) assert not result['errors'] - assert result['receipt_url'].endswith('receipt.html') + assert result['invoice_url'].endswith('invoice.html') assert self.fetch().succeeded diff --git a/tests/ttw/test_homepage.py b/tests/ttw/test_homepage.py index b17bf041fe..0a4f4956d6 100644 --- a/tests/ttw/test_homepage.py +++ b/tests/ttw/test_homepage.py @@ -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) @@ -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): @@ -50,7 +59,21 @@ def test_anon_can_post(self): 'alice@example.com', 'Wonderland', 'http://www.example.com/', 'thebestbutter', 'Love me! Love me! Say that you love me!') assert self.submit_succeeds() + self.wait_for('a.invoice').click() + self.wait_for('#txnid') + assert self.css('#items tbody tr').text == 'open source software $ 537.00' - def test_optional_are_optional(self): + def test_options_are_optional(self): self.fill_form('537', '4242424242424242', '1020', '123') assert self.submit_succeeds() + + def test_errors_are_handled(self): + self.fill_form('1,000', '4242424242424242', '1020', '123', 'Alice Liddell', + 'alice@example', 'Wonderland', + 'htp://www.example.com/', 'thebestbutter', 'Love me!') + self.css('fieldset.submit button').click() + assert self.wait_for_error() == 'Eep! Mind looking over your info for us?' + assert self.css('.field.email_address').has_class('error') + assert self.css('.field.promotion_url').has_class('error') + assert not self.css('.field.email_address').has_class('amount') + assert self.fetch() is None diff --git a/www/browse/payments/%uuid/invoice.spt b/www/browse/payments/%uuid/invoice.spt new file mode 100644 index 0000000000..975000c60d --- /dev/null +++ b/www/browse/payments/%uuid/invoice.spt @@ -0,0 +1,214 @@ +from aspen import Response +from gratipay.models.payment_for_open_source import PaymentForOpenSource + +[-------------------] + +try: + uuid = request.path['uuid'] +except ValueError: + raise Response(400) + +# pfos - payment_for_open_source +pfos = PaymentForOpenSource.from_uuid(uuid) + +if pfos is None: + raise Response(404) + + +[-------------------] text/html + + + + Gratipay Invoice {{ pfos.uuid }} + + +
+ +

Invoice

+ + + + + + +
+ ID: {{ pfos.uuid }} + Date: {{ pfos.ctime.strftime("%B %d, %Y").replace(' 0', ' ') }}
+ +
+

From:

+
+ Gratipay, LLC
+ 716 Park Road
+ Ambridge, PA 15003
+ USA +
+ +

To:

+
+ {{ pfos.name }}
+ {{ pfos.promotion_name }}
+ {{ pfos.email_address }}
+
+
+
+ +
+

Items

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
open source software${{ pfos.amount }}.00
Subtotal:${{ pfos.amount }}.00
Tax:$0.00
Total:${{ pfos.amount }}.00
Amount Paid:${{ pfos.amount }}.00
Amount Due:$0.00
+
+
+

Notes

+ +

Please contact support@gratipay.com with any questions. Thank + you for your business!

+ +
+ +
diff --git a/www/index.spt b/www/index.spt index 36881b0a36..621f2dc510 100644 --- a/www/index.spt +++ b/www/index.spt @@ -12,7 +12,7 @@ result = pay_for_open_source(website.app, request.body) if request.method == 'PO if result and result['errors']: # Hmmm ... bit of an Aspen rough spot ... interaction w/ error.spt, skip it # by overriding 200 for both success and failure. :( - result['msg'] = _("Sorry, we could not process your payment.") + result['msg'] = _("Eep! Mind looking over your info for us?") [---] application/json via json_dump result [---] text/html @@ -36,7 +36,11 @@ result {% block scripts %} {{ super() }} @@ -46,13 +50,13 @@ $(document).ready(function() { -
+
+
+

+ {{ _("We weren't able to process your card.") }} +

+
@@ -110,20 +123,26 @@ $(document).ready(function() {

{{ _('Who are you?') }}

{{ _('And do you wish to subscribe to our newsletter?') }}

-
+
+

+ {{ _("Please enter a value shorter than 256 characters.") }} +

-