Files
dtf-system/app/payments.py
Cauê Faleiros 933bd30cbd feat: an unpaid cart's files are kept 2 days, a paid order's 30
Files are uploaded before payment so the price and the security check use
the file itself, but an abandoned cart kept them for 30 days. Now a finished
upload is held 2 days, a quote waiting for review 7, an approved quote 2 more
to be paid, and the paid order keeps its originals for 30 days from upload.
A payment never starts for files that are gone; one under way holds them a
day. Files attached to an order take the order's window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00

159 lines
7.0 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
TEST_PROVIDER = 'teste'
def is_test(order):
"""An operator's test order: it goes through production and notifies no one."""
return (order.get('payment') or {}).get('provider') == TEST_PROVIDER
# How long a file is kept while nothing has been paid for it. A cart's files
# are uploaded before payment, so the price and the security check are done on
# the file itself; a cart that is never paid must not keep them for 30 days.
UNPAID_HOLD = '2 days'
# A quote waiting for an operator's review keeps its files this long.
REVIEW_HOLD = '7 days'
# Once paid, the order keeps its originals for this long from the upload.
ORDER_RETENTION = '30 days'
def quote_uploads(quote_or_draft):
return [uid for item in quote_or_draft['items'] for uid in item['uploads']]
def hold_uploads(c, upload_ids, interval):
"""Keep these files at least `interval` from now; never shortens a hold."""
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, now()+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (interval, [str(u) for u in upload_ids]))
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()
# Paid: the files are kept for the order's retention, counted from upload.
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, created_at+%s::interval)
WHERE id=ANY(%s) AND purged_at IS NULL''', (ORDER_RETENTION, quote_uploads(approved)))
queue_print_files(c, order['id'], len(approved['items']))
if is_test(order):
return order, True
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)')