From 4c01e932c3767499670d3047ac191aa9be0e392d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 24 Sep 2026 13:14:56 -0300 Subject: [PATCH] feat: place PDF artwork in print files, add card payment, count only failed logins 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 --- app/adapters.py | 3 +- app/api/customer.py | 5 +- app/api/health.py | 6 +- app/api/operator.py | 3 +- app/api/payments.py | 14 ++- app/core/auth.py | 17 ++- app/mercadopago.py | 8 +- app/printfile.py | 180 ++++++++++++++++++++++++----- compose.local.yaml | 4 + deploy/nginx.conf.template | 2 +- docker-compose.yml | 4 + docs/LOCAL_SETUP.md | 22 +++- docs/ROADMAP.md | 33 ++++-- infra/nginx.conf.template | 2 +- infra/requirements.lock | 231 ++++++++++++++++++++++++++++++++++++- infra/requirements.txt | 1 + tests/payment_test.py | 14 +++ tests/print_file_test.py | 20 +++- tests/test_mercadopago.py | 9 +- tests/test_printfile.py | 109 ++++++++++++++++- web/checkout.js | 75 ++++++++++++ web/kanban.html | 2 +- web/kanban.js | 3 +- 23 files changed, 703 insertions(+), 64 deletions(-) diff --git a/app/adapters.py b/app/adapters.py index 6b29aab..4cac37a 100644 --- a/app/adapters.py +++ b/app/adapters.py @@ -105,7 +105,8 @@ class FakePayment: return secret.encode() if secret else None def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict: - return {'provider': 'fake', 'id': f'local-{quote_id}', + 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: diff --git a/app/api/customer.py b/app/api/customer.py index 802eb87..2a4c4d5 100644 --- a/app/api/customer.py +++ b/app/api/customer.py @@ -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) diff --git a/app/api/health.py b/app/api/health.py index a6f3c32..30c0320 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -1,4 +1,6 @@ """Health, session bootstrap and freight quoting.""" +import os + from fastapi import APIRouter, HTTPException, Request, Response from ..core import db @@ -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(), 'payment_provider': payment.name} + '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): diff --git a/app/api/operator.py b/app/api/operator.py index d1d664a..433d6d4 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -12,7 +12,7 @@ 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) + 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 @@ -35,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) diff --git a/app/api/payments.py b/app/api/payments.py index d49b094..963255e 100644 --- a/app/api/payments.py +++ b/app/api/payments.py @@ -71,10 +71,16 @@ def intent(body: PaymentIntent, session_id=Depends(owner)): raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal)) if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone(): raise HTTPException(409, 'Quote is already paid') - existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method=%s - AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id, body.method.type)).fetchone() - if existing: - return existing['response'] + # 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()) diff --git a/app/core/auth.py b/app/core/auth.py index f21b09f..f049ca4 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -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', '') diff --git a/app/mercadopago.py b/app/mercadopago.py index 5a364dc..9edc10a 100644 --- a/app/mercadopago.py +++ b/app/mercadopago.py @@ -79,8 +79,12 @@ class MercadoPagoPayment: body['issuer_id'] = method['issuer_id'] else: raise ValueError('Unsupported payment method') - response = self.http.post('/v1/payments', json=body, - headers={'X-Idempotency-Key': f'dtf-quote-{quote_id}-{method["type"]}'}) + # 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 {} diff --git a/app/printfile.py b/app/printfile.py index a54dd80..5f3484c 100644 --- a/app/printfile.py +++ b/app/printfile.py @@ -12,13 +12,15 @@ in the file, a JPEG keeps its original bytes, and transparency survives as a soft mask. The output is therefore about the size of the artwork, not of a 57 cm x 20 m raster, and it never needs that raster in memory. -Only formats whose pixels can be read here are generated: JPEG, PNG, WebP and -TIFF. Anything else (PDF, PSD, AI, CDR), or a file whose proportions do not -match the size it was quoted at, is refused with a reason, and the operator -prepares that item by hand exactly as before. +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 @@ -288,8 +290,85 @@ def placement_matrix(placement, page_height_pt): return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy] -def check_layout(item, sizes): - """Refuse a layout the file cannot reproduce faithfully. Returns the lowest DPI.""" +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 @@ -307,6 +386,8 @@ def check_layout(item, sizes): 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 @@ -317,6 +398,10 @@ def render(item, files, out, title): `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: @@ -325,33 +410,23 @@ def render(item, files, out, title): try: for index in range(len(production['sources'])): path, name = files[index] - sources.append(SourceImage(path, name)) - lowest_dpi = check_layout(item, [source.size for source in sources]) + 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)) - pdf = PdfWriter(out) - images = [source.embed(pdf) for source in sources] - commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else [] - for placement in production['placements']: - matrix = placement_matrix(placement, height_pt) - commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) + - b' cm /Im%d Do Q\n' % placement['source_index']) - content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands)) - pages = pdf.alloc() - page_box = [0, 0, width_pt / unit, height_pt / unit] - page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box, - 'Resources': {'XObject': {f'Im{index}': ref for index, ref in enumerate(images)}}, - 'Contents': content} - if unit > 1: - page['UserUnit'] = unit - page_ref = pdf.obj(page) - pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages) - root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages}) - info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'}) - pdf.finish(root, info) + 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() @@ -360,5 +435,54 @@ def render(item, files, out, title): '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') diff --git a/compose.local.yaml b/compose.local.yaml index 22766b6..9decb7a 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -35,6 +35,7 @@ x-app: &app 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: ${TINY_ADAPTER:-fake} TINY_CLIENT_ID: ${TINY_CLIENT_ID:-} @@ -192,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 @@ -215,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: diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 70c0b55..9a0f5b9 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -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/ { diff --git a/docker-compose.yml b/docker-compose.yml index 0def095..bc842e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -150,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 @@ -172,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 diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md index de59dfd..07bfa45 100644 --- a/docs/LOCAL_SETUP.md +++ b/docs/LOCAL_SETUP.md @@ -231,7 +231,10 @@ 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. -Only JPEG, PNG, WebP and TIFF are generated. PDF, PSD, AI and CDR artwork, a +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. @@ -304,14 +307,25 @@ agree the test with the client and cancel the test orders afterwards. 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. The order is created only by the signed +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. Card payment needs the Mercado Pago public key -and its card form on the Site; that part is not built yet. +operator records the resolution. ## Local backup and restore check diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5989d54..fc5c342 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -230,8 +230,15 @@ From the report already sent. These are dated promises, not backlog. 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 (needs the - public key), sandbox run and refund policy remain. + 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. @@ -268,8 +275,13 @@ From the report already sent. These are dated promises, not backlog. 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`), and - PDF artwork, which this generator does not compose. + 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 @@ -692,13 +704,12 @@ print-file evidence still need correction before this item can close. 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. -- `[ ]` 5.16 — The operator login limit (10 per account per 15 minutes) counts - successful logins too, and every test client signs in separately. The CI - sequence sits close to that limit: one extra login made the final browser - test's sign-in fail with 429 until `print_file_test` was changed to reuse - one session. Either count only failures toward the account bucket or give - the suites a shared operator session, so adding a suite cannot break - another. +- `[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. diff --git a/infra/nginx.conf.template b/infra/nginx.conf.template index c68e7d5..25b609c 100644 --- a/infra/nginx.conf.template +++ b/infra/nginx.conf.template @@ -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; diff --git a/infra/requirements.lock b/infra/requirements.lock index b0573a4..a2d2b3e 100644 --- a/infra/requirements.lock +++ b/infra/requirements.lock @@ -68,6 +68,233 @@ 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 \ @@ -156,7 +383,9 @@ pillow==12.3.0 \ --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 - # via -r infra/requirements.txt + # via + # -r infra/requirements.txt + # pikepdf psycopg==3.2.9 \ --hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \ --hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700 diff --git a/infra/requirements.txt b/infra/requirements.txt index 9ce22e2..89869dd 100644 --- a/infra/requirements.txt +++ b/infra/requirements.txt @@ -5,3 +5,4 @@ psycopg[binary]==3.2.9 boto3==1.38.23 httpx==0.28.1 pillow==12.3.0 +pikepdf==10.13.0.post1 diff --git a/tests/payment_test.py b/tests/payment_test.py index 77db03e..c50e529 100644 --- a/tests/payment_test.py +++ b/tests/payment_test.py @@ -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}) diff --git a/tests/print_file_test.py b/tests/print_file_test.py index ca40a6f..9b6a82a 100644 --- a/tests/print_file_test.py +++ b/tests/print_file_test.py @@ -23,13 +23,16 @@ PT_PER_CM = 72 / 2.54 CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'} -def artwork_png(): +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() - image.save(out, 'PNG') + if kind == 'PDF': + image.convert('RGB').save(out, 'PDF', resolution=72) + else: + image.save(out, kind) return out.getvalue() @@ -75,7 +78,7 @@ def run(): client = Client() client.call('/session') - uid = upload_bytes(client, artwork_png(), name='LOCAL-PRINT-TEST.png') + 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)) @@ -103,6 +106,17 @@ def run(): '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') diff --git a/tests/test_mercadopago.py b/tests/test_mercadopago.py index d08810b..277fdda 100644 --- a/tests/test_mercadopago.py +++ b/tests/test_mercadopago.py @@ -106,9 +106,16 @@ class MercadoPagoTests(unittest.TestCase): 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}) - body = json.loads(self.requests[-1].content) + 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__': diff --git a/tests/test_printfile.py b/tests/test_printfile.py index 484ec8b..aed5b19 100644 --- a/tests/test_printfile.py +++ b/tests/test_printfile.py @@ -122,8 +122,13 @@ class PrintFileTests(unittest.TestCase): 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 an image'): + 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). @@ -186,5 +191,107 @@ class PrintFileTests(unittest.TestCase): 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() diff --git a/web/checkout.js b/web/checkout.js index e9eb07e..7bb5bbc 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -137,6 +137,14 @@ 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') { @@ -198,6 +206,73 @@ } 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');} diff --git a/web/kanban.html b/web/kanban.html index 4dc2830..49466b0 100644 --- a/web/kanban.html +++ b/web/kanban.html @@ -18,4 +18,4 @@ label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:gri

Eventos locais de integração
- + diff --git a/web/kanban.js b/web/kanban.js index b08a545..361e867 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -55,7 +55,8 @@ function printFiles(card,order){ 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.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));