Design Rationale — Purchasing

Non-obvious design choices for the purchasing module — the WHY behind each decision.

Purchasing (locked 2026-06-10)

Vendor master lives in purchasing, not CRM

Decision: purchasing.vendor is the vendor master. Vendors are not a customer_type in CRM.

Why: Vendors and customers are fundamentally different relationships — you buy from vendors, sell to customers. The data model differs (payment terms, bank account refs, 1099 EIN, invoice matching); the lifecycle differs; the FK targets differ. Separate-different-things.

Guard: Vendor is not a CRM customer — do not merge them. A supplier who is also a retail customer has two separate records, one in each schema, joined by business logic at the application layer.


vendor_invoice has no 'paid' status — Billing writes that back

Decision: vendor_invoice.status tracks the document lifecycle (received / matched / approved / disputed) but has no 'paid' value. Whether the invoice is paid is expressed via Billing's write-back columns: billing_ap_ref, payment_status_ref, paid_at.

Why: Payment is Billing's control concern; Purchasing owns the document. Single owner per fact — if Purchasing tracked payment status independently, two systems would disagree whenever a payment is reversed or disputed.

Guard: Do not add a 'paid' status to vendor_invoice or a payment flag owned by Purchasing. Billing writes paid_at when the payable is settled; Purchasing reads it.


3-way match at line grain via vendor_invoice_match (N:M)

Decision: PO-receipt-invoice matching is an N:M junction table at line grain (vendor_invoice_match), not a header-level link.

Why: Real-world invoices partially match across multiple POs and receipts. A single vendor invoice may cover lines from two POs received on different days, with each line matched to a specific receipt line and a specific PO line. A header-level link cannot express partial or split matches.


Dual-UOM cost snapshot at receipt

Decision: Receipt and cost lines snapshot both the purchase unit of measure (e.g. case of 24) and the stocking unit of measure (e.g. each), with costs in both units.

Why: You buy in one unit and stock in another. Both costs must be captured at the moment of receipt — the purchase-UOM cost for AP matching, and the stocking-UOM cost for weighted-average cost updates on item_variant. The conversion rate at receipt time is fixed; back-calculating it later is error-prone.


Receipt creates inventory.stock_movement via idempotent seam

Decision: Receiving a purchase order creates inventory.stock_movement rows via InventoryService. purchase_receipt.idempotency_key / purchase_receipt_line.inventory_movement_id is the seam; the movement idempotency key prevents a re-run from double-receiving.

Why: Receipt is the point where purchased stock enters inventory. Using InventoryService (rather than writing to stock directly) keeps the stock decrement/increment path consistent — all stock changes go through one service, ensuring the maintained balance and the movement log stay in sync.

Guard: Purchasing code must never write to inventory.stock directly. The path is PurchasingServiceInventoryService.receive() with the idempotency key. purchase_receipt_line.inventory_movement_id is a deferred forward-ref UUID until the Inventory migration confirms the movement row ID; it closes when Purchasing and Inventory migrations run together.


Last modified: Jun 17, 2026, 8:37 PM PT
On this page
Esc