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

Commit

Permalink
Merge pull request #4598 from gratipay/new-homepage
Browse files Browse the repository at this point in the history
"Pay for Open Source" form on homepage
  • Loading branch information
chadwhitacre authored Sep 12, 2017
2 parents d57585d + c88b721 commit aec5008
Show file tree
Hide file tree
Showing 43 changed files with 1,854 additions and 109 deletions.
5 changes: 4 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,13 @@ You should then find this in your browser at

![Success](https://raw.github.com/gratipay/gratipay.com/master/img-src/success.png)

Congratulations! Sign in using Twitter or GitHub and you're off and
running. At some point, try [running the test suite](#testing-).
Congratulations! Now enter a dollar amount [less than
2000](https://developers.braintreepayments.com/reference/general/testing/python#test-amounts)
(ironically), and submit the form to complete the basic flow:

![More Success](https://raw.github.com/gratipay/gratipay.com/master/img-src/more-success.png)

You're off and running! At some point, try [running the test suite](#testing-).


Help!
Expand Down
3 changes: 3 additions & 0 deletions defaults.env
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ BRAINTREE_SANDBOX_MODE=true
BRAINTREE_MERCHANT_ID=bk8h97tqzyqjhtfn
BRAINTREE_PUBLIC_KEY=xbty5dc9bgxpv5nb
BRAINTREE_PRIVATE_KEY=9d8646957c982bb0fb1aac764b582f7a
BRAINTREE_CLIENT_AUTHORIZATION=sandbox_cr9dyy9c_bk8h97tqzyqjhtfn

COINBASE_API_KEY=uETKVUrnPuXzVaVj
COINBASE_API_SECRET=32zAkQCcHHYkGGn29VkvEZvn21PM1lgO
Expand Down Expand Up @@ -105,3 +106,5 @@ PROJECT_REVIEW_USERNAME=
PROJECT_REVIEW_TOKEN=

RAISE_SIGNIN_NOTIFICATIONS=no

LOAD_BRAINTREE_FORM_ON_HOMEPAGE=no
23 changes: 23 additions & 0 deletions deploy/before.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
BEGIN;
CREATE TYPE follow_up AS ENUM ('monthly', 'quarterly', 'yearly', 'never');
CREATE TABLE payments_for_open_source
( uuid text PRIMARY KEY
, ctime timestamptz NOT NULL DEFAULT now()

-- card charge
, amount bigint NOT NULL
, braintree_transaction_id text UNIQUE DEFAULT NULL
, braintree_result_message text DEFAULT NULL

-- contact info
, name text NOT NULL
, follow_up follow_up NOT NULL
, email_address text NOT NULL

-- promotion details
, promotion_name text NOT NULL DEFAULT ''
, promotion_url text NOT NULL DEFAULT ''
, promotion_twitter text NOT NULL DEFAULT ''
, promotion_message text NOT NULL DEFAULT ''
);
END;
18 changes: 18 additions & 0 deletions emails/paid-for-open-source.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{{ _("Invoice from Gratipay") }}

[---] text/html
{{ _( "Thank you for your payment of {amount} for open source!"
, amount=format_currency(amount, 'USD')
) }}
<br>
<br>
<a href="{{ invoice_url }}" style="{{ button_style }}">{{ _("View Invoice") }}</a>

[---] text/plain
{{ _( "Thank you for your payment of {amount} for open source!"
, amount=format_currency(amount, 'USD')
) }}

{{ _("Follow this link to view your invoice:") }}

{{ invoice_url }}
2 changes: 2 additions & 0 deletions gratipay/application.py
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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()
118 changes: 118 additions & 0 deletions gratipay/homepage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""This is the Python library behind gratipay.com.
"""
from __future__ import absolute_import, division, print_function, unicode_literals

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


def _parse(raw):
"""Given a POST request.body, return (parsed<dict>, errors<list>).
"""

errors = []
x = lambda f: raw[f].strip() # KeyError -> 400

# amount
amount = x('amount')
if (not amount.isdigit()) or (int(amount) < 10):
errors.append('amount')
amount = ''.join(x for x in amount.split('.')[0] if x.isdigit())

# credit card nonce
payment_method_nonce = x('payment_method_nonce')
if len(payment_method_nonce) > 36:
errors.append('payment_method_nonce')
payment_method_nonce = ''

# name
name = x('name')
if len(name) > 255:
name = name[:255]
errors.append('name')

# email address
email_address = x('email_address')
if email_address and not utils.is_valid_email_address(email_address):
email_address = email_address[:255]
errors.append('email_address')

# follow_up
follow_up = x('follow_up')
if follow_up not in ('monthly', 'quarterly', 'yearly', 'never'):
follow_up = 'monthly'
errors.append('follow_up')

promotion_name = x('promotion_name')
if len(promotion_name) > 32:
promotion_name = promotion_name[:32]
errors.append('promotion_name')

promotion_url = x('promotion_url')
is_link = lambda x: (x.startswith('http://') or x.startswith('https://')) and '.' in x
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')
if len(promotion_twitter) > 32:
promotion_twitter = promotion_twitter[:32]
# TODO What are Twitter's rules?
errors.append('promotion_twitter')

promotion_message = x('promotion_message')
if len(promotion_message) > 128:
promotion_message = promotion_message[:128]
errors.append('promotion_message')

parsed = { 'amount': amount
, 'payment_method_nonce': payment_method_nonce
, 'name': name
, 'email_address': email_address
, 'follow_up': follow_up
, 'promotion_name': promotion_name
, 'promotion_url': promotion_url
, 'promotion_twitter': promotion_twitter
, 'promotion_message': promotion_message
}
return parsed, errors


def _store(parsed):
return PaymentForOpenSource.insert(**parsed)


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)


def _send(app, pfos):
app.email_queue.put( to=None
, template='paid-for-open-source'
, email=pfos.email_address
, amount=pfos.amount
, invoice_url=pfos.invoice_url
)


def pay_for_open_source(app, raw):
parsed, errors = _parse(raw)
out = {'errors': errors, 'invoice_url': None}
if not errors:
payment_method_nonce = parsed.pop('payment_method_nonce')
pfos = _store(parsed)
_charge(app, pfos, payment_method_nonce)
if pfos.succeeded:
out['invoice_url'] = pfos.invoice_url
if pfos.email_address:
_send(app, pfos)
else:
out['errors'].append('charging')
return out
4 changes: 3 additions & 1 deletion gratipay/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
from .exchange_route import ExchangeRoute
from .package import Package
from .participant import Participant
from .payment_for_open_source import PaymentForOpenSource
from .team import Team


MODELS = (AccountElsewhere, Community, Country, ExchangeRoute, Package, Participant, Team)
MODELS = (AccountElsewhere, Community, Country, ExchangeRoute, Package, Participant,
PaymentForOpenSource, Team)


@contextmanager
Expand Down
80 changes: 80 additions & 0 deletions gratipay/models/payment_for_open_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

import gratipay
from uuid import uuid4
from postgres.orm import Model


class PaymentForOpenSource(Model):

typname = "payments_for_open_source"

def __repr__(self):
return '<PaymentForOpenSource: {}>'.format(repr(self.amount))


@property
def succeeded(self):
return self.braintree_result_message == ''


@property
def invoice_url(self):
if not self.succeeded:
return None
return '{}/browse/payments/{}/invoice.html'.format(gratipay.base_url, self.uuid)


@classmethod
def from_uuid(cls, uuid, cursor=None):
"""Take a uuid and return an object.
"""
return (cursor or cls.db).one("""
SELECT pfos.*::payments_for_open_source
FROM payments_for_open_source pfos
WHERE uuid = %s
""", (uuid,))


@classmethod
def insert(cls, amount, name, follow_up, email_address,
promotion_name, promotion_url, promotion_twitter, promotion_message,
cursor=None):
"""Take baseline info and insert into the database.
"""
uuid = uuid4().hex
return (cursor or cls.db).one("""
INSERT INTO payments_for_open_source
(uuid, amount, name, follow_up, email_address,
promotion_name, promotion_url, promotion_twitter, promotion_message)
VALUES (%s, %s, %s, %s, %s,
%s, %s, %s, %s)
RETURNING payments_for_open_source.*::payments_for_open_source
""", (uuid, amount, name, follow_up, email_address,
promotion_name, promotion_url, promotion_twitter, promotion_message))


def process_result(self, result):
"""Take a Braintree API result and update the database.
"""
result_message = '' if result.is_success else result.message
transaction_id = None
if result.transaction:
transaction_id = result.transaction.id

# Verify that Braintree is sending us the right payload.
# TODO This is hard to test and it should be a pretty tight guarantee,
# so I am commenting out for now. :(
#pfos_uuid = result.transaction.custom_fields['pfos_uuid']
#assert pfos_uuid == self.uuid, (pfos_uuid, transaction_id)

self.db.run("""
UPDATE payments_for_open_source
SET braintree_result_message=%s
, braintree_transaction_id=%s
WHERE uuid=%s
""", (result_message, transaction_id, self.uuid))
self.set_attributes( braintree_result_message=result_message
, braintree_transaction_id=transaction_id
)
Loading

0 comments on commit aec5008

Please sign in to comment.