feat: accept payment notifications, once, from a verified sender
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>
This commit is contained in:
@@ -1,41 +1,38 @@
|
||||
"""Paid orders. Local development payment only; no provider is wired yet."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
"""Local development checkout.
|
||||
|
||||
The real path is the provider webhook. This exists so the local stack can reach
|
||||
a paid order without a provider account, and it goes through the same service so
|
||||
the two cannot drift apart.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import Pay
|
||||
from ..runtime import ENVIRONMENT, enqueue, payment, upload_row
|
||||
from ..scanning import require_clean
|
||||
from ..runtime import ENVIRONMENT, payment
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/api/orders/dev-paid')
|
||||
def dev_paid(body: Pay, session_id=Depends(owner)):
|
||||
if ENVIRONMENT != 'local':
|
||||
raise HTTPException(503, 'Checkout is not configured yet')
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s FOR UPDATE', (body.quote_id,session_id)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
if not row['approved']:
|
||||
raise HTTPException(409, 'An operator must verify length and grade first')
|
||||
if row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24):
|
||||
raise HTTPException(409, 'Quote expired; request a new quote')
|
||||
approved = row['approved']
|
||||
for item in approved['items']:
|
||||
for upload_id in item['uploads']:
|
||||
require_clean(upload_row(c, UUID(upload_id), session_id))
|
||||
paid = payment.pay(str(body.quote_id), approved['total_cents'])
|
||||
result = c.execute('INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(),body.quote_id,session_id,Jsonb(approved),Jsonb(paid))).fetchone()
|
||||
for provider in ('tiny','whatsapp'):
|
||||
enqueue(c, f"{result['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(result['id']), 'number': result['number'], 'event': 'payment_approved', 'order': approved})
|
||||
return result
|
||||
try:
|
||||
quote = payments.approved_quote(c, body.quote_id, session_id)
|
||||
except payments.PaymentRefused as refusal:
|
||||
# The quote may already be paid; that is not a refusal.
|
||||
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s',
|
||||
(body.quote_id,)).fetchone()
|
||||
if existing:
|
||||
return existing
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
# The charge happens inside the transaction that persists the order, so a
|
||||
# failure to record it cannot leave a customer charged without an order.
|
||||
receipt = payment.pay(str(body.quote_id), quote['approved']['total_cents'])
|
||||
order, _ = payments.create_order(c, quote, receipt)
|
||||
return order
|
||||
|
||||
Reference in New Issue
Block a user