Files are uploaded before payment so the price and the security check use the file itself, but an abandoned cart kept them for 30 days. Now a finished upload is held 2 days, a quote waiting for review 7, an approved quote 2 more to be paid, and the paid order keeps its originals for 30 days from upload. A payment never starts for files that are gone; one under way holds them a day. Files attached to an order take the order's window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
10 KiB
Python
185 lines
10 KiB
Python
"""The webhook path, against a running stack.
|
|
|
|
A provider retries. It delivers out of order, twice, and late. None of that may
|
|
produce a second order or a second notification to the customer, and nothing
|
|
unsigned may produce one at all.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
from urllib.error import HTTPError
|
|
from urllib.request import Request, urlopen
|
|
from uuid import uuid4
|
|
|
|
from app.core import db
|
|
from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host
|
|
|
|
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
|
|
|
|
|
def deliver(payload, expected=200, signature=None):
|
|
body = json.dumps(payload).encode()
|
|
sig = signature if signature is not None else hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
|
request = Request(BASE + '/api/payments/webhook', data=body,
|
|
headers=with_host({'Content-Type': 'application/json',
|
|
'x-payment-signature': sig}))
|
|
try:
|
|
with urlopen(request, timeout=30) as response:
|
|
assert response.status == expected, (response.status, expected)
|
|
return json.load(response)
|
|
except HTTPError as exc:
|
|
assert exc.code == expected, (exc.code, expected, exc.read().decode())
|
|
return {}
|
|
|
|
|
|
def reviewed_quote():
|
|
"""A quote an operator has approved, ready to be paid."""
|
|
customer = Client()
|
|
customer.call('/session')
|
|
uid = upload_bytes(customer, b'PAYMENT WEBHOOK TEST')
|
|
item = item_spec('file', '1.01', 0, uid)
|
|
profile = {'cnpj': '11222333000181', 'zap': '11999999999',
|
|
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
|
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
|
'items': [item], 'freight': {'service': 'pickup'}})
|
|
approved = approved_quote(customer, quote, [item])
|
|
return customer, quote['id'], approved['total_cents']
|
|
|
|
|
|
def run():
|
|
customer, quote_id, total = reviewed_quote()
|
|
|
|
# Nothing unsigned creates an order, and a tampered body is not signed.
|
|
deliver({'event_id': 'unsigned-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': total}, expected=403, signature='')
|
|
deliver({'event_id': 'tampered-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': total}, expected=403, signature='0' * 64)
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order'
|
|
print('PASS: unsigned and tampered deliveries are refused and create nothing')
|
|
|
|
# Starting a PIX twice returns the same one. A card in review blocks every
|
|
# further attempt, so one quote can never be charged twice.
|
|
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
|
|
assert pix['expires_at']
|
|
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
|
|
# Once the code has expired, asking again opens a new one, and only one.
|
|
with db.connect() as c:
|
|
c.execute('''UPDATE dtf_local.payment_intents
|
|
SET response=jsonb_set(response,'{expires_at}',to_jsonb((now()-interval '1 minute')::text))
|
|
WHERE provider_payment_id=%s''', (pix['id'],))
|
|
renewed = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
|
|
assert renewed['id'] != pix['id'], 'an expired PIX was offered again'
|
|
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == renewed['id']
|
|
with db.connect() as c:
|
|
statuses = {r['provider_payment_id']: r['status'] for r in c.execute(
|
|
"SELECT provider_payment_id,status FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'",
|
|
(quote_id,)).fetchall()}
|
|
assert statuses == {pix['id']: 'expired', renewed['id']: 'pending'}, statuses
|
|
print('PASS: a PIX code expires after 30 minutes and is replaced by exactly one new code')
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422)
|
|
card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1}
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': card})
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {**card, 'token': 'tok-2'}}, expected=409)
|
|
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=409)
|
|
stranger = Client()
|
|
stranger.call('/session')
|
|
stranger.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=404)
|
|
print('PASS: payment start is idempotent for PIX and refuses a second charge')
|
|
|
|
# An approved payment for the wrong amount must not become an order.
|
|
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': total - 100})
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'underpayment created an order'
|
|
deliver({'event_id': 'missing-amount-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved'})
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'missing paid amount created an order'
|
|
deliver({'event_id': 'invalid-amount-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'approved', 'amount_cents': str(total)})
|
|
assert not customer.call('/quotes/' + quote_id)['order'], 'non-integer paid amount created an order'
|
|
print('PASS: a missing, invalid or mismatched paid amount is refused')
|
|
|
|
# The real thing, then the same delivery again, and a second event for the
|
|
# same quote: a provider does all three.
|
|
event = 'paid-' + uuid4().hex
|
|
payload = {'event_id': event, 'reference': quote_id, 'status': 'approved',
|
|
'amount_cents': total}
|
|
first = deliver(payload)
|
|
assert first['status'] == 'applied', first
|
|
order = customer.call('/quotes/' + quote_id)['order']
|
|
assert order, 'approved payment did not create an order'
|
|
|
|
again = deliver(payload)
|
|
assert again['status'] == 'duplicate', again
|
|
later = deliver({**payload, 'event_id': 'retry-' + uuid4().hex})
|
|
assert 'already existed' in later.get('outcome', ''), later
|
|
assert customer.call('/quotes/' + quote_id)['order']['id'] == order['id'], 'a second order appeared'
|
|
print('PASS: one order from a repeated and re-sent approval')
|
|
|
|
assert customer.call('/orders/dev-paid', {'quote_id': quote_id})['id'] == order['id']
|
|
other = Client()
|
|
other.call('/session')
|
|
other.call('/orders/dev-paid', {'quote_id': quote_id}, expected=404)
|
|
print('PASS: another customer cannot retrieve the paid order by quote id')
|
|
|
|
# The customer is told once, not once per delivery.
|
|
board = Client()
|
|
events = board.call('/operator/events?order=' + str(order['number']), operator=True)['events']
|
|
paid = [e for e in events if e['payload'].get('order_id') == order['id']
|
|
and e['payload'].get('event') == 'payment_approved']
|
|
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'
|
|
assert {e['provider'] for e in paid} == {'tiny', 'whatsapp'}, paid
|
|
print('PASS: exactly one notification per provider for the order')
|
|
|
|
# A payment that was never reviewed, and one for something that is not a quote.
|
|
deliver({'event_id': 'nonsense-' + uuid4().hex, 'reference': 'not-a-uuid',
|
|
'status': 'approved', 'amount_cents': 100})
|
|
deliver({'event_id': 'missing-' + uuid4().hex, 'reference': str(uuid4()),
|
|
'status': 'approved', 'amount_cents': 100})
|
|
deliver({'event_id': 'pending-' + uuid4().hex, 'reference': quote_id,
|
|
'status': 'pending', 'amount_cents': total})
|
|
print('PASS: unknown references and non-approved statuses are recorded without acting')
|
|
|
|
# An operator's test order runs the production flow and notifies no one.
|
|
tester, test_quote, _ = reviewed_quote()
|
|
order = tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)
|
|
assert order['payment']['provider'] == 'teste' and order['state'] == 'rec', order
|
|
assert tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)['id'] == order['id']
|
|
version = order['version']
|
|
for state in ('tra',):
|
|
moved = tester.call('/operator/orders/' + order['id'] + '/move', {'state': state, 'version': version}, operator=True)
|
|
version = moved['version']
|
|
with db.connect() as c:
|
|
queued = c.execute("SELECT count(*) AS n FROM dtf_local.outbox WHERE event_key LIKE %s", (order['id'] + ':%',)).fetchone()['n']
|
|
jobs = c.execute('SELECT count(*) AS n FROM dtf_local.print_files WHERE order_id=%s', (order['id'],)).fetchone()['n']
|
|
assert queued == 0 and jobs == 1, (queued, jobs)
|
|
print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp')
|
|
|
|
# Files are uploaded before payment. An unpaid cart keeps them briefly; a
|
|
# paid order keeps them for its 30 days; a payment never starts for files
|
|
# that are gone.
|
|
def files_of(qid):
|
|
with db.connect() as c:
|
|
q = c.execute('SELECT approved,draft FROM dtf_local.quotes WHERE id=%s', (qid,)).fetchone()
|
|
return [u for item in (q['approved'] or q['draft'])['items'] for u in item['uploads']]
|
|
|
|
def days(ids, since='now()'):
|
|
with db.connect() as c:
|
|
return [float(r['d']) for r in c.execute(
|
|
f'SELECT extract(epoch FROM expires_at-{since})/86400 AS d FROM dtf_local.uploads WHERE id=ANY(%s)',
|
|
(ids,)).fetchall()]
|
|
fresh = upload_bytes(customer, b'UNPAID HOLD TEST')
|
|
assert all(1.99 < d <= 2.0 for d in days([fresh])), days([fresh])
|
|
assert all(abs(d - 30) < 0.01 for d in days(files_of(quote_id), 'created_at')), days(files_of(quote_id), 'created_at')
|
|
late, late_quote, _ = reviewed_quote()
|
|
assert all(d > 1.99 for d in days(files_of(late_quote))), days(files_of(late_quote))
|
|
with db.connect() as c:
|
|
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=ANY(%s)",
|
|
(files_of(late_quote),))
|
|
late.call('/payments/intent', {'quote_id': late_quote, 'method': {'type': 'pix'}}, expected=410)
|
|
print('PASS: unpaid files are held 2 days, paid ones 30 days, and expired files are never charged for')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run()
|