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>
104 lines
4.4 KiB
Python
104 lines
4.4 KiB
Python
"""Turning a payment into an order, once.
|
|
|
|
A provider may deliver the same notification several times, out of order, or
|
|
long after the fact. None of that may produce a second order, a second charge,
|
|
or a second WhatsApp message. Every delivery is recorded under the provider's
|
|
own event id and applied inside one transaction, so a duplicate is a no-op and a
|
|
crash mid-way is retried rather than half-applied.
|
|
|
|
Order creation lives here rather than in a route because two paths reach it: the
|
|
webhook, and the local development checkout. They must agree.
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import UUID, uuid4
|
|
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from .core.auth import audit
|
|
from .runtime import enqueue, upload_row
|
|
from .scanning import require_clean
|
|
|
|
QUOTE_VALID_HOURS = 24
|
|
|
|
|
|
class PaymentRefused(Exception):
|
|
"""The payment cannot become an order, with a reason worth recording."""
|
|
|
|
|
|
def approved_quote(c, quote_id, owner=None):
|
|
"""The reviewed quote behind a payment, or a refusal explaining why not."""
|
|
sql = 'SELECT * FROM dtf_local.quotes WHERE id=%s' + (' AND owner=%s' if owner else '')
|
|
row = c.execute(sql + ' FOR UPDATE', (quote_id, owner) if owner else (quote_id,)).fetchone()
|
|
if not row:
|
|
raise PaymentRefused('quote not found')
|
|
if not row['approved']:
|
|
raise PaymentRefused('quote was never reviewed')
|
|
if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS):
|
|
raise PaymentRefused('quote expired before payment')
|
|
return row
|
|
|
|
|
|
def create_order(c, quote, payment):
|
|
"""Create the order for a reviewed quote, or return the one already there.
|
|
|
|
Returns (order, created). The caller decides what to do about a duplicate;
|
|
the important part is that asking twice cannot produce two orders, because
|
|
orders.quote_id is unique and this runs inside the caller's transaction.
|
|
"""
|
|
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (quote['id'],)).fetchone()
|
|
if existing:
|
|
return existing, False
|
|
|
|
approved = quote['approved']
|
|
for item in approved['items']:
|
|
for upload_id in item['uploads']:
|
|
require_clean(upload_row(c, UUID(upload_id), quote['owner']))
|
|
|
|
order = c.execute(
|
|
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
|
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
|
|
for provider in ('tiny', 'whatsapp'):
|
|
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
|
{'order_id': str(order['id']), 'number': order['number'],
|
|
'event': 'payment_approved', 'order': approved})
|
|
return order, True
|
|
|
|
|
|
def record(c, provider, event):
|
|
"""Store a delivery. Returns None if this exact event was already seen."""
|
|
inserted = c.execute(
|
|
'''INSERT INTO dtf_local.payment_events(id,provider,event_id,reference,status,amount_cents,payload)
|
|
VALUES(%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(provider,event_id) DO NOTHING RETURNING *''',
|
|
(uuid4(), provider, event.event_id, event.reference, event.status,
|
|
event.amount_cents, Jsonb(event.raw))).fetchone()
|
|
return inserted
|
|
|
|
|
|
def apply(c, event):
|
|
"""Act on a payment notification. Returns the outcome recorded against it."""
|
|
if event.status != 'approved':
|
|
return f'ignored: {event.status}'
|
|
|
|
try:
|
|
quote_id = UUID(event.reference)
|
|
except (ValueError, AttributeError):
|
|
return 'refused: reference is not a quote id'
|
|
|
|
try:
|
|
quote = approved_quote(c, quote_id)
|
|
except PaymentRefused as refusal:
|
|
return f'refused: {refusal}'
|
|
|
|
# The provider is the authority on what was paid, and the reviewed quote is
|
|
# the authority on what was owed. If they disagree, no order is created:
|
|
# underpayment would ship artwork that was not paid for, and overpayment
|
|
# means something is wrong that a person should look at.
|
|
expected = quote['approved']['total_cents']
|
|
if event.amount_cents is not None and event.amount_cents != expected:
|
|
audit('payment_amount_mismatch', quote=str(quote_id),
|
|
expected_cents=expected, paid_cents=event.amount_cents)
|
|
return f'refused: paid {event.amount_cents} but quote total is {expected}'
|
|
|
|
order, created = create_order(c, quote, {'provider': 'webhook', **event.raw})
|
|
return f"order {order['number']}" + ('' if created else ' (already existed)')
|