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>
126 lines
5.6 KiB
Python
126 lines
5.6 KiB
Python
"""Turning a payment into an order, once.
|
|
|
|
A provider may deliver the same notification several times, out of order, or
|
|
long after the fact. None of that may produce a second order, a second charge,
|
|
or a second WhatsApp message. Every delivery is recorded under the provider's
|
|
own event id and applied inside one transaction, so a duplicate is a no-op and a
|
|
crash mid-way is retried rather than half-applied.
|
|
|
|
Order creation lives here rather than in a route because two paths reach it: the
|
|
webhook, and the local development checkout. They must agree.
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
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
|
|
|
|
QUOTE_VALID_HOURS = 24
|
|
|
|
|
|
class PaymentRefused(Exception):
|
|
"""The payment cannot become an order, with a reason worth recording."""
|
|
|
|
|
|
def approved_quote(c, quote_id, owner=None):
|
|
"""The reviewed quote behind a payment, or a refusal explaining why not."""
|
|
sql = 'SELECT * FROM dtf_local.quotes WHERE id=%s' + (' AND owner=%s' if owner else '')
|
|
row = c.execute(sql + ' FOR UPDATE', (quote_id, owner) if owner else (quote_id,)).fetchone()
|
|
if not row:
|
|
raise PaymentRefused('quote not found')
|
|
if not row['approved']:
|
|
raise PaymentRefused('quote was never reviewed')
|
|
if any(item.get('production', {}).get('version') != 2 for item in row['approved']['items']):
|
|
raise PaymentRefused('quote uses an obsolete production layout; request a new quote')
|
|
if row['approved_at'] < datetime.now(timezone.utc) - timedelta(hours=QUOTE_VALID_HOURS):
|
|
raise PaymentRefused('quote expired before payment')
|
|
return row
|
|
|
|
|
|
def create_order(c, quote, payment):
|
|
"""Create the order for a reviewed quote, or return the one already there.
|
|
|
|
Returns (order, created). The caller decides what to do about a duplicate;
|
|
the important part is that asking twice cannot produce two orders, because
|
|
orders.quote_id is unique and this runs inside the caller's transaction.
|
|
"""
|
|
existing = c.execute('SELECT * FROM dtf_local.orders WHERE quote_id=%s', (quote['id'],)).fetchone()
|
|
if existing:
|
|
return existing, False
|
|
|
|
approved = quote['approved']
|
|
for item in approved['items']:
|
|
for upload_id in item['uploads']:
|
|
require_clean(upload_row(c, UUID(upload_id), quote['owner']))
|
|
|
|
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'],
|
|
'event': 'payment_approved', 'order': approved})
|
|
return order, True
|
|
|
|
|
|
def record(c, provider, event):
|
|
"""Store a delivery. Returns None if this exact event was already seen."""
|
|
inserted = c.execute(
|
|
'''INSERT INTO dtf_local.payment_events(id,provider,event_id,reference,status,amount_cents,payload)
|
|
VALUES(%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(provider,event_id) DO NOTHING RETURNING *''',
|
|
(uuid4(), provider, event.event_id, event.reference, event.status,
|
|
event.amount_cents, Jsonb(event.raw))).fetchone()
|
|
return inserted
|
|
|
|
|
|
def apply(c, event):
|
|
"""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}'
|
|
|
|
try:
|
|
quote_id = UUID(event.reference)
|
|
except (ValueError, AttributeError):
|
|
return 'refused: reference is not a quote id'
|
|
|
|
try:
|
|
quote = approved_quote(c, quote_id)
|
|
except PaymentRefused as refusal:
|
|
return f'refused: {refusal}'
|
|
|
|
# The provider is the authority on what was paid, and the reviewed quote is
|
|
# the authority on what was owed. If they disagree, no order is created:
|
|
# underpayment would ship artwork that was not paid for, and overpayment
|
|
# means something is wrong that a person should look at.
|
|
expected = quote['approved']['total_cents']
|
|
if type(event.amount_cents) is not int or event.amount_cents != expected:
|
|
audit('payment_amount_mismatch', quote=str(quote_id),
|
|
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': event.raw.get('provider', 'webhook'), **event.raw})
|
|
return f"order {order['number']}" + ('' if created else ' (already existed)')
|