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': []}