Sales safety — Tier 1¶
Backend domain: app/graphql/invoices/events/invoice_event_handler.py, app/graphql/orders/, app/graphql/invoices/
Migration: add_sales_safety_tier_1
Problem / motivation¶
The sales pipeline has three correctness gaps that all live on the invoice-creation hot path and should ship together:
- Orders that reserved stock get double-counted. Tier-1 inventory shipped a
StockReservationprimitive (see inventory safety tier 1), but nothing in the sales pipeline consumes them. An order can reserve 10 units; the matching invoice then seesavailable = on_hand − reservedand the negative-stock guard refuses the deduction even though the order's own reservation is what's blocking it. - Same order line can be invoiced twice.
OrderDetailStatus.INVOICEDis set but never checked. Nothing preventsconvertOrderToInvoicefrom running twice on the same order, or partial-fulfillment from being over-fulfilled across multiple invoices. - Direct quote → invoice loses line traceability. When the conversion path skips orders (
invoice.quote_idset, noorder_id), invoice lines carryorder_detail_id = NULLand there's noquote_detail_id. We can find the source quote but not the source line.
In scope¶
- Invoice-creation flow consumes the order's reservations when the invoice carries
order_id, and tellsupdate_quantityto exclude that order's reservations from the availability check (so the order's own reservation no longer blocks its own invoice). - New
order_details.quantity_invoicedcolumn +OrderLineOverInvoicedErrorraised when an invoice line would push it pastorder_detail.quantity. Updated on insert / update / revert. - New
invoice_details.quote_detail_idnullable FK. Threaded throughInvoiceFactory.from_quoteso direct quote→invoice conversions carry it. - New
InvoiceEventDTO.order_idfield so the handler knows which order's reservations to consume without an extra DB hit.
Out of scope¶
- Wrapping conversions in an explicit savepoint (Tier 1 #3 — needs its own pass; conversions are scattered across several factories).
- Credit-limit enforcement (Tier 1 #5).
- Quote-side reservation creation (Tier 2 — quotes don't reserve yet; this tier only handles the consumption side once a Tier 2 feature wires the reservation side).
- Reverse traceability beyond the new FK (no aggregated "lines invoiced for quote X" report yet).
Data model changes¶
| Table | Change | Why |
|---|---|---|
order_details |
+ quantity_invoiced DOUBLE PRECISION NOT NULL DEFAULT 0 |
Running counter so the guard knows how much has already been invoiced across all invoices linked to this order. |
invoice_details |
+ quote_detail_id UUID NULL FK quote_details.id |
Closes the direct quote→invoice traceability gap. |
Alembic revision: add_sales_safety_tier_1, down_revision add_inventory_safety_tier_1.
GraphQL surface¶
Changed inputs¶
InvoiceDetailInput: +quoteDetailId: UUID(optional).InvoiceFactory.from_quotepopulates it automatically.
Changed responses¶
InvoiceDetail: +quoteDetailId: UUID, +orderDetail.quantityInvoiced: Decimal(existing field onOrderDetail, now populated).
New errors¶
OrderLineOverInvoicedError(message)— raised fromInvoiceEventHandler.post_insert/post_updatewhen a line would pushorder_detail.quantity_invoiced > order_detail.quantity.
No new queries or mutations — this is enforcement and traceability on existing flows.
Behaviour rules¶
- Reservation consumption. Inside
InvoiceEventHandler.post_insert, after the per-line deduction loop, if the invoice hasorder_idwe callStockReservationRepository.consume_for_source("order", order_id). The same call runs onpost_update(idempotent —consume_for_sourceonly flips ACTIVE rows). - Reservation exclusion on availability check. Each per-line
update_quantitycall passesreservation_source=("order", order_id)when the invoice is order-backed. The availability check inupdate_quantityalready supports this — it excludes that source's active reservations so the order's own reservation no longer blocks its own deduction. - Partial-fulfillment guard. For each invoice line with an
order_detail_id, we incrementorder_detail.quantity_invoicedby the line quantity first. If the new value would exceedorder_detail.quantity, raiseOrderLineOverInvoicedErrorand the whole transaction rolls back. Onpost_updatewe reverse the old counters before re-applying. Onrevert_invoice_effects(delete / void) we decrement the counters. - Quote-detail traceability.
InvoiceFactory.from_quotewritesquote_detail_id = detail.idon every line. Direct invoice creation (not from a quote) leaves it NULL, same as today.
What's being implemented¶
New files:
alembic/versions/20260524_add_sales_safety_tier_1.py
Modified files:
app/core/exceptions.py—OrderLineOverInvoicedError.app/graphql/orders/models/order_detail.py—quantity_invoicedcolumn.app/graphql/invoices/models/invoice_detail.py—quote_detail_idcolumn.app/graphql/invoices/strawberry/invoice_detail_input.py/invoice_detail_response.py— surfacequoteDetailId.app/graphql/invoices/factories/invoice_factory.py— populatequote_detail_idfromquote.quote_detailsand exposeorder_id+quote_detail_idon the event DTO.app/events/common/models/detail_event_dto.py—quote_detail_idonInvoiceDetailEventDTO.app/graphql/invoices/events/models/invoice_event_dto.py—order_idonInvoiceEventDTO.app/graphql/invoices/events/invoice_event_handler.py— reservation consumption, exclusion, and partial-fulfillment guard wiring.
Tests¶
tests/graphql/invoices/test_invoice_event_handler_sales_safety.py— reservation consumption excludes own source; over-invoicing raises and rolls back; revert decrements counters.
Frontend contract¶
- Order line UI — surface
orderDetail.quantityInvoicednext to ordered qty so reps can see remaining-to-invoice at a glance. Disable the "convert to invoice" CTA whenquantityInvoiced == quantity. - Invoice creation error handling — handle
OrderLineOverInvoicedErroras a per-line validation; the error message includes the order line and the remaining-to-invoice amount. - Quote → invoice path — the new
quoteDetailIdfield is populated automatically; no UI change needed unless a downstream feature wants to surface "from quote line X" on the invoice line.
Future additions¶
- Wrap multi-step conversions in explicit savepoints (Tier 1 #3 — deferred).
- Credit-limit enforcement (Tier 1 #5 — deferred).
- Auto-reserve stock on quote acceptance / order creation (Tier 2 — pairs naturally with this work).
quoteLinesInvoicedReportquery: aggregate invoiced qty per quote line for reps tracking pipeline.