feat: generate print files, collect delivery addresses, add provider adapters
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>
This commit is contained in:
Cauê Faleiros
2026-09-24 11:56:46 -03:00
parent cfcbe545f1
commit c18b9e5b87
35 changed files with 2032 additions and 61 deletions

View File

@@ -17,9 +17,24 @@ def require_runtime():
checkout until their audited implementations are added.
"""
environment = os.environ.get('APP_ENV', 'local')
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
for name in ('FREIGHT', 'WHATSAPP'):
if os.environ.get(f'{name}_ADAPTER') != 'fake':
raise RuntimeError(f'{name} must use the currently supported fake adapter')
tiny = os.environ.get('TINY_ADAPTER')
if tiny == 'tiny':
if not os.environ.get('TINY_TOKEN'):
raise RuntimeError('TINY_TOKEN is required for the Tiny adapter')
elif tiny != 'fake':
raise RuntimeError('TINY must use the fake or tiny adapter')
# Mercado Pago is selectable only with its credentials present; it has not
# yet passed the sandbox flows, so production preflight still blocks it.
payment = os.environ.get('PAYMENT_ADAPTER')
if payment == 'mercadopago':
for name in ('MP_ACCESS_TOKEN', 'MP_WEBHOOK_SECRET'):
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for the Mercado Pago adapter')
elif payment != 'fake':
raise RuntimeError('PAYMENT must use the fake or mercadopago adapter')
if environment == 'local':
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
raise RuntimeError('Local runtime requires local S3 storage')
@@ -60,14 +75,14 @@ class PaymentEvent(NamedTuple):
class PaymentAdapter(Protocol):
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> 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:
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
"""Whether this delivery genuinely came from the provider."""
def parse(self, body: bytes) -> PaymentEvent | None:
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
"""Normalise a verified delivery, or None if it is not about a payment."""
@@ -80,13 +95,14 @@ class FakePayment:
the service changes.
"""
name = 'fake'
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:
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
return {'provider': 'fake', 'id': f'local-{quote_id}',
'status': 'pending', 'total_cents': total_cents}
@@ -96,7 +112,7 @@ class FakePayment:
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
return hmac.new(secret, body, hashlib.sha256).hexdigest()
def verify(self, headers, body: bytes) -> bool:
def verify(self, headers, body: bytes, query=None) -> 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.
@@ -105,7 +121,7 @@ class FakePayment:
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):
def parse(self, body: bytes, query=None):
try:
data = json.loads(body)
except ValueError:
@@ -158,6 +174,8 @@ class ObjectStorage(Protocol):
def complete(self, key: str, upload_id: str, parts: list): ...
def size(self, key: str) -> int: ...
def download(self, key: str, name: str) -> str: ...
def fetch(self, key: str, path: str): ...
def store(self, key: str, path: str, content_type: str): ...
def health(self): ...
def discard(self, key: str, upload_id: str, complete: bool): ...
@@ -206,6 +224,14 @@ class LocalS3Storage:
def size(self, key):
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
def fetch(self, key, path):
"""Copy a stored object to a local file (the worker's scratch space)."""
self.client.download_file(self.bucket, key, path)
def store(self, key, path, content_type):
"""Upload a file the service generated itself, such as a print file."""
self.client.upload_file(path, self.bucket, key, ExtraArgs={'ContentType': content_type})
def download(self, key, name):
from urllib.parse import quote
return self.public.generate_presigned_url('get_object', Params={

View File

@@ -5,7 +5,7 @@ from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import Freight
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
router = APIRouter()
@@ -32,7 +32,7 @@ def session(request: Request, response: Response):
with db.connect() as c:
session_id = new_session(c, response)
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
'max_upload_bytes': upload_limit_bytes()}
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name}
@router.post('/api/freight')
def quote_freight(body: Freight):

View File

@@ -12,8 +12,9 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
password_matches, throttle)
from ..core.models import Move, OperatorLogin, Review
from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price
from ..printjobs import queue as queue_print_files
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean
@@ -80,8 +81,20 @@ def board(user=Depends(operator)):
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
orders = active + list(reversed(finished))
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
# A paid notification that did not become an order is money received
# for nothing the factory will make. It stays on the board until a
# person records what was done about it.
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
ORDER BY received_at LIMIT 100''').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS,
'orders': active + list(reversed(finished)),
'orders': orders, 'payment_issues': refused,
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total,
@@ -131,6 +144,7 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
'quality_acknowledged': original['quality_acknowledged']})
quoted_freight = freight.quote(**draft['freight'])
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
'destination': draft.get('destination'),
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
return approved
@@ -166,6 +180,33 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
'customer_path': f'/portal.html?order={uid}'})
return changed
@router.post('/api/operator/orders/{uid}/print-files')
def regenerate(uid: UUID, user=Depends(operator)):
"""Queue generation again for items that failed or went to manual preparation,
and for orders paid before the generator existed."""
with db.connect() as c:
row = c.execute('SELECT id,snapshot,state FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'Order not found')
if row['state'] not in ('rec','tra'):
raise HTTPException(409, 'Print files are generated only before the order is queued')
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1", (uid,)).fetchone():
raise HTTPException(409, 'A customer correction replaced the original artwork; prepare the final file by hand')
queue_print_files(c, uid, len(row['snapshot']['items']), only_missing=True)
audit('print_file_requeued', order=str(uid), operator=user)
return c.execute('SELECT * FROM dtf_local.print_files WHERE order_id=%s ORDER BY item_index', (uid,)).fetchall()
@router.post('/api/operator/payment-events/{uid}/resolve')
def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
with db.connect() as c:
row = c.execute('''UPDATE dtf_local.payment_events SET resolved_at=now(), resolved_by=%s, resolution=%s
WHERE id=%s AND (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') AND resolved_at IS NULL RETURNING id''',
(user, body.note, uid)).fetchone()
if not row:
raise HTTPException(404, 'No open payment issue with this id')
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
return {'ok': True}
@router.get('/api/operator/orders/{uid}/history')
def history(uid: UUID, user=Depends(operator)):
with db.connect() as c:

View File

@@ -5,11 +5,15 @@ 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 fastapi import APIRouter, HTTPException, Request
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, rate_limit
from ..core.auth import audit, client_ip, owner, rate_limit
from ..core.models import PaymentIntent
from ..runtime import payment
router = APIRouter()
@@ -24,11 +28,12 @@ async def webhook(request: Request):
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
body = await request.body()
if not payment.verify(request.headers, 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)
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.
@@ -50,4 +55,37 @@ async def webhook(request: Request):
def event_provider():
return getattr(payment, 'name', payment.__class__.__name__.replace('Payment', '').lower() or 'fake')
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

View File

@@ -9,9 +9,29 @@ from uuid import UUID, uuid4
from fastapi import HTTPException
from .printjobs import generated_identity
from .runtime import upload_row
from .scanning import require_clean
def generated_owner(c, order, ref, kind):
"""The owner to look a generated print file up under, or None if it is not one.
A generated file may be approved as the final for the item it was made
from, and only while that item still has its original artwork: after a
customer correction it reproduces a layout nobody wants printed.
"""
generated = c.execute('''SELECT item_index FROM dtf_local.print_files
WHERE order_id=%s AND upload_id=%s AND status='ready' ''', (order['id'], ref.upload_id)).fetchone()
if not generated:
return None
if kind != 'final' or generated['item_index'] != ref.item_index:
raise HTTPException(422, 'A generated print file can only be the final file of its own item')
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
(order['id'],)).fetchone():
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
return generated_identity(order['id'])
def submit_files(c, order, body, identity, kind, actor):
if order['version'] != body.version:
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
@@ -32,7 +52,7 @@ def submit_files(c, order, body, identity, kind, actor):
if expiry <= datetime.now(timezone.utc):
raise HTTPException(410, 'Order artwork retention has expired')
for ref in body.files:
upload = upload_row(c, ref.upload_id, identity, lock=True)
upload = upload_row(c, ref.upload_id, generated_owner(c, order, ref, kind) or identity, lock=True)
if not upload['complete']:
raise HTTPException(409, 'Complete all uploads first')
require_clean(upload)

View File

@@ -44,6 +44,29 @@ class Freight(StrictModel):
service: Literal['pickup','mock-standard'] = 'pickup'
postal_code: str = Field(default='', max_length=8)
UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
class Destination(StrictModel):
"""Where a shipped order goes. A CEP alone quotes freight; it does not deliver."""
recipient: str = Field(min_length=2, max_length=120)
street: str = Field(min_length=2, max_length=160)
number: str = Field(min_length=1, max_length=20)
complement: str = Field(default='', max_length=80)
district: str = Field(min_length=2, max_length=80)
city: str = Field(min_length=2, max_length=80)
state: UF
postal_code: str = Field(pattern=r'^[0-9]{8}$')
@field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before')
@classmethod
def trimmed(cls, value):
if isinstance(value, str):
value = ' '.join(value.split())
if any(ord(ch) < 32 for ch in value):
raise ValueError('Invalid characters')
return value
class UploadStart(StrictModel):
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
size: int = Field(gt=0, strict=True)
@@ -136,6 +159,18 @@ class QuoteRequest(StrictModel):
customer: Customer
items: list[Item] = Field(min_length=1, max_length=30)
freight: Freight
destination: Destination | None = None
@model_validator(mode='after')
def destination_matches_freight(self):
if self.freight.service == 'pickup':
if self.destination is not None:
raise ValueError('Pickup orders do not take a delivery address')
elif self.destination is None:
raise ValueError('Delivery requires the full address')
elif self.destination.postal_code != self.freight.postal_code:
raise ValueError('The delivery address CEP must be the CEP freight was quoted for')
return self
class Review(StrictModel):
items: list[Item] = Field(min_length=1, max_length=30)
@@ -143,11 +178,33 @@ class Review(StrictModel):
class Pay(StrictModel):
quote_id: UUID
class PaymentMethod(StrictModel):
type: Literal['pix', 'card']
# Card fields come from the provider's own form, which tokenises the card
# in the browser; the number never reaches this server.
token: str | None = Field(default=None, max_length=200)
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
installments: int = Field(default=1, ge=1, le=12, strict=True)
issuer_id: str | None = Field(default=None, max_length=40)
@model_validator(mode='after')
def card_needs_token(self):
if self.type == 'card' and not (self.token and self.payment_method_id):
raise ValueError('Card payment requires the provider token and method')
return self
class PaymentIntent(StrictModel):
quote_id: UUID
method: PaymentMethod
class Move(StrictModel):
state: Literal['rec','tra','fil','imp','cor','fin']
version: int = Field(ge=0)
reason: str = Field(default='', max_length=1000)
class Resolution(StrictModel):
note: str = Field(min_length=3, max_length=1000)
class Register(StrictModel):
customer: Customer
password: str = Field(min_length=12, max_length=128)

160
app/mercadopago.py Normal file
View File

@@ -0,0 +1,160 @@
"""Mercado Pago: payment creation, webhook verification and status lookup.
Written from the public API documentation and exercised only against a fake
HTTP transport. It is not a verified integration until it has passed the
sandbox flows in docs/PRODUCTION_INPUTS.md with the client's own account;
until then the runtime refuses to select it without explicit credentials.
The notification is only a pointer. Its body says "payment 123 changed" and
nothing about amount or status, so nothing in it is trusted beyond the id:
the payment is fetched from the API with our access token, and that response
is what the order service compares against the approved quote.
Signature (x-signature: "ts=<unix>,v1=<hex>"): HMAC-SHA256, keyed with the
webhook secret from the integration panel, over the manifest
"id:<data.id>;request-id:<x-request-id>;ts:<ts>;", where data.id comes from
the notification URL's query string (lower-cased when alphanumeric). A part
whose value is absent from the notification is left out of the manifest.
"""
import hashlib
import hmac
import json
import os
import time
from decimal import Decimal, InvalidOperation
from typing import Mapping
import httpx
from .adapters import PaymentEvent
API = 'https://api.mercadopago.com'
# How old a signed timestamp may be. Mercado Pago retries a failed delivery
# every 15 minutes, re-signing each attempt, so a replayed old one is refused.
MAX_SIGNATURE_AGE = 30 * 60
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
class MercadoPagoPayment:
name = 'mercadopago'
def __init__(self, access_token=None, webhook_secret=None, notification_url=None,
transport=None, clock=time.time):
self.access_token = access_token or os.environ.get('MP_ACCESS_TOKEN', '')
self.webhook_secret = (webhook_secret or os.environ.get('MP_WEBHOOK_SECRET', '')).encode()
self.notification_url = notification_url or os.environ.get('MP_NOTIFICATION_URL', '')
if not self.access_token or not self.webhook_secret:
raise RuntimeError('Mercado Pago needs MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET')
self.http = httpx.Client(base_url=API, transport=transport, timeout=15,
headers={'Authorization': f'Bearer {self.access_token}'})
self.clock = clock
# Payments ------------------------------------------------------------
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
"""Create a payment for an approved quote.
The quote id is the idempotency key, so a retry after a timeout returns
the payment already created rather than charging again. `method` is
{'type': 'pix'} or {'type': 'card', 'token', 'payment_method_id',
'installments', 'issuer_id'} from Mercado Pago's card form: card data is
tokenised in the customer's browser and never reaches this server.
"""
method = method or {'type': 'pix'}
body = {'transaction_amount': float(Decimal(total_cents) / 100),
'description': f'DTF - cotação {quote_id[:8]}',
'external_reference': quote_id,
'payer': {'email': customer['mail'],
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
if self.notification_url:
body['notification_url'] = self.notification_url
if method['type'] == 'pix':
body['payment_method_id'] = 'pix'
elif method['type'] == 'card':
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
installments=int(method.get('installments', 1)))
if method.get('issuer_id'):
body['issuer_id'] = method['issuer_id']
else:
raise ValueError('Unsupported payment method')
response = self.http.post('/v1/payments', json=body,
headers={'X-Idempotency-Key': f'dtf-quote-{quote_id}-{method["type"]}'})
response.raise_for_status()
payment = response.json()
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
return {'provider': self.name, 'id': str(payment['id']),
'status': STATUSES.get(payment.get('status'), 'pending'),
'status_detail': payment.get('status_detail'),
'total_cents': total_cents,
'pix_qr_code': transaction.get('qr_code'),
'pix_qr_code_base64': transaction.get('qr_code_base64'),
'ticket_url': transaction.get('ticket_url')}
def lookup(self, payment_id: str) -> dict:
response = self.http.get(f'/v1/payments/{payment_id}')
response.raise_for_status()
return response.json()
# Webhooks ------------------------------------------------------------
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
signature = headers.get('x-signature') or ''
parts = dict(part.strip().split('=', 1) for part in signature.split(',') if '=' in part)
ts, supplied = parts.get('ts', ''), parts.get('v1', '')
if not ts.isdigit() or not supplied:
return False
if abs(self.clock() - int(ts[:10])) > MAX_SIGNATURE_AGE:
return False
data_id = (query or {}).get('data.id', '')
if data_id.isalnum():
data_id = data_id.lower()
manifest = ''
if data_id:
manifest += f'id:{data_id};'
request_id = headers.get('x-request-id') or ''
if request_id:
manifest += f'request-id:{request_id};'
manifest += f'ts:{ts};'
expected = hmac.new(self.webhook_secret, manifest.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, supplied.lower())
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
try:
data = json.loads(body)
except ValueError:
return None
if not isinstance(data, dict) or data.get('type') != 'payment':
return None
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
if not payment_id.isdigit():
return None
payment = self.lookup(payment_id)
return event_from_payment(payment, notification_id=str(data.get('id', '')))
def event_from_payment(payment: dict, notification_id: str = '') -> PaymentEvent:
"""Normalise a payment fetched from the API. Amount is None unless it is BRL
and a whole number of centavos, so a foreign or malformed amount is refused."""
amount = None
if payment.get('currency_id') == 'BRL':
try:
cents = Decimal(str(payment.get('transaction_amount'))) * 100
if cents == cents.to_integral_value():
amount = int(cents)
except (InvalidOperation, TypeError, ValueError):
amount = None
status = STATUSES.get(payment.get('status'), 'pending')
# One event per payment state: a notification id alone would let the same
# approval be applied twice under two notifications, and would collapse a
# later refund into the earlier approval.
event_id = f"{payment.get('id')}:{payment.get('status')}"
return PaymentEvent(event_id=event_id, reference=str(payment.get('external_reference') or ''),
status=status, amount_cents=amount,
raw={'provider': 'mercadopago', 'payment_id': str(payment.get('id')),
'status': payment.get('status'), 'status_detail': payment.get('status_detail'),
'currency_id': payment.get('currency_id'),
'transaction_amount': payment.get('transaction_amount'),
'date_approved': payment.get('date_approved'),
'notification_id': notification_id})

View File

@@ -15,6 +15,7 @@ from uuid import UUID, uuid4
from psycopg.types.json import Jsonb
from .core.auth import audit
from .printjobs import queue as queue_print_files
from .runtime import enqueue, upload_row
from .scanning import require_clean
@@ -59,6 +60,7 @@ def create_order(c, quote, payment):
order = c.execute(
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
queue_print_files(c, order['id'], len(approved['items']))
for provider in ('tiny', 'whatsapp'):
enqueue(c, f"{order['id']}:paid:{provider}", provider,
{'order_id': str(order['id']), 'number': order['number'],
@@ -77,7 +79,25 @@ def record(c, provider, event):
def apply(c, event):
"""Act on a payment notification. Returns the outcome recorded against it."""
"""Act on a payment notification. Returns the outcome recorded against it.
Outcomes starting 'refused' (money arrived, no order) or 'attention' (an
order exists but its payment was reversed) stay on the Kanban until an
operator records a resolution.
"""
provider_id = event.raw.get('payment_id')
if provider_id:
c.execute('''UPDATE dtf_local.payment_intents SET status=%s, updated_at=now()
WHERE provider_payment_id=%s''', (event.status, provider_id))
if event.status in ('refunded', 'cancelled'):
try:
order = c.execute('SELECT number FROM dtf_local.orders WHERE quote_id=%s',
(UUID(event.reference),)).fetchone()
except (ValueError, AttributeError):
order = None
if order:
audit('payment_reversed', order=order['number'], status=event.status)
return f"attention: payment {event.status} for order {order['number']}"
if event.status != 'approved':
return f'ignored: {event.status}'
@@ -101,5 +121,5 @@ def apply(c, event):
expected_cents=expected, paid_cents=event.amount_cents)
return f'refused: paid {event.amount_cents} but quote total is {expected}'
order, created = create_order(c, quote, {'provider': 'webhook', **event.raw})
order, created = create_order(c, quote, {'provider': event.raw.get('provider', 'webhook'), **event.raw})
return f"order {order['number']}" + ('' if created else ' (already existed)')

364
app/printfile.py Normal file
View File

@@ -0,0 +1,364 @@
"""The print file: the reviewed layout, reproduced exactly, at the film's size.
The customer is quoted on a layout the Site computes: each copy of each artwork
at a width, rotation, mirror and position on the film. Production spec v2 keeps
that layout through the approved order. This module turns it into one PDF per
order item, with a page exactly as wide as the film and as long as the layout,
so the operator imports what the customer approved instead of rebuilding it.
Each source image is embedded once, at its original resolution, and every copy
is a placement of it. Nothing is resampled: a 300 DPI artwork is still 300 DPI
in the file, a JPEG keeps its original bytes, and transparency survives as a
soft mask. The output is therefore about the size of the artwork, not of a
57 cm x 20 m raster, and it never needs that raster in memory.
Only formats whose pixels can be read here are generated: JPEG, PNG, WebP and
TIFF. Anything else (PDF, PSD, AI, CDR), or a file whose proportions do not
match the size it was quoted at, is refused with a reason, and the operator
prepares that item by hand exactly as before.
"""
import math
import os
import zlib
from decimal import Decimal
from PIL import Image, ImageOps
PT_PER_CM = 72 / 2.54
# Acrobat's page limit. Longer layouts scale user space with /UserUnit (PDF 1.6)
# rather than cutting the film into pages a RIP might print with gaps.
MAX_PAGE_PT = 14400
# How far a file's proportions may drift from the quoted size before the item
# is refused: rounding in the Site keeps real files well inside this.
ASPECT_TOLERANCE = 0.01
# The quote may round up (10 cm steps, 1 m minimum) but never down.
HEIGHT_TOLERANCE_CM = Decimal('0.05')
SUPPORTED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'TIFF'}
STRIP_ROWS = 256
# Decoding holds the whole image in memory (about 4 bytes a pixel). 57 cm x 3 m
# at 300 DPI is 239 Mpx; above this the item goes to the operator instead.
MAX_DECODED_PIXELS = int(os.environ.get('PRINT_MAX_PIXELS', '250000000'))
# Pillow's own bomb guard would refuse a genuine long sheet before we can
# decide; the explicit limit above is the one that applies.
Image.MAX_IMAGE_PIXELS = None
class Unsupported(Exception):
"""This item cannot be generated automatically; the reason is for the operator."""
class Name(str):
pass
class Ref(int):
pass
def serialize(value):
if isinstance(value, Ref):
return b'%d 0 R' % value
if isinstance(value, Name):
return b'/' + value.encode('ascii')
if isinstance(value, bool):
return b'true' if value else b'false'
if isinstance(value, int):
return b'%d' % value
if isinstance(value, (float, Decimal)):
text = f'{float(value):.4f}'.rstrip('0').rstrip('.')
return (text if text not in ('', '-0') else '0').encode('ascii')
if isinstance(value, dict):
return b'<<' + b''.join(b'/' + k.encode('ascii') + b' ' + serialize(v)
for k, v in value.items()) + b'>>'
if isinstance(value, (list, tuple)):
return b'[' + b' '.join(serialize(v) for v in value) + b']'
if isinstance(value, str):
escaped = value.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
return b'(' + escaped.encode('latin-1', 'replace') + b')'
raise TypeError(f'Cannot serialize {type(value).__name__}')
class PdfWriter:
"""A sequential PDF writer: objects go straight to the file, streams included.
Stream lengths are indirect objects written after the data, so an image is
compressed strip by strip into the output without being held in memory.
"""
def __init__(self, fp):
self.fp = fp
self.offsets = {}
self.next_id = 1
self.write(b'%PDF-1.6\n%\xe2\xe3\xcf\xd3\n')
def write(self, data):
self.fp.write(data)
def tell(self):
return self.fp.tell()
def alloc(self):
ref = Ref(self.next_id)
self.next_id += 1
return ref
def obj(self, value, ref=None):
ref = ref or self.alloc()
self.offsets[ref] = self.tell()
self.write(b'%d 0 obj\n' % ref + serialize(value) + b'\nendobj\n')
return ref
def stream(self, dictionary, chunks, ref=None):
"""Write a stream from an iterable of already-encoded byte chunks."""
ref = ref or self.alloc()
length = self.alloc()
self.offsets[ref] = self.tell()
self.write(b'%d 0 obj\n' % ref + serialize({**dictionary, 'Length': length}) + b'\nstream\n')
start = self.tell()
for chunk in chunks:
self.write(chunk)
size = self.tell() - start
self.write(b'\nendstream\nendobj\n')
self.obj(size, length)
return ref
def finish(self, root, info):
xref = self.tell()
count = self.next_id
self.write(b'xref\n0 %d\n0000000000 65535 f \n' % count)
for ref in range(1, count):
self.write(b'%010d 00000 n \n' % self.offsets[ref])
self.write(b'trailer\n' + serialize({'Size': count, 'Root': root, 'Info': info}) +
b'\nstartxref\n%d\n%%%%EOF\n' % xref)
def deflate(pieces):
compressor = zlib.compressobj(6)
for piece in pieces:
out = compressor.compress(piece)
if out:
yield out
yield compressor.flush()
class SourceImage:
"""One customer file, opened for reading pixels and measured as the browser sees it."""
def __init__(self, path, name):
self.path = path
self.name = name
try:
self.image = Image.open(path)
self.format = self.image.format
except Image.DecompressionBombError as exc:
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
if self.format not in SUPPORTED_FORMATS:
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
'only JPEG, PNG, WebP and TIFF are generated automatically')
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
raise Unsupported(f'"{name}" is animated or has several frames')
# Browsers draw a photo upright according to its EXIF orientation, and
# the Site measured it that way, so the print must too.
try:
self.orientation = self.image.getexif().get(0x0112, 1)
except Exception:
self.orientation = 1
width, height = self.image.size
self.size = (height, width) if self.orientation in (5, 6, 7, 8) else (width, height)
def passthrough(self):
"""Whether the original JPEG bytes can go into the PDF unchanged."""
return (self.format == 'JPEG' and self.orientation == 1 and
self.image.mode in ('L', 'RGB', 'CMYK'))
def embed(self, pdf):
"""Write this image (and its alpha) as XObjects; returns the image reference."""
if self.passthrough():
return self._embed_jpeg(pdf)
width, height = self.image.size
if width * height > MAX_DECODED_PIXELS:
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
'generator decodes; prepare this item by hand')
return self._embed_pixels(pdf)
def _colorspace(self, pdf, mode):
components = {'L': 1, 'RGB': 3, 'CMYK': 4}[mode]
device = Name({'L': 'DeviceGray', 'RGB': 'DeviceRGB', 'CMYK': 'DeviceCMYK'}[mode])
profile = self.image.info.get('icc_profile')
if not profile:
return device
icc = pdf.stream({'N': components, 'Alternate': device, 'Filter': Name('FlateDecode')},
deflate([profile]))
return [Name('ICCBased'), icc]
def _embed_jpeg(self, pdf):
image = self.image
extra = {}
if image.mode == 'CMYK' and 'adobe' in image.info:
# Adobe writes CMYK JPEGs inverted; PDF readers expect the Decode flip.
extra['Decode'] = [1, 0, 1, 0, 1, 0, 1, 0]
def chunks():
with open(self.path, 'rb') as source:
while block := source.read(1 << 20):
yield block
return pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': image.width, 'Height': image.height,
'ColorSpace': self._colorspace(pdf, image.mode),
'BitsPerComponent': 8, 'Filter': Name('DCTDecode'), **extra},
chunks())
def _upright(self):
image = self.image
image.load()
if self.orientation != 1:
image = ImageOps.exif_transpose(image)
return image
def _embed_pixels(self, pdf):
image = self._upright()
mode = image.mode
has_alpha = mode in ('RGBA', 'LA', 'PA', 'RGBa', 'La') or (
mode == 'P' and 'transparency' in image.info) or (
mode in ('L', 'RGB') and 'transparency' in image.info)
if mode in ('I;16', 'I;16B', 'I;16L', 'I'):
image = image.convert('I').point(lambda value: value * (1 / 257)).convert('L')
mode = 'L'
if mode == 'CMYK':
color_mode = 'CMYK'
elif mode in ('1', 'L', 'LA', 'La'):
color_mode = 'L'
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
color_mode = 'RGB'
else:
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
if has_alpha:
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
width, height = image.size
def strips(convert):
for top in range(0, height, STRIP_ROWS):
yield convert(image.crop((0, top, width, min(height, top + STRIP_ROWS)))).tobytes()
smask = None
if has_alpha:
smask = pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': width, 'Height': height,
'ColorSpace': Name('DeviceGray'), 'BitsPerComponent': 8,
'Filter': Name('FlateDecode')},
deflate(strips(lambda strip: strip.getchannel('A'))))
colorspace = self._colorspace(pdf, color_mode)
dictionary = {'Type': Name('XObject'), 'Subtype': Name('Image'),
'Width': width, 'Height': height, 'ColorSpace': colorspace,
'BitsPerComponent': 8, 'Filter': Name('FlateDecode')}
if smask:
dictionary['SMask'] = smask
return pdf.stream(dictionary, deflate(strips(lambda strip: strip.convert(color_mode))))
def close(self):
self.image.close()
def placement_matrix(placement, page_height_pt):
"""Map the image's unit square onto its box on the film.
Matches the Site's canvas: the artwork is mirrored first, then turned
clockwise about the centre of its box, and the turned image fills the box.
"""
x = float(placement['x_cm']) * PT_PER_CM
top = page_height_pt - float(placement['y_cm']) * PT_PER_CM
w = float(placement['width_cm']) * PT_PER_CM
h = float(placement['length_cm']) * PT_PER_CM
rotation = placement['rotation_degrees'] % 360
mirrored = placement['mirrored']
def to_page(u, v):
# Unit square (v up) -> image frame (s right, t down).
s, t = u, 1 - v
if mirrored:
s = 1 - s
s, t = {0: (s, t), 90: (1 - t, s), 180: (1 - s, 1 - t), 270: (t, 1 - s)}[rotation]
return x + s * w, top - t * h
ox, oy = to_page(0, 0)
ax, ay = to_page(1, 0)
cx, cy = to_page(0, 1)
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
def check_layout(item, sizes):
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest DPI."""
production = item['production']
height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100
if height > billed + HEIGHT_TOLERANCE_CM:
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
lowest = None
for placement in production['placements']:
width_px, height_px = sizes[placement['source_index']]
if placement['rotation_degrees'] % 180 == 90:
width_px, height_px = height_px, width_px
width_cm = float(placement['width_cm'])
length_cm = float(placement['length_cm'])
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
if drift > ASPECT_TOLERANCE:
source = production['sources'][placement['source_index']]
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
dpi = width_px / (width_cm / 2.54)
lowest = dpi if lowest is None else min(lowest, dpi)
return lowest
def render(item, files, out, title):
"""Write the PDF for one approved order item.
`files` maps each source index to (local path, original name). Returns the
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
"""
production = item['production']
if production.get('version') != 2:
raise Unsupported('item uses an obsolete production layout')
sources = []
try:
for index in range(len(production['sources'])):
path, name = files[index]
sources.append(SourceImage(path, name))
lowest_dpi = check_layout(item, [source.size for source in sources])
width_pt = float(production['film_width_cm']) * PT_PER_CM
height_pt = float(production['height_cm']) * PT_PER_CM
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
pdf = PdfWriter(out)
images = [source.embed(pdf) for source in sources]
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
for placement in production['placements']:
matrix = placement_matrix(placement, height_pt)
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Im%d Do Q\n' % placement['source_index'])
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
pages = pdf.alloc()
page_box = [0, 0, width_pt / unit, height_pt / unit]
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
'Resources': {'XObject': {f'Im{index}': ref for index, ref in enumerate(images)}},
'Contents': content}
if unit > 1:
page['UserUnit'] = unit
page_ref = pdf.obj(page)
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
pdf.finish(root, info)
finally:
for source in sources:
source.close()
return {'film_width_cm': str(production['film_width_cm']),
'height_cm': str(production['height_cm']),
'billed_metres': str(item['billed_metres']),
'placements': len(production['placements']),
'sources': len(sources),
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
'user_unit': unit}

139
app/printjobs.py Normal file
View File

@@ -0,0 +1,139 @@
"""Generating print files in the background, one order item at a time.
A paid order queues one job per item. The worker claims a job, commits the
claim, and renders outside any transaction, so a long render never holds a row
lock or a database connection. A claim older than CLAIM_TIMEOUT is assumed to
belong to a worker that died and is taken again.
The result is an ordinary upload row owned by an identity derived from the
order, already marked clean: its only inputs are artwork that passed the
malware scan, and the bytes are written here. The operator still decides
whether it becomes the final file; generation never approves anything.
"""
import logging
import os
import tempfile
import time
from datetime import timedelta
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
from psycopg.types.json import Jsonb
from .core.auth import audit
from .core.db import connect
from .printfile import Unsupported, render
CLAIM_TIMEOUT = timedelta(minutes=15)
MAX_ATTEMPTS = 3
def generated_identity(order_id):
return uuid5(NAMESPACE_URL, f'dtf-print-file:{order_id}')
def queue(c, order_id, items, only_missing=False):
"""Queue (or re-queue) generation for every item of an order."""
for index in range(items):
if only_missing:
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
ON CONFLICT(order_id,item_index) DO UPDATE SET status='pending', attempts=0,
claimed_at=NULL, finished_at=NULL, detail='{}'
WHERE dtf_local.print_files.status IN ('failed','manual')''',
(uuid4(), order_id, index))
else:
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
ON CONFLICT(order_id,item_index) DO NOTHING''', (uuid4(), order_id, index))
def claim(c):
return c.execute('''SELECT p.*, o.snapshot, o.number FROM dtf_local.print_files p
JOIN dtf_local.orders o ON o.id=p.order_id
WHERE p.status='pending' OR (p.status='rendering' AND p.claimed_at < now()-%s)
ORDER BY p.created_at FOR UPDATE OF p SKIP LOCKED LIMIT 1''', (CLAIM_TIMEOUT,)).fetchone()
def render_one(storage):
with connect() as c:
job = claim(c)
if not job:
return False
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
attempts=attempts+1 WHERE id=%s''', (job['id'],))
item = job['snapshot']['items'][job['item_index']]
uploads = c.execute('''SELECT id,name,object_key,scan_state,purged_at,expires_at,
(expires_at<=now()) AS expired FROM dtf_local.uploads WHERE id=ANY(%s)''',
([UUID(u) for u in item['uploads']],)).fetchall()
# Every generated file shares its order's artwork retention deadline.
expiry = c.execute('SELECT min(created_at)+interval \'30 days\' AS e FROM dtf_local.uploads WHERE id=ANY(%s)',
([UUID(u) for u in item['uploads']],)).fetchone()['e']
by_id = {str(row['id']): row for row in uploads}
try:
result = produce(storage, job, item, by_id)
except Unsupported as reason:
finish(job, 'manual', {'reason': str(reason)})
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
return True
except Exception as exc:
logging.exception('Print file generation failed')
final = job['attempts'] + 1 >= MAX_ATTEMPTS
finish(job, 'failed' if final else 'pending', {'error': type(exc).__name__})
return True
path, name, size, detail = result
try:
uid = uuid4()
key = f'originals/{uid}'
storage.store(key, path, 'application/pdf')
finally:
os.unlink(path)
with connect() as c:
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,
expires_at,scan_state,scan_reason,scanned_at)
VALUES(%s,%s,%s,%s,%s,'',true,%s,'clean','generated from scanned artwork',now())''',
(uid, generated_identity(job['order_id']), name, size, key, expiry))
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
finished_at=now() WHERE id=%s''', (uid, Jsonb(detail), job['id']))
audit('print_file_ready', order=str(job['order_id']), item=job['item_index'])
return True
def produce(storage, job, item, uploads):
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
for upload_id in item['uploads']:
row = uploads.get(upload_id)
if not row or row['scan_state'] != 'clean':
raise Unsupported('an original file is missing or not cleared by the malware scan')
if row['purged_at'] or row['expired']:
raise Unsupported('an original file has passed its retention period')
number = job['number']
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
files = {}
for index, upload_id in enumerate(item['uploads']):
local = os.path.join(scratch, f'source-{index}')
storage.fetch(uploads[upload_id]['object_key'], local)
files[index] = (local, uploads[upload_id]['name'])
handle, output = tempfile.mkstemp(prefix='print-', suffix='.pdf')
try:
with os.fdopen(handle, 'wb') as out:
detail = render(item, files, out, f'Pedido {number} - item {job["item_index"] + 1}')
except BaseException:
os.unlink(output)
raise
return output, name, os.path.getsize(output), detail
def finish(job, status, detail):
with connect() as c:
c.execute('''UPDATE dtf_local.print_files SET status=%s, detail=%s,
finished_at=CASE WHEN %s='pending' THEN NULL ELSE now() END,
claimed_at=NULL WHERE id=%s''', (status, Jsonb(detail), status, job['id']))
def render_loop(storage):
while True:
try:
if render_one(storage):
continue
except Exception:
logging.exception('Print render worker tick failed')
time.sleep(2)

View File

@@ -20,7 +20,11 @@ load_secret_files()
require_runtime()
storage = LocalS3Storage()
payment = FakePayment()
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')

View File

@@ -90,6 +90,28 @@ CREATE TABLE IF NOT EXISTS dtf_local.order_files (
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(order_id,upload_id,kind)
);
-- A payment started at the provider for an approved quote: which provider
-- payment belongs to which quote, and its latest known status.
CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
id uuid PRIMARY KEY, quote_id uuid NOT NULL REFERENCES dtf_local.quotes(id),
provider text NOT NULL, provider_payment_id text NOT NULL, method text NOT NULL,
status text NOT NULL, amount_cents bigint NOT NULL, response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(provider, provider_payment_id)
);
-- The print file generated from each paid item's approved layout. One row per
-- item: the worker claims it, renders, and records either the file or why the
-- item has to be prepared by hand. Regenerating replaces the row's result.
CREATE TABLE IF NOT EXISTS dtf_local.print_files (
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
item_index integer NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','rendering','ready','manual','failed')),
upload_id uuid REFERENCES dtf_local.uploads(id), detail jsonb NOT NULL DEFAULT '{}',
attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
UNIQUE(order_id,item_index)
);
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
@@ -131,3 +153,19 @@ CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(
-- its own index, and it stays the size of the backlog.
CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at)
WHERE processed_at IS NULL;
-- The render worker polls for unclaimed or abandoned jobs; the order view reads
-- by order through the unique constraint.
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
WHERE status IN ('pending','rendering');
-- The Kanban lists payment events a person must act on (money without an
-- order, or a reversed payment on an existing order) until resolved.
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_by text;
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolution text;
CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_events(received_at)
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL;
-- Payment intents look up by quote (customer retry) and by provider id (webhook).
CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC);

118
app/tiny.py Normal file
View File

@@ -0,0 +1,118 @@
"""Tiny/Olist (API 2.0): create the sales order once a payment is approved.
Written from the public API documentation and exercised only against a fake
HTTP transport. Endpoints, product codes, tags and rate limits still have to
be confirmed against the client's account before this is a real integration.
Idempotency: the outbox may deliver the same event more than once (a timeout
after Tiny accepted it, a worker restart). Every order is created with
numero_pedido_ecommerce = "DTF-<order number>", and Tiny is searched for that
number first, so a second delivery finds the first order instead of creating
another one.
Tiny answers HTTP 200 for most failures and reports them in retorno.status,
so the body decides success. Anything unexpected raises, and the outbox
retries with backoff; nothing is marked delivered unless Tiny confirmed it.
"""
import json
import os
from decimal import Decimal
import httpx
API = 'https://api.tiny.com.br/api2'
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
'uvfile': 'DTF UV 28,5 cm · folha montada',
'uv': 'DTF UV 28,5 cm · artes avulsas'}
class TinyError(Exception):
pass
def ecommerce_number(number):
return f'DTF-{number}'
def money(cents):
return f'{Decimal(cents) / 100:.2f}'
def order_payload(payload):
"""The Tiny 'pedido' for a paid order's approved snapshot."""
order = payload['order']
customer = order['customer']
destination = order.get('destination')
client = {'nome': (destination or {}).get('recipient') or customer['mail'],
'tipo_pessoa': 'J', 'cpf_cnpj': customer['cnpj'],
'fone': customer['zap'], 'email': customer['mail']}
if destination:
client.update(endereco=destination['street'], numero=destination['number'],
complemento=destination.get('complement', ''), bairro=destination['district'],
cep=destination['postal_code'], cidade=destination['city'], uf=destination['state'])
items = []
for item in order['items']:
code = os.environ.get(f"TINY_SKU_{item['mode'].upper()}", '')
entry = {'descricao': PRODUCTS[item['mode']] + f" · nota {item['grade']}",
'unidade': 'M', 'quantidade': item['billed_metres'],
'valor_unitario': money(item['unit_cents'])}
if code:
entry['codigo'] = code
items.append({'item': entry})
freight = order['freight']
pedido = {'numero_pedido_ecommerce': ecommerce_number(payload['number']),
'cliente': client, 'itens': items,
'valor_frete': money(freight['total_cents']),
'obs': f"Pedido DTF #{payload['number']} · pago · {payload['order_id']}"}
if freight.get('service') == 'pickup':
# What dispatch already receives for pickups today (see web/site-config.js).
pedido.update(forma_envio='X', nome_transportador='DropStar', frete_por_conta='R')
tag = os.environ.get('TINY_TAG', '')
if tag:
pedido['marcadores'] = [{'marcador': {'descricao': tag}}]
return {'pedido': pedido}
class TinyOrders:
def __init__(self, token=None, transport=None):
self.token = token or os.environ.get('TINY_TOKEN', '')
if not self.token:
raise RuntimeError('Tiny needs TINY_TOKEN')
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
def call(self, method, **params):
response = self.http.post(f'/{method}.php', data={'token': self.token, 'formato': 'json', **params})
response.raise_for_status()
body = response.json().get('retorno', {})
if body.get('status') != 'OK':
errors = body.get('erros') or body.get('registros') or []
# "No records" is how the search reports an empty result.
if str(body.get('codigo_erro')) == '20':
return {'pedidos': []}
raise TinyError(f"{method}: {body.get('codigo_erro')} {json.dumps(errors, ensure_ascii=False)[:300]}")
return body
def find(self, number):
found = self.call('pedidos.pesquisa', numeroEcommerce=ecommerce_number(number))
for entry in found.get('pedidos') or []:
pedido = entry.get('pedido', entry)
if str(pedido.get('numero_ecommerce')) == ecommerce_number(number):
return pedido
return None
def deliver(self, event_key, payload):
if payload.get('event') != 'payment_approved':
# Production progress is not written to Tiny; only the sale is.
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
'event': payload.get('event')}
existing = self.find(payload['number'])
if existing:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numero'))}
created = self.call('pedido.incluir', pedido=json.dumps(order_payload(payload), ensure_ascii=False))
record = (created.get('registros') or [{}])[0].get('registro', {})
if record.get('status') != 'OK':
raise TinyError(f"pedido.incluir: {json.dumps(record, ensure_ascii=False)[:300]}")
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(record.get('id')), 'tiny_number': str(record.get('numero'))}

View File

@@ -1,6 +1,7 @@
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
import json
import logging
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -8,15 +9,21 @@ from psycopg.types.json import Jsonb
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
from .core.secrets import load as load_secret_files
from .core.db import connect
from .printjobs import render_loop
from .scanning import ClamAV, scan_loop
load_secret_files()
require_runtime()
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
# Not yet confirmed against the client's account; see app/tiny.py.
if os.environ.get('TINY_ADAPTER') == 'tiny':
from .tiny import TinyOrders
adapters['tiny'] = TinyOrders()
last_tick = 0.0
last_cleanup = 0.0
storage = LocalS3Storage()
scan_thread = None
render_thread = None
def cleanup():
"""Delete only expired object bytes; retain order/file metadata and history."""
@@ -64,16 +71,20 @@ class Health(BaseHTTPRequestHandler):
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
except Exception:
pass
healthy = time.monotonic()-last_tick < 15 and scanner
renderer = bool(render_thread and render_thread.is_alive())
healthy = time.monotonic()-last_tick < 15 and scanner and renderer
self.send_response(200 if self.path == '/health' and healthy else 503)
self.end_headers()
self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable',
'scanner': 'ok' if scanner else 'unavailable'}).encode())
'scanner': 'ok' if scanner else 'unavailable',
'print_files': 'ok' if renderer else 'unavailable'}).encode())
def log_message(self, *args):
pass
if __name__ == '__main__':
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
scan_thread.start()
render_thread = threading.Thread(target=render_loop,args=(storage,),daemon=True)
render_thread.start()
threading.Thread(target=loop, daemon=True).start()
HTTPServer(('0.0.0.0',8002),Health).serve_forever()