Skip to content

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:

  1. Orders that reserved stock get double-counted. Tier-1 inventory shipped a StockReservation primitive (see inventory safety tier 1), but nothing in the sales pipeline consumes them. An order can reserve 10 units; the matching invoice then sees available = on_hand − reserved and the negative-stock guard refuses the deduction even though the order's own reservation is what's blocking it.
  2. Same order line can be invoiced twice. OrderDetailStatus.INVOICED is set but never checked. Nothing prevents convertOrderToInvoice from running twice on the same order, or partial-fulfillment from being over-fulfilled across multiple invoices.
  3. Direct quote → invoice loses line traceability. When the conversion path skips orders (invoice.quote_id set, no order_id), invoice lines carry order_detail_id = NULL and there's no quote_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 tells update_quantity to 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_invoiced column + OrderLineOverInvoicedError raised when an invoice line would push it past order_detail.quantity. Updated on insert / update / revert.
  • New invoice_details.quote_detail_id nullable FK. Threaded through InvoiceFactory.from_quote so direct quote→invoice conversions carry it.
  • New InvoiceEventDTO.order_id field 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_quote populates it automatically.

Changed responses

  • InvoiceDetail: + quoteDetailId: UUID, + orderDetail.quantityInvoiced: Decimal (existing field on OrderDetail, now populated).

New errors

  • OrderLineOverInvoicedError(message) — raised from InvoiceEventHandler.post_insert / post_update when a line would push order_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 has order_id we call StockReservationRepository.consume_for_source("order", order_id). The same call runs on post_update (idempotent — consume_for_source only flips ACTIVE rows).
  • Reservation exclusion on availability check. Each per-line update_quantity call passes reservation_source=("order", order_id) when the invoice is order-backed. The availability check in update_quantity already 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 increment order_detail.quantity_invoiced by the line quantity first. If the new value would exceed order_detail.quantity, raise OrderLineOverInvoicedError and the whole transaction rolls back. On post_update we reverse the old counters before re-applying. On revert_invoice_effects (delete / void) we decrement the counters.
  • Quote-detail traceability. InvoiceFactory.from_quote writes quote_detail_id = detail.id on 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.pyOrderLineOverInvoicedError.
  • app/graphql/orders/models/order_detail.pyquantity_invoiced column.
  • app/graphql/invoices/models/invoice_detail.pyquote_detail_id column.
  • app/graphql/invoices/strawberry/invoice_detail_input.py / invoice_detail_response.py — surface quoteDetailId.
  • app/graphql/invoices/factories/invoice_factory.py — populate quote_detail_id from quote.quote_details and expose order_id + quote_detail_id on the event DTO.
  • app/events/common/models/detail_event_dto.pyquote_detail_id on InvoiceDetailEventDTO.
  • app/graphql/invoices/events/models/invoice_event_dto.pyorder_id on InvoiceEventDTO.
  • 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.quantityInvoiced next to ordered qty so reps can see remaining-to-invoice at a glance. Disable the "convert to invoice" CTA when quantityInvoiced == quantity.
  • Invoice creation error handling — handle OrderLineOverInvoicedError as a per-line validation; the error message includes the order line and the remaining-to-invoice amount.
  • Quote → invoice path — the new quoteDetailId field 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).
  • quoteLinesInvoicedReport query: aggregate invoiced qty per quote line for reps tracking pipeline.