Compare commits

...

4 Commits

Author SHA1 Message Date
Cauê Faleiros
641dc6053b ci: publish images from every green push to main
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 2m5s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m54s
The release job enforced the production source preflight, which blocks while
the payment and messaging adapters are fake. They still are, by design, so
since 2026-09-23 no release could succeed and production kept running older
images while main moved on.

Pushes to main that pass validation, the integration suite and the scans now
build, scan and publish the images. Nothing is deployed automatically:
production changes when the stack is pulled and redeployed in Portainer. A
manual run also calls the Portainer webhook when one is configured. The
preflight stays in the scan job, advisory unless ENFORCE_PRODUCTION_PREFLIGHT
is true, in which case a blocked preflight stops publishing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:30:43 -03:00
Cauê Faleiros
4c01e932c3 feat: place PDF artwork in print files, add card payment, count only failed logins
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
PDF artwork: a single-page PDF source is placed in the print file as a
vector form through pikepdf, never rasterised, using the CropBox and
inherited /Rotate the Site measured with pdf.js. Multi-page and protected
PDFs go to hand preparation. PyMuPDF was not used because of its AGPL
licence. Raster tests cover crop, page rotation, placement rotation and
mirroring, and fail when the rotation or crop handling is broken.

Card payment: Mercado Pago's Card Payment Brick on the Site when
MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's
secure fields. Each card attempt has its own idempotency key, and the intent
route refuses new attempts once a payment is approved or a card is in
review, so a quote cannot be charged twice. The Site CSP admits Mercado
Pago's origins only through PAYMENT_CSP_SOURCES, empty by default.

Logins: every attempt counts against the source address, only failures
against the account. Counting successful sign-ins let ordinary use lock an
operator out and made CI's final browser sign-in fail.

No new required settings; production behaviour is unchanged until the
provider credentials are configured. Verified with the full CI integration
sequence locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:14:56 -03:00
Cauê Faleiros
e3d5558198 feat: connect Tiny through its v3 API with OAuth
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from
the Kanban; the callback is authorised by a single-use state, because Tiny's
cross-site redirect does not carry the SameSite=Strict operator cookie.
Tokens are kept in provider_tokens, the refresh token rotates under a row
lock, and the worker keeps the connection alive while order creation is off.

Orders find or create the customer's contact by CNPJ, then POST /pedidos
with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA
and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the
customer's recent orders for that number first. The product settings avoid a
_FILE suffix, which the secrets loader reads as a secret file path.

Production passes the application credentials through but keeps
TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a
supervised test. compose.providers.yaml gives the local API and worker an
internet route for provider testing; the default local stack still has none.

Verified with the full CI integration sequence locally, including the new
tiny_oauth_test against the real database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:46:09 -03:00
Cauê Faleiros
c18b9e5b87 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>
2026-09-24 11:56:46 -03:00
46 changed files with 3205 additions and 100 deletions

View File

@@ -20,6 +20,23 @@ APP_ENV=local
PAYMENT_ADAPTER=fake
FREIGHT_ADAPTER=fake
TINY_ADAPTER=fake
# Sandbox only (see docs/LOCAL_SETUP.md, "Provider sandboxes"):
# PAYMENT_ADAPTER=mercadopago
# MP_ACCESS_TOKEN=TEST-...
# MP_WEBHOOK_SECRET=...
# MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
# Tiny API v3: client ID/secret come from the "Aplicativo" created in Tiny
# (Configurações > Geral > Aplicativos); the redirect URI registered there
# must be exactly TINY_REDIRECT_URI. Product ids are the Tiny products each
# Site product becomes. Tiny has no sandbox: orders created are real.
# TINY_CLIENT_ID=...
# TINY_CLIENT_SECRET=...
# TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback
# TINY_PRODUCT_TEXTIL_FOLHA=...
# TINY_PRODUCT_TEXTIL_AVULSA=...
# TINY_PRODUCT_UV_FOLHA=...
# TINY_PRODUCT_UV_AVULSA=...
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders
WHATSAPP_ADAPTER=fake
STORAGE_ADAPTER=s3-local
MOCK_FREIGHT_CENTS=1500

View File

@@ -73,7 +73,7 @@ jobs:
# one a developer exercises on localhost.
- name: API and workflow regressions
run: |
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test; do
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test print_file_test; do
echo "--- $suite"
$COMPOSE exec -T \
-e SITE_BASE_URL=http://site \
@@ -81,10 +81,18 @@ jobs:
api python -m "tests.$suite"
done
# Need Pillow and httpx, which only the application image has. The raster
# check needs PyMuPDF as well and skips here; run it locally when changing
# the generator's geometry. The provider suites use a fake transport: they
# prove the documented contract, not the integration.
- name: Print-file geometry and provider adapters
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny -v
- name: Runtime and retention regressions
run: |
$COMPOSE exec -T api python -m tests.retention_test
$COMPOSE exec -T api python -m tests.runtime_security_test
$COMPOSE exec -T api python -m tests.tiny_oauth_test
# Run Chrome on the Compose network. It must resolve the same storage:9000
# hostname used in presigned URLs, and absence of Chrome must fail CI.
@@ -126,9 +134,9 @@ jobs:
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
--no-progress /src
# Keep push feedback advisory while the provider adapters are fake.
# The manual release job enforces the source preflight unconditionally.
# ENFORCE_PRODUCTION_PREFLIGHT can make push checks fail on blockers too.
# Advisory while the provider adapters are fake. This is the only copy of
# the gate: set ENFORCE_PRODUCTION_PREFLIGHT=true and a blocked preflight
# fails this job, which stops images from being published.
- name: Production source preflight
run: |
set +e
@@ -145,10 +153,14 @@ jobs:
fi
echo "::warning::Source preflight reports blockers (advisory; set ENFORCE_PRODUCTION_PREFLIGHT=true to gate)."
# Every push to main that passes validation, the integration suite and the
# scans publishes images. Production changes only when someone pulls and
# redeploys the stack in Portainer; a manual run of this workflow also calls
# the Portainer webhook when one is configured.
publish-and-deploy:
name: Publish images and notify Portainer
name: Publish images
needs: [validate, integration, scan]
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
@@ -158,12 +170,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Require production readiness
env:
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
run: |
python3 deploy/production_preflight.py --source-only
test -n "$PORTAINER_WEBHOOK"
- name: Sign in to the Gitea Container Registry
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
@@ -228,7 +234,12 @@ jobs:
done
- name: Trigger Portainer redeployment
if: gitea.event_name == 'workflow_dispatch'
env:
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
run: |
if [ -z "$PORTAINER_WEBHOOK" ]; then
echo "No PORTAINER_WEBHOOK configured; redeploy the stack in Portainer."
exit 0
fi
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"

View File

@@ -17,9 +17,26 @@ 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':
from .tiny import required_settings
for name in required_settings():
if not os.environ.get(name):
raise RuntimeError(f'{name} 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 +77,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,14 +97,16 @@ 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:
return {'provider': 'fake', 'id': f'local-{quote_id}',
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
kind = (method or {}).get('type', 'pix')
return {'provider': 'fake', 'id': f'local-{quote_id}-{kind}',
'status': 'pending', 'total_cents': total_cents}
def sign(self, body: bytes) -> str:
@@ -96,7 +115,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 +124,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 +177,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 +227,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

@@ -9,7 +9,7 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..artwork import submit_files
from ..core.auth import (DUMMY_PASSWORD_HASH, audit, client_ip, new_session, owner,
password_hash, password_matches, session_row, throttle, transfer_guest)
login_failed, password_hash, password_matches, session_row, throttle, transfer_guest)
from ..core.models import ArtworkSubmission, Login, Register
from ..runtime import STATES, file_rows, owned_order, storage
from ..scanning import require_clean
@@ -26,6 +26,8 @@ def current(request):
def register(body: Register, request: Request, response: Response):
email = body.customer.mail.strip().lower()
throttle(email, request)
# Registration attempts keep counting against the email, as before.
login_failed(email)
previous = current(request)
encoded = password_hash(body.password)
identity = uuid4()
@@ -53,6 +55,7 @@ def login(body: Login, request: Request, response: Response):
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not matches:
login_failed(email)
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
previous = current(request)

View File

@@ -1,11 +1,13 @@
"""Health, session bootstrap and freight quoting."""
import os
from fastapi import APIRouter, HTTPException, Request, Response
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 +34,9 @@ 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,
# Public by design: Mercado Pago's card form needs it in the browser.
'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else ''}
@router.post('/api/freight')
def quote_freight(body: Freight):

View File

@@ -7,13 +7,16 @@ from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
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
login_failed, password_matches, throttle)
from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price
from ..printjobs import queue as queue_print_files
from .. import tiny
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean
@@ -32,6 +35,7 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches:
login_failed('operator:'+email)
audit('operator_login_failed', ip=client_ip(request), operator=email)
raise HTTPException(401, 'Invalid operator login')
token = secrets.token_urlsafe(32)
@@ -80,8 +84,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, 'tiny': tiny_status(),
'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 +147,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 +183,61 @@ 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}
def tiny_status():
if not tiny.configured():
return {'configured': False}
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
**tiny.TinyAuth().status()}
@router.post('/api/operator/tiny/connect')
def tiny_connect(user=Depends(operator)):
"""Start the one-time authorisation of this system in the client's Tiny."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)}
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
single-use state an operator created is what authorises it."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
who = tiny.TinyAuth().complete(code, state)
except tiny.TinyError:
audit('tiny_connect_failed')
return RedirectResponse('/?tiny=failed', status_code=303)
audit('tiny_connected', operator=who)
return RedirectResponse('/?tiny=connected', status_code=303)
@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,43 @@ 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')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
AND (status='approved' OR (method='card' AND status='pending'))''', (body.quote_id,)).fetchone():
raise HTTPException(409, 'A payment for this quote is already approved or in review')
if body.method.type == 'pix':
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id,)).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

@@ -94,10 +94,25 @@ def rate_limit(scope, identity, limit, seconds=900):
audit('rate_limit', scope=scope)
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
ACCOUNT_FAILURES = 10
def throttle(email, request):
# Independent account and source buckets prevent bypass by rotating emails.
# Every attempt counts against the source. Only failures count against the
# account: guessing a password is what the account bucket stops, and
# counting successful sign-ins too let ordinary use lock an operator out.
rate_limit('auth-source', client_ip(request), 60)
rate_limit('auth-account', email, 10)
key = hashlib.sha256(('auth-account|'+email).encode()).hexdigest()
with connect() as c:
row = c.execute("""SELECT attempts FROM dtf_local.login_attempts
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account')
raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account."""
rate_limit('auth-account', email, 1_000_000)
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')

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)

164
app/mercadopago.py Normal file
View File

@@ -0,0 +1,164 @@
"""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')
# A PIX retry must return the same code. A card retry after a decline
# is a new attempt with a new token, so the token is part of the key;
# the intent route refuses new attempts once one is approved or in review.
key = f'dtf-quote-{quote_id}-pix' if method['type'] == 'pix' else \
f"dtf-quote-{quote_id}-card-{hashlib.sha256(method['token'].encode()).hexdigest()[:24]}"
response = self.http.post('/v1/payments', json=body, headers={'X-Idempotency-Key': key})
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)')

488
app/printfile.py Normal file
View File

@@ -0,0 +1,488 @@
"""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.
Raster sources are JPEG, PNG, WebP and TIFF. A single-page PDF is placed as a
vector form, never rasterised. Anything else (PSD, AI, CDR, multi-page or
protected PDFs), 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 tempfile
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]
class PdfPage:
"""One page of a customer PDF, placed as a vector form: nothing is rasterised.
The box and orientation are the ones the Site measured with pdf.js: the
CropBox (which pikepdf uses for the form's BBox), turned by the page's
/Rotate, inherited or not.
"""
def __init__(self, path, name):
import pikepdf
self.name = name
try:
self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc:
raise Unsupported(f'"{name}" is password-protected') from exc
except Exception as exc:
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
try:
pages = len(self.pdf.pages)
if pages != 1:
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
self.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270):
raise Unsupported(f'"{name}" has an unsupported page rotation')
box = [float(v) for v in self.page.cropbox]
except Unsupported:
self.pdf.close()
raise
except Exception as exc:
self.pdf.close()
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0:
self.pdf.close()
raise Unsupported(f'"{name}" has an empty page')
# As displayed, which is what the proportions are checked against.
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
def normalise(self):
"""Matrix from the page's box, as displayed, onto the unit square."""
a, d = 1 / self.w, 1 / self.h
scale = [a, 0, 0, d, -self.x0 * a, -self.y0 * d]
turn = {0: [1, 0, 0, 1, 0, 0], 90: [0, -1, 1, 0, 0, 1],
180: [-1, 0, 0, -1, 1, 1], 270: [0, 1, -1, 0, 1, 0]}[self.rotation]
return multiply(scale, turn)
def form(self, target):
"""The page as a form XObject copied into the target document."""
form = self.page.as_form_xobject(handle_transformations=False)
group = self.page.obj.get('/Group')
if group is not None and '/Group' not in form:
form.Group = group
return target.copy_foreign(form)
def close(self):
self.pdf.close()
def multiply(first, then):
"""PDF matrices: a point transformed by `first`, then by `then`."""
a1, b1, c1, d1, e1, f1 = first
a2, b2, c2, d2, e2, f2 = then
return [a1 * a2 + b1 * c2, a1 * b2 + b1 * d2, c1 * a2 + d1 * c2, c1 * b2 + d1 * d2,
e1 * a2 + f1 * c2 + e2, e1 * b2 + f1 * d2 + f2]
def open_source(path, name):
with open(path, 'rb') as handle:
head = handle.read(1024)
if b'%PDF-' in head:
return PdfPage(path, name)
return SourceImage(path, name)
def check_layout(item, sizes, vector=()):
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest
DPI of the raster sources (vector pages have none)."""
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')
if placement['source_index'] in vector:
continue
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.
Raster sources are written by the streaming writer above. PDF sources are
then added as vector forms by a second pass through pikepdf, so a sheet
exported as PDF keeps its vectors, fonts and transparency.
"""
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(open_source(path, name))
vector = {index for index, source in enumerate(sources) if isinstance(source, PdfPage)}
lowest_dpi = check_layout(item, [source.size for source in sources], vector)
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))
first = tempfile.TemporaryFile() if vector else out
try:
write_rasters(production, sources, first, title, width_pt, height_pt, unit)
if vector:
first.seek(0)
add_vector_pages(production, sources, first, out, height_pt)
finally:
if vector:
first.close()
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),
'vector_sources': len(vector),
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
'user_unit': unit}
def write_rasters(production, sources, out, title, width_pt, height_pt, unit):
pdf = PdfWriter(out)
images = {index: source.embed(pdf) for index, source in enumerate(sources)
if isinstance(source, SourceImage)}
# The user-space scale is not wrapped in q/Q, so it also applies to the
# vector placements appended by the second pass.
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']:
if placement['source_index'] not in images:
continue
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 images.items()}},
'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)
def add_vector_pages(production, sources, first, out, height_pt):
import pikepdf
with pikepdf.open(first) as document:
page = document.pages[0]
xobjects = page.obj.Resources.XObject
for index, source in enumerate(sources):
if isinstance(source, PdfPage):
xobjects[f'/Pdf{index}'] = source.form(document)
commands = []
for placement in production['placements']:
source = sources[placement['source_index']]
if not isinstance(source, PdfPage):
continue
matrix = multiply(source.normalise(), placement_matrix(placement, height_pt))
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
b' cm /Pdf%d Do Q\n' % placement['source_index'])
page.contents_add(pikepdf.Stream(document, b''.join(commands)), prepend=False)
document.save(out, min_version='1.6')

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,6 +20,10 @@ 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')

View File

@@ -90,6 +90,42 @@ 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)
);
-- OAuth connections to providers (Tiny). One row per provider; the refresh
-- token rotates on use, so it lives here, never in configuration.
CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz,
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Single-use states for an operator-started OAuth connection. They protect the
-- callback, which arrives cross-site without the operator's cookie.
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL,
expires_at timestamptz NOT NULL
);
-- 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 +167,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);

276
app/tiny.py Normal file
View File

@@ -0,0 +1,276 @@
"""Tiny/Olist ERP (API v3): create the sales order once a payment is approved.
Written from the public API documentation and exercised only against a fake
HTTP transport. Tiny has no sandbox: the first real test creates a real order
in the client's ERP, so test with a marked order and cancel it afterwards.
Authentication is OAuth2 on Tiny's Keycloak. The client creates an
"Aplicativo" in Tiny (Configurações > Geral > Aplicativos), which yields a
client ID and secret and registers our callback URL. An operator then clicks
"Conectar Tiny" on the Kanban once; the tokens are kept in the database and
the refresh token is rotated on every refresh, under a row lock so two
workers never spend the same one.
Orders are created by contact and product id: the customer's contact is found
by CNPJ or created, and each product mode maps to a product that must already
exist in Tiny (PRODUCT_SETTINGS).
Idempotency: the outbox may deliver the same event more than once. Every order
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
customer's recent orders are searched for that number, so a second delivery
finds the first order instead of creating another.
"""
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from urllib.parse import urlencode
import httpx
API = 'https://api.tiny.com.br/public-api/v3'
AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect'
# Not TINY_PRODUCT_<MODE>: app/core/secrets.py reads any variable ending in
# _FILE as a path to a secret file, and one product mode is called "file".
PRODUCT_SETTINGS = {'file': 'TINY_PRODUCT_TEXTIL_FOLHA', 'avulsa': 'TINY_PRODUCT_TEXTIL_AVULSA',
'uvfile': 'TINY_PRODUCT_UV_FOLHA', 'uv': 'TINY_PRODUCT_UV_AVULSA'}
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'}
# How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7
STATE_MINUTES = 10
# Brazil has had no daylight saving since 2019; the order date is the local day.
BRASILIA = timezone(timedelta(hours=-3))
def local_today():
return datetime.now(BRASILIA).date()
class TinyError(Exception):
pass
class TinyNotConnected(TinyError):
"""No authorised connection yet: an operator must click "Conectar Tiny"."""
def purchase_order(number):
return f'DTF-{number}'
def configured():
"""Whether the OAuth application is configured (orders may still be fake)."""
return all(os.environ.get(name) for name in ('TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'))
def required_settings():
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
# OAuth -------------------------------------------------------------------
class TinyAuth:
"""The OAuth application and the stored connection."""
def __init__(self, connect=None, transport=None, clock=time.time):
self.client_id = os.environ.get('TINY_CLIENT_ID', '')
self.client_secret = os.environ.get('TINY_CLIENT_SECRET', '')
self.redirect_uri = os.environ.get('TINY_REDIRECT_URI', '')
if connect is None:
from .core.db import connect
self.connect = connect
self.http = httpx.Client(timeout=20, transport=transport)
self.clock = clock
def authorize_url(self, operator):
"""Start a connection. The state is single-use, short-lived, and only an
authenticated operator can create one, which is what protects the
callback: Tiny's redirect back is cross-site, so the operator's
SameSite=Strict cookie does not travel with it."""
state = secrets.token_urlsafe(32)
with self.connect() as c:
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
'redirect_uri': self.redirect_uri, 'scope': 'openid',
'state': state})
def complete(self, code, state):
"""Exchange the authorisation code; returns the operator who started it."""
with self.connect() as c:
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
'redirect_uri': self.redirect_uri})
self._store(c, tokens, row['operator'])
return row['operator']
def status(self):
with self.connect() as c:
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
if not row:
return {'connected': False}
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
return {'connected': not expired, 'connected_by': row['connected_by'],
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
def access_token(self):
"""A valid access token, refreshing (and rotating) under a row lock."""
with self.connect() as c:
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
FOR UPDATE''').fetchone()
if not row:
raise TinyNotConnected('Tiny is not connected; use "Conectar Tiny" on the Kanban')
now = datetime.now(timezone.utc)
if row['access_expires_at'] > now + timedelta(seconds=60):
return row['access_token']
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
def _token(self, form):
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
'client_secret': self.client_secret})
if response.status_code == 400 and form['grant_type'] == 'refresh_token':
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
response.raise_for_status()
tokens = response.json()
if not tokens.get('access_token') or not tokens.get('refresh_token'):
raise TinyError('Tiny token response is missing tokens')
return tokens
def _store(self, c, tokens, operator, refreshed=False):
now = datetime.now(timezone.utc)
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
refresh_in = tokens.get('refresh_expires_in')
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
operator, refreshed, refreshed))
# Orders ------------------------------------------------------------------
def money(cents):
return float(Decimal(cents) / 100)
def contact_payload(order):
customer = order['customer']
destination = order.get('destination')
contact = {'nome': (destination or {}).get('recipient') or customer['mail'],
'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'],
'celular': customer['zap'], 'situacao': 'A'}
if destination:
contact['endereco'] = address(destination)
return contact
def address(destination):
return {'endereco': destination['street'], 'numero': destination['number'],
'complemento': destination.get('complement', ''), 'bairro': destination['district'],
'municipio': destination['city'], 'cep': destination['postal_code'],
'uf': destination['state'], 'pais': 'Brasil'}
def order_payload(payload, contact_id, today=None):
"""The v3 'pedido' for a paid order's approved snapshot."""
order = payload['order']
items = []
for item in order['items']:
setting = PRODUCT_SETTINGS[item['mode']]
product = os.environ.get(setting, '')
if not product.isdigit():
raise TinyError(f'{setting} must be the Tiny product id')
items.append({'produto': {'id': int(product)},
'quantidade': float(Decimal(item['billed_metres'])),
'valorUnitario': money(item['unit_cents']),
'infoAdicional': PRODUCTS[item['mode']] + f" · nota {item['grade']}"})
freight = order['freight']
pickup = freight.get('service') == 'pickup'
pedido = {'data': (today or local_today()).isoformat(),
'idContato': contact_id,
'numeroOrdemCompra': purchase_order(payload['number']),
'itens': items,
'valorFrete': money(freight['total_cents']),
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
destination = order.get('destination')
if destination:
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
'nomeDestinatario': destination['recipient']}
del pedido['enderecoEntrega']['numero']
ecommerce = os.environ.get('TINY_ECOMMERCE_ID', '')
if ecommerce.isdigit():
pedido['ecommerce'] = {'id': int(ecommerce), 'numeroPedidoEcommerce': purchase_order(payload['number'])}
return pedido
class TinyOrders:
def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)]
if auth is None and missing:
raise RuntimeError('Tiny needs ' + ', '.join(missing))
self.auth = auth or TinyAuth()
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
self.today = today
def request(self, method, path, **kwargs):
headers = {'Authorization': f'Bearer {self.auth.access_token()}'}
response = self.http.request(method, path, headers=headers, **kwargs)
if response.status_code == 429:
raise TinyError('Tiny rate limit reached; the outbox will retry')
if response.status_code >= 400:
raise TinyError(f'{method} {path}: {response.status_code} {response.text[:300]}')
return response.json() if response.content else {}
def contact(self, order):
cnpj = order['customer']['cnpj']
found = self.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5})
for entry in found.get('itens') or []:
if ''.join(ch for ch in str(entry.get('cpfCnpj') or '') if ch.isdigit()) == cnpj:
return entry['id']
return self.request('POST', '/contatos', json=contact_payload(order))['id']
def find(self, payload):
"""An order a previous delivery already created, or None."""
wanted = purchase_order(payload['number'])
since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat()
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
'dataInicial': since, 'limit': 100})
for entry in found.get('itens') or []:
detail = self.request('GET', f"/pedidos/{entry['id']}")
if detail.get('numeroOrdemCompra') == wanted:
return detail
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)
if existing:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
contact_id = self.contact(payload['order'])
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}

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,22 @@ 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
last_tiny_keepalive = 0.0
storage = LocalS3Storage()
scan_thread = None
render_thread = None
def cleanup():
"""Delete only expired object bytes; retain order/file metadata and history."""
@@ -45,6 +53,27 @@ def tick():
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
last_tick = time.monotonic()
def tiny_keepalive():
"""Keep a connected Tiny authorised even while no orders are sent.
The refresh token expires unless it is used; access_token() refreshes (and
rotates) only when the access token is about to expire, so asking every few
minutes renews the connection roughly once per access-token lifetime.
"""
global last_tiny_keepalive
if time.monotonic()-last_tiny_keepalive < 600:
return
last_tiny_keepalive = time.monotonic()
from . import tiny
if not tiny.configured():
return
try:
tiny.TinyAuth().access_token()
except tiny.TinyNotConnected:
pass
except Exception:
logging.exception('Tiny connection refresh failed')
def loop():
global last_cleanup
while True:
@@ -53,6 +82,7 @@ def loop():
if time.monotonic()-last_cleanup > 60:
cleanup()
last_cleanup = time.monotonic()
tiny_keepalive()
except Exception:
logging.exception('Local worker tick failed')
time.sleep(1)
@@ -64,16 +94,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()

View File

@@ -27,10 +27,25 @@ x-app: &app
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}}
COOKIE_SECURE: "false"
PAYMENT_ADAPTER: fake
# Sandbox testing only: set PAYMENT_ADAPTER=mercadopago with the MP_* test
# credentials, or TINY_ADAPTER=tiny with a TINY_TOKEN, in .env. Never real
# production credentials on a developer machine.
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret}
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
FREIGHT_ADAPTER: fake
TINY_ADAPTER: fake
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
TINY_ECOMMERCE_ID: ${TINY_ECOMMERCE_ID:-}
WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-local
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
@@ -66,11 +81,14 @@ services:
retries: 30
storage:
# quay.io, not Docker Hub: minio/minio there now answers anonymous pulls
# with 401 authentication required, which breaks any runner that is not
# logged in. Same image — identical image ID. Override MINIO_IMAGE to use
# a mirror of your own.
image: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}
# MinIO stopped publishing public images: since September 2026 both
# Docker Hub (minio/minio) and quay.io answer anonymous pulls with 401,
# which breaks any machine or runner without a cached copy. Chainguard's
# build still pulls anonymously, ships sh and mc (the healthcheck and
# storage-init need both) and runs as a non-root user. Pinned by digest
# because Chainguard's free tier only publishes :latest. Override
# MINIO_IMAGE to use a mirror of your own.
image: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
command: server /data --console-address :9001
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
@@ -112,7 +130,7 @@ services:
context: .
dockerfile: infra/Dockerfile.storage-init
args:
MINIO_IMAGE: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}
MINIO_IMAGE: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
entrypoint: [/bin/sh, /init.sh]
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
@@ -175,6 +193,8 @@ services:
dockerfile: infra/Dockerfile.web
environment:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
ports:
# Published ports are host-wide even bound to loopback, so on a shared
# machine any of them can collide with something unrelated. CI overrides
@@ -198,6 +218,7 @@ services:
environment:
WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
PAYMENT_CSP_SOURCES: ""
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge]
depends_on:

16
compose.providers.yaml Normal file
View File

@@ -0,0 +1,16 @@
# Provider sandbox testing only: gives the API and worker a route to the
# internet so they can reach Mercado Pago and Tiny. The default local stack
# keeps them on an internal network with no external route, which is what
# every other local run should keep doing.
#
# docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
#
# Credentials go in .env (see .env.example); never production credentials.
services:
api:
networks: [local, provider-egress]
worker:
networks: [local, provider-egress]
networks:
provider-egress: {}

View File

@@ -27,7 +27,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {

View File

@@ -29,6 +29,14 @@ OPERATOR_EMAIL=TBD
PAYMENT_ADAPTER=TBD
FREIGHT_ADAPTER=TBD
TINY_ADAPTER=TBD
# Tiny API v3 application (Configurações > Geral > Aplicativos in Tiny). The
# redirect URL registered there must equal TINY_REDIRECT_URI.
TINY_CLIENT_ID=TBD
TINY_REDIRECT_URI=https://<KANBAN_DOMAIN>/api/operator/tiny/callback
TINY_PRODUCT_TEXTIL_FOLHA=TBD
TINY_PRODUCT_TEXTIL_AVULSA=TBD
TINY_PRODUCT_UV_FOLHA=TBD
TINY_PRODUCT_UV_AVULSA=TBD
WHATSAPP_ADAPTER=TBD
STORAGE_QUOTA_BYTES=TBD
OWNER_UPLOAD_QUOTA_BYTES=TBD

View File

@@ -5,21 +5,24 @@ from .production_preflight import config_errors, source_errors
class ReleaseWorkflowTests(unittest.TestCase):
def test_main_push_cannot_publish_and_manual_release_is_gated(self):
def test_only_checked_main_publishes_and_pushes_never_deploy(self):
workflow = (Path(__file__).resolve().parents[1] /
'.gitea/workflows/deploy.yml').read_text()
release = workflow.split(' publish-and-deploy:\n', 1)[1]
self.assertIn("if: gitea.event_name == 'workflow_dispatch' && "
"gitea.ref == 'refs/heads/main'", release)
preflight = release.index('python3 deploy/production_preflight.py --source-only')
webhook = release.index('test -n "$PORTAINER_WEBHOOK"')
checks, release = workflow.split(' publish-and-deploy:\n', 1)
# Publishing waits for every check, and only ever happens from main.
self.assertIn('needs: [validate, integration, scan]', release)
self.assertIn("if: gitea.ref == 'refs/heads/main' && ", release)
# The source preflight lives in the scan job and can be made blocking.
self.assertIn('python3 deploy/production_preflight.py --source-only', checks)
self.assertIn('ENFORCE_PRODUCTION_PREFLIGHT', checks)
# Images are scanned before they are pushed, and a push to main only
# publishes: redeployment is Portainer's (or a manual run's) decision.
scan = release.index('- name: Image vulnerabilities')
publish = release.index('- name: Publish validated images')
redeploy = release.index('- name: Trigger Portainer redeployment')
self.assertLess(preflight, scan)
self.assertLess(webhook, scan)
self.assertLess(scan, publish)
self.assertLess(publish, redeploy)
self.assertIn("if: gitea.event_name == 'workflow_dispatch'", release[redeploy:])
def valid_config():

View File

@@ -25,7 +25,18 @@ x-app-environment: &app-environment
# when the provider is configured, never to a value anyone could guess.
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
FREIGHT_ADAPTER: fake
# Order creation in Tiny stays off until it has been tested against the
# client's account (Tiny has no sandbox). The application credentials can be
# set now: they let an operator connect Tiny from the Kanban, and the worker
# keeps that connection alive. Callback: https://<KANBAN_DOMAIN>/api/operator/tiny/callback
TINY_ADAPTER: fake
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
TINY_REDIRECT_URI: ${TINY_REDIRECT_URI:-}
TINY_PRODUCT_TEXTIL_FOLHA: ${TINY_PRODUCT_TEXTIL_FOLHA:-}
TINY_PRODUCT_TEXTIL_AVULSA: ${TINY_PRODUCT_TEXTIL_AVULSA:-}
TINY_PRODUCT_UV_FOLHA: ${TINY_PRODUCT_UV_FOLHA:-}
TINY_PRODUCT_UV_AVULSA: ${TINY_PRODUCT_UV_AVULSA:-}
WHATSAPP_ADAPTER: fake
STORAGE_ADAPTER: s3-r2
PUBLIC_ORIGIN: https://${SITE_DOMAIN:?set SITE_DOMAIN}
@@ -139,6 +150,9 @@ services:
WEB_INDEX: index.html
PUBLIC_HOST: ${SITE_DOMAIN:?set SITE_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
# Mercado Pago's card form loads from these origins; empty keeps the
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
networks: [backend]
ports:
- target: 8080
@@ -161,6 +175,7 @@ services:
WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
PAYMENT_CSP_SOURCES: ""
networks: [backend]
ports:
- target: 8080

View File

@@ -72,7 +72,9 @@ operator interfaces.
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
select the manually prepared final files for every item, enter a review note,
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
fixture again only for this local test; no printable file is generated.
fixture again only for this local test. The text fixture is not an image, so
its print file shows **preparar à mão**; with a PNG or JPEG artwork the
generated PDF is preselected instead (see "Print files").
Continue through **Fila de impressão →
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
@@ -193,8 +195,10 @@ must resolve from the browser; keep `http://localhost:9000` for this stack.
Parts use 15-minute presigned URLs and unfinished reservations expire after
one hour. Clients can cancel a reservation through the upload DELETE endpoint.
Only fake integration adapters and `s3-local` storage are accepted. Startup fails
if a production adapter/environment or nonlocal S3 endpoint is selected.
Fake integration adapters and `s3-local` storage are the default. Mercado Pago
and Tiny can be selected for sandbox testing only (see "Provider sandboxes");
startup fails if their credentials are missing, if any other adapter is
selected, or if a production environment or nonlocal S3 endpoint is used.
The API, worker, and database run on an internal Docker network; web gateways
and MinIO also join a network that permits loopback port publishing.
@@ -216,6 +220,113 @@ storage permits; otherwise reselect them. Saved quote IDs also survive reloads.
Clearing cookies loses a guest session, while registered customers can sign in
again. The operator can still inspect order records.
## Print files
Every paid order queues one print-file job per item. The worker renders a PDF
exactly as wide as the film and as long as the approved layout, placing each
copy at the position, rotation and mirror the customer reviewed. Each source
image is embedded once at its original resolution (JPEG bytes unchanged, PNG
transparency kept as a soft mask), so nothing is resampled. The Kanban card
shows the status per item, the page size and the lowest DPI, and offers
**Baixar PDF**. In **Arquivos de produção** the generated file is preselected
as the final file; untick it to upload one by hand instead.
JPEG, PNG, WebP and TIFF are embedded as images. A single-page PDF is placed
as a vector form (never rasterised), using the page's CropBox and `/Rotate`
exactly as the Site measured it with pdf.js; the card then shows **PDF
vetorial**. PSD, AI and CDR artwork, multi-page or password-protected PDFs, a
file whose proportions do not match the quoted size, a layout longer than was
billed, or an image above `PRINT_MAX_PIXELS` (250 Mpx by default) goes to
**preparar à mão** with the reason, and the operator prepares it as before.
**Gerar arquivos de impressão** retries those items and generates files for
orders paid before the generator existed. After a customer correction the
generated file is no longer offered: it reproduces the replaced artwork.
Layouts longer than about 5 m use the PDF `UserUnit` page scale instead of
being split into pages. Confirm on the factory's FlexiPRINT that such a file
imports at full length before relying on it for long orders.
```bash
docker compose -f compose.local.yaml exec -T api python -m unittest tests.test_printfile -v
docker compose -f compose.local.yaml exec -T api python -m tests.print_file_test
```
With PyMuPDF installed locally (`pip install pymupdf`), the unit suite also
draws each page and checks where every quadrant of the artwork lands.
## Provider sandboxes
`app/mercadopago.py` and `app/tiny.py` follow the providers' public API
documentation and pass their unit suites against a fake transport. They are not
verified integrations until they pass with the client's accounts.
The default local stack gives the API and worker no route to the internet, so
provider testing adds `compose.providers.yaml`, which does:
```bash
docker compose -f compose.local.yaml -f compose.providers.yaml up -d --wait
```
Put only **test** credentials in `.env`:
```bash
PAYMENT_ADAPTER=mercadopago
MP_ACCESS_TOKEN=TEST-...
MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings
MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
```
### Tiny (API v3)
Tiny uses OAuth2. In the client's Tiny (Construa plan or above, with the
"Gestão de Aplicativos" extension): **Configurações → Geral → Aplicativos →
+ novo aplicativo**, with the redirect URL set to this system's callback. That
gives a client ID and secret:
```bash
TINY_CLIENT_ID=...
TINY_CLIENT_SECRET=...
TINY_REDIRECT_URI=http://localhost:8081/api/operator/tiny/callback # exactly as registered in Tiny
TINY_PRODUCT_TEXTIL_FOLHA=... # Tiny product id for each Site product
TINY_PRODUCT_TEXTIL_AVULSA=...
TINY_PRODUCT_UV_FOLHA=...
TINY_PRODUCT_UV_AVULSA=...
```
With the client ID and secret set, the Kanban header shows **Conectar Tiny**.
Someone with a Tiny login approves access once; the tokens are stored in the
database (the refresh token rotates on every use) and the worker keeps the
connection alive. Only then set `TINY_ADAPTER=tiny`, which starts creating an
order in Tiny for every paid order: find or create the customer's contact by
CNPJ, then `POST /pedidos` with `numeroOrdemCompra = DTF-<order number>`. A
retry searches the customer's recent orders for that number first, so it does
not create a second one. **Tiny has no sandbox**: every test order is real, so
agree the test with the client and cancel the test orders afterwards.
`tests.tiny_oauth_test` checks the connection flow against the real database
with a fake token server; it saves and restores any existing connection.
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
Site instead of the local test button, and **Pagar com cartão** when
`MP_PUBLIC_KEY` is set. The card form is Mercado Pago's Card Payment Brick: the
card is typed into Mercado Pago's secure fields and only a one-time token
reaches the API. It loads from Mercado Pago, so the Site's CSP must allow it:
```bash
MP_PUBLIC_KEY=TEST-...
PAYMENT_CSP_SOURCES=https://sdk.mercadopago.com https://*.mercadopago.com https://*.mlstatic.com https://*.mercadolibre.com
```
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
`script-src 'self'`. Once a payment for a quote is approved, or a card payment
is in review, the API refuses any further attempt for that quote. The order is created only by the signed
notification, after the payment is fetched from the Mercado Pago API and its
BRL amount matches the approved total. Mercado Pago must be able to reach the
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid
notification that cannot become an order, or a refund on an existing order,
appears under **Pagamentos que precisam de atenção** on the Kanban until an
operator records the resolution.
## Local backup and restore check
```bash

View File

@@ -17,9 +17,11 @@ Cloudflare R2, so MinIO is not part of this stack.
The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request
runs static validation, the integration suite against a real stack, and a secret
scan. A push to `main` does not publish or deploy. A manual workflow run on
`main` repeats those checks, builds and scans the images, then publishes both
`latest` and the full commit SHA and calls Portainer.
scan. When all of them pass on a push to `main`, the images are built, scanned
and published as both `latest` and the full commit SHA. Nothing is deployed:
production changes when someone pulls and redeploys the stack in Portainer. A
manual workflow run on `main` does the same and also calls the Portainer
webhook, if `PORTAINER_WEBHOOK` is configured.
What actually gates a deployment:
@@ -29,16 +31,18 @@ What actually gates a deployment:
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
| Browser suites | yes; Chrome runs in the Compose test container |
| Trivy secret scan (HIGH/CRITICAL) | yes |
| Source preflight (`deploy/production_preflight.py --source-only`) | yes for manual release; advisory on pushes unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true` |
| Source preflight (`deploy/production_preflight.py --source-only`) | advisory unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true`, which blocks publishing |
| Trivy image vulnerabilities, CRITICAL | yes |
| Trivy image vulnerabilities, HIGH | no — reported before publication |
| Configured Portainer webhook | yes for manual release |
| Configured Portainer webhook | no — called on manual runs when present; otherwise redeploy in Portainer |
The source preflight refuses a release while the payment and messaging adapters
are fake. It remains advisory on push checks so development can continue, but a
manual release is blocked until those adapters are replaced. Set the repository
variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` when all pushes should also
fail on those blockers. Before a manual release, run the full configuration
The source preflight reports while the payment and messaging adapters are
fake. From 2026-09-23 it was enforced on every manual release, and since the
adapters are still fake no release could succeed: production kept running
older images while `main` moved on. It is now advisory everywhere. Set the
repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once the real
adapters are in place, and a blocked preflight then stops images from being
published. Before a manual release, run the full configuration
preflight below against the actual Portainer values; CI checks source only.
CRITICAL image findings block. Both images carry none: the bases are pinned by

View File

@@ -7,7 +7,18 @@
> Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history.
**Current step (2026-09-23, Week 2):** Payment safety fixes 2.13 and 2.14 and
**Current step (2026-09-24, Week 2):** Client inputs for Mercado Pago and
freight were requested on 2026-09-24; Tiny access is already with the client.
Built without them: server-side print-file generation (1.4), the delivery
address (3.8), the Kanban's payment-issue and print-file views (1.5), and
Mercado Pago and Tiny adapters written from the public API documentation and
tested against fake transports (1.1, 1.3). None of the provider work is a
verified integration. The complete CI integration sequence passed locally on
2026-09-24 (all API suites including the new `print_file_test`, adapter and
generator unit suites, retention, runtime security, and both browser suites),
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run.
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
the local order-correctness work in 3.6/3.9 have passed integration checks.
Production specification v2 now records each copy's film coordinates and is
kept through the approved order; 4.6 now pages pending and approved unpaid
@@ -211,14 +222,71 @@ From the report already sent. These are dated promises, not backlog.
A refused paid event must be visible for operator resolution rather than silently
treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook
administration, event mapping and an approved refund policy.
**Groundwork (2026-09-24):** `app/mercadopago.py` creates PIX or card-token
payments with the quote as idempotency key, verifies `x-signature` as
documented (HMAC-SHA256 over `id;request-id;ts`, 30-minute replay window),
and treats the notification as a pointer: the payment is fetched from the
API and only a BRL amount in whole centavos is compared. `payment_intents`
binds each provider payment to its quote; `/api/payments/intent` starts a
PIX and the Site shows its QR code. Refused paid events and refunds on
existing orders now stay on the Kanban until an operator records a
resolution. Unit-tested against a fake transport only.
**Card form (2026-09-24):** Mercado Pago's Card Payment Brick on the Site,
shown when `MP_PUBLIC_KEY` is set; the card becomes a one-time token in
Mercado Pago's secure fields. Each card attempt has its own idempotency key
(a decline can be retried with another card) and the intent route refuses
any new attempt once a payment is approved or a card is in review, so a
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
against a real public key; sandbox run and refund policy remain.
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
packaging weight/dimensions per length, subsidy policy.
- `[ ]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
Confirm endpoints, tag behaviour and rate limits first.
- `[ ]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
Keycloak. An operator starts the connection from the Kanban; the callback is
authorised by a single-use state (the operator cookie is SameSite=Strict and
does not survive Tiny's cross-site redirect). Tokens live in
`provider_tokens`; the refresh token rotates under a row lock and the worker
keeps the connection alive. Orders: contact found by CNPJ or created, then
`POST /pedidos` with product ids from `TINY_PRODUCT_TEXTIL_FOLHA` / `_TEXTIL_AVULSA` / `_UV_FOLHA`
/ `_UV_AVULSA` (not `_<MODE>`: a name ending in `_FILE` is read as a secret
file path by `app/core/secrets.py`) and
`numeroOrdemCompra = DTF-<number>`; a retry searches the customer's last
seven days of orders for that number first. Production passes the app
credentials through but keeps `TINY_ADAPTER: fake`. Tested against fake
transports (`tests.test_tiny`) and, for OAuth, the real database
(`tests.tiny_oauth_test`). Tiny has no sandbox: the first real test creates
real orders. Still to confirm on the client's account: plan (Construa+),
product ids, token lifetimes, whether pickup needs a transportador, and
rate limits.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job).
- `[ ]` 1.5 — Main Kanban production states consolidated.
**Built (2026-09-24):** 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 (`app/printfile.py`, rendered by the worker from
`app/printjobs.py`). Sources are embedded once at original resolution; JPEG
bytes pass through, PNG alpha becomes a soft mask, EXIF orientation is
honoured. A file whose proportions differ from the quote, a layout longer than
billed, or PDF/PSD/AI/CDR artwork goes to hand preparation with the reason.
The operator approves the generated PDF as the final file through the
existing review. Unit tests include a raster check of every rotation and
mirror, and `tests.print_file_test` passes on the running stack (generate,
download, approve as final, queue; hand-preparation routing and retry).
**Still open:** a FlexiPRINT import of real
generated files (including one longer than 5 m, which uses `UserUnit`).
**PDF artwork (2026-09-24):** a single-page PDF source is placed as a vector
form through pikepdf (MPL-2.0; PyMuPDF was rejected for its AGPL licence),
using the CropBox and inherited `/Rotate` the Site measured with pdf.js.
Raster tests cover crop, page rotation, placement rotation and mirroring,
and were shown to fail when the rotation or crop handling is broken.
Multi-page and protected PDFs go to hand preparation.
- `[~]` 1.5 — Main Kanban production states consolidated. The six states and
their transitions are unchanged; cards now show the delivery address, the
print-file status per item, and a panel lists payments that need a person
(money without an order, refunds after an order) until resolved. Confirm
with the operation that these are the main states before closing.
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
@@ -497,12 +565,20 @@ overpayment and provider success followed by database failure. Record refused
paid events for resolution. Decide who reconciles them and when production must
stop or refund. Implement with 1.1 after the checkout/refund policy is approved.
### `[ ]` 3.8 — Collect a deliverable destination before charging freight
### `[~]` 3.8 — Collect a deliverable destination before charging freight
The quote has a shipping service and CEP but no recipient, street, number,
city/state or delivery snapshot. Add and validate these fields with 1.2, then
bind the chosen service and final freight amount to the payment intent.
**Local progress 2026-09-24:** the Site collects recipient, street, number,
complement, district, city and UF for delivery; the API requires them for any
non-pickup quote, requires the CEP to be the one freight was quoted for, and
refuses an address on pickup. The address is part of the reviewed quote, the
order snapshot, the Kanban card and the Tiny payload. Changing it after a quote
invalidates that quote in the browser like any other cart change. Binding the
chosen freight service to the payment intent waits on 1.2.
### `[~]` 3.9 — Make artwork quality and geometry evidence explicit
Reject or route for review when PDF page count/geometry, image decoding or DPI
@@ -622,11 +698,26 @@ print-file evidence still need correction before this item can close.
data, a production API image import, local security status, and a local
backup/restore of the database plus 78 clean objects. Production offsite
recovery and signature freshness remain separate open items.
- `[x]` 5.15 — MinIO stopped publishing public images: by 2026-09-24 both
Docker Hub and quay.io answered anonymous pulls with 401, so a runner or
machine without a cached image could not start the stack. The local/CI
storage and storage-init now use Chainguard's MinIO build (ships `sh` and
`mc`, non-root), pinned by digest. Verified with a fresh local build and the
full integration sequence. Production uses R2 and is unaffected.
- `[x]` 5.16 — The operator login limit (10 per account per 15 minutes) counted
successful logins too, so ordinary use could lock an operator out, and one
extra test login made CI's final browser sign-in fail with 429. Now every
attempt counts against the source address and only failures count against
the account; registration still counts every attempt. The security suite's
lockout check (ten failures, then 429) is unchanged and passes.
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file.
- `[ ]` 5.14 — Promote and verify one immutable release. Normal `main` pushes
now run checks only; manual dispatch requires source preflight and a configured
- `[ ]` 5.14 — Promote and verify one immutable release. **2026-09-24:** green
pushes to `main` now publish images and Portainer's pull-and-redeploy is the
release gate; the source preflight is advisory unless enforced by variable,
because enforcing it while the adapters are fake made every release fail.
Previously: normal `main` pushes ran checks only; manual dispatch required source preflight and a configured
webhook, and scans images before publishing. Still make the full preflight
validate the active stack, deploy the tested immutable image references, test
clean install and upgrade, check application readiness after Portainer

View File

@@ -1,5 +1,5 @@
# Provisioning script and policies baked in, for the same reason as the scanner.
ARG MINIO_IMAGE=quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z
ARG MINIO_IMAGE=cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1
FROM ${MINIO_IMAGE}
COPY infra/storage-init.sh /init.sh
COPY infra/storage-policy.json /policy.json

View File

@@ -9,7 +9,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;

View File

@@ -21,7 +21,7 @@ anyio==4.15.1 \
boto3==1.38.23 \
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
# via -r local/requirements.txt
# via -r infra/requirements.txt
botocore==1.38.46 \
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
@@ -41,7 +41,7 @@ click==8.5.0 \
fastapi==0.141.1 \
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
# via -r local/requirements.txt
# via -r infra/requirements.txt
h11==0.16.0 \
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
@@ -55,7 +55,7 @@ httpcore==1.0.9 \
httpx==0.28.1 \
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r local/requirements.txt
# via -r infra/requirements.txt
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
@@ -68,10 +68,328 @@ jmespath==1.1.0 \
# via
# boto3
# botocore
lxml==6.1.3 \
--hash=sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4 \
--hash=sha256:05f5bce9af14fd1506997594bd81cee6d9c6b58ea80a39c058327aa6371ed9e9 \
--hash=sha256:0794e04ba343852c6d78e996c58ef4b8e579b4ecc72f8df0d4058bf843b4c96e \
--hash=sha256:0ab2467e405e748d93495fb5568e74044802b8d3ff2b2a1607c3f78c6e982de5 \
--hash=sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe \
--hash=sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc \
--hash=sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748 \
--hash=sha256:0f17d83c48ee9dfd96abae3ac3e2108c76d2fc86ce96355e37b8da9f7f4ecc08 \
--hash=sha256:0feebef8d0521188d0157f758356072e840173aa61ca45b8b3f87959ac283dd5 \
--hash=sha256:13a620a3fcc20023f9e6ed5c383e00e826f1c2d5db554df2f67240760f9118e8 \
--hash=sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741 \
--hash=sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87 \
--hash=sha256:16148acd77ed1d8836a56db883af2f5eed720f9723088110b16a0d08582130a6 \
--hash=sha256:170773d8a3cdc76259065523ddd978c44f9806e28605f08812e8f86783e44ac6 \
--hash=sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633 \
--hash=sha256:18a4db52b5a7b53a3540b0b0f4123319334621ee8083d496de314d0bf06ff59a \
--hash=sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d \
--hash=sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa \
--hash=sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e \
--hash=sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70 \
--hash=sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867 \
--hash=sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f \
--hash=sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12 \
--hash=sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156 \
--hash=sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6 \
--hash=sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5 \
--hash=sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75 \
--hash=sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48 \
--hash=sha256:23c366231259cd75ad06495174701afb3fcb36a92917fa47de2d1f1bd9d95739 \
--hash=sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37 \
--hash=sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626 \
--hash=sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015 \
--hash=sha256:2b9b1325ca1c2a9a2dbb6eb913ae563313f2082ae60b03210f7e83ee80712274 \
--hash=sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165 \
--hash=sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e \
--hash=sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79 \
--hash=sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d \
--hash=sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d \
--hash=sha256:302f72413251c03f671e063c9414bed5dc8c927069e5abb69245521e51a4e81b \
--hash=sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026 \
--hash=sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad \
--hash=sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11 \
--hash=sha256:3847e71a78cbbc1aff955dbbbaf2fff12153f611d3162c5beaa3395636cbc2f9 \
--hash=sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385 \
--hash=sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7 \
--hash=sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd \
--hash=sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f \
--hash=sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c \
--hash=sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a \
--hash=sha256:40bcbd9f94166ffe925811e730607385cec959f42fb1bb7dad83748680465221 \
--hash=sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167 \
--hash=sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a \
--hash=sha256:41e2d428110b408e963b6fb18f9bbf1f5c027b56bd4b498d54556476c0aeb1c3 \
--hash=sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054 \
--hash=sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245 \
--hash=sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21 \
--hash=sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6 \
--hash=sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e \
--hash=sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13 \
--hash=sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b \
--hash=sha256:4b061064b4a2fe8598a466d723d43dbcd5a610a5d5cfe02fb6226f5c17349f75 \
--hash=sha256:4e11e885e0704be185867fcf71b904d8f65d7d6877bc121f69870b0d0479ba7b \
--hash=sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d \
--hash=sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0 \
--hash=sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69 \
--hash=sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414 \
--hash=sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d \
--hash=sha256:5929d9df5e7e3379183be0e21f7d559618a5b61cb63280df6164019242e337ed \
--hash=sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f \
--hash=sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf \
--hash=sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2 \
--hash=sha256:5d12669a2c419b0e8dc423d23dea24bb82f6f9cb829f32e04674b0ba40322a7c \
--hash=sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2 \
--hash=sha256:61116cec57ed69aebc70f37a545eec095339bb829efbdabcfb97c51e9536e158 \
--hash=sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d \
--hash=sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d \
--hash=sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c \
--hash=sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861 \
--hash=sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd \
--hash=sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0 \
--hash=sha256:6ba4fe5bfbef6811a8e49b3719cde373ad399006c0c1ac184b7297116ecbba5d \
--hash=sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5 \
--hash=sha256:6e1eb8a4cbffd5553680ad96be6680e364710656eced73d1dc90ec489df599a3 \
--hash=sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0 \
--hash=sha256:71532ebf30be0048a45559b4fab15333fbaaf9042f658e878d918ecd0cf09805 \
--hash=sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a \
--hash=sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8 \
--hash=sha256:773062aec2f2e56b2b22d37054123f0de8a22a4688a0c3376c3fe42685f975cf \
--hash=sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559 \
--hash=sha256:7b2bb7d703bed7ac893bf7f40d97b5d9279d35d2ce460624ca28929eab0d5a3d \
--hash=sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c \
--hash=sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a \
--hash=sha256:7dd624c1eaa629ad44b59a1a0145fdf2d67895592dce94c9358b938b3d075e65 \
--hash=sha256:7f75b9b9fec2a9c6b18095c81865580e795b1441c429e42d22fcc82a77f40039 \
--hash=sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92 \
--hash=sha256:8499d464de86fab0f102313cce32a9bed9ab1f06ec813cf025cb790964fbb765 \
--hash=sha256:869dfcd4d381cb0ea87085cc4f011b9171b494ef21e76ad8665f6d5e2d1dc8a1 \
--hash=sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0 \
--hash=sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1 \
--hash=sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2 \
--hash=sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758 \
--hash=sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473 \
--hash=sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310 \
--hash=sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c \
--hash=sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4 \
--hash=sha256:92d96586376fb79a33474797186bf993250152ee5c32650b67db78d54b92e6f3 \
--hash=sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17 \
--hash=sha256:97acecb11cbc411473f15b8d780df06d7a9f3a2aad9aca78364f56640c8fb70e \
--hash=sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9 \
--hash=sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48 \
--hash=sha256:9e67324961ac9bbe616cce5100514d2e34d88665aeb07071e8b16eac55d06d94 \
--hash=sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a \
--hash=sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2 \
--hash=sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55 \
--hash=sha256:a2e3f70673a1d5b82f38255f777d26cd855bf2092b1436c4867464a7892f9238 \
--hash=sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e \
--hash=sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56 \
--hash=sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0 \
--hash=sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0 \
--hash=sha256:aa9fd1ee2a5dacfc41039ed49ffeeacfa75bafbd255b69f3b578e11897a0e623 \
--hash=sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e \
--hash=sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1 \
--hash=sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a \
--hash=sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c \
--hash=sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed \
--hash=sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6 \
--hash=sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4 \
--hash=sha256:b50343241eb69fd85f7791cf8bcc7b1c4729826b7d59ba2f6b27db29638fa745 \
--hash=sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae \
--hash=sha256:be5346653c0b0e34be96869ff9dbeba23860156f89a2896a64c64fb419260cb6 \
--hash=sha256:c00e26288784460885fe76e4d4b293573e0f791f52e6d60e27b42edf005922eb \
--hash=sha256:c1b50797ac246bb2942a04b6c0f69af0667aba7cf7535f39bbb1b3208fd5d128 \
--hash=sha256:c34ca1dc41bd86d9ff830d5bdf4e4a752bba6c54f7d2707027ce0eabd36084c9 \
--hash=sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5 \
--hash=sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9 \
--hash=sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415 \
--hash=sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8 \
--hash=sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11 \
--hash=sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8 \
--hash=sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2 \
--hash=sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a \
--hash=sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300 \
--hash=sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0 \
--hash=sha256:cc669256d28736f7f3a149df5c380c50ace2692ba3e62203d10656fade4a2145 \
--hash=sha256:ce1f220114959941170e22b8ad44279f6dee2dcef7591814d01ae805dc058889 \
--hash=sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9 \
--hash=sha256:d077f21f4b16f0471353883748f126f62038760397c107bb9fad2ca94dc0dfb7 \
--hash=sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559 \
--hash=sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962 \
--hash=sha256:d44442effeb8781f392340c5dc8c6716fba41dbeacb82fd4c0f09026fb5ff682 \
--hash=sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e \
--hash=sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb \
--hash=sha256:d9a0d12846d6ce434fb3857918eef4315ec9b4769deb020c75828798614bfcfd \
--hash=sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc \
--hash=sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8 \
--hash=sha256:da85db328e507da922d586c3c7416ec360ec22e9cd9e0700691afacde0c81f53 \
--hash=sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e \
--hash=sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed \
--hash=sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d \
--hash=sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32 \
--hash=sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477 \
--hash=sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023 \
--hash=sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887 \
--hash=sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41 \
--hash=sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6 \
--hash=sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376 \
--hash=sha256:ec295280f4b37769256da025acf5890370355ac589c27e89caae0b5e9eedc702 \
--hash=sha256:f6449672f9c93316deb5e2839e18931f468670e44d5bd9b1301a5a9655d45c07 \
--hash=sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5 \
--hash=sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2 \
--hash=sha256:f8b9c8ceebae6387d0dc77f7f4dbbfbfc962dba2efbfe6877486075a480726b4 \
--hash=sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011 \
--hash=sha256:fbfb70ba01355251faf6b293171df49f73a88a1b6494db109ffea85442574458 \
--hash=sha256:fe91993149523aa59941b9e3c90e2eb45f57ad014697aef6c8b13339a59c019e \
--hash=sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0 \
--hash=sha256:ff88a92cafde90888511242d1c54afcc1a8adbb6dc0a88fa7f87e29e92400d4a
# via pikepdf
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via pikepdf
pikepdf==10.13.0.post1 \
--hash=sha256:01f80ca046d984752cf6f08093debc193884bee91c01c104aa0236767711a20f \
--hash=sha256:092a9bf15739e931ecab15ec3baee5d9629dad90ea4e42b779f6b439d2d1e462 \
--hash=sha256:0efb4faed9cbb59c1486f668af326096dac1ff0ca058eb48ec485742a9a655af \
--hash=sha256:1f76fcbe5d86f2ae6f231ba542cd04793d4c89e75bd5f62526b7412926ff2100 \
--hash=sha256:20c76343128ec41d5be58337b23c6f9f620fa7b8256a2d942460a48c07bbfe7b \
--hash=sha256:2c6e83f8a1828ec79cdec4df8cc07209eaf10ed7e4f5a90a7356b254bacc07d5 \
--hash=sha256:2db9a18074ba112e7c517e8c21dfd8894cc13b8a37ccf49a5841192170fb68eb \
--hash=sha256:365b94f2be7e2857c6cb5445b56dc52dc7417ba9f06c8a4282d9f521cb2d0fb8 \
--hash=sha256:3e18d5a009bbe5f3ab18f916fb9e28f0c5b0d920736e3a85cc10c627ec633596 \
--hash=sha256:414f42c83e5e6029870de1a988625dafc95781ebff10e15d82caeb5e69a83c9c \
--hash=sha256:43d70f244a4a1120a11cfe2227f8c03249fac6a7e3789a3116173102a3b9fe37 \
--hash=sha256:4b73f926ebae81f04bf14527af330bd00bb268be767e0f189f7c4c3e4ad7ae0a \
--hash=sha256:4bb5fe2090d246ad4b325d17f33a186f60f6763bba3d4ac3c4b863c8890e913a \
--hash=sha256:51fae4a4a3c6549aa4c405896ff7010f3e43e0c4f407c0bcee071ef13d271202 \
--hash=sha256:544f1be1b1e5630a79cd182a8663c439504099eb9eae0d342a50173d9825bdf3 \
--hash=sha256:55e53b4d8a4b1700f686f76e3a68411e421e962a4c8b1b90d00aab3f3e494a55 \
--hash=sha256:571efcd1d54e0dd817973c76c253feb6fb758c93bb0c16a893cc68f2a178d404 \
--hash=sha256:6b038cd5bcbb6c1952bcc271695eaf24c4199d72e45606ee5e467f837760820e \
--hash=sha256:7ba09ef5a5f26e38ee558d2a08223fee08a5ef1868962ae2d8590d4de3c8c92f \
--hash=sha256:87141ada970386ff6640db54f0bda734d3bde7960d3ba04a76768b48e25028ca \
--hash=sha256:8cb976331cb8b03ec3465e06d9e7a3eadbadb7e622be888e70f918ac732a105e \
--hash=sha256:8fb8f82dc43056a4b4f891e78ee1db4e3ced75ba3e87b836f8e28c8771228928 \
--hash=sha256:90f17cb174db88e08075c5bfa3c619df00dd84abbad34bfa1edf84863f0b01a7 \
--hash=sha256:94e04ed92fe42b8bcebf8c40462868dca4eb92581dcc2be1f0c4b8ee23347386 \
--hash=sha256:9613505f5b22203465d4224a3fd8cf69876ce8278442c6478ac6849f54724a30 \
--hash=sha256:98a7305e330f797da02b543d3ad57a134c4a14c6ec6f8d86d91aa9dd130c425b \
--hash=sha256:996a714a47cc725e3c48fe3901691d3353f3c2eeb9e0571aa2b16164abc40ff9 \
--hash=sha256:99f6afccd6119233e7133bd4c2ade48461de3ddd4269cc28a4f90cdf7c1372f5 \
--hash=sha256:a3e9104db5ea5b5a7c5fde417147900966a687de39ef91a5ea103fd4b773aee1 \
--hash=sha256:b63577c44fedf7ed6b971076f7c7ed0ff95a8bac627ac93b79e8b542214861a7 \
--hash=sha256:b69b89577b50617248185b6ad73cda0e4f15c57da11f7da8cc4032bf0d7d9ebe \
--hash=sha256:b7b0cbb135de32ec3f41651a08ab294e3c18ae9fec32516a48d27e64a53a47f0 \
--hash=sha256:b948e11f7dd3710f939194f00b4d75b0df87a30d53b31414128768ac25da77d8 \
--hash=sha256:c0b5127df90b0164dd846bddd0ed542326b2f69bd11f627afe48762cf16c2904 \
--hash=sha256:d3b58ccb30b93ba400e6a6a83315b4830eea49f6f19e18d78241fe6b1c49fec2 \
--hash=sha256:e19bab4320e4e8771b7816f7319ba37530fd28a40372840b75cab394ebf868e4 \
--hash=sha256:ef4ed47d40aa44deb063feb4a88e8bcf1c8fa0183ce526dc4295f7cbdb1292f8 \
--hash=sha256:f0eb89f06cad9231b9db54d81a22592b03b63924824a6b850febd2b75daa6546 \
--hash=sha256:f2463f650efab46905b9e279f5c776faf96c65bb45c1acf9f2de0e8a6eec5fb7 \
--hash=sha256:f3dedd02795626f17ee42d5c02ec4ec94e28aa47046ef430d4478454a8fbd07f \
--hash=sha256:f515a31c76cce043bbb7b781e77a343a4e26fa8f520ba337d30ddea0f7a0ce50 \
--hash=sha256:f7962a75cf22d0d683b49ab19b8966e94dc7014d9aa6806f3c0d2b37fb9ae607 \
--hash=sha256:fb05fb7b42d85754219b55111aa61beff4e48da56069e971de1e5aca380c0ac1
# via -r infra/requirements.txt
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
# via
# -r infra/requirements.txt
# pikepdf
psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
# via -r local/requirements.txt
# via -r infra/requirements.txt
psycopg-binary==3.2.9 \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
@@ -281,7 +599,7 @@ starlette==1.6.0 \
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via
# -r local/requirements.txt
# -r infra/requirements.txt
# fastapi
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
@@ -307,6 +625,4 @@ urllib3==2.7.0 \
uvicorn==0.34.2 \
--hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \
--hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403
# via -r local/requirements.txt
# The following packages are considered to be unsafe in a requirements file:
# via -r infra/requirements.txt

View File

@@ -4,3 +4,5 @@ uvicorn==0.34.2
psycopg[binary]==3.2.9
boto3==1.38.23
httpx==0.28.1
pillow==12.3.0
pikepdf==10.13.0.post1

View File

@@ -58,6 +58,20 @@ def run():
assert not customer.call('/quotes/' + quote_id)['order'], 'unsigned delivery created an order'
print('PASS: unsigned and tampered deliveries are refused and create nothing')
# Starting a PIX twice returns the same one. A card in review blocks every
# further attempt, so one quote can never be charged twice.
pix = customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})
assert customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}})['id'] == pix['id']
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'card'}}, expected=422)
card = {'type': 'card', 'token': 'tok-1', 'payment_method_id': 'visa', 'installments': 1}
customer.call('/payments/intent', {'quote_id': quote_id, 'method': card})
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {**card, 'token': 'tok-2'}}, expected=409)
customer.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=409)
stranger = Client()
stranger.call('/session')
stranger.call('/payments/intent', {'quote_id': quote_id, 'method': {'type': 'pix'}}, expected=404)
print('PASS: payment start is idempotent for PIX and refuses a second charge')
# An approved payment for the wrong amount must not become an order.
deliver({'event_id': 'short-' + uuid4().hex, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 100})

158
tests/print_file_test.py Normal file
View File

@@ -0,0 +1,158 @@
"""Print files and payment issues, against a running stack.
A paid order must get a print file generated from its approved layout, which
the operator can download and approve as the final file without re-uploading
anything. Artwork the generator cannot read goes to hand preparation with a
reason, and a paid notification that did not become an order stays on the
Kanban until someone records what was done.
Run inside the API container (it needs Pillow): python -m tests.print_file_test
"""
import io
import re
import time
from urllib.request import urlopen
from uuid import uuid4
from PIL import Image
from tests.payment_test import deliver
from tests.smoke_test import Client, upload_bytes
PT_PER_CM = 72 / 2.54
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
def artwork(kind='PNG'):
image = Image.new('RGBA', (600, 300), (0, 0, 0, 0))
for x in range(40, 560):
for y in range(40, 260):
image.putpixel((x, y), (220, 30, 60, 255))
out = io.BytesIO()
if kind == 'PDF':
image.convert('RGB').save(out, 'PDF', resolution=72)
else:
image.save(out, kind)
return out.getvalue()
def loose_item(uid, copies=2):
"""Two copies of a 20 x 10 cm artwork side by side on 57 cm film."""
placements = [{'source_index': 0, 'copy_index': i, 'x_cm': 20 * i, 'y_cm': 0,
'width_cm': 20, 'length_cm': 10, 'rotation_degrees': 0, 'mirrored': False}
for i in range(copies)]
return {'mode': 'avulsa', 'metres': '0.1', 'grade': 90, 'uploads': [uid],
'production': {'version': 2, 'film_width_cm': 57, 'height_cm': 10,
'sources': [{'upload_id': uid, 'kind': 'artwork', 'width_cm': 20,
'length_cm': 10, 'copies': copies, 'rotation_degrees': 0,
'mirrored': False, 'measurement': 'file'}],
'placements': placements},
'quality_status': 'ok', 'quality_acknowledged': False}
def approved_quote(client, item):
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
'items': [item], 'freight': {'service': 'pickup'}})
approved = client.call('/operator/quotes/' + quote['id'] + '/approve', {'items': [item]}, operator=True)
return quote['id'], approved['total_cents']
def paid_order(client, item):
quote_id, _ = approved_quote(client, item)
return client.call('/orders/dev-paid', {'quote_id': quote_id})
def wait_print(client, oid, wanted):
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
order = next(o for o in client.call('/operator/board', operator=True)['orders'] if o['id'] == oid)
rows = order['print_files']
if rows and rows[0]['status'] in ('ready', 'manual', 'failed'):
assert rows[0]['status'] == wanted, rows
return order, rows[0]
time.sleep(1)
raise AssertionError('Print file was not generated in time')
def run():
client = Client()
client.call('/session')
uid = upload_bytes(client, artwork(), name='LOCAL-PRINT-TEST.png')
order = paid_order(client, loose_item(uid))
order, row = wait_print(client, order['id'], 'ready')
assert row['detail']['placements'] == 2 and row['detail']['min_dpi'] == round(600 / (20 / 2.54))
link = client.call('/operator/uploads/' + str(row['upload_id']) + '/download', operator=True)
with urlopen(link['url'], timeout=30) as response:
pdf = response.read()
assert pdf.startswith(b'%PDF-') and pdf.rstrip().endswith(b'%%EOF')
width, height = map(float, re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf).groups())
assert abs(width - 57 * PT_PER_CM) < 0.01 and abs(height - 10 * PT_PER_CM) < 0.01, (width, height)
print('PASS: paid order generated a 57 x 10 cm print file with both copies')
# The operator approves the generated file as the final one, with no upload,
# and the order can then enter the print queue.
oid = order['id']
result = client.call('/operator/orders/' + oid + '/final-files',
{'version': order['version'], 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
'note': 'Generated print file checked'}, operator=True)
version = result['version']
for state in ('tra', 'fil'):
client.call('/operator/orders/' + oid + '/move', {'state': state, 'version': version}, operator=True)
version += 1
# Once queued for printing, the final set can no longer change.
client.call('/operator/orders/' + oid + '/final-files',
{'version': version, 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
'note': 'again'}, operator=True, expected=409)
print('PASS: generated file approved as final without re-uploading; order queued for printing')
# A single-page PDF is placed as a vector form, not rasterised.
pdf_upload = upload_bytes(client, artwork('PDF'), name='LOCAL-PRINT-TEST.pdf')
pdf_order = paid_order(client, loose_item(pdf_upload))
_, pdf_row = wait_print(client, pdf_order['id'], 'ready')
assert pdf_row['detail']['vector_sources'] == 1 and pdf_row['detail']['min_dpi'] is None, pdf_row
link = client.call('/operator/uploads/' + str(pdf_row['upload_id']) + '/download', operator=True)
with urlopen(link['url'], timeout=30) as response:
generated = response.read()
assert b'/Subtype /Form' in generated or b'/Subtype/Form' in generated
print('PASS: PDF artwork generated as a vector print file')
# A customer cannot see or reuse another order's generated file.
other = Client()
other.call('/session')
other.call('/uploads/' + str(row['upload_id']), expected=404)
# Artwork the generator cannot read goes to hand preparation, with a reason.
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
order = paid_order(client, loose_item(manual, copies=1))
order, row = wait_print(client, order['id'], 'manual')
assert 'not an image' in row['detail']['reason'], row
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
assert rows[0]['status'] == 'pending'
wait_print(client, order['id'], 'manual')
print('PASS: unreadable artwork is routed to hand preparation and can be retried')
# A signed, paid notification for the wrong amount does not become an order;
# it waits on the Kanban until an operator records the resolution.
# Same client, so the same operator session: operator logins share a
# 10-per-15-minutes account limit with every other suite in the run.
quote_id, total = approved_quote(client, loose_item(uid, copies=1))
event_id = 'print-test-underpaid-' + uuid4().hex
outcome = deliver({'event_id': event_id, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 1})
assert outcome['outcome'].startswith('refused'), outcome
issues = client.call('/operator/board', operator=True)['payment_issues']
issue = next(i for i in issues if i['event_id'] == event_id)
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
operator=True, expected=422)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: refunded the underpayment'}, operator=True)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: second resolution'}, operator=True, expected=404)
issues = client.call('/operator/board', operator=True)['payment_issues']
assert not any(i['event_id'] == event_id for i in issues)
print('PASS: refused paid notification is listed until an operator resolves it')
if __name__ == '__main__':
run()

View File

@@ -129,8 +129,16 @@ def run():
items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')]
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}}
client.call('/quotes',{**draft,'total_cents':1},expected=422)
# Freight quoted by CEP alone cannot ship: the address is required, must be
# the quoted CEP, and a pickup order takes none.
client.call('/quotes',{**draft,'destination':None},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'postal_code':'01001000'}},expected=422)
client.call('/quotes',{**draft,'destination':{**draft['destination'],'state':'XX'}},expected=422)
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=422)
client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
outside={**items[0],'production':{**items[0]['production'],
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
@@ -139,7 +147,7 @@ def run():
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
quote=client.call('/quotes',draft)
assert client.call('/quotes',draft)['id']==quote['id']
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
qid=quote['id']
other.call('/quotes/'+qid,expected=404)
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
@@ -152,6 +160,7 @@ def run():
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
assert approved['items'][0]['total_cents']==2189
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
assert approved['destination']=={**draft['destination'],'complement':''}
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)

122
tests/test_mercadopago.py Normal file
View File

@@ -0,0 +1,122 @@
"""The Mercado Pago adapter against a fake HTTP transport.
This proves the adapter follows the documented contract. It does not prove the
integration: that needs the sandbox flows with the client's own account.
Runs where httpx is installed (the API image, or a local virtualenv).
"""
import hashlib
import hmac
import json
import unittest
import httpx
from app.mercadopago import MercadoPagoPayment, event_from_payment
SECRET = 'test-webhook-secret'
NOW = 1_790_000_000
def signature(data_id, request_id, ts, secret=SECRET):
manifest = ''
if data_id:
manifest += f'id:{data_id};'
if request_id:
manifest += f'request-id:{request_id};'
manifest += f'ts:{ts};'
return f'ts={ts},v1=' + hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest()
class MercadoPagoTests(unittest.TestCase):
def setUp(self):
self.requests = []
self.payments = {}
def handler(request):
self.requests.append(request)
if request.method == 'GET':
payment_id = request.url.path.rsplit('/', 1)[-1]
return httpx.Response(200, json=self.payments[payment_id])
body = json.loads(request.content)
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
'point_of_interaction': {'transaction_data': {
'qr_code': '000201PIX', 'qr_code_base64': 'aW1n', 'ticket_url': 'https://mp/t'}},
**{k: body[k] for k in ('transaction_amount', 'external_reference')}}
return httpx.Response(201, json=payment)
self.mp = MercadoPagoPayment('TEST-token', SECRET, 'https://dtf.example/api/payments/webhook',
transport=httpx.MockTransport(handler), clock=lambda: NOW)
def test_signature_follows_the_documented_manifest(self):
headers = {'x-signature': signature('123456', 'req-1', NOW), 'x-request-id': 'req-1'}
self.assertTrue(self.mp.verify(headers, b'{}', {'data.id': '123456'}))
# Any change to the signed values breaks it.
self.assertFalse(self.mp.verify(headers, b'{}', {'data.id': '123457'}))
self.assertFalse(self.mp.verify({**headers, 'x-request-id': 'req-2'}, b'{}', {'data.id': '123456'}))
self.assertFalse(self.mp.verify({'x-signature': signature('123456', 'req-1', NOW, 'other'),
'x-request-id': 'req-1'}, b'{}', {'data.id': '123456'}))
self.assertFalse(self.mp.verify({}, b'{}', {'data.id': '123456'}))
def test_absent_values_are_left_out_and_alphanumeric_ids_lowercased(self):
self.assertTrue(self.mp.verify({'x-signature': signature('abc123', None, NOW)}, b'{}',
{'data.id': 'ABC123'}))
def test_old_signatures_are_refused(self):
old = NOW - 3600
self.assertFalse(self.mp.verify({'x-signature': signature('1', 'r', old), 'x-request-id': 'r'},
b'{}', {'data.id': '1'}))
def test_notification_is_only_a_pointer(self):
# The body claims nothing about amount or status; the API is asked.
self.payments['999'] = {'id': 999, 'status': 'approved', 'currency_id': 'BRL',
'transaction_amount': 123.45, 'external_reference': 'quote-1'}
body = json.dumps({'id': 42, 'type': 'payment', 'action': 'payment.updated',
'data': {'id': '999'}}).encode()
event = self.mp.parse(body, {'data.id': '999', 'type': 'payment'})
self.assertEqual((event.status, event.amount_cents, event.reference), ('approved', 12345, 'quote-1'))
self.assertEqual(event.event_id, '999:approved')
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
def test_amounts_outside_brl_centavos_are_not_trusted(self):
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
{'currency_id': 'BRL', 'transaction_amount': 10.001},
{'currency_id': 'BRL', 'transaction_amount': None}):
self.assertIsNone(event_from_payment({'id': 1, 'status': 'approved', **payment}).amount_cents)
self.assertEqual(event_from_payment({'id': 1, 'status': 'approved', 'currency_id': 'BRL',
'transaction_amount': 0.1}).amount_cents, 10)
def test_statuses_map_to_the_service_vocabulary(self):
for provider, ours in (('in_process', 'pending'), ('charged_back', 'refunded'),
('cancelled', 'cancelled'), ('rejected', 'rejected')):
self.assertEqual(event_from_payment({'id': 1, 'status': provider}).status, ours)
def test_pix_payment_is_idempotent_on_the_quote(self):
created = self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
{'mail': 'a@example.test', 'cnpj': '11222333000181'})
request = self.requests[-1]
body = json.loads(request.content)
self.assertEqual(request.headers['x-idempotency-key'],
'dtf-quote-11111111-2222-3333-4444-555555555555-pix')
self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45))
self.assertEqual(body['external_reference'], '11111111-2222-3333-4444-555555555555')
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending'))
def test_card_payment_uses_the_browser_token_only(self):
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_abc', 'payment_method_id': 'visa', 'installments': 3})
request = self.requests[-1]
body = json.loads(request.content)
self.assertEqual((body['token'], body['payment_method_id'], body['installments']), ('tok_abc', 'visa', 3))
self.assertNotIn('card_number', json.dumps(body))
# A new card attempt after a decline must not collide with the first.
first_key = request.headers['x-idempotency-key']
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
self.assertTrue(first_key.startswith('dtf-quote-q-card-'))
if __name__ == '__main__':
unittest.main()

297
tests/test_printfile.py Normal file
View File

@@ -0,0 +1,297 @@
"""The print file must reproduce the reviewed layout: size, place, turn and mirror.
Runs where Pillow is installed (the API image, or a local virtualenv). The
raster checks additionally need PyMuPDF to draw the page, and skip without it;
the structural checks do not.
"""
import io
import os
import re
import tempfile
import unittest
import zlib
from PIL import Image
from app.printfile import PT_PER_CM, Unsupported, placement_matrix, render
try:
import fitz # PyMuPDF, a development-only rasteriser
except ImportError:
fitz = None
RED, GREEN, BLUE, YELLOW = (255, 0, 0), (0, 160, 0), (0, 0, 255), (255, 220, 0)
def quadrants(width=80, height=40, alpha=False):
"""Top-left red, top-right green, bottom-left blue, bottom-right yellow."""
image = Image.new('RGBA' if alpha else 'RGB', (width, height))
for x in range(width):
for y in range(height):
left, top = x < width // 2, y < height // 2
color = RED if left and top else GREEN if top else BLUE if left else YELLOW
image.putpixel((x, y), color + ((255,) if alpha else ()))
return image
def item(placements, sources, height_cm, billed='1.0', film=57):
return {'mode': 'avulsa', 'billed_metres': billed,
'production': {'version': 2, 'film_width_cm': film, 'height_cm': height_cm,
'sources': sources, 'placements': placements}}
def source(width_cm, length_cm, copies=1, rotation=0, mirrored=False):
return {'upload_id': '00000000-0000-0000-0000-000000000000', 'kind': 'artwork',
'width_cm': width_cm, 'length_cm': length_cm, 'copies': copies,
'rotation_degrees': rotation, 'mirrored': mirrored, 'measurement': 'file'}
def placement(x, y, width, length, rotation=0, mirrored=False, index=0, copy=0):
return {'source_index': index, 'copy_index': copy, 'x_cm': x, 'y_cm': y,
'width_cm': width, 'length_cm': length,
'rotation_degrees': rotation, 'mirrored': mirrored}
class PrintFileTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def save(self, image, name, **options):
path = os.path.join(self.dir.name, name)
image.save(path, **options)
return path
def render(self, spec, paths):
out = io.BytesIO()
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
return out.getvalue(), detail
def test_page_is_the_film_and_the_layout_length(self):
path = self.save(quadrants(), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
[source(20, 10, copies=2)], 10), [path])
box = re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf)
self.assertAlmostEqual(float(box.group(1)), 57 * PT_PER_CM, places=2)
self.assertAlmostEqual(float(box.group(2)), 10 * PT_PER_CM, places=2)
# One embedded image, drawn once per copy.
self.assertEqual(pdf.count(b'/Subtype /Image'), 1)
content = self.content(pdf)
self.assertEqual(content.count(b' Do '), 2)
self.assertEqual(detail['placements'], 2)
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
self.assertTrue(pdf.startswith(b'%PDF-1.6') and pdf.rstrip().endswith(b'%%EOF'))
def content(self, pdf):
# The page content is the last Flate stream before the page object.
streams = re.findall(rb'/Filter /FlateDecode/Length \d+ 0 R>>\nstream\n(.*?)\nendstream', pdf, re.S)
return zlib.decompress(streams[-1])
def test_jpeg_bytes_are_embedded_unchanged(self):
path = self.save(quadrants(), 'a.jpg', quality=90)
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
with open(path, 'rb') as original:
self.assertIn(original.read(), pdf)
self.assertIn(b'/DCTDecode', pdf)
def test_transparency_survives_as_a_soft_mask(self):
image = quadrants(alpha=True)
image.putpixel((0, 0), (0, 0, 0, 0))
path = self.save(image, 'a.png')
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
self.assertIn(b'/SMask', pdf)
self.assertEqual(pdf.count(b'/Subtype /Image'), 2)
def test_exif_orientation_is_honoured_like_a_browser(self):
# Stored landscape, tagged "rotate 90": browsers show it portrait.
image = quadrants(80, 40)
exif = Image.Exif()
exif[0x0112] = 6
path = self.save(image, 'a.jpg', exif=exif)
pdf, _ = self.render(item([placement(0, 0, 10, 20)], [source(10, 20)], 20), [path])
self.assertNotIn(b'/DCTDecode', pdf)
with self.assertRaises(Unsupported):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
def test_refuses_what_it_cannot_reproduce(self):
png = self.save(quadrants(), 'a.png')
with self.assertRaisesRegex(Unsupported, 'proportions'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
with self.assertRaisesRegex(Unsupported, 'billed'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n')
with self.assertRaisesRegex(Unsupported, 'not a PDF'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
cdr = os.path.join(self.dir.name, 'art.cdr')
with open(cdr, 'wb') as handle:
handle.write(b'not artwork')
with self.assertRaisesRegex(Unsupported, 'not an image'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
def test_long_layouts_scale_user_space_instead_of_splitting(self):
# 6 m is longer than a PDF page may be (about 5.08 m).
path = self.save(quadrants(40, 800), 'a.png')
pdf, detail = self.render(item([placement(0, 0, 30, 600)], [source(30, 600)], 600,
billed='6.0'), [path])
self.assertEqual(detail['user_unit'], 2)
self.assertIn(b'/UserUnit 2', pdf)
def test_matrix_maps_corners_like_the_canvas(self):
# Box 20 x 10 cm at the page's top-left; which image corner lands where.
page = 10 * PT_PER_CM
def corner(matrix, u, v):
a, b, c, d, e, f = matrix
x, y = a * u + c * v + e, b * u + d * v + f
return round(x / PT_PER_CM, 6), round((page - y) / PT_PER_CM, 6)
# Image top-left is unit (0, 1).
cases = {(0, False): (0, 0), (90, False): (20, 0), (180, False): (20, 10),
(270, False): (0, 10), (0, True): (20, 0), (90, True): (20, 10)}
for (rotation, mirrored), expected in cases.items():
matrix = placement_matrix(placement(0, 0, 20, 10, rotation, mirrored), page)
self.assertEqual(corner(matrix, 0, 1), expected, (rotation, mirrored))
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
def test_drawn_page_matches_the_layout(self):
# Each case: the colour expected in the box's TL, TR, BL, BR quadrant.
cases = {
(0, False): (RED, GREEN, BLUE, YELLOW),
(90, False): (BLUE, RED, YELLOW, GREEN),
(180, False): (YELLOW, BLUE, GREEN, RED),
(0, True): (GREEN, RED, YELLOW, BLUE),
(90, True): (YELLOW, GREEN, BLUE, RED),
}
image = quadrants(80, 40)
for (rotation, mirrored), expected in cases.items():
turned = rotation % 180 == 90
width, length = (10, 20) if turned else (20, 10)
path = self.save(image, f'q{rotation}{mirrored}.png')
# Sources are described after the customer's turn, like the box.
spec = item([placement(12, 5, width, length, rotation, mirrored)],
[source(width, length, rotation=rotation % 180, mirrored=mirrored)], 30)
pdf, _ = self.render(spec, [path])
page = fitz.open(stream=pdf, filetype='pdf')[0]
dpi = 40
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
def sample(x_cm, y_cm):
px = int(x_cm / 2.54 * dpi)
py = int(y_cm / 2.54 * dpi)
return pixmap.pixel(px, py)
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
for actual, wanted in zip(got, expected):
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
f'rotation {rotation} mirrored {mirrored}: {got} != {expected}')
# Outside the box the film stays empty.
self.assertEqual(sample(2, 2), (255, 255, 255))
class PdfSourceTests(unittest.TestCase):
"""A customer PDF is placed as a vector form, sized and turned like an image."""
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def pdf(self, name, image=None, rotate=None, crop=None, pages=1, password=None):
import pikepdf
path = os.path.join(self.dir.name, name)
image = image or quadrants(80, 40)
image.save(path, 'PDF', resolution=72, save_all=pages > 1,
append_images=[image] * (pages - 1))
if rotate is not None or crop or password:
with pikepdf.open(path, allow_overwriting_input=True) as document:
if rotate is not None:
document.Root.Pages.Rotate = rotate # inherited, not on the page
if crop:
document.pages[0].obj.CropBox = pikepdf.Array(crop)
encryption = pikepdf.Encryption(owner=password, user=password) if password else None
document.save(path, encryption=encryption or False)
return path
def render(self, spec, paths):
out = io.BytesIO()
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
return out.getvalue(), detail
def test_pdf_page_is_a_vector_form_placed_per_copy(self):
path = self.pdf('sheet.pdf')
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
[source(20, 10, copies=2)], 10), [path])
self.assertTrue(pdf.startswith(b'%PDF-1.6'))
self.assertEqual(detail['vector_sources'], 1)
self.assertIsNone(detail['min_dpi'])
import pikepdf
with pikepdf.open(io.BytesIO(pdf)) as document:
page = document.pages[0]
forms = [x for x in page.Resources.XObject.values() if x.Subtype == '/Form']
self.assertEqual(len(forms), 1)
self.assertAlmostEqual(float(page.mediabox[2]), 57 * PT_PER_CM, places=2)
content = b''.join(s.read_bytes() for s in page.obj.Contents) if isinstance(page.obj.Contents, pikepdf.Array) else page.obj.Contents.read_bytes()
self.assertEqual(content.count(b'/Pdf0 Do'), 2)
def test_mixed_raster_and_pdf_sources(self):
png = os.path.join(self.dir.name, 'a.png')
quadrants(80, 40).save(png)
pdf_path = self.pdf('b.pdf')
spec = item([placement(0, 0, 20, 10), placement(0, 0, 20, 10, index=1)],
[source(20, 10), source(20, 10)], 10)
spec['production']['placements'][1]['x_cm'] = 25
pdf, detail = self.render(spec, [png, pdf_path])
self.assertEqual((detail['sources'], detail['vector_sources']), (2, 1))
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
def test_refuses_pdfs_it_cannot_place(self):
with self.assertRaisesRegex(Unsupported, '2 pages'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
with self.assertRaisesRegex(Unsupported, 'password'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
[self.pdf('locked.pdf', password='secret')])
with self.assertRaisesRegex(Unsupported, 'proportions'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
def test_drawn_pdf_matches_what_the_site_measured(self):
framed = Image.new('RGB', (100, 60), (255, 255, 255))
framed.paste(quadrants(80, 40), (10, 10))
cases = {
# (page /Rotate, placement rotation, mirrored) -> TL, TR, BL, BR as drawn
'plain': (self.pdf('plain.pdf'), 0, False, (RED, GREEN, BLUE, YELLOW)),
# CropBox trims the white frame, exactly as pdf.js shows the page.
'cropped': (self.pdf('crop.pdf', image=framed, crop=[10, 10, 90, 50]), 0, False,
(RED, GREEN, BLUE, YELLOW)),
# An inherited /Rotate 90 is displayed turned clockwise.
'rotated page': (self.pdf('rot.pdf', rotate=90), 0, False, (BLUE, RED, YELLOW, GREEN)),
'placement turn': (self.pdf('turn.pdf'), 90, False, (BLUE, RED, YELLOW, GREEN)),
'mirrored': (self.pdf('mirror.pdf'), 0, True, (GREEN, RED, YELLOW, BLUE)),
}
for label, (path, turn, mirrored, expected) in cases.items():
displayed_portrait = label == 'rotated page'
portrait = displayed_portrait != (turn == 90)
width, length = (10, 20) if portrait else (20, 10)
src = (10, 20) if displayed_portrait else (20, 10)
spec = item([placement(12, 5, width, length, turn, mirrored)],
[source(*src, rotation=0, mirrored=mirrored)], 30)
pdf, _ = self.render(spec, [path])
page = fitz.open(stream=pdf, filetype='pdf')[0]
dpi = 40
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
def sample(x_cm, y_cm):
return pixmap.pixel(int(x_cm / 2.54 * dpi), int(y_cm / 2.54 * dpi))
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
for actual, wanted in zip(got, expected):
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
f'{label}: {got} != {expected}')
self.assertEqual(sample(2, 2), (255, 255, 255), label)
if __name__ == '__main__':
unittest.main()

116
tests/test_tiny.py Normal file
View File

@@ -0,0 +1,116 @@
"""The Tiny v3 adapter against a fake HTTP transport: payload and idempotency.
This proves the documented contract only; the client's account must still
confirm products, contacts and order fields. Runs where httpx is installed.
The OAuth connection against the real database is tests/tiny_oauth_test.py.
"""
import json
import os
import unittest
from datetime import date
from unittest import mock
import httpx
from app.tiny import TinyError, TinyOrders, contact_payload, order_payload
PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved',
'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'},
'items': [{'mode': 'avulsa', 'grade': 90, 'billed_metres': '2.8', 'unit_cents': 2490}],
'freight': {'service': 'mock-standard', 'total_cents': 1500},
'destination': {'recipient': 'Loja Teste', 'street': 'Rua A', 'number': '10',
'complement': '', 'district': 'Centro', 'city': 'Franca',
'state': 'SP', 'postal_code': '14400000'},
'total_cents': 8472}}
PRODUCTS = {'TINY_PRODUCT_TEXTIL_FOLHA': '101', 'TINY_PRODUCT_TEXTIL_AVULSA': '102',
'TINY_PRODUCT_UV_FOLHA': '103', 'TINY_PRODUCT_UV_AVULSA': '104'}
class FakeAuth:
def access_token(self):
return 'access-1'
@mock.patch.dict(os.environ, PRODUCTS)
class TinyTests(unittest.TestCase):
def setUp(self):
self.contacts = []
self.orders = []
self.calls = []
def handler(request):
path = request.url.path.removeprefix('/public-api/v3')
self.calls.append((request.method, path))
assert request.headers['authorization'] == 'Bearer access-1'
if request.method == 'GET' and path == '/contatos':
cnpj = request.url.params['cpfCnpj']
return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
if request.method == 'POST' and path == '/contatos':
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
self.contacts.append(contact)
return httpx.Response(200, json={'id': contact['id']})
if request.method == 'GET' and path == '/pedidos':
return httpx.Response(200, json={'itens': [{'id': o['id']} for o in self.orders]})
if request.method == 'GET' and path.startswith('/pedidos/'):
wanted = int(path.rsplit('/', 1)[-1])
return httpx.Response(200, json=next(o for o in self.orders if o['id'] == wanted))
if request.method == 'POST' and path == '/pedidos':
order = {**json.loads(request.content), 'id': 9000 + len(self.orders),
'numeroPedido': str(100 + len(self.orders))}
self.orders.append(order)
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
return httpx.Response(404)
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
today=date(2026, 9, 24))
def test_payload_carries_contact_products_address_and_freight(self):
pedido = order_payload(PAID, 777, date(2026, 9, 24))
self.assertEqual((pedido['idContato'], pedido['numeroOrdemCompra'], pedido['data']),
(777, 'DTF-42', '2026-09-24'))
item = pedido['itens'][0]
self.assertEqual((item['produto'], item['quantidade'], item['valorUnitario']),
({'id': 102}, 2.8, 24.9))
self.assertEqual(pedido['valorFrete'], 15.0)
self.assertEqual((pedido['enderecoEntrega']['cep'], pedido['enderecoEntrega']['enderecoNro'],
pedido['enderecoEntrega']['nomeDestinatario']), ('14400000', '10', 'Loja Teste'))
contact = contact_payload(PAID['order'])
self.assertEqual((contact['tipoPessoa'], contact['cpfCnpj'], contact['endereco']['uf']),
('J', '11222333000181', 'SP'))
pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None,
'freight': {'service': 'pickup', 'total_cents': 0}}}, 1)
self.assertNotIn('enderecoEntrega', pickup)
self.assertIn('retirada', pickup['observacoes'])
def test_second_delivery_finds_the_first_order(self):
first = self.tiny.deliver('k1', PAID)
second = self.tiny.deliver('k1', PAID)
self.assertEqual((first['status'], second['status']), ('created', 'already-created'))
self.assertEqual(first['tiny_id'], second['tiny_id'])
self.assertEqual(self.calls.count(('POST', '/pedidos')), 1)
self.assertEqual(self.calls.count(('POST', '/contatos')), 1)
def test_existing_contact_is_reused(self):
self.contacts.append({'id': 321, 'cpfCnpj': '11222333000181'})
self.tiny.deliver('k1', PAID)
self.assertNotIn(('POST', '/contatos'), self.calls)
self.assertEqual(self.orders[0]['idContato'], 321)
def test_production_events_are_not_sent(self):
self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable')
self.assertEqual(self.calls, [])
def test_missing_product_mapping_fails_rather_than_guessing(self):
with mock.patch.dict(os.environ, {'TINY_PRODUCT_TEXTIL_AVULSA': ''}):
with self.assertRaises(TinyError):
self.tiny.deliver('k3', PAID)
self.assertNotIn(('POST', '/pedidos'), self.calls)
def test_rate_limit_is_a_retryable_failure(self):
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
with self.assertRaisesRegex(TinyError, 'rate limit'):
tiny.deliver('k4', PAID)
if __name__ == '__main__':
unittest.main()

105
tests/tiny_oauth_test.py Normal file
View File

@@ -0,0 +1,105 @@
"""The Tiny OAuth connection against the real database, with a fake token server.
Run inside the API container: python -m tests.tiny_oauth_test
It saves any existing Tiny connection first and restores it afterwards.
"""
import os
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlparse
import httpx
from app.core.db import connect
from app.tiny import TinyAuth, TinyError, TinyNotConnected
os.environ.update(TINY_CLIENT_ID='test-client', TINY_CLIENT_SECRET='test-secret',
TINY_REDIRECT_URI='http://localhost:8081/api/operator/tiny/callback')
def run():
with connect() as c:
saved = c.execute("SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
try:
exercise()
finally:
with connect() as c:
c.execute("DELETE FROM dtf_local.provider_tokens WHERE provider='tiny'")
if saved:
columns = ','.join(saved)
c.execute(f'INSERT INTO dtf_local.provider_tokens({columns}) VALUES({",".join(["%s"] * len(saved))})',
tuple(saved.values()))
def exercise():
issued = []
def token_server(request):
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
assert (form['client_id'], form['client_secret']) == ('test-client', 'test-secret')
if form['grant_type'] == 'authorization_code':
assert form['code'] == 'good-code' and form['redirect_uri'].endswith('/tiny/callback')
elif form['grant_type'] == 'refresh_token':
if form['refresh_token'] != issued[-1]:
return httpx.Response(400, json={'error': 'invalid_grant'})
n = len(issued) + 1
issued.append(f'refresh-{n}')
return httpx.Response(200, json={'access_token': f'access-{n}', 'expires_in': 14400,
'refresh_token': f'refresh-{n}', 'refresh_expires_in': 86400})
auth = TinyAuth(transport=httpx.MockTransport(token_server))
assert auth.status() == {'connected': False}
try:
auth.access_token()
raise AssertionError('an unconnected Tiny must not yield a token')
except TinyNotConnected:
pass
url = urlparse(auth.authorize_url('operator@example.test'))
query = {k: v[0] for k, v in parse_qs(url.query).items()}
assert url.netloc == 'accounts.tiny.com.br' and query['client_id'] == 'test-client'
assert query['redirect_uri'].endswith('/api/operator/tiny/callback') and len(query['state']) > 30
try:
auth.complete('good-code', 'forged-state')
raise AssertionError('a state no operator created must be refused')
except TinyError:
pass
assert auth.complete('good-code', query['state']) == 'operator@example.test'
try:
auth.complete('good-code', query['state'])
raise AssertionError('a state must be single-use')
except TinyError:
pass
status = auth.status()
assert status['connected'] and status['connected_by'] == 'operator@example.test'
assert auth.access_token() == 'access-1'
print('PASS: operator-started state is required, single-use, and stores the connection')
# An access token about to expire is refreshed, and the refresh token rotates.
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET access_expires_at=now() WHERE provider='tiny'")
assert auth.access_token() == 'access-2'
with connect() as c:
row = c.execute("SELECT refresh_token,connected_by FROM dtf_local.provider_tokens WHERE provider='tiny'").fetchone()
assert row == {'refresh_token': 'refresh-2', 'connected_by': 'operator@example.test'}
assert auth.access_token() == 'access-2'
print('PASS: expiring access token refreshed once, refresh token rotated and stored')
# A refused refresh (revoked in Tiny) asks for a new connection, not a retry loop.
with connect() as c:
c.execute("""UPDATE dtf_local.provider_tokens SET access_expires_at=now(), refresh_token='revoked'
WHERE provider='tiny'""")
try:
auth.access_token()
raise AssertionError('a refused refresh must report the connection as lost')
except TinyNotConnected:
pass
with connect() as c:
c.execute("UPDATE dtf_local.provider_tokens SET refresh_expires_at=%s WHERE provider='tiny'",
(datetime.now(timezone.utc) - timedelta(seconds=1),))
assert not auth.status()['connected']
print('PASS: revoked or expired connections report that Tiny must be connected again')
if __name__ == '__main__':
run()

View File

@@ -35,6 +35,9 @@
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
$('cepIn').value=entrega.cep;
entrega.end=entrega.end||{};
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||'';
$('atual').style.display='none';pintaEntrega();
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
}else{

View File

@@ -73,6 +73,7 @@
if (busy) return;
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
if (!clienteOk() || !entrega.cotado) return;
if (!enderecoOk()) return message('Preencha o endereço de entrega.');
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.');
@@ -95,6 +96,8 @@
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged});
}
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
const destination = destinoApi();
if (destination) content.destination = destination;
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');
const serialized = JSON.stringify(content);
if (!requestKey || serialized !== requestBody) {
@@ -127,6 +130,23 @@
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
} else if (quote.status==='approved') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
if ((await ready).payment_provider === 'mercadopago') {
button('Pagar com PIX',async event=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true;
try { showPix(await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}})); }
catch(error) { message(error.message); event.target.disabled=false; }
});
if ((await ready).payment_public_key) {
button('Pagar com cartão',async event=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true;
try { await showCard(quote.approved.total_cents); }
catch(error) { message(error.message); event.target.disabled=false; }
});
}
return;
}
if ((await ready).environment !== 'local') {
message('Cotação revisada. O pagamento online ainda não está disponível.');
return;
@@ -156,6 +176,103 @@
button('Limpar referência e tentar de novo',clearDraft);
}
}
// PIX: the provider's QR code and copy-and-paste code. The order is created
// by the provider's notification, not by this page, so the page only waits.
let pixTimer=null;
function showPix(intent) {
actions.replaceChildren();
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
message('Pague o PIX de '+rs(intent.total_cents/100)+'. O pedido entra na produção assim que o pagamento for confirmado.');
if (intent.pix_qr_code_base64) {
const img=document.createElement('img');
img.src='data:image/png;base64,'+intent.pix_qr_code_base64;
img.alt='QR code do PIX';img.width=220;img.height=220;img.style.display='block';
actions.append(img);
}
const code=document.createElement('input');
code.readOnly=true;code.value=intent.pix_qr_code;code.style.cssText='width:100%;margin-top:8px;padding:8px';
actions.append(code);
button('Copiar código PIX',async()=>{ try{ await navigator.clipboard.writeText(intent.pix_qr_code); message('Código copiado.'); }catch(_){ code.select(); } });
clearInterval(pixTimer);
pixTimer=setInterval(async()=>{
try {
const quote=await api('/quotes/'+draftId);
if (quote.status==='paid') {
clearInterval(pixTimer);
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
await refresh();
}
} catch(_) {}
},5000);
}
// Card: Mercado Pago's own form (Card Payment Brick). The card is typed into
// Mercado Pago's secure fields and becomes a one-time token; the number never
// reaches this page's code or our server. As with PIX, the order is created
// by the provider's notification, so the page only waits for it.
let sdkLoading=null;
function loadMercadoPago() {
if (window.MercadoPago) return Promise.resolve();
sdkLoading = sdkLoading || new Promise((resolve,reject)=>{
const script=document.createElement('script');
script.src='https://sdk.mercadopago.com/js/v2';
script.onload=resolve;
script.onerror=()=>{sdkLoading=null;reject(new Error('Não foi possível carregar o formulário do Mercado Pago.'));};
document.head.append(script);
});
return sdkLoading;
}
let cardBrick=null;
async function showCard(totalCents) {
await loadMercadoPago();
const session=await ready;
actions.replaceChildren();
const holder=document.createElement('div');
holder.id='cardPaymentBrick';
holder.style.cssText='max-width:520px;margin-top:8px';
actions.append(holder);
message('Pagamento com cartão: '+rs(totalCents/100)+'.');
if (cardBrick) { try { await cardBrick.unmount(); } catch(_) {} }
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
initialization:{amount:totalCents/100, payer:{email:cliente.mail}},
customization:{paymentMethods:{maxInstallments:12}},
callbacks:{
onReady:()=>{},
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
onSubmit:async data=>{
const result=await api('/payments/intent',{quote_id:draftId,method:{
type:'card', token:data.token, payment_method_id:data.payment_method_id,
installments:Number(data.installments)||1,
issuer_id:data.issuer_id==null?null:String(data.issuer_id)}});
if (result.status==='approved' || result.status==='pending') {
message(result.status==='approved'
? 'Pagamento aprovado. Seu pedido entra na produção em instantes.'
: 'Pagamento em análise pelo Mercado Pago. Avisamos assim que for confirmado.');
waitForOrder();
} else {
message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+
'Confira os dados ou use outro cartão.');
throw new Error('rejected');
}
}
}
});
}
function waitForOrder() {
clearInterval(pixTimer);
pixTimer=setInterval(async()=>{
try {
const quote=await api('/quotes/'+draftId);
if (quote.status==='paid') {
clearInterval(pixTimer);
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
await refresh();
}
} catch(_) {}
},5000);
}
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}

View File

@@ -644,6 +644,8 @@ h1 em{font-style:normal;color:var(--laranja)}
font-family:"Inter",sans-serif;font-weight:600;font-size:12.5px}
.cepL button:hover{border-color:var(--laranja);color:var(--laranja)}
.cep p{font-size:11.5px;color:var(--fraco);margin-top:9px;line-height:1.5}
.cep .endereco{margin-top:12px}
.cep .err{color:var(--vermelho)}
.avisoE:empty{display:none}
.avisoE{font-size:11.5px;color:#1F5C3A;background:#F1FBF5;border:1px solid #BFE6CE;
border-radius:8px;padding:10px 12px;line-height:1.5;margin-top:9px}
@@ -1229,6 +1231,23 @@ footer a:hover{color:var(--laranja2)}
<button id="bCep">Calcular</button>
</div>
<p id="cepMsg"></p>
<div class="campos endereco" id="endereco">
<div class="cp larga"><label>Quem recebe</label>
<input id="eNome" maxlength="120" autocomplete="name" placeholder="Nome ou empresa"></div>
<div class="cp larga"><label>Rua / avenida</label>
<input id="eRua" maxlength="160" autocomplete="address-line1"></div>
<div class="cp"><label>Número</label>
<input id="eNum" maxlength="20" placeholder="123 ou S/N"></div>
<div class="cp"><label>Complemento (opcional)</label>
<input id="eComp" maxlength="80" autocomplete="address-line2"></div>
<div class="cp"><label>Bairro</label>
<input id="eBairro" maxlength="80"></div>
<div class="cp"><label>Cidade</label>
<input id="eCidade" maxlength="80" autocomplete="address-level2"></div>
<div class="cp"><label>UF</label>
<input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div>
</div>
<p class="err" id="eEnd"></p>
</div>
<p class="avisoE" id="avisoE"></p>
</div>

View File

@@ -10,12 +10,12 @@ button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padd
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#tiny{font-size:12px;color:var(--fraco);margin-right:8px}#tiny button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
</style></head><body>
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<header><h1>Kanban DTF</h1><span id="tiny"></span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
<p id="status" role="status"></p>
<section id="reviews"></section><div id="kan"></div>
<section id="payments"></section><section id="reviews"></section><div id="kan"></div>
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
<script src="/upload.js"></script><script src="/kanban.js?v=operator-email-1"></script></body></html>
<script src="/upload.js"></script><script src="/kanban.js?v=pdf-print-1"></script></body></html>

View File

@@ -40,6 +40,53 @@ function productionLines(container,item){
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
}
const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto',
manual:'preparar à mão',failed:'falhou ao gerar'};
function download(uid){return async()=>{
const result=await api('/uploads/'+uid+'/download');
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
};}
function printFiles(card,order){
const rows=order.print_files||[];
const box=node('div',undefined,'print');
box.append(node('b','Arquivo de impressão'));
order.snapshot.items.forEach((item,index)=>{
const row=rows.find(r=>r.item_index===index);
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
if(row?.status==='ready'){
line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'')+
(row.detail.vector_sources?' · PDF vetorial':'');
line.append(action('Baixar PDF',download(row.upload_id)));
}
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));
box.append(line);
});
const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index);
return !row||row.status==='manual'||row.status==='failed';});
if(retry&&['rec','tra'].includes(order.state))
box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();}));
card.append(box);
}
function paymentIssues(){
$('payments').replaceChildren();
if(!board.payment_issues?.length)return;
$('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length));
$('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+
'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta'));
for(const issue of board.payment_issues){
const card=node('article',undefined,'review');
card.append(node('b',issue.provider+' · evento '+issue.event_id),
node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+
(issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'),
node('p',issue.outcome));
card.append(action('Registrar resolução',async()=>{
const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return;
await api('/payment-events/'+issue.id+'/resolve',{note});await load();
}));
$('payments').append(card);
}
}
async function load(){
try{
board=await api('/board');
@@ -47,7 +94,7 @@ async function load(){
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
$('login').hidden=true;
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString();
$('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice;
render();
}catch(e){$('status').textContent=e.message;$('login').hidden=false;}
}
@@ -62,7 +109,25 @@ async function loadMoreQuotes(kind){
moreQuotes[kind]=page.has_more;
render();
}
// The one-time authorisation of this system in the client's Tiny. Shown only
// when the Tiny application is configured on the server.
function tinyStatus(){
const box=$('tiny');box.replaceChildren();
const tiny=board.tiny||{};
if(!tiny.configured)return;
box.append(node('span',tiny.connected
? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado')
: 'Tiny não conectado'));
box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{
const result=await api('/tiny/connect',{});location.href=result.url;
}));
}
const tinyResult=new URLSearchParams(location.search).get('tiny');
const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):'';
if(tinyResult)history.replaceState(null,'',location.pathname);
function render(){
tinyStatus();
paymentIssues();
$('reviews').replaceChildren();
if(board.pending_total || board.approved_total)
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
@@ -107,11 +172,15 @@ function render(){
column.append(node('h2',title+' · '+count));
for(const order of orders){
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
card.append(node('b','#'+order.number+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
const to=order.snapshot.destination;
card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+
' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta'));
for(const item of order.snapshot.items){
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
productionLines(card,item);
}
printFiles(card,order);
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
const artwork=node('div');
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
@@ -140,22 +209,39 @@ async function artworkPanel(order,container){
for(const file of revisions){
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
row.append(node('p',file.note));
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
if(!file.expired)row.append(action('Baixar '+file.name,download(file.upload_id)));
container.append(row);
}
if(!['rec','tra','cor'].includes(order.state))return;
const form=node('form');
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
// A generated print file reproduces the approved layout; offer it first, and
// keep the upload for items that need hand preparation. Not after a
// correction: the file was made from artwork the customer has replaced.
const corrected=revisions.some(file=>file.kind==='correction');
const inputs=order.snapshot.items.map((item,index)=>{
const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
if(generated){
const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true;
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block';
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
}
label.append(input);form.append(label);return input;});
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
const refs=[];
for(const [index,input] of inputs.entries())for(const file of input.files){
for(const [index,input] of inputs.entries()){
const generated=input.generated?.();
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
for(const file of input.files){
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
refs.push({item_index:index,upload_id:uid});
}
}}
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
await load();
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};

View File

@@ -53,7 +53,7 @@ function pintaPedido(){
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
: 'Ir para o pagamento';
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk();
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk() || !enderecoOk();
if(pedido.length || itemAtual) $('carr').classList.add('on');
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
window.dispatchEvent(new Event('dtf-cart-changed'));

View File

@@ -75,7 +75,7 @@ let pedido=[]; // itens já fechados · o pagamento
// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
// grava forma_envio X, transportador DropStar e frete por conta do remetente — igual
// ao que a expedição já recebe hoje, sem criar processo novo.
let entrega={tipo:'retira', cep:'', valor:0, cotado:true};
let entrega={tipo:'retira', cep:'', valor:0, cotado:true, end:{}};
let cliente={cnpj:'', zap:'', mail:''};
let caminho='auto'; // o que o cliente diz que vai mandar
@@ -93,6 +93,19 @@ function cnpjOk(v){
const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; };
const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim());
const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail);
// Frete cotado pelo CEP não entrega nada sozinho: a transportadora e o Tiny
// precisam do endereço completo antes do pagamento.
const UFS='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'.split(' ');
const enderecoOk=()=>{
if(entrega.tipo!=='frete') return true;
const e=entrega.end||{}, t=v=>(v||'').trim();
return t(e.nome).length>=2 && t(e.rua).length>=2 && t(e.num).length>=1 &&
t(e.bairro).length>=2 && t(e.cidade).length>=2 && UFS.includes(t(e.uf).toUpperCase());
};
const destinoApi=()=>entrega.tipo!=='frete' ? null : {
recipient:entrega.end.nome.trim(), street:entrega.end.rua.trim(), number:entrega.end.num.trim(),
complement:(entrega.end.comp||'').trim(), district:entrega.end.bairro.trim(),
city:entrega.end.cidade.trim(), state:entrega.end.uf.trim().toUpperCase(), postal_code:entrega.cep};
const larguraFilme=()=>MODOS[modo].larg;
// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);

View File

@@ -87,7 +87,9 @@ function pintaEntrega(){
? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
'<b>pronto para retirar</b> — não venha antes do aviso.'
: '';
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !cartPodeEnviar();
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar();
$('eEnd').textContent = entrega.tipo==='frete' && entrega.cotado && !enderecoOk()
? 'Preencha o endereço de entrega para seguir.' : '';
pintaPedido();
}
@@ -144,6 +146,14 @@ $('cepIn').addEventListener('input',e=>{
e.target.value = v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v;
entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega();
});
for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){
$(id).addEventListener('input',e=>{
if(chave==='uf') e.target.value=e.target.value.replace(/[^a-z]/gi,'').toUpperCase();
entrega.end={...(entrega.end||{}),[chave]:e.target.value};
pintaEntrega();
});
}
$('bCep').addEventListener('click',async()=>{
if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; }
if(window.dtfFreight){ await window.dtfFreight(); return; }