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>
159 lines
7.0 KiB
Python
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)')
|