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
+
+
+
+ ID: {{ pfos.uuid }} | ++ Date: {{ pfos.ctime.strftime("%B %d, %Y").replace(' 0', ' ') }} | +
+ Gratipay, LLC+ +
+ 716 Park Road
+ Ambridge, PA 15003
+ USA +
+ {{ pfos.name }}+
+ {{ pfos.promotion_name }}
+ {{ pfos.email_address }}
+
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 | +
Please contact support@gratipay.com with any questions. Thank + you for your business!
+ ++ {{ _("We weren't able to process your card.") }} +
+