106 lines
4.6 KiB
Python
106 lines
4.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 .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()
|
|
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."""
|
|
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': 'webhook', **event.raw})
|
|
return f"order {order['number']}" + ('' if created else ' (already existed)')
|