From 933bd30cbd135550553b2f660fcb57cd1d091cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 30 Sep 2026 12:09:02 -0300 Subject: [PATCH] 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 --- app/api/payments.py | 10 ++++++++++ app/api/quotes.py | 4 ++++ app/api/uploads.py | 6 +++++- app/artwork.py | 3 ++- app/payments.py | 23 +++++++++++++++++++++++ app/quote_review.py | 3 +++ tests/payment_test.py | 24 ++++++++++++++++++++++++ 7 files changed, 71 insertions(+), 2 deletions(-) diff --git a/app/api/payments.py b/app/api/payments.py index 3a97eac..8787ace 100644 --- a/app/api/payments.py +++ b/app/api/payments.py @@ -85,6 +85,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') + # Never charge for files that are gone: an unpaid cart's files are + # removed after a while. A payment under way keeps them a day longer, + # time enough for the provider's notice to become the order. + uploads = payments.quote_uploads(quote['approved']) + live = c.execute('''SELECT count(*) AS n FROM dtf_local.uploads WHERE id=ANY(%s) + AND purged_at IS NULL AND expires_at>now()''', (uploads,)).fetchone()['n'] + if live != len(set(uploads)): + raise HTTPException(410, 'Os arquivos deste pedido expiraram porque ele não foi pago a tempo. ' + 'Monte o pedido de novo para pagar.') + payments.hold_uploads(c, uploads, '1 day') # Never a second charge: an approved payment is waiting for its # notification to become the order, and a card in review may still be. # A card waiting for the bank's confirmation (3-D Secure) blocks only diff --git a/app/api/quotes.py b/app/api/quotes.py index 38d21c2..79c8c4d 100644 --- a/app/api/quotes.py +++ b/app/api/quotes.py @@ -7,6 +7,7 @@ from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException from psycopg.types.json import Jsonb +from .. import payments from ..core import db from ..core.auth import owner from ..core.models import QuoteRequest @@ -50,6 +51,9 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)): if row['id'] == uid and reason is None: quote_review.approve(c, row, body.items, quote_review.AUTO) return {'id': row['id'], 'status': 'approved'} + if row['id'] == uid: + # An operator reviews it first; the files wait for that review. + payments.hold_uploads(c, payments.quote_uploads(draft), payments.REVIEW_HOLD) return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'} @router.get('/api/quotes/{uid}') diff --git a/app/api/uploads.py b/app/api/uploads.py index 232d3df..36e2c13 100644 --- a/app/api/uploads.py +++ b/app/api/uploads.py @@ -14,6 +14,7 @@ from ..core import db from ..core.auth import audit, owner, rate_limit from ..core.limits import upload_limit_bytes from ..core.models import UploadStart +from ..payments import UNPAID_HOLD from ..runtime import PART_BYTES, storage, upload_row router = APIRouter() @@ -87,7 +88,10 @@ def complete_upload(uid: UUID, session_id=Depends(owner)): existing_size = storage.size(row['object_key']) if existing_size != row['size']: raise HTTPException(409, 'Stored size differs from declared size') - c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,)) + # Held while the cart is unpaid: an abandoned cart's files go after + # UNPAID_HOLD; a paid order keeps them for its 30 days (app/payments.py). + c.execute('UPDATE dtf_local.uploads SET complete=true,expires_at=now()+%s::interval WHERE id=%s', + (UNPAID_HOLD, uid)) return {'id': uid, 'complete': True} @router.delete('/api/uploads/{uid}') diff --git a/app/artwork.py b/app/artwork.py index bf2c3ac..e73c626 100644 --- a/app/artwork.py +++ b/app/artwork.py @@ -67,7 +67,8 @@ def submit_files(c, order, body, identity, kind, actor): for ref in body.files: c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)', (uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor)) - c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id)) + # The order's window, whatever the upload's own hold was. + c.execute('UPDATE dtf_local.uploads SET expires_at=%s WHERE id=%s', (expiry,ref.upload_id)) c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],)) if kind == 'final': # Artwork approval, not commercial quote approval, starts original cleanup. diff --git a/app/payments.py b/app/payments.py index a46d246..43418d6 100644 --- a/app/payments.py +++ b/app/payments.py @@ -49,6 +49,26 @@ def is_test(order): 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. @@ -68,6 +88,9 @@ def create_order(c, quote, payment): 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 diff --git a/app/quote_review.py b/app/quote_review.py index e18d2c4..0d445a2 100644 --- a/app/quote_review.py +++ b/app/quote_review.py @@ -18,6 +18,7 @@ from decimal import Decimal from fastapi import HTTPException from psycopg.types.json import Jsonb +from . import payments from .core.pricing import price from .runtime import freight, upload_row from .scanning import require_clean @@ -83,4 +84,6 @@ def approve(c, row, items, reviewer): 'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']} c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved), reviewer, row['id'])) + # Approved and payable now: the files wait for the payment, not for ever. + payments.hold_uploads(c, payments.quote_uploads(approved), payments.UNPAID_HOLD) return approved diff --git a/tests/payment_test.py b/tests/payment_test.py index f323925..51dc1e7 100644 --- a/tests/payment_test.py +++ b/tests/payment_test.py @@ -155,6 +155,30 @@ def run(): assert queued == 0 and jobs == 1, (queued, jobs) print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp') + # Files are uploaded before payment. An unpaid cart keeps them briefly; a + # paid order keeps them for its 30 days; a payment never starts for files + # that are gone. + def files_of(qid): + with db.connect() as c: + q = c.execute('SELECT approved,draft FROM dtf_local.quotes WHERE id=%s', (qid,)).fetchone() + return [u for item in (q['approved'] or q['draft'])['items'] for u in item['uploads']] + + def days(ids, since='now()'): + with db.connect() as c: + return [float(r['d']) for r in c.execute( + f'SELECT extract(epoch FROM expires_at-{since})/86400 AS d FROM dtf_local.uploads WHERE id=ANY(%s)', + (ids,)).fetchall()] + fresh = upload_bytes(customer, b'UNPAID HOLD TEST') + assert all(1.99 < d <= 2.0 for d in days([fresh])), days([fresh]) + assert all(abs(d - 30) < 0.01 for d in days(files_of(quote_id), 'created_at')), days(files_of(quote_id), 'created_at') + late, late_quote, _ = reviewed_quote() + assert all(d > 1.99 for d in days(files_of(late_quote))), days(files_of(late_quote)) + with db.connect() as c: + c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=ANY(%s)", + (files_of(late_quote),)) + late.call('/payments/intent', {'quote_id': late_quote, 'method': {'type': 'pix'}}, expected=410) + print('PASS: unpaid files are held 2 days, paid ones 30 days, and expired files are never charged for') + if __name__ == '__main__': run()