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>
39 lines
1.5 KiB
Python
39 lines
1.5 KiB
Python
"""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 .. import payments
|
|
from ..core import db
|
|
from ..core.auth import owner
|
|
from ..core.models import Pay
|
|
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:
|
|
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
|