diff --git a/deploy/before.sql b/deploy/before.sql
index 0f00613514..72f8a10db2 100644
--- a/deploy/before.sql
+++ b/deploy/before.sql
@@ -5,13 +5,14 @@ BEGIN;
, ctime timestamptz NOT NULL DEFAULT now()
-- card charge
- , amount bigint NOT NULL
- , transaction_id text UNIQUE NOT NULL
+ , amount bigint NOT NULL
+ , transaction_id text UNIQUE DEFAULT NULL
+ , succeeded bool NOT NULL DEFAULT FALSE
-- contact info
- , name text NOT NULL
- , follow_up follow_up NOT NULL
- , email_address text NOT NULL
+ , name text NOT NULL
+ , follow_up follow_up NOT NULL
+ , email_address text NOT NULL
-- promotion details
, promotion_name text NOT NULL DEFAULT ''
diff --git a/gratipay/homepage.py b/gratipay/homepage.py
index e560efa423..a183288803 100644
--- a/gratipay/homepage.py
+++ b/gratipay/homepage.py
@@ -3,6 +3,7 @@
"""
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
@@ -16,11 +17,15 @@ def _parse(raw):
# amount
amount = x('amount') or '0'
- if (not amount.isdigit()) or (int(amount) < 50):
+ if (not amount.isdigit()) or (int(amount) < 10):
errors.append('amount')
amount = ''.join(x for x in amount.split('.')[0] if x.isdigit())
- # TODO credit card token?
+ # 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')
@@ -63,6 +68,7 @@ def _parse(raw):
errors.append('promotion_message')
parsed = { 'amount': amount
+ , 'payment_method_nonce': payment_method_nonce
, 'name': name
, 'email_address': email_address
, 'follow_up': follow_up
@@ -74,34 +80,36 @@ def _parse(raw):
return parsed, errors
-def _charge(app, parsed):
- raise NotImplementedError
+def _store(parsed):
+ return PaymentForOpenSource.insert(**parsed)
-def _store(parsed, transaction_id):
- return PaymentForOpenSource.insert(transaction_id=transaction_id, **parsed)
+def _charge(amount, payment_method_nonce, _sale=braintree.Transaction.sale):
+ return _sale({ 'amount': amount
+ , 'payment_method_nonce': payment_method_nonce
+ , 'options': {'submit_for_settlement': True}
+ })
-def _send(app, parsed, payment_for_open_source):
+def _send(app, email_address, payment_for_open_source):
app.email_queue.put( to=None
, template='paid-for-open-source'
- , email=parsed['email_address']
+ , email=email_address
, amount=payment_for_open_source.amount
, receipt_url=payment_for_open_source.receipt_url
)
-def pay_for_open_source(app, raw, _parse=_parse, _charge=_charge, _send=_send, _store=_store):
+def pay_for_open_source(app, raw):
parsed, errors = _parse(raw)
+ payment_method_nonce = parsed.pop('payment_method_nonce')
+ payment_for_open_source = _store(parsed)
if not errors:
- transaction_id = _charge(app, parsed)
- if not transaction_id:
+ result = _charge(parsed['amount'], payment_method_nonce)
+ payment_for_open_source.process_result(result)
+ if not payment_for_open_source.succeeded:
errors.append('charging')
- if not errors:
- payment_for_open_source = _store(parsed, transaction_id)
- if parsed['email_address']:
- sent = _send(app, parsed['email_address'], payment_for_open_source)
- if not sent:
- errors.append('sending')
- parsed= {}
+ if not errors and parsed['email_address']:
+ _send(app, parsed['email_address'], payment_for_open_source)
+ parsed = {} # no need to populate form anymore
return {'parsed': parsed, 'errors': errors}
diff --git a/gratipay/models/payment_for_open_source.py b/gratipay/models/payment_for_open_source.py
index 0ed4406a0d..905a122e2a 100644
--- a/gratipay/models/payment_for_open_source.py
+++ b/gratipay/models/payment_for_open_source.py
@@ -21,6 +21,8 @@ def receipt_url(self):
@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
@@ -29,15 +31,31 @@ def from_uuid(cls, uuid, cursor=None):
@classmethod
- def insert(cls, amount, transaction_id, name, follow_up, email_address,
+ 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 = uuidlib.uuid4().hex
return (cursor or cls.db).one("""
INSERT INTO payments_for_open_source
- (uuid, amount, transaction_id, name, follow_up, email_address,
+ (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, %s)
+ VALUES (%s, %s, %s, %s, %s,
+ %s, %s, %s, %s)
RETURNING payments_for_open_source.*::payments_for_open_source
- """, (uuid, amount, transaction_id, name, follow_up, email_address,
+ """, (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.
+ """
+ transaction_id = result.transaction.id if result.transaction else None
+ self.db.run("""
+ UPDATE payments_for_open_source
+ SET transaction_id=%s
+ , succeeded=%s
+ WHERE uuid=%s
+ """, (transaction_id, result.is_success, self.uuid))
+ self.set_attributes(transaction_id=transaction_id, succeeded=result.is_success)
diff --git a/gratipay/testing/harness.py b/gratipay/testing/harness.py
index 599b5413cf..eb8f8ae9f9 100644
--- a/gratipay/testing/harness.py
+++ b/gratipay/testing/harness.py
@@ -146,7 +146,6 @@ def clear_tables(self):
def make_payment_for_open_source(self, **info):
defaults = dict( amount='1000'
, name='Alice Liddell'
- , transaction_id='deadbeef'
, email_address='alice@example.com'
, follow_up='monthly'
, promotion_name='Wonderland'
diff --git a/gratipay/testing/vcr.py b/gratipay/testing/vcr.py
index bbdefbf03e..11855c9cfc 100644
--- a/gratipay/testing/vcr.py
+++ b/gratipay/testing/vcr.py
@@ -39,7 +39,7 @@ def deserialize(cassette_str):
vcr = VCR(
cassette_library_dir = FIXTURES_ROOT,
record_mode = 'once',
- match_on = ['url', 'method'],
+ match_on = ['url', 'method', 'body'],
)
vcr.register_serializer('custom', CustomSerializer)
diff --git a/tests/py/fixtures/BadCharge.yml b/tests/py/fixtures/BadCharge.yml
new file mode 100644
index 0000000000..e89a0ce90f
--- /dev/null
+++ b/tests/py/fixtures/BadCharge.yml
@@ -0,0 +1,44 @@
+interactions:
+- request:
+ body: !!python/unicode 10saletruefake-valid-nonce
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.braintreegateway.com:443/merchants/j9gwdfjdkxymhdgr/transactions
+ response:
+ body:
+ string: !!binary |
+ H4sIAP0Ls1kAA+RYS2/jNhC+51cYvjOSnWzjBIqCoosteuheNtnDXgJKHFuMKVLlw7Hz6zuUZFmK
+ qCQ9tFigN2vm45AznMdHJ3f7Usx2oA1X8na+OI/nM5C5YlxubucP91/Ian6XniVWU2lobhGVns1m
+ CWfpE9W7NSyWSYQfXmYstc6kxmUltxbY41rpRwPWCihB2iRqAR5rDxWkhgpIovqnl+VOa9z7QLhR
+ BI8A6cO3z0k0FnswLZWTNl3E53GcRO2XV5Sg84JKS2ieeyHB0xmbXW8vr15esqel/uXTUxKFULUP
+ LiMB3UxycTu32sE8anahxoL+EFRphsiAItdAMUyE2pmPwe2c4aflJczTZby4IvE1iVf3y8XNRXxz
+ cfUDI9EtqNe7iv2z9acFbbyNVeiB/6iv9PUJUbjm2lgiaQkBpaDTulyVFZWHgAZKykVA/gyZ4TZk
+ qyqUDMnXdD8KatT3Ksm4EJjJ/7GHxmoAzAnGNBgTCsHegmT+JiYhQuVUcBsyr2GDZRiKk8IKE02N
+ XF8u4qsk6ouOx8Y81Ydprxq1X0GoqAq6/BDq4j2UdHgpPB9fWO+O0LW1kyxULJ3GtMlOtaaHgRLj
+ 2WtTISMV1ZZjOE5N6dWKkHHqbKE0f3nffM9sRm1eBDEFr6r/ZUq+kSA/TS72b6ftj2TNQTDT5sLO
+ ENBaaYIxqpQ0EHStxvVcH6LTP3FgvQk4mhje2ivQH42VNzG1G7vdeP+x0EM3OB6e6QE1T9BkOU4c
+ M77YpNIqx90wDsfqoDW8tnT/9fdPP37D3vMWaGhleJRF7Gf6lHZipcUMTn+tULMDFlxdI+rQMsb9
+ STD4Y9jI153iub+gNV48rsDcyUCPI+I8E8BdmnE/gbJ0TxquElTBHsrqOM0zpQRQOU/XVBjPkzrA
+ kT2gFySnmrUpbtUWQjWYcZlexovlauWbrez3kct0sVotkqj9aEsFTZKalX3nhmKudN/HVlFx3Vxl
+ qaQtUs8BR8IR9gBUIzFZxgNwLW33bSc38Y2mZpoP307z/CQ9nbJQog52uH3wkm6AOC3SwtrK3EQR
+ NdiizXmmKZe+bNp8P8e+GVX04Dv3YwmYq+xRqI2Kduj/eSU3dyB3XCvpAbeGSpapPdLezn7b6zRU
+ FHnkV+XTr/ndaAqgwhZ4YqS0civVs0yinqwBMci4Pembz1blNF4c5uDGCc/geqjXmm4QeGqKs+4E
+ 7cna89KDVqKHOAra8BnjsBXiKJPbE2YgHbZWtSZeS2UOqd9uLD3GSTGX15T7tPVJ1oCc5H85aOsI
+ xRh5jp14VFK+QEGWihi2nSicTt8SxGHhtM8WUnDMM30YEIBueNYIQEPtjfhqQ6KNirL6IPnu8J2F
+ 9jl04hf9F1KNmHrkNBEySHK1KritqBNbrMFa0p2vx1WMwu4EKa04nmMsb7yMxm7+y55/5KX4E8Sh
+ k7Qp0vR7QcMc0GUm17ya5Ig9fdefawJMKuQkihGkYcSHNNjJB0g8lrZBLB751T5+6BGcbwGCy7ip
+ izWog8aKOjbpiV479UTD7jg+29Aokkf/xEe/Juq30zdzD9/bEsZW8c53flCvAaZGrN9WPZPmNkda
+ DEPmtGn4OwOLr1Rz7L8DVfhueuQ/vP0QM/pX44Nw2PsA4NzR4WP4hxBmKlLWkEGX5wFujzcy4bv3
+ vHIW3noXNXOcsicc1H59CNsOV8Il0lXXPLw8oWh68aPvxUk0BRoSvl5Qhrywz/kmQe/bqlnie7Y6
+ KmkLpBEES9HnKODR12oY3UGjSc/+BgAA//8DAFQWy6JwEwAA
+ headers:
+ cache-control: ['max-age=0, private, must-revalidate']
+ content-encoding: [gzip]
+ content-type: [application/xml; charset=utf-8]
+ etag: [W/"48d3905da353929ec2b8cd3d54737edd"]
+ strict-transport-security: [max-age=31536000; includeSubDomains]
+ transfer-encoding: [chunked]
+ vary: [Accept-Encoding]
+ status: {code: 201, message: Created}
+version: 1
diff --git a/tests/py/fixtures/GoodCharge.yml b/tests/py/fixtures/GoodCharge.yml
new file mode 100644
index 0000000000..a2d88c1579
--- /dev/null
+++ b/tests/py/fixtures/GoodCharge.yml
@@ -0,0 +1,25 @@
+interactions:
+- request:
+ body: !!python/unicode 10saletruedeadbeef
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.braintreegateway.com:443/merchants/j9gwdfjdkxymhdgr/transactions
+ response:
+ body:
+ string: !!binary |
+ H4sIAP0Ls1kAA6RSu27DMAzc8xWCdkfJkKIFFGXLFzRzQMdMatR6gKLb+O8rP/OA26UbyaNOdyfp
+ 3dVW4gsplt5t5Xq5kgLdyRelu2zl4X2fvcqdWWgIZYZEnjLCGLyLaBZC6G4U23JqBDcBtxKIoJFq
+ gJjARThxuqSfzK+P2Ije+jRJotC8rTcvG626+h4EZirzmnHgi43NfSVNgMai46NF/vDF0Xl3Qq2m
+ 7QcOizHCBc3BfTr/7YQngddQEhZijmap1XjiJls96B7aMSD1FMM9rAMQ2PhHXmB97disV0l/X45I
+ a9lEqJKzrhznPrQE8U5erHNbcnZO7xiRucLWlWGq09F5bHLyRKaHSLI+kqyLxBQIRY541moW/i2G
+ m/f/PELKZeaX/gAAAP//AwB33+lL4gIAAA==
+ headers:
+ cache-control: [no-cache]
+ content-encoding: [gzip]
+ content-type: [application/xml; charset=utf-8]
+ strict-transport-security: [max-age=31536000; includeSubDomains]
+ transfer-encoding: [chunked]
+ vary: [Accept-Encoding]
+ status: {code: 422, message: Unprocessable Entity}
+version: 1
diff --git a/tests/py/fixtures/PayForOpenSource.yml b/tests/py/fixtures/PayForOpenSource.yml
new file mode 100644
index 0000000000..46d34d2c97
--- /dev/null
+++ b/tests/py/fixtures/PayForOpenSource.yml
@@ -0,0 +1,152 @@
+interactions:
+- request:
+ body: !!python/unicode 1000saletruedeadbeef
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.braintreegateway.com:443/merchants/j9gwdfjdkxymhdgr/transactions
+ response:
+ body:
+ string: !!binary |
+ H4sIABcPs1kAA6RSu27DMAzc8xWCdkf2kKIFFGXLFzRzQMdMatR6QKTb+O8rO3ZecLt0I3nkiXeU
+ 3pxtI74wUu3dWhbLXAp0B1/V7rSWu/dt9io3ZqEh1BnG6GMWkYJ3hGYhhB5K1IfXRHAXcC0hRuik
+ GiGO4AgOnB65VObbJ2xCb3mqpKXQvBWrl5VWQ3wPAnOsy5Zx5KPOlr6RJkBn0fHeIn/4au+8O6BW
+ 1+4HDotEcEKzc5/Ofzvho8BzqCNWYo5mqdU0cVtbPew9ppNB6smGe1gHiGDpD7/A+taxKfI8Twou
+ yYT1og1Bk7QN4VT3oaeguwWpLW3N2TFdkpC5wV6X4dim0XnsquWJTI+mZBdTssEUUyFUJeJRq1n4
+ NyNu6v9zhuTLzD/9AQAA//8DAPacZCDkAgAA
+ headers:
+ cache-control: [no-cache]
+ content-encoding: [gzip]
+ content-type: [application/xml; charset=utf-8]
+ strict-transport-security: [max-age=31536000; includeSubDomains]
+ transfer-encoding: [chunked]
+ vary: [Accept-Encoding]
+ status: {code: 422, message: Unprocessable Entity}
+- request:
+ body: !!python/unicode 2000saletruefake-valid-nonce
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.braintreegateway.com:443/merchants/j9gwdfjdkxymhdgr/transactions
+ response:
+ body:
+ string: !!binary |
+ H4sIABgPs1kAA+xYO3PjNhDu/Ss06mFKOl8se2i6uckkRa65uxRpPCC5FGGRAA2AsuRfnwXBtwCd
+ UiVFOnL3wxL7wOJbhs/HslgcQCom+NNyfbtaLoAnImV897T88f1Xsl0+RzchrRgBKYUkElQluILo
+ ZrEIG5Eyj/3LQp8qeFpSKelpGTSoYICFFZW07FZoSbmiicZvWwnKaClqrqPNarUKg/al0xnTkaIF
+ hEHz2MlFZUyo7h0lqo5LpkmGG1agdQEloB0ta1zq1nW2gpkx3PHJAEgJOhcp4YInEGV0D+RAC9YK
+ wsAJs24GMz8NuItCWIJSdAfRF7H4KvTiN8GFDINOeuOKUsjSaJ9nb9u3rAoDfLFSpamuVVRJkeBq
+ IV9SSArGIUWXrerGH8UwqaXEzJ8IU4JgAUD049uXMDgX38zTdDvPFPokk5xiMGiSGDHBPSodP+zv
+ 7j8+4teN/OXzq/HxHHXTZY84tAvOiqelSaItLPMlqjTIK8FCpoh1qhIJVENKqG7rN8VXzUpYoovr
+ e7J6IKvt98368e7T4+f1XxiXfkFroa7Sf2ZhWNBnQGmBnvSV59opijMmlSacluBUF/SSNhFlRfnJ
+ qYOSssKpeYdYMe22WOWCuzUZPTpCHUz9DGNWYJXu/jWvlZYAWDlpiq1NuQNz1MBTk6sLoEIk2A+0
+ +yMSdnh83fETeDoLe7Ye7tare2wPI9HgAta1PF3y0QLMKkKLKqebK3Gffo7jNSaMJa50TvKHjmY1
+ T92HrNc5LolejVEeNTy3IWyfmmGAhvY9W+P+AK2xNUv2cc0nRqZjqpPcg8pZVf1fvE2NXCyi/1zV
+ TjPX9l6SMShS1dfLQc1Ij8fRBjkKxRQf/YHX40XAYGSazxnsd2vnIqZ153A438O50IJ3eA290xPq
+ XsGeCLzblCvhYc8tSHeWaLPAE5YBPv2uJXc+rXetxlKe8SQfqo1omjKzP4z6ObDZxeJxMTV4eYk1
+ exAsMSnMsDgQh1UWg3S5XxtWgluyxMOL0/RILH/yKOEIZdXxiliIAihfIgctlOFwPWDgMugDSahM
+ B+Ys9uA+wTHj0d1qvdluTSvn0550F62323UYtC/9IUPTpOGOfzJFsbL696HpVEza2igF13m03uAY
+ MBc60CegEjOzWU3gjbT/essfiGlaDSv+8W1gFYN0vNtcFE0KfG2Ilci2SS2LKNe6Uo9BQBVeAeo2
+ lpRxc+DaU3KL3bjj+i+W678UYieCA0bituK7Z+AHJgU3gCdFeRqLI5L03n7fOSVUFBnvV2Gq2D53
+ uhxooXPcN5JwvufinYfBSNbBUoiZHhD2tVfWEtOJVbqrC8MzR7i5ZnTRGCKNN+sAHslGA5EUxQjT
+ CfpgKlVja8WLk+8H1EQ6b9ciI0ZPzdBkPnouHeIm0jpphoVhA4Osg9WcvdXQnjlUYDYYdnfH8TMH
+ GngpiEr3niPW63sKOz9i7fBFcoZVKE8T+jG6rBsMHEbzpjmbOCSgqqyuHBx6/HjevW72s13xwuhm
+ Y6eQnEuRM13RutjjyW0kox2P2JMS2OUgohWbzLmdvB+rz53vZW3Quq5ZUB8nq2OVSFZdYG0jxKjD
+ NQSVVMgFcDJHMkRMiD3dcILFDUrtQTc/EmZfMzcOwZvCST9TpprC9mjB2hJDo/N2K//IhR3GtdO5
+ caR0ZrBHTz0V3+u7OwWnaw4u21gZB3MdZgD+S8x8XrwTm2+HHkMT11JZvp2Cxjl0+AEzVfoyNyLs
+ vm1MUWd/Nq5eAEcTDuzn0rcZM8hgXSOZdButk8TJxDFT3kiYOFS1hsuzjb0vafqK16Gx4UZ3P6sY
+ RzJZ2wHKXN+2r72Yvjb80ZqD5vxsFKIpkRvTMy/oGmsNpfuZtRHv0zle2ASPr6liQAcyMY/17Kcc
+ dkHHH86/AQAA//8DAMjWcQAeFQAA
+ headers:
+ cache-control: [no-cache]
+ content-encoding: [gzip]
+ content-type: [application/xml; charset=utf-8]
+ strict-transport-security: [max-age=31536000; includeSubDomains]
+ transfer-encoding: [chunked]
+ vary: [Accept-Encoding]
+ status: {code: 422, message: Unprocessable Entity}
+- request:
+ body: !!python/unicode 1000saletruefake-valid-nonce
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.braintreegateway.com:443/merchants/j9gwdfjdkxymhdgr/transactions
+ response:
+ body:
+ string: !!binary |
+ H4sIABgPs1kAA+RYTW/jNhC951cYvjOSvU7jBIqCAsUCBdpedrOHvQSUOLYYU6TKD8fOr+9QkmUp
+ opLsocUCvVkzj8OZ4XDm0cn9oRSzPWjDlbybLy7j+QxkrhiX27v5w9fPZD2/Ty8Sq6k0NLeISi9m
+ s4SzNCvWtLimcRLhh5cZS60zqXFZya0F9rhR+tGAtQJKkDaJWoDH2mMFqaECkqj+6WW50xr3PhJu
+ FEEXIH348lsSjcUeTEvlpE0XcRxfxuhD++1VJei8oNISmudeSNA/Y7Ob3er65SV7Wupfrp6SKISq
+ o3AZCehmkou7udUO5lGzCzUW9IegSjNEBhS5BoqJItTOfBbu5gw/LS9hni7jxTWJb0i8/rpc3K4+
+ 3V4tv2MuugX1elexH1t/XtBm3FiFEfiP+lBfe4jCDdfGEklLCCgFndblqqyoPAY0UFIuAvJnyAy3
+ IVtVoWRIvqGHUVKjflRJxoXAWv6PIzRWA2BNMKbBmFAKDhYk8ycxCREqp4LbkHkNW7yIoTwpvGOi
+ uSU3q0V8nUR90cltrFN9nI6qUfsVhIqqoMsPoT69h5IOD4Xn4wPrnRGGtnGShS5LpzFtsVOt6XGg
+ xHz2GlXISEW15ZiOc1t6tSJknDpbKM1f3jffM5tRmxdBTMGr6n9Zkm8UyE9Ti/3Tafsj2XAQzLS1
+ sDcEtFaaYI4qJQ0EQ6txvdCH6PRPHFhvAk4mhqf2CvR7Y+VNTB3Gfj/efyz00C2Oh2d6RM0TNFWO
+ E8eMDzaptMpxN8zD6XbQGl5b+mP1+fvVN+w9b4GGVoau+LHeXz52NKCzWMHprxVq9sCCq2tEnVrG
+ uPcEkz+GjWLdK577A9rgweMKrJ0M9DgjzjMB3KUZ9xMoSw+k4SpBFRygrE7TPFNKAJXzdEOF8Uyp
+ A5zYA0ZBcqpZW+JW7SB0BzMu01W8WK7XvtnKfh9ZpYv1epFE7Ud7VdAkqXnZN24o1kr3fWoVFdfN
+ UZZK2iJdLJNoJBxhj0A1EpNlPADX0nbfdnIT32hqrvnw5TzPz9Kzl4USdbLD7YOXdAvEaZEW1lbm
+ NoqowRZtLjNNufTXpq33S+ybUUWPvnM/loC1yh6F2qpoj/FfVnJ7D3LPtZIecGeoZJk6IPHt7Le9
+ TkNFkUf+pXz5Nb8bTQFU2AI9RlIrd1I9yyTqyRoQg4zbs775bFVO48FhDW6d8Ayuh3qt6QaBp6Y4
+ 687Qnqz1lx61Ej3ESdCmzxiHrRBHmdydMQPpsLWqDfFaKnNI/XZj6SlPirm8ptznrc+yBuQk/9tB
+ e49QjJnn2IlHV8pfUJClIobtJi5Op28J4vDitA8XUnCsM30cEIBueNYIQEPtifjbhkQbFWX1QfLd
+ 4TsL7YPozC/6b6QaMf3MaXJkkOZqVXBbUSd2eAtrSedhj60Yhf0JUlpx9GQsb+KMxoH+y7F/5LX4
+ U2Sik7Rl0vR8QcM80GUm17ya5Ik9fdejaxJMKuQlihGkYsQnNdjNB0h0S9sgFl1+tY8ffARnXIDk
+ Mm7qCxvUQWNFnRr1RL+deqZhhxz7NjSKBNI/9DGuiTvc6ZvZh29uCWOreOZ7P6w3AFNj1m+rnklz
+ miMtpiFz2jQcnoHFl6o59eCBKnw2vQdAePshZvTfxgfhcPAJwNmjw274xxBWKtLWkEGX5wF+jycy
+ EbuPvHIW3nobNbOcsicc1n59CNsOWMIlUlbXPL48qWj68aPvx0k0BRqSvl5Shtywz/smQe/bqpni
+ e7Y6OmkLpBIEr6KvUUDXN2qY3UGjSS/+AQAA//8DAOlGQbB2EwAA
+ headers:
+ cache-control: ['max-age=0, private, must-revalidate']
+ content-encoding: [gzip]
+ content-type: [application/xml; charset=utf-8]
+ etag: [W/"a44f9ce080b64ff731748576de95b990"]
+ strict-transport-security: [max-age=31536000; includeSubDomains]
+ transfer-encoding: [chunked]
+ vary: [Accept-Encoding]
+ status: {code: 201, message: Created}
+- request:
+ body: !!python/unicode 1000saletruefake-valid-nonce
+ headers: {}
+ method: POST
+ uri: https://api.sandbox.braintreegateway.com:443/merchants/j9gwdfjdkxymhdgr/transactions
+ response:
+ body:
+ string: !!binary |
+ H4sIABkPs1kAA+RYS2/jNhC+51cYvjOSnWzjBIqCAsUWLdCiwCZ72EtAiWOLsURq+XDs/PoOJVqW
+ IipJDy0W6M2a+TjkDOfx0cndvipnO1CaS3E7X5zH8xmIXDIuNrfzh/vPZDW/S88So6jQNDeISs9m
+ s4SzlH6vcni+WCQRfjiZNtRYnWqbVdwYYI9rqR41GFNCBcIkkQc4rDnUkGpaQhI1P50st0rh3gfC
+ tSR4BEgfvvySRGOxA9NKWmHSRRzH53GcRP7bqSpQeUGFITTPnZDg+bTJrreXVy8v2dNS/fTpKYlC
+ qMYLm5GAbiZ4eTs3ysI8aneh2oD6EFQqhsiAIldAMVCEmpmLwu2c4afhFczTZby4IvE1iVf3y8XN
+ 5cXNp4tvGItuQbPe1uyfrT8t8BHXRqIH7qO51NcnROGaK22IoBUElCWd1uWyqqk4BDRQUV4G5M+Q
+ aW5CtupCipB8TfejoEZ9r5KMlyXm8n/soTYKAHOCMQVah0KwNyCYu4lJSClzWnITMq9gg4UYipPE
+ GivbKrm+XMRXSdQXHY+NeaoO0161areC0LIu6PJDqIv3UMLipfB8fGG9O0LX1lawULF0Gu2TnSpF
+ DwMlxrPXqEJGaqoMx3Cc2tKrFSHj1JpCKv7yvvme2YyavAhiCl7X/8uUfCNBfphc7N+O749kzaFk
+ 2ufCThNQSiqCMaql0BB0rcH1XB+i0z9wYL0JOJoY3tor0G+tlTcxjRu73Xj/sdBBNzgenukBNU/Q
+ ZjlOHD2+2KRWMsfdMA7H6qANvLH017fPv97/jr3nLdDQyvAobqz3l48PGtAZzOD05xo1O2DB1Q2i
+ CS1j3J0Egz+GjXzdSZ67C1rjxeMKzJ0M1Dgi1jEB3KUd9xMoQ/ek5SpBFeyhqo/TPJOyBCrm6ZqW
+ 2jGlDnBkD+gFyaliPsWN3EKoBjMu0st4sVytXLMV/T5ymS5WK+Rw/sOXCpokDS/7yjXFXOm+j62i
+ 5qq9ykoKU6SLZRKNhCPsAahCYrKMB+BG6vf1k5u4RtNwzYcvp3l+kp5OWciyCXa4ffCKboBYVaaF
+ MbW+iSKqsUXr80xRLlzZ+Hw/x74Z1fTgOvdjBZir7LGUGxnt0P/zWmzuQOy4ksIBbjUVLJN7JL6d
+ fd/rFNQUeeSf0qVf+7vVFEBLU+CJkdSKrZDPIol6shbEIOPmpG8/vcoqvDjMwY0tHYProV5rukHg
+ qCnOuhO0J/PnpQclyx7iKPDh09piK8RRJrYnzEA6bK1yTZyWihxSt91YeoyTZDZvKPdp65OsBVnB
+ v1vwdYRijDzHTjwqKVegICpJNNtOFE6n9wRxWDj+4UIKjnmmDgMC0A3PBgFoyN+IqzYk2qio6g+S
+ 7w7fWfAPohO/6L+RGsT0M6eNkUaaq2TBTU1tucUqbCTdCXtsRUvsT5DSmuNJxvLWz2js6L/s+0de
+ iz9EJDqJT5O255c0zANtpnPF60me2NN3PbohwaRGXiIZQSpGXFCD3XyAxGMpE8TikV/t4wYfwRkX
+ ILmM66Zggzporchjo57ot1PPNOyQ47MNjSKBdA999Guihjt9O/vwzS1gbBXvfOeG9Rpgasy6beUz
+ aW9zpMUwZFbplsMzMPhS1ccePFCF76b3AAhvP8SM/tv4IBz2LgA4e1T4GO4xhJmKtDVk0OZ5gN/j
+ jUz47jyvrYG33kbtLKfsCYe1Wx/C+gFLuEDKatvHlyMVbT9+dP04iaZAQ9LXC8qQG/Z53yTofVsN
+ U3zPVkcnTYFUgmApuhwFPPpaDqM7aDTp2d8AAAD//wMAQ1Nv8HYTAAA=
+ headers:
+ cache-control: ['max-age=0, private, must-revalidate']
+ content-encoding: [gzip]
+ content-type: [application/xml; charset=utf-8]
+ etag: [W/"ac4b2e3ca6df6cdc17b520446f540b1e"]
+ strict-transport-security: [max-age=31536000; includeSubDomains]
+ transfer-encoding: [chunked]
+ vary: [Accept-Encoding]
+ status: {code: 201, message: Created}
+version: 1
diff --git a/tests/py/test_payments_for_open_source.py b/tests/py/test_payments_for_open_source.py
index d9f2af60e6..f5c6ccfd35 100644
--- a/tests/py/test_payments_for_open_source.py
+++ b/tests/py/test_payments_for_open_source.py
@@ -14,3 +14,23 @@ def test_can_insert(self):
def test_can_fetch(self):
uuid = self.make_payment_for_open_source().uuid
assert PaymentForOpenSource.from_uuid(uuid).name == 'Alice Liddell'
+
+ def test_can_update(self):
+ pfos = self.make_payment_for_open_source()
+ assert pfos.transaction_id is None
+ assert not pfos.succeeded
+
+ class Transaction:
+ id = 'deadbeef'
+ class Result:
+ transaction = Transaction()
+ is_success = True
+ result = Result()
+
+ pfos.process_result(result)
+ assert pfos.transaction_id == 'deadbeef'
+ assert pfos.succeeded
+
+ fresh = self.db.one("SELECT * FROM payments_for_open_source")
+ assert fresh.transaction_id == 'deadbeef'
+ assert fresh.succeeded
diff --git a/tests/py/test_www_homepage.py b/tests/py/test_www_homepage.py
index 3a19295c50..edab4c09ac 100644
--- a/tests/py/test_www_homepage.py
+++ b/tests/py/test_www_homepage.py
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals
+import json
import urllib
import aspen.body_parsers
-from gratipay.homepage import pay_for_open_source, _parse, _store, _send
+from gratipay.homepage import pay_for_open_source, _parse, _store, _charge, _send
from gratipay.testing import Harness
from gratipay.testing.email import QueuedEmailHarness
-from pytest import raises
_oh_yeah = lambda *a: 'oh yeah'
@@ -15,6 +15,7 @@
GOOD = { 'amount': '1000'
+ , 'payment_method_nonce': 'fake-valid-nonce'
, 'name': 'Alice Liddell'
, 'email_address': 'alice@example.com'
, 'follow_up': 'monthly'
@@ -24,6 +25,7 @@
, 'promotion_message': 'Love me! Love me! Say that you love me!'
}
BAD = { 'amount': '1,000'
+ , 'payment_method_nonce': 'deadbeef' * 5
, 'name': 'Alice Liddell' * 20
, 'email_address': 'alice' * 100 + '@example.com'
, 'follow_up': 'cheese'
@@ -33,6 +35,7 @@
, 'promotion_message': 'Love me!' * 50
}
SCRUBBED = { 'amount': '1000'
+ , 'payment_method_nonce': ''
, 'name': 'Alice Liddell' * 19 + 'Alice Lid'
, 'email_address': 'alice' * 51
, 'follow_up': 'monthly'
@@ -41,10 +44,16 @@
, 'promotion_twitter': 'thebestbutterthebestbutterthebes'
, 'promotion_message': 'Love me!' * 16
}
-ALL = ['amount', 'name', 'email_address', 'follow_up',
+ALL = ['amount', 'payment_method_nonce', 'name', 'email_address', 'follow_up',
'promotion_name', 'promotion_url', 'promotion_twitter', 'promotion_message']
+class PayForOpenSourceHarness(Harness):
+
+ def fetch(self):
+ return self.db.one('SELECT * FROM payments_for_open_source')
+
+
class Parse(Harness):
def test_good_values_survive(self):
@@ -57,29 +66,59 @@ def test_bad_values_get_scrubbed_and_flagged(self):
assert parsed == SCRUBBED
assert errors == ALL
+ def test_10_dollar_minimum(self):
+ bad = GOOD.copy()
+ bad['amount'] = '9'
+ assert _parse(bad)[1] == ['amount']
+
+ good = GOOD.copy()
+ good['amount'] = '10'
+ assert _parse(good)[1] == []
+
+
+# Valid nonces for testing:
+# https://developers.braintreepayments.com/reference/general/testing/python#valid-nonces
+#
+# Separate classes to force separate fixtures to avoid conflation. #2588
+# suggests we don't want to match on body for some reason? Hacking here vs.
+# getting to the bottom of that.
-class Store(Harness):
+class GoodCharge(Harness):
+
+ def test_bad_nonce_fails(self):
+ result = _charge('10', 'deadbeef')
+ assert not result.is_success
+
+class BadCharge(Harness):
+
+ def test_good_nonce_succeeds(self):
+ result = _charge('10', 'fake-valid-nonce')
+ assert result.is_success
+
+
+class Store(PayForOpenSourceHarness):
def test_stores_info(self):
parsed, errors = _parse(GOOD)
- fetch = lambda: self.db.one('SELECT * FROM payments_for_open_source')
- assert fetch() is None
- _store(parsed, 'deadbeef')
- assert fetch().follow_up == 'monthly'
+ parsed.pop('payment_method_nonce')
+ assert self.fetch() is None
+ _store(parsed)
+ assert self.fetch().follow_up == 'monthly'
class Send(QueuedEmailHarness):
def test_sends_receipt_link(self):
parsed, errors = _parse(GOOD)
- payment_for_open_source = _store(parsed, 'deadbeef')
- _send(self.app, parsed, payment_for_open_source)
+ parsed.pop('payment_method_nonce')
+ payment_for_open_source = _store(parsed)
+ _send(self.app, parsed['email_address'], payment_for_open_source)
msg = self.get_last_email()
assert msg['to'] == 'alice@example.com'
assert msg['subject'] == 'Payment for open source'
-class PayForOpenSource(Harness):
+class PayForOpenSource(PayForOpenSourceHarness):
def as_body(self, **raw):
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
@@ -95,20 +134,40 @@ def bad(self):
return self.as_body(**BAD)
def test_pays_for_open_source(self):
- fetch = lambda: self.db.one('SELECT * FROM payments_for_open_source')
- assert fetch() is None
- result = pay_for_open_source(self.app, self.good, _charge=_oh_yeah, _send=_none)
- assert result == {'parsed': {}, 'errors': ['sending']} # TODO revisit once we have _send
- assert fetch().transaction_id == 'oh yeah'
-
- def test_scrubs_and_flags_errors_and_doesnt_store(self):
- fetch = lambda: self.db.one('SELECT * FROM payments_for_open_source')
- assert fetch() is None
- result = pay_for_open_source(self.app, self.bad, _charge=_oh_yeah, _send=_none)
- assert result == {'parsed': SCRUBBED, 'errors': ALL}
- assert fetch() is None
+ assert self.fetch() is None
+ result = pay_for_open_source(self.app, self.good)
+ assert result == {'parsed': {}, 'errors': []}
+ assert self.fetch().succeeded
+
+ def test_scrubs_and_flags_errors_and_also_stores(self):
+ assert self.fetch() is None
+ result = pay_for_open_source(self.app, self.bad)
+ scrubbed = SCRUBBED.copy()
+ scrubbed.pop('payment_method_nonce') # consumed
+ assert result == {'parsed': scrubbed, 'errors': ALL}
+ assert self.fetch().name.endswith('Alice Lid')
+
+ def test_flags_errors_with_no_transaction_id(self):
+ error = GOOD.copy()
+ error['payment_method_nonce'] = 'deadbeef'
+ result = pay_for_open_source(self.app, error)
+ assert result['errors'] == ['charging']
+ pfos = self.fetch()
+ assert not pfos.succeeded
+ assert pfos.transaction_id is None
+
+ def test_flags_failures_with_transaction_id(self):
+ failure = GOOD.copy()
+ failure['amount'] = '2000'
+ result = pay_for_open_source(self.app, failure)
+ assert result['errors'] == ['charging']
+ pfos = self.fetch()
+ assert not pfos.succeeded
+ assert pfos.transaction_id is not None
def test_post_gets_json(self):
- with raises(NotImplementedError):
- self.client.POST('/', data=GOOD)
+ response = self.client.POST('/', data=GOOD)
+ assert response.code == 200
+ assert response.headers['Content-Type'] == 'application/json'
+ assert json.loads(response.body) == {'parsed': {}, 'errors': []}