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:
Cauê Faleiros
2026-09-22 13:25:08 -03:00
parent 7386469404
commit ccc25a2d5d
12 changed files with 537 additions and 35 deletions

View File

@@ -1,6 +1,9 @@
"""Local-only composition root. No production provider implementations/imports."""
import hashlib
import hmac
import json
import os
from typing import Protocol
from typing import Mapping, NamedTuple, Protocol
from urllib.parse import urlparse
import boto3
from botocore.config import Config
@@ -41,10 +44,81 @@ def require_runtime():
# Compatibility alias for local-only callers outside the active runtime.
require_local = require_runtime
class PaymentEvent(NamedTuple):
"""One provider notification, normalised.
`event_id` identifies the delivery and makes it idempotent. `reference` is
our quote id, echoed back by the provider. `amount_cents` is what the
provider says was actually paid, which the service compares against the
approved total before it will create an order.
"""
event_id: str
reference: str
status: str # 'approved' | 'rejected' | 'pending' | 'refunded'
amount_cents: int | None
raw: dict
class PaymentAdapter(Protocol):
def pay(self, quote_id: str, total_cents: int) -> dict: ...
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
"""Start a payment. Must be idempotent on quote_id: a retry after a
timeout has to return the existing payment, never charge twice."""
def verify(self, headers: Mapping[str, str], body: bytes) -> bool:
"""Whether this delivery genuinely came from the provider."""
def parse(self, body: bytes) -> PaymentEvent | None:
"""Normalise a verified delivery, or None if it is not about a payment."""
class FakePayment:
"""Local stand-in with a real signature scheme, so the webhook path is
exercised end to end rather than waiting for a provider account.
Signs the body with HMAC-SHA256 under PAYMENT_WEBHOOK_SECRET. A real adapter
replaces verify() and parse() with the provider's own scheme; nothing else in
the service changes.
"""
header = 'x-payment-signature'
def _secret(self) -> bytes | None:
secret = os.environ.get('PAYMENT_WEBHOOK_SECRET', '')
return secret.encode() if secret else None
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
return {'provider': 'fake', 'id': f'local-{quote_id}',
'status': 'pending', 'total_cents': total_cents}
def sign(self, body: bytes) -> str:
secret = self._secret()
if secret is None:
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
return hmac.new(secret, body, hashlib.sha256).hexdigest()
def verify(self, headers, body: bytes) -> bool:
# No configured secret means nothing can be verified, so nothing is
# accepted. A guessable default would let anyone forge an approval and
# create an order that was never paid for.
if self._secret() is None:
return False
supplied = headers.get(self.header) or headers.get(self.header.title()) or ''
return hmac.compare_digest(supplied, self.sign(body))
def parse(self, body: bytes):
try:
data = json.loads(body)
except ValueError:
return None
if not isinstance(data, dict) or 'event_id' not in data:
return None
return PaymentEvent(event_id=str(data['event_id']),
reference=str(data.get('reference', '')),
status=str(data.get('status', 'pending')),
amount_cents=data.get('amount_cents'),
raw=data)
# The local development checkout still needs a direct "it is paid" path.
def pay(self, quote_id: str, total_cents: int) -> dict:
return {'provider': 'fake', 'id': f'local-{quote_id}',
'status': 'paid', 'total_cents': total_cents}