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>
This commit is contained in:
@@ -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))
|
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():
|
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
|
||||||
raise HTTPException(409, 'Quote is already paid')
|
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
|
# Never a second charge: an approved payment is waiting for its
|
||||||
# notification to become the order, and a card in review may still be.
|
# 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
|
# A card waiting for the bank's confirmation (3-D Secure) blocks only
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from uuid import UUID, uuid4
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from .. import payments
|
||||||
from ..core import db
|
from ..core import db
|
||||||
from ..core.auth import owner
|
from ..core.auth import owner
|
||||||
from ..core.models import QuoteRequest
|
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:
|
if row['id'] == uid and reason is None:
|
||||||
quote_review.approve(c, row, body.items, quote_review.AUTO)
|
quote_review.approve(c, row, body.items, quote_review.AUTO)
|
||||||
return {'id': row['id'], 'status': 'approved'}
|
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'}
|
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
|
||||||
|
|
||||||
@router.get('/api/quotes/{uid}')
|
@router.get('/api/quotes/{uid}')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..core import db
|
|||||||
from ..core.auth import audit, owner, rate_limit
|
from ..core.auth import audit, owner, rate_limit
|
||||||
from ..core.limits import upload_limit_bytes
|
from ..core.limits import upload_limit_bytes
|
||||||
from ..core.models import UploadStart
|
from ..core.models import UploadStart
|
||||||
|
from ..payments import UNPAID_HOLD
|
||||||
from ..runtime import PART_BYTES, storage, upload_row
|
from ..runtime import PART_BYTES, storage, upload_row
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -87,7 +88,10 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
|
|||||||
existing_size = storage.size(row['object_key'])
|
existing_size = storage.size(row['object_key'])
|
||||||
if existing_size != row['size']:
|
if existing_size != row['size']:
|
||||||
raise HTTPException(409, 'Stored size differs from declared 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}
|
return {'id': uid, 'complete': True}
|
||||||
|
|
||||||
@router.delete('/api/uploads/{uid}')
|
@router.delete('/api/uploads/{uid}')
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ def submit_files(c, order, body, identity, kind, actor):
|
|||||||
for ref in body.files:
|
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)',
|
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))
|
(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'],))
|
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
|
||||||
if kind == 'final':
|
if kind == 'final':
|
||||||
# Artwork approval, not commercial quote approval, starts original cleanup.
|
# Artwork approval, not commercial quote approval, starts original cleanup.
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ def is_test(order):
|
|||||||
return (order.get('payment') or {}).get('provider') == TEST_PROVIDER
|
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):
|
def create_order(c, quote, payment):
|
||||||
"""Create the order for a reviewed quote, or return the one already there.
|
"""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(
|
order = c.execute(
|
||||||
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
'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()
|
(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']))
|
queue_print_files(c, order['id'], len(approved['items']))
|
||||||
if is_test(order):
|
if is_test(order):
|
||||||
return order, True
|
return order, True
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from decimal import Decimal
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from psycopg.types.json import Jsonb
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from . import payments
|
||||||
from .core.pricing import price
|
from .core.pricing import price
|
||||||
from .runtime import freight, upload_row
|
from .runtime import freight, upload_row
|
||||||
from .scanning import require_clean
|
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']}
|
'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',
|
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s',
|
||||||
(Jsonb(approved), reviewer, row['id']))
|
(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
|
return approved
|
||||||
|
|||||||
@@ -155,6 +155,30 @@ def run():
|
|||||||
assert queued == 0 and jobs == 1, (queued, jobs)
|
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')
|
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__':
|
if __name__ == '__main__':
|
||||||
run()
|
run()
|
||||||
|
|||||||
Reference in New Issue
Block a user