There was no inbound payment path at all: a button called a fake synchronously and wrote an order. A real provider does the opposite — it charges, then tells us, repeatedly, out of order, and sometimes long afterwards. POST /api/payments/webhook verifies the signature before the body is parsed, so an unsigned or tampered delivery is refused and recorded without touching an order. Verified deliveries are stored under the provider's own event id with a unique constraint, and applied inside the same transaction that marks them processed: a repeat is a no-op, a crash is retried rather than half-applied. An approval whose amount disagrees with the reviewed quote does not become an order. Underpayment would ship artwork nobody paid for, and overpayment means something a person should look at. Order creation moved to app/payments.py so the webhook and the local development checkout share one implementation and cannot drift. That also closes 3.5: the charge happens inside the transaction that persists the order, rather than before it. The adapter contract is create/verify/parse. FakePayment implements it with a real HMAC scheme so the whole path is exercised now, by tests/payment_test.py: unsigned, tampered, underpaid, duplicate, re-sent, unknown reference, and non-approved statuses. Connecting Mercado Pago is one adapter; no service code changes. PAYMENT_WEBHOOK_SECRET is optional in production on purpose. Required would break the next Portainer render, and a guessable default would be worse than either: with no secret configured the adapter verifies nothing and therefore accepts nothing, which is the right state until a provider is connected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
4.9 KiB
Python
105 lines
4.9 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 tests.smoke_test import BASE, Client, upload_bytes, 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 = {'mode': 'file', 'metres': '1.01', 'grade': 0, 'uploads': [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 = customer.call('/operator/quotes/' + quote['id'] + '/approve',
|
|
{'items': [item]}, operator=True)
|
|
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')
|
|
|
|
# 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'
|
|
print('PASS: an amount that disagrees with the reviewed quote 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')
|
|
|
|
# The customer is told once, not once per delivery.
|
|
board = Client()
|
|
events = board.call('/operator/board', 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')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run()
|