All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m3s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m31s
The payment page lists credit card (preselected), debit card and PIX. Each card option limits Mercado Pago's form to its kind; debit is paid at once. Card payments ask for 3-D Secure when the issuer requires it, and a challenge opens the bank's page in a frame, which needs PAYMENT_CHALLENGE_SOURCES=https: (frames and form posts only). A card left waiting for that confirmation stops blocking a new attempt after ten minutes, and a refusal reported by the notification returns the customer to the payment choice. Written from the documentation; not yet run with a real debit card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
5.6 KiB
Python
109 lines
5.6 KiB
Python
"""Composition root, and the pieces every router needs.
|
|
|
|
app.py held the adapters, the configuration, the shared query helpers and all
|
|
nineteen of its routes, so customer.py could not import from it without a cycle
|
|
and was wired instead by passing nine callables into install_routes. Everything
|
|
shared lives here: routers import downwards, never from each other.
|
|
"""
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import uuid5, NAMESPACE_URL
|
|
|
|
from fastapi import HTTPException
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
|
|
from .core.secrets import load as load_secret_files
|
|
|
|
# Secret files must resolve before any configuration below is read.
|
|
load_secret_files()
|
|
require_runtime()
|
|
|
|
storage = LocalS3Storage()
|
|
if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
|
|
from .mercadopago import MercadoPagoPayment
|
|
payment = MercadoPagoPayment()
|
|
else:
|
|
payment = FakePayment()
|
|
freight = FakeFreight()
|
|
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
|
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
|
|
ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
|
|
ALLOWED_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
|
|
# Per source and generous: a browser needs one session and keeps the cookie, but
|
|
# offices and mobile carriers put many real customers behind one address, so a
|
|
# tight per-IP ceiling would lock out the same people the old global one did.
|
|
GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
|
|
# The board returned every order ever created. Finished ones are terminal, so
|
|
# they were pure growth: at a few hundred a day the response and the page both
|
|
# degrade with no operator benefit.
|
|
BOARD_FINISHED_LIMIT = int(os.environ.get('BOARD_FINISHED_LIMIT', '50'))
|
|
BOARD_QUOTE_LIMIT = int(os.environ.get('BOARD_QUOTE_LIMIT', '100'))
|
|
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
|
|
if not 5242880 <= PART_BYTES <= 67108864:
|
|
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
|
|
STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impressão',
|
|
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
|
|
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
|
|
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
|
|
# Undoing a move made by mistake: one stage back, with an internal reason. It
|
|
# is not a correction: the customer is not told and approved finals stay.
|
|
BACK = {'tra': 'rec', 'fil': 'tra', 'imp': 'fil', 'fin': 'imp'}
|
|
|
|
|
|
def require_delivery_available(service):
|
|
"""Outside the local stack, a simulated freight price must never reach a
|
|
customer: until a real freight provider exists, only pickup is offered."""
|
|
if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake':
|
|
raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.')
|
|
|
|
|
|
def upload_row(c, upload_id, session_id, lock=False):
|
|
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
|
|
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, 'Upload not found')
|
|
days = 30 if row['complete'] else 1
|
|
if row['purged_at'] or row['expires_at'] <= datetime.now(timezone.utc) or row['created_at'] < datetime.now(timezone.utc) - timedelta(days=days):
|
|
raise HTTPException(410, 'Upload expired; select the file again')
|
|
return row
|
|
|
|
|
|
def quote_view(c, row):
|
|
from .quote_review import review_reason # it imports this module
|
|
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
|
|
# The latest payment attempt, so the payment page can tell a refused card
|
|
# (the notification updates it) from one still waiting.
|
|
attempt = c.execute('''SELECT method,status,response->>'status_detail' AS status_detail
|
|
FROM dtf_local.payment_intents WHERE quote_id=%s ORDER BY created_at DESC LIMIT 1''', (row['id'],)).fetchone()
|
|
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
|
return {'id': row['id'], 'created_at': row['created_at'],
|
|
'draft': row['draft'], 'approved': row['approved'],
|
|
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
|
'auto_approved': row.get('reviewed_by') == 'auto',
|
|
'review_reason': None if row['approved'] else review_reason(row['draft']),
|
|
'payment': attempt,
|
|
'order': order}
|
|
|
|
|
|
def enqueue(c, event_key, provider, payload):
|
|
c.execute('INSERT INTO dtf_local.outbox(event_key,provider,payload) VALUES(%s,%s,%s) ON CONFLICT(event_key) DO NOTHING',
|
|
(event_key, provider, Jsonb(payload)))
|
|
|
|
|
|
def owned_order(c, oid, identity, lock=False):
|
|
row = c.execute('SELECT * FROM dtf_local.orders WHERE id=%s AND owner=%s'+(' FOR UPDATE' if lock else ''), (oid,identity)).fetchone()
|
|
if not row: raise HTTPException(404, 'Order not found')
|
|
return row
|
|
|
|
|
|
def file_rows(c, oid):
|
|
return c.execute('''SELECT f.id,f.upload_id,f.item_index,f.kind,f.active,f.note,f.created_at,
|
|
u.name,u.size,u.expires_at,(u.expires_at<=now()) AS expired
|
|
FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id
|
|
WHERE order_id=%s ORDER BY f.created_at''', (oid,)).fetchall()
|
|
|
|
|
|
def operator_identity(user):
|
|
return uuid5(NAMESPACE_URL, 'dtf-local-operator:'+user)
|