All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Week 2 work that did not need client inputs. Print files (1.4): each paid item gets a PDF the width of the film and the length of the approved layout, with every copy at its reviewed position, rotation and mirror. Sources are embedded once at original resolution; JPEG bytes pass through and PNG alpha becomes a soft mask. Artwork the generator cannot reproduce goes to hand preparation with the reason. The worker renders outside any transaction, and the operator approves the generated file as the final one through the existing review. Delivery address (3.8): required for any non-pickup quote, bound to the quoted CEP, carried into the order snapshot, the Kanban card and Tiny. Kanban (1.5): print-file status per item, and a panel of payment events that need a person (money without an order, refunds after an order) until an operator records the resolution. Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API documentation and tested against fake transports only. Selectable for sandbox testing with their credentials; the production preflight still blocks release. Adds payment intents and a PIX step on the Site. MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI storage use Chainguard's MinIO build, pinned by digest. Verified with the full CI integration sequence on a fresh local build, including the new print_file_test and both browser suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
4.1 KiB
Python
92 lines
4.1 KiB
Python
"""The provider's callback.
|
|
|
|
Unauthenticated by necessity — a payment provider has no session — so the
|
|
signature is the only thing standing between this endpoint and an attacker
|
|
creating orders. It is verified before the body is parsed, let alone acted on,
|
|
and an unverified delivery is recorded and refused rather than retried.
|
|
"""
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from .. import payments
|
|
from ..core import db
|
|
from ..core.auth import audit, client_ip, owner, rate_limit
|
|
from ..core.models import PaymentIntent
|
|
from ..runtime import payment
|
|
|
|
router = APIRouter()
|
|
|
|
# Generous: a provider legitimately retries, and a signature check is cheap.
|
|
# This exists so an unsigned flood cannot keep the database busy.
|
|
WEBHOOK_LIMIT = 600
|
|
|
|
|
|
@router.post('/api/payments/webhook')
|
|
async def webhook(request: Request):
|
|
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
|
|
body = await request.body()
|
|
|
|
query = dict(request.query_params)
|
|
if not payment.verify(request.headers, body, query):
|
|
audit('payment_webhook_rejected', ip=client_ip(request), reason='signature')
|
|
raise HTTPException(403, 'Invalid signature')
|
|
|
|
event = payment.parse(body, query)
|
|
if event is None:
|
|
# Verified, so genuinely from the provider, but not about a payment.
|
|
# Acknowledge it: refusing would make the provider retry for ever.
|
|
return {'status': 'ignored'}
|
|
|
|
with db.connect() as c:
|
|
stored = payments.record(c, event_provider(), event)
|
|
if stored is None:
|
|
# Already delivered. Acknowledge without acting again.
|
|
return {'status': 'duplicate'}
|
|
outcome = payments.apply(c, event)
|
|
c.execute('UPDATE dtf_local.payment_events SET processed_at=now(), outcome=%s WHERE id=%s',
|
|
(outcome, stored['id']))
|
|
|
|
# audit()'s own first parameter is named `event`, so the id goes under another key.
|
|
audit('payment_webhook_applied', payment_event=event.event_id,
|
|
status=event.status, outcome=outcome)
|
|
return {'status': 'applied', 'outcome': outcome}
|
|
|
|
|
|
def event_provider():
|
|
return payment.name
|
|
|
|
|
|
@router.post('/api/payments/intent')
|
|
def intent(body: PaymentIntent, session_id=Depends(owner)):
|
|
"""Start paying an approved quote: a PIX code, or a card token from the
|
|
provider's own form. Asking twice for the same method returns the same
|
|
payment; a quote already paid returns 409."""
|
|
rate_limit('payment-intent', str(session_id), 30, 900)
|
|
with db.connect() as c:
|
|
try:
|
|
quote = payments.approved_quote(c, body.quote_id, session_id)
|
|
except payments.PaymentRefused as refusal:
|
|
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
|
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
|
|
raise HTTPException(409, 'Quote is already paid')
|
|
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method=%s
|
|
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id, body.method.type)).fetchone()
|
|
if existing:
|
|
return existing['response']
|
|
try:
|
|
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
|
quote['approved']['customer'], body.method.model_dump())
|
|
except ValueError as exc:
|
|
raise HTTPException(422, str(exc))
|
|
except Exception:
|
|
audit('payment_intent_failed', quote=str(body.quote_id))
|
|
raise HTTPException(502, 'Payment provider unavailable; try again')
|
|
c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method,
|
|
status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)
|
|
ON CONFLICT(provider,provider_payment_id) DO NOTHING''',
|
|
(uuid4(), body.quote_id, payment.name, created['id'], body.method.type,
|
|
created['status'], quote['approved']['total_cents'], Jsonb(created)))
|
|
return created
|