purchasing — module #16, the BUY path (completes the supply loop)

15 tables, 353 columns — schema-locked 2026-07-07; reopened 2026-07-10 for Header/Line Remediation fixes #10, #12, #4 (+1 table vendor_credit_line / +8 cols); reopened again 2026-07-10 (same day, batch 2's closing bare-FK fix) for vendor_invoice_match.vendor_invoice_line_id's composite-FK upgrade (constraint-only); reopened a 3rd time 2026-07-10 (same day, the Receiving module extraction) — purchase_receipt/purchase_receipt_line MOVED OUT entirely to a brand-new receiving schema (renamed goods_receipt/goods_receipt_line), taking 2 tables / 57 cols with them: 17 tables/410 cols → 15 tables/353 cols. See the dedicated subsection near the end of this file, schema_docs/receiving.md, and PROJECT_DECISIONS #55. purchasing is now the inbound supply chain's paperwork/commitment side: vendor master, purchase orders, 3-way match, vendor invoices, credits, and returns — the buy-side counterpart to orders/pos' sell side; receiving (the physical goods-receipt event + the inbound stock_movement) is its own module now, purchasing's downstream neighbor. It closes the stock loop: orders/pos move stock OUT (a stock_movement decrement); receiving brings it IN (a stock_movement receipt) on purchasing's behalf. Up from v1's 16 tables / 341 cols (+57 at lock), zero tables consolidated or dropped at original lock — second module built under the Design-Phase Integrity rules (retained v1→v2 delta record in PROJECT_DECISIONS #30); the 2026-07-10 Receiving extraction is the first time tables have moved OUT of this module. Depends on platform, identity (actor), multi_loc (site), shared (currency/country/administrative_region/unit_of_measure), inventory (item_variant/stock_movement/lot), receiving (goods_receipt_line — the 3-way-match/return seam, NEW 2026-07-10), crm (customer — the vendor↔customer link), and orders (order_header — special orders + the draft_po_id closure).

PROJECT_DECISIONS entries: #30, #47 (2026-07-10 Header/Line Remediation reopen), #53 (2026-07-10 Header/Line Remediation batch 2 closing bare-FK fix), #55 (2026-07-10 Receiving module extraction).

Groups: Vendor Master (vendor, vendor_contact, vendor_address, vendor_item) / Purchase Orders (purchase_order, purchase_order_line, purchase_order_template, purchase_order_template_line) / Invoice + 3-Way Match (vendor_invoice, vendor_invoice_line, vendor_invoice_match) / Credit + Return (vendor_credit, vendor_credit_line, vendor_return, vendor_return_line). Receiving group MOVED, 2026-07-10purchase_receipt/purchase_receipt_line are now receiving.goods_receipt/goods_receipt_line; see schema_docs/receiving.md and PROJECT_DECISIONS #55.

Global rules for this schema:

  • Uniform tenant-scoping — all 15 tables (16 at lock + vendor_credit_line added 2026-07-10, minus 2 for purchase_receipt/purchase_receipt_line moving to receiving the same day) carry tenant_id NOT NULL FK → platform.tenant, no mixed-scope case. All 15 have RLS enabled with a permissive <table>_tenant_isolation policy on current_setting('app.current_tenant_id')::uuid. Plain tenant_id index on all 15.
  • updated_at trigger-maintained via platform.set_updated_at() (shared, reused — no new one-off function) on 14 of the 15 tables — vendor_credit_line (added 2026-07-10) is the one exception: write-once, no updated_at at all, matching tax.tax_calculation_jurisdiction's own precedent.
  • Soft delete (deleted_at) on 14 of the 15 tables — vendor_credit_line again the exception, write-once with no deleted_at; every UNIQUE (vendor.code, po_number, invoice_number, credit_number, return_number, the vendor_item triple, plus the 3 remaining line_number partial uniques added 2026-07-10 on vendor_invoice_line/vendor_return_line/purchase_order_template_line — a 4th, purchase_receipt_line's own, moved to receiving.goods_receipt_line the same day, see below) is WHERE deleted_at IS NULL.
  • Agent-as-actor — every *_actor_id targets identity.actor (retargeted from v1's identity.identity_user).
  • Autonomy tiers, selective (6 FULL / 7 LIGHT / 2 ZERO). FULL = created_by_actor_id + automation_source + full review seam + decision_provenance, on the decision-bearing tables (vendor, vendor_item, purchase_order, purchase_order_line, purchase_order_template, vendor_invoicepurchase_receipt MOVED to receiving.goods_receipt 2026-07-10, still FULL there). LIGHT = created_by_actor_id + automation_source only, on the subordinate/mechanical tables (vendor_contact, vendor_address, vendor_invoice_line, vendor_invoice_match, vendor_credit, vendor_return, vendor_return_linepurchase_receipt_line MOVED to receiving.goods_receipt_line, still LIGHT there). ZERO = purchase_order_template_line (zero autonomy columns, a mechanical template row) and, as of 2026-07-10, vendor_credit_line (zero autonomy columns too, but for a different reason — a pure write-once decomposition line with no agent-decision surface of its own, matching tax.tax_calculation_jurisdiction's precedent). Note: purchasing's LIGHT carries automation_source uniformly — a deliberate, disclosed deviation from crm's lighter created_by-only tier (provenance is useful on every purchasing row given the module's reorder/OCR/cost-flag agent surface).
  • Global-firstvendor_address uses shared.administrative_region/shared.country natural-key FKs (not v1's us_state), all currency_code defaults dropped (service-resolved), purchase_uom_id → shared.unit_of_measure.code.
  • v1 baseline vs. this build — v1's schema_purchasing.md specified 16 tables / 341 cols. This build (2026-07-07 lock): 16 tables / 398 cols (+57), zero consolidation. The only removal is vendor.search_vector (Search deferred). See PROJECT_DECISIONS #30 for the full per-table delta. Reopened 2026-07-10 for Header/Line Remediation fixes #10/#12/#4: +1 table (vendor_credit_line) / +8 cols on top of Remediation Phase 4's 402 — 17 tables / 410 cols (PROJECT_DECISIONS #47). Reopened again 2026-07-10 (batch 2's closing bare-FK fix, constraint-only, PROJECT_DECISIONS #53) — still 17 tables / 410 cols. Reopened a 3rd time 2026-07-10 for the Receiving module extraction: purchase_receipt/purchase_receipt_line MOVED OUT to the new receiving schema (−2 tables / −57 cols) — 15 tables / 353 cols current total. See PROJECT_DECISIONS #55 and the dedicated subsection below.

Cross-Phase / Cross-Module Foreign Keys (purchasing)

Column Target Notes
*.tenant_id (all 15) platform.tenant NOT NULL
vendor.default_site_id, purchase_order.site_id, vendor_invoice.site_id, vendor_return.site_id, vendor_credit.site_id multi_loc.site purchase_order/invoice/return/credit site NOT NULL; vendor default nullable. (purchase_receipt.site_id MOVED with the table to receiving.goods_receipt.site_id, 2026-07-10 — see schema_docs/receiving.md.)
vendor.currency_code, and every line/header currency_code shared.currency.iso_code char(3), NOT NULL, no default
vendor_address.country_code shared.country.iso_alpha2 char(2), NOT NULL, ON DELETE RESTRICT
vendor_address.region_code shared.administrative_region.iso_3166_2 text, nullable, ON DELETE SET NULL; +region/country match CHECK
vendor_item.purchase_uom_id, purchase_order_line.purchase_uom_id shared.unit_of_measure.code text, NOT NULL
vendor.linked_customer_id crm.customer.id NEW, nullable — vendor↔customer overlap link
vendor.payment_terms_id, purchase_order.payment_terms_id shared.payment_terms_catalog.id NEW (Remediation Phase 4, Item 17b), nullable — additive-interim, old payment_terms CHECK-enum unchanged
purchase_order.entity_id, vendor_invoice.entity_id platform.legal_entity.id NEW (Remediation Phase 4, Item 15), nullable — header-only, no line-item equivalent
vendor_item.variant_id, purchase_order_line.variant_id, purchase_order_template_line.item_variant_id, vendor_invoice_line.variant_id, vendor_return_line.item_variant_id inventory.item_variant invoice_line's is nullable (freight/tax/misc lines). (purchase_receipt_line.variant_id MOVED with the table to receiving.goods_receipt_line.variant_id.)
vendor_return_line.inventory_movement_id inventory.stock_movement REAL FK (v1 deferred) — the return seam, bare single-column. (purchase_receipt_line.inventory_movement_id MOVED with the table, renamed stock_movement_id, and upgraded to a composite FK — see schema_docs/receiving.md.)
vendor_invoice_match.vendor_invoice_line_id purchasing.vendor_invoice_line (id, tenant_id) composite FK — upgraded from bare 2026-07-10, Header/Line Remediation batch 2, vendor_invoice_match_vendor_invoice_line_id_tenant_fkey
vendor_invoice_match.goods_receipt_line_id receiving.goods_receipt_line (id, tenant_id) composite FK, vendor_invoice_match_goods_receipt_line_tenant_fkey — RENAMED from purchase_receipt_line_id and upgraded from bare 2026-07-10 (Receiving module extraction), closing the gap the batch-2 fix above deliberately left open. See PROJECT_DECISIONS #55.
vendor_return_line.goods_receipt_line_id receiving.goods_receipt_line (id, tenant_id) composite FK, vendor_return_line_goods_receipt_line_tenant_fkey — RENAMED from purchase_receipt_line_id and upgraded from bare 2026-07-10 (Receiving module extraction); nullable. This was BLOCKER 2 of the extraction's own pre-build design-phase independent verification (the first design draft named only vendor_invoice_match's identical dependency). See PROJECT_DECISIONS #55.
vendor_return_line.lot_id inventory.lot nullable. (purchase_receipt_line.lot_id MOVED with the table.)
purchase_order.source_order_id orders.order_header REAL FK (v1 deferred) — special order
orders.order_header.draft_po_id purchasing.purchase_order CLOSED THIS BUILD — the reciprocal FK orders locked without, added via ALTER (constraint order_header_draft_po_id_fkey)
every *_by_actor_id identity.actor nullable throughout
vendor_invoice.billing_ap_ref/payment_status_ref/paid_at, purchase_order.amount_paid_cents — (Billing write-backs) NO FK — Billing writes these; PurchasingService writes no value
vendor.search_vector — (deferred, Search) NOT built — Search doesn't exist

Table reference (by group)

Full column lists live in the Drizzle source (packages/db/src/schema/purchasing/) and the migration; this section states each table's tier, key columns, and non-obvious constraints.

vendor (38, FULL) — vendor master. code (unique/tenant), vendor_type (supplier/service_provider/both), status (active/inactive/on_hold), payment_terms (net_7…net_90/cod/prepaid), is_1099, tax_id, credit_limit_cents, lead_time_days, blackout_config jsonb, linked_customer_id (NEW → crm.customer), payment_terms_id (NEW, Phase 4 Item 17bshared.payment_terms_catalog, nullable, additive-interim alongside the untouched payment_terms enum). search_vector dropped (Search deferred). CHECKs: vendor_type, status, payment_terms, + full-pack autonomy CHECKs. +UNIQUE(id, tenant_id) 2026-07-10 (vendor_id_tenant_id_unique) — Receiving extraction prerequisite for receiving.goods_receipt_vendor_tenant_fkey.

vendor_contact (16, LIGHT) — contacts per vendor; is_primary partial-unique per vendor while not deleted.

vendor_address (17, LIGHT)global-reshaped: region_code → shared.administrative_region.iso_3166_2, country_code NOT NULL → shared.country.iso_alpha2 (no 'US' default), chk_vendor_address_region_country_match (mirrors crm.address). address_type (billing/shipping/remittance/other). +UNIQUE(id, tenant_id) 2026-07-10 (vendor_address_id_tenant_id_unique) — Receiving extraction prerequisite for receiving.goods_receipt_ship_from_vendor_address_tenant_fkey (nullable).

vendor_item (29, FULL) — supplier-item catalog. cost_cents, last_cost_cents/last_cost_updated_at (the cost-change-alert surface a cost-trend agent flags), purchase_uom_id → shared.unit_of_measure, purchase_to_stock_factor, minimum_order_qty, is_preferred. UNIQUE (tenant_id,vendor_id,variant_id) WHERE not deleted.

purchase_order (46, FULL) — the reorder-agent draft target. status (draft/pending_approval/approved/sent/partial/received/cancelled), approval_status (the PO send-approval gate, distinct from the agent-anomaly review_* seam), source_order_id (REAL FK → orders.order_header), dual money rollups, amount_paid_cents (Billing write-back), tags text[], payment_terms_id (NEW, Phase 4 Item 17bshared.payment_terms_catalog, nullable), entity_id (NEW, Phase 4 Item 15platform.legal_entity, nullable). CHECKs incl. chk_purchase_order_approved_requires_actor_at (promoted from v1 service-only). +UNIQUE(id, tenant_id) 2026-07-10 (purchase_order_id_tenant_id_unique) — Receiving extraction prerequisite for receiving.goods_receipt_purchase_order_tenant_fkey.

purchase_order_line (30, FULL)dual-UOM cost snapshot frozen at PO time (purchase_unit_cost_cents / purchase_to_stock_factor / stock_unit_cost_cents), preserved verbatim — the 3-way-match inputs. ordered_qty > 0; received/invoiced/cancelled rollup caches ≥ 0. UNIQUE (purchase_order_id,line_number) WHERE not deleted. Quantity-rollup CHECK added 2026-07-10 (Header/Line Remediation fix #10): chk_purchase_order_line_quantity_rollupreceived_qty + invoiced_qty + cancelled_qty <= ordered_qty. Pre-migration audit confirmed zero violating rows live (table was empty); added directly, no NOT VALID fallback needed. +UNIQUE(id, tenant_id) 2026-07-10 (purchase_order_line_id_tenant_id_unique) — Receiving extraction prerequisite for receiving.goods_receipt_line_purchase_order_line_tenant_fkey, the line-grain receiving seam; also the load-bearing input to BLOCKER 1's capped write-back (see the dedicated Receiving Module Extraction subsection below).

purchase_order_template (15, FULL) / purchase_order_template_line (10, ZERO) — recurring PO template (agent-proposable) + its lines (byte-for-byte unchanged from v1, zero autonomy columns). +1 partial unique index 2026-07-10 (fix #12): purchase_order_template_line_template_line_number_unique on (template_id,line_number) WHERE not deleted.

purchase_receipt / purchase_receipt_line — MOVED to a new module, 2026-07-10. These 2 tables are no longer part of purchasing. They moved (with renames) to a brand-new receiving schema as part of the Receiving Module Extraction: purchase_receiptreceiving.goods_receipt (30→33 cols, +3 new: voided_at/voided_by_actor_id/void_reason, closing a pre-existing gap where 'void' was already a valid status value with zero attribution) and purchase_receipt_linereceiving.goods_receipt_line (27→29 cols, +2 new: stock_movement_line_id — the movement-line-grain linkage — and reversal_of_goods_receipt_line_id — a self-referencing composite FK powering the reversal design). The full table reference (column lists, CHECKs, the fix #7/#11 detail on movement-line linkage and over-receipt tolerance) now lives in schema_docs/receiving.md — see PROJECT_DECISIONS #55 for the complete extraction record, and the dedicated "Receiving Module Extraction" subsection near the end of this file for purchasing's own side of the change (the 4 new UNIQUE(id, tenant_id) prerequisites and the 2 renamed/upgraded FKs on vendor_invoice_match/vendor_return_line that now point into the new schema).

vendor_invoice (40, FULL) — the AI-OCR draft surface. status (pending/approved/disputed/void — deliberately NO 'paid': Billing owns payment). billing_ap_ref/payment_status_ref/paid_at are Billing write-backs (no FK). entity_id (NEW, Phase 4 Item 15platform.legal_entity, nullable). CHECKs: disputed/void/approved-requires-timestamp (promoted from v1 service-only). AP-aging index on (tenant_id,due_date) WHERE status ≠ 'void'.

vendor_invoice_line (20, LIGHT)line_type (item/freight/tax/misc); chk_vendor_invoice_line_item_requires_variant (promoted). billed_qty > 0, matched_qty rollup. 2026-07-10 additions (Header/Line Remediation): vendor_invoice_line_invoice_line_number_unique partial unique on (vendor_invoice_id,line_number) WHERE not deleted (fix #12, dedup audit confirmed zero duplicates live); vendor_invoice_line_id_tenant_id_unique UNIQUE(id,tenant_id) (fix #4 prerequisite — the composite-FK target for the new vendor_credit_line.vendor_invoice_line_id).

vendor_invoice_match (18, LIGHT) — N:M line-to-line 3-way-match junction, mutable. match_status (matched/quantity_variance/price_variance/over_billed/under_billed/disputed/resolved) IS its own variance/resolution machine; chk_..._resolved_requires_actor_at (promoted). Open-discrepancy partial index. 2026-07-10 (Header/Line Remediation batch 2): vendor_invoice_line_id upgraded from a bare FK to composite vendor_invoice_match_vendor_invoice_line_id_tenant_fkey (vendor_invoice_line_id, tenant_id) → vendor_invoice_line(id, tenant_id) — the prerequisite vendor_invoice_line_id_tenant_id_unique already existed (added 2026-07-10 by fix #4's vendor_credit_line build, PROJECT_DECISIONS #47 — confirmed by checking when that constraint was actually added, not assumed). purchase_receipt_line_id RENAMED goods_receipt_line_id, 2026-07-10 (Receiving module extraction, same day) — the gap the batch-2 fix above deliberately left bare is now CLOSED: composite FK vendor_invoice_match_goods_receipt_line_tenant_fkey (goods_receipt_line_id, tenant_id) → receiving.goods_receipt_line(id, tenant_id), since goods_receipt_line carries its own UNIQUE(id, tenant_id) from birth. Both FK columns on this table (vendor_invoice_line_id, goods_receipt_line_id) are now composite-enforced. See PROJECT_DECISIONS #53 (the original bare-FK disclosure) and #55 (the closure).

vendor_credit (24, LIGHT) — credit memo (resolved decision: LIGHT). credit_type (price_adjustment/return_credit/overpayment/other), status (open/partially_applied/fully_applied/void), remaining_amount_cents ≥ 0. Links to source invoice/return. 2026-07-10: gained vendor_credit_id_tenant_id_unique UNIQUE(id,tenant_id) (Header/Line Remediation fix #4 prerequisite — the composite-FK target for the new vendor_credit_line.vendor_credit_id); now also the parent of vendor_credit_line (see below) — a header-is-truth trigger on the child rejects any line write that would push SUM(lines.amount_cents) over this table's own credit_amount_cents.

vendor_return (23, LIGHT) — RMA lifecycle (resolved decision: LIGHT). status (draft/authorized/shipped/vendor_received/completed/cancelled), reason (damaged/defective/wrong_item/overstock/quality/other). CHECKs: rma_number-required-when-authorized, authorized-actor/at-required-when-not-draft (both promoted).

vendor_return_line (19, LIGHT) — each line writes an outbound inventory.stock_movement. inventory_movement_id (v1's name, KEPT) DEFERRED→REAL FK → inventory.stock_movement (movement_type='returned', bare single-column). lot_id → inventory.lot. qty_returned > 0. 2026-07-10 additions (Header/Line Remediation): vendor_return_line_return_line_number_unique partial unique on (vendor_return_id,line_number) WHERE not deleted (fix #12, dedup audit confirmed zero duplicates live); vendor_return_line_id_tenant_id_unique UNIQUE(id,tenant_id) (fix #4 prerequisite — the composite-FK target for the new vendor_credit_line.vendor_return_line_id). purchase_receipt_line_id RENAMED goods_receipt_line_id, 2026-07-10 (Receiving module extraction, same day) — nullable, upgraded from bare to composite FK vendor_return_line_goods_receipt_line_tenant_fkey (goods_receipt_line_id, tenant_id) → receiving.goods_receipt_line(id, tenant_id). This was BLOCKER 2 of the extraction's own pre-build design-phase independent verification — the first design draft named only vendor_invoice_match's identical dependency; this 2nd dependency on the moved table was caught before build started. See PROJECT_DECISIONS #55.

vendor_credit_line (8, ZERO) — NEW 2026-07-10, Header/Line Remediation fix #4. Per-line decomposition of a vendor_credit's total amount — write-once, no status/updated_at/deleted_at (Decision B case 1, matching tax.tax_calculation_jurisdiction's precedent). Columns: id (PK, platform.uuid_generate_v7()), tenant_id, vendor_credit_id, vendor_invoice_line_id (nullable), vendor_return_line_id (nullable), amount_cents, description (nullable), created_at. 3 composite FKs, all (col, tenant_id) → (id, tenant_id): vendor_credit_line_credit_tenant_fkeyvendor_credit, vendor_credit_line_invoice_line_tenant_fkeyvendor_invoice_line, vendor_credit_line_return_line_tenant_fkeyvendor_return_line — all 3 parent tables needed the new UNIQUE(id, tenant_id) prerequisite noted above (all 3 confirmed missing it). chk_vendor_credit_line_target requires exactly one of vendor_invoice_line_id/vendor_return_line_id set, or both NULL (the 'overpayment'/'other' credit_type case, which has no line to target) — never both; NULL-safe, full truth-table verified. Reconciliation trigger trg_vendor_credit_line_validate_against_credit (function purchasing.validate_vendor_credit_line_against_credit()), BEFORE INSERT OR UPDATE OF amount_cents, vendor_credit_id — rejects any write that would make SUM(lines.amount_cents) for a vendor_credit_id exceed that credit's own credit_amount_cents; lines may sum to LESS (an undecomposed remainder is fine), never more — header is truth, the same pattern this effort's design established for Billing's own future ar_charge_line. RLS enabled, permissive vendor_credit_line_tenant_isolation policy. Indexes: PK on id; plain indexes on tenant_id, vendor_credit_id; partial indexes on vendor_invoice_line_id/vendor_return_line_id WHERE NOT NULL.


The seams

The receiving seam — MOVED to the receiving module, 2026-07-10. Physical goods receipt (the inbound counterpart to a sale's outbound decrement) is no longer a purchasing concern — it lives entirely in the new receiving schema now (goods_receipt/goods_receipt_line; full detail, incl. the idempotency dedup and the weighted-average cost update, in schema_docs/receiving.md). What stays in purchasing: the PO itself (the commitment) and the 3-way match (PO line ↔ receiving.goods_receipt_linevendor_invoice_line) — see vendor_invoice_match above, whose goods_receipt_line_id is purchasing's one remaining pointer into the receiving seam. Purchasing still never writes inventory.stock directly (v1 guard, unaffected by the move — receiving owns that write now, on purchasing's behalf).

The orders seam, both directions. purchase_order.source_order_id → orders.order_header (special order); AND the reciprocal orders.order_header.draft_po_id → purchasing.purchase_order — the forward-ref orders locked without, CLOSED this build via ALTER TABLE orders.order_header ADD CONSTRAINT order_header_draft_po_id_fkey (v1's planned closure; one constraint on an existing column, not an orders reopen). Live-tested both ways + bogus-FK rejection.

The return seam. vendor_return_line.inventory_movement_id → inventory.stock_movement (movement_type='returned', outbound to vendor, bare single-column FK). Since 2026-07-10, vendor_return_line.goods_receipt_line_id → receiving.goods_receipt_line (composite FK, nullable) also links a return back to the specific receipt line it originated from, when there is one.

The reorder signal (read-only). A reorder agent reads inventory.stock.reorder_point/reorder_qty/available_qty (all live on inventory.stock) to detect low stock and DRAFT a PO. The signal lives on inventory.stock, NOT stock_adjustment_request (which is the stock-adjustment gate). purchase_order is the buy-side propose/execute counterpart to stock_adjustment_request's pattern, not an FK to it.

The Billing boundary. Purchasing owns the invoice DOCUMENT + 3-way match; Billing owns PAYMENT. vendor_invoice.status has no 'paid'; billing_ap_ref/payment_status_ref/paid_at/amount_paid_cents are Billing write-backs PurchasingService never writes.

The credit/return reconciliation seam (2026-07-10). vendor_credit_line decomposes a vendor_credit down to the invoice-line or return-line it offsets — vendor_credit_id/vendor_invoice_line_id/vendor_return_line_id, all composite (col, tenant_id) FKs. Header is truth: the credit's own credit_amount_cents never changes; the child-row reconciliation trigger only ever rejects an over-sum, never auto-adjusts the header. See PROJECT_DECISIONS #47.


purchasing — Design Patterns Summary

Column-count reconciliation

Table Cols Table Cols
vendor 38 vendor_invoice 40
vendor_contact 16 vendor_invoice_line 20
vendor_address 17 vendor_invoice_match 18
vendor_item 29 vendor_credit 24
purchase_order 46 vendor_credit_line 8
purchase_order_line 30 vendor_return 23
purchase_order_template 15 vendor_return_line 19
purchase_order_template_line 10
Total 353

Verified live via information_schema.columns, schema purchasing: 15 tables, 353 columns (down from 410 after the 2026-07-10 Receiving module extraction moved purchase_receipt/purchase_receipt_line out — −2 tables / −57 cols. Full progression: 398 after original lock → 402 after Remediation Phase 4 → 410 after the same-day Header/Line Remediation reopen added vendor_credit_line → 410 unchanged after batch 2's constraint-only closing bare-FK fix → 353 now after the Receiving extraction. See PROJECT_DECISIONS #55.) RLS enabled on all 15; set_updated_at on 14 of 15 (vendor_credit_line is write-once, no updated_at).

Remediation Phase 1 (2026-07-08)

Cross-cutting remediation pass closing gaps a senior-architect review found; no column/table count change (398 cols unchanged). See PROJECT_DECISIONS #37 for the full cross-module record.

  • purchase_order gained chk_purchase_order_sent_requires_approval (a PO can't reach status IN ('sent','partial','received') without approval_status='approved' recorded — the C8 financial-autonomy boundary) and chk_purchase_order_no_self_approval (approved_by_actor_id must differ from created_by_actor_id when both are set).
  • Corrective fix (same day): the first version of chk_purchase_order_sent_requires_approval had a live NULL-bypass bug — a bare approval_status = 'approved' comparison passes when approval_status IS NULL, since Postgres CHECK constraints treat NULL as satisfied. Found by this phase's own live-reproduction testing and fixed via corrective migration 20260708160000, which explicitly guards with approval_status IS NOT NULL.
  • vendor_credit gained 2 CHECKs: chk_vendor_credit_fully_applied_requires_actor_at and chk_vendor_credit_void_requires_actor_at (both require a recorded actor + timestamp for their respective terminal state).

Remediation Phase 4 (2026-07-08)

Futureproofing pass, items 15 and 17b — 398 → 402 cols (+4), 16 tables unchanged. See PROJECT_DECISIONS #40 for the full cross-module record (11 modules touched; this module's slice is 2 of the 10 entity_id rollout tables plus 2 of the 3 payment_terms_id additive-interim tables).

  • Item 15 — Legal Entity. purchase_order.entity_id and vendor_invoice.entity_id (both nullable uuid FK → new platform.legal_entity) — 2 of the 10 tables independently derived as plausibly differing per legal entity within one tenant (a tenant that operates as more than one incorporated LLC). Header-only by disclosed rule: no entity_id on purchase_order_line, vendor_invoice_line, purchase_receipt, or vendor itself — a line item's entity is always inherited via its header's FK, and vendor (a shared cross-entity relationship) never needed one. Migration: packages/db/migrations/20260709010000_phase4_item15_legal_entity.sql. Both columns confirmed nullable and FK-valid at build time (purchase_order/vendor_invoice both 0 rows), zero backfill risk.
  • Item 17b — Payment Terms enum→catalog (additive-interim). vendor.payment_terms_id and purchase_order.payment_terms_id (both nullable uuid FK → new shared.payment_terms_catalog, 10 seeded rows incl. 2_10_net_30net_days=30/discount_percent=2.00/discount_days=10, representing "2/10 net 30" which the pre-existing bare CHECK-enum cannot). Purely additive: the old vendor.payment_terms CHECK-enum (net_7…net_90/cod/prepaid) is completely unchanged — no reconciliation, no backfill, no deprecation yet. Disclosed interim gap: the new FK column is not yet constrained to stay in sync with the legacy enum column; the sync-cutover decision (including a real mapping for 4 catalog codes — cod/prepaid/net_7/2_10_net_30 — that have no clean vendor.payment_terms equivalent) is deferred, logged to OPEN_ITEMS. Migration: packages/db/migrations/20260709030000_phase4_item17_enum_to_catalog.sql (part b). Both columns confirmed nullable, both tables 0 rows at build time, zero backfill risk.
  • Both items independently live-reproduced and adversarially verified (2 separate lenses, CLEAN) as part of the full Phase 4 pass — see PROJECT_DECISIONS #40.

Header/Line Remediation — Fixes #10, #12, #4 (2026-07-10)

The second of 3 reopens (POS → Purchasing → Platform) executing the coordinated, 6-module "Header/Line Remediation" design (vrida-header-line-remediation-design-2026-07-10.md, §4 — the Purchasing section), bundled per the user's own request into one reopen. POS landed first (fix #8, PROJECT_DECISIONS #46); Platform lands separately under the same effort, not detailed here. Net effect on purchasing: +1 table (vendor_credit_line), +8 columns — 16→17 tables, 402→410 cols. Full cross-module record: PROJECT_DECISIONS #47.

Fix #10 (must-fix, financial correctness) — purchase_order_line quantity-rollup CHECK. purchase_order_line was the one qty-counter-without-status table in this codebase missing its own rollup CHECK (sale_line/order_line's siblings already had theirs). New: chk_purchase_order_line_quantity_rollupreceived_qty + invoiced_qty + cancelled_qty <= ordered_qty. Mandatory pre-migration audit (SELECT ... WHERE received_qty + invoiced_qty + cancelled_qty > ordered_qty) confirmed zero violating rows live (the table itself had zero rows at the time) — added directly, no NOT VALID fallback needed. Safe by construction, not by NULL-branching: all 4 operands are already NOT NULL in the live schema.

Fix #12 (guardrail/consistency) — line_number uniqueness on 4 tables. Added as PARTIAL unique indexes (matching purchase_order_line's own pre-existing shape, WHERE deleted_at IS NULL):

Table Index
purchase_receipt_line purchase_receipt_line_receipt_line_number_unique on (purchase_receipt_id,line_number)
vendor_invoice_line vendor_invoice_line_invoice_line_number_unique on (vendor_invoice_id,line_number)
vendor_return_line vendor_return_line_return_line_number_unique on (vendor_return_id,line_number)
purchase_order_template_line purchase_order_template_line_template_line_number_unique on (template_id,line_number)

Mandatory pre-migration dedup audit, once per table (GROUP BY ... HAVING COUNT(*) > 1 on the not-deleted rows), confirmed zero duplicates on all 4 (all 4 were zero-row tables at the time) — a plain UNIQUE has no NOT VALID fallback in Postgres, unlike fix #10's CHECK, so this audit was the only safety valve.

Fix #4 (structural improvement) — new table purchasing.vendor_credit_line. Per-line decomposition of a vendor_credit's total amount, fully additive (existing vendor_credit rows simply have zero lines — no backfill). See the dedicated table-reference entry above for full column/constraint/trigger detail. 3 prerequisite UNIQUE(id, tenant_id) constraints were required first — vendor_credit, vendor_invoice_line, and vendor_return_line were all confirmed missing this constraint (needed by vendor_credit_line's own composite FKs); all 3 are zero-risk additions since id is already each table's sole PK. The reconciliation trigger trg_vendor_credit_line_validate_against_credit enforces HEADER-IS-TRUTH: SUM(lines.amount_cents) may never exceed vendor_credit.credit_amount_cents (lines may legitimately sum to less — an undecomposed remainder is fine).

Migration: packages/db/migrations/20260710010000_headerline_purchasing_fixes.sql. Schema files: packages/db/src/schema/purchasing/order.ts (purchase_order_line's new CHECK, purchase_order_template_line's new unique index), packages/db/src/schema/purchasing/receipt.ts (purchase_receipt_line's new unique index), packages/db/src/schema/purchasing/invoice.ts (vendor_invoice_line's new unique index + UNIQUE(id,tenant_id)), packages/db/src/schema/purchasing/credit_return.ts (vendor_return_line's new unique index + UNIQUE(id,tenant_id), vendor_credit's new UNIQUE(id,tenant_id), and the brand-new vendor_credit_line table).

Verification. Live-reproduced and test-confirmed: over-quantity purchase_order_line updates rejected (fix #10); duplicate line_number inserts rejected on all 4 tested tables (fix #12); vendor_credit_line inserts — valid single-target, over-sum rejection, exact-limit success, both-targets rejection, neither-target (overpayment) success, and cross-tenant composite-FK rejection — all behave correctly (fix #4). New test coverage: apps/api/src/purchasing/__tests__/purchasing-schema.spec.ts, new sections L (3 tests), M (3 tests), N (6 tests) — 58/58 in this module's own schema-spec suite.

Post-script (2026-07-10, later the same day). The Receiving module extraction (see the dedicated subsection near the end of this file) moved purchase_receipt/purchase_receipt_line out of purchasing entirely and renamed them receiving.goods_receipt/goods_receipt_line. Every purchase_receipt/purchase_receipt_line reference in this subsection — including the fix #12 table row above and the receipt.ts schema-file citation below — describes the state as it stood at THIS reopen, before that later move. Deliberately not rewritten here, to preserve the historical record; see PROJECT_DECISIONS #55 for the current shape.

Next. Platform reopens next under the same effort, per the design doc's own recommended sequence — not detailed here; it gets its own PROJECT_DECISIONS entry when it lands.

Header/Line Remediation Batch 2 — Closing Bare-FK Fix (2026-07-10)

Constraint-only — 17 tables / 410 cols unchanged. This is the LAST item in the entire 2-batch "Header/Line Remediation" effort (batch 1: POS/Purchasing/Platform, PROJECT_DECISIONS #46-48; batch 2: Inventory/Orders/Billing/Identity/this fix, PROJECT_DECISIONS #49-53), landing in a single 3-module migration alongside platform.payment and billing.ar_charge (see those modules' own schema_docs for their own legs). Full cross-module record in PROJECT_DECISIONS #53.

  • vendor_invoice_match.vendor_invoice_line_id upgraded from a bare FK to composite vendor_invoice_match_vendor_invoice_line_id_tenant_fkey (vendor_invoice_line_id, tenant_id) → vendor_invoice_line(id, tenant_id). The prerequisite vendor_invoice_line_id_tenant_id_unique already existed — added in the FIRST Header/Line Remediation batch's own Purchasing reopen (fix #4's vendor_credit_line build, PROJECT_DECISIONS #47, 2026-07-10), not this batch — confirmed by checking the constraint's actual origin rather than assumed.
  • Pre-migration audit: 0 live rows in vendor_invoice_match — safe direct ADD.
  • purchase_receipt_line_id DELIBERATELY remains barepurchase_receipt_line has no UNIQUE(id, tenant_id) today, out of this fix's own explicit named scope (only vendor_invoice_line_id was requested); disclosed via a code comment in invoice.ts, not silently left inconsistent. Logged to OPEN_ITEMS with a concrete future trigger. CLOSED 2026-07-10, later the same day, by the Receiving module extraction: purchase_receipt_line (renamed receiving.goods_receipt_line) gained UNIQUE(id, tenant_id) as part of that extraction, and this column was itself renamed goods_receipt_line_id and upgraded to a composite FK (vendor_invoice_match_goods_receipt_line_tenant_fkey). See PROJECT_DECISIONS #55; this bullet is kept as the historical record of batch 2's own deliberate scope decision.
  • The standout independent-verification finding of this whole fix: vendor_invoice_match had ZERO live rows in either vendor_invoice_line/purchase_receipt_line at build time, so the build itself could not reproduce the cross-tenant-rejection scenario. The independent verifier built a full fixture chain from scratch (vendor → purchase_order → purchase_order_line → purchase_receipt → purchase_receipt_line, plus vendor_invoice → vendor_invoice_line, across 2 tenants) and proved BOTH the same-tenant-succeeds and cross-tenant-rejected cases for real.
  • Migration: packages/db/migrations/20260710070000_headerline_bare_fk_fixes.sql. Schema file: packages/db/src/schema/purchasing/invoice.ts (vendorInvoiceMatch's FK definition retargeted to the composite form + the purchase_receipt_line_id scope-exception comment).
  • Tests: apps/api/src/purchasing/__tests__/purchasing-schema.spec.ts, new Section O (2 tests: O1 valid same-tenant reference, O2 cross-tenant rejection) — using the same from-scratch fixture chain the independent verifier proved necessary. The file's own header comment claiming Section O at verification time (before it was actually written) has since been confirmed landed.

Receiving Module Extraction (2026-07-10)

A 3rd reopen the same day, immediately after batch 2's closing bare-FK fix above — carves purchase_receipt/purchase_receipt_line out of purchasing entirely into a brand-new receiving schema (2 tables / 62 cols total: goods_receipt 33 cols, goods_receipt_line 29 cols — up from the moved tables' old combined 57, +5 net new columns on them). Net effect on purchasing: −2 tables, −57 cols — 17→15 tables, 410→353 cols. Full extraction record: PROJECT_DECISIONS #55. Full receiving-side table reference: schema_docs/receiving.md. This is the 2nd time in this codebase tables have moved out of an already-locked module into a brand-new one (the 1st was approvals out of admin, PROJECT_DECISIONS #44) — and the first time purchasing itself has lost tables rather than gained them.

Entitlement note. No per-module entitlement mechanism exists anywhere in this codebase yet (documented only, nothing built). receiving is bundled to purchasing's own entitlement toggle — it gets no independent toggle of its own.

What moved and what stayed. purchase_receiptreceiving.goods_receipt (+3 new cols: voided_at/voided_by_actor_id/void_reason — closing a real pre-existing gap where 'void' was already a valid status value with zero attribution). purchase_receipt_linereceiving.goods_receipt_line (+2 new cols: stock_movement_line_id — the movement-LINE-grain linkage, fix #7 — and reversal_of_goods_receipt_line_id, a self-referencing composite FK powering the reversal design). purchasing keeps the PO (the commitment) and the 3-way match; receiving now owns the physical dock event and the inventory.stock_movement/stock_movement_line write it originates.

4 new UNIQUE(id, tenant_id) prerequisites, added to purchasing's OWN tables (needed because receiving.goods_receipt/goods_receipt_line now hold composite FKs pointing back INTO purchasing):

purchasing table New constraint Serves (in receiving)
vendor vendor_id_tenant_id_unique goods_receipt_vendor_tenant_fkey
vendor_address vendor_address_id_tenant_id_unique goods_receipt_ship_from_vendor_address_tenant_fkey (nullable)
purchase_order purchase_order_id_tenant_id_unique goods_receipt_purchase_order_tenant_fkey
purchase_order_line purchase_order_line_id_tenant_id_unique goods_receipt_line_purchase_order_line_tenant_fkey (the line-grain receiving seam)

All 4 were zero-risk — id was already each table's sole PK, so the constraint could never be violated; all 4 tables also had zero live rows at build time.

2 renamed + upgraded FKs, both pointing OUT of purchasing into the new schema:

Table.column Old New
vendor_invoice_match purchase_receipt_line_id (bare, deliberately left that way at batch 2 — PROJECT_DECISIONS #53) goods_receipt_line_id, composite vendor_invoice_match_goods_receipt_line_tenant_fkey → receiving.goods_receipt_line(id, tenant_id)
vendor_return_line purchase_receipt_line_id (bare; not previously called out in this file's own Cross-Phase FK table) goods_receipt_line_id, composite vendor_return_line_goods_receipt_line_tenant_fkey → receiving.goods_receipt_line(id, tenant_id)

The vendor_return_line dependency was BLOCKER 2 of this extraction's own pre-build design-phase independent verification — the first design draft named only vendor_invoice_match's identical dependency; this 2nd, initially-missed dependency was caught before build started, not discovered mid-build.

BLOCKER 1 (fix #10 × fix #11 interaction, pre-build-caught) — touches purchase_order_line directly. The new over-receipt-tolerance trigger on receiving.goods_receipt_line (fix #11) writes back to purchasing.purchase_order_line.received_qty — and that write-back is capped at LEAST(accepted_qty, ordered_qty − received_qty − invoiced_qty − cancelled_qty), never the raw accepted_qty, specifically so it can never violate purchase_order_line's own chk_purchase_order_line_quantity_rollup (fix #10, landed in the Header/Line Remediation reopen immediately above). Live-reproduced both the capped-write-back-holds case (over-tolerance receipt of 120 against an ordered_qty of 100 writes back exactly 100, not 120; the CHECK holds) and that an uncapped write-back attempt genuinely violates the CHECK — confirming the cap is load-bearing, not coincidental. Full trigger/tolerance detail in schema_docs/receiving.md.

Also touched, outside purchasing (documented in their own schema_docs, cross-referenced here for completeness): inventory (item_variant/lot/stock_movement/stock_movement_line each gained the composite-FK prerequisite UNIQUE(id, tenant_id) — pure constraint-only, 25 tables / 351 cols unchanged) and admin (custom_field_definition.entity_type's CHECK vocabulary value 'purchasing.purchase_receipt''receiving.goods_receipt', with an in-migration backfill of 11 pre-existing test-fixture rows).

3 real bugs found and fixed during this build's own live-reproduction (distinct from the 2 pre-build-caught BLOCKERs above): (1) receiving.goods_receipt was initially missing its own UNIQUE(id, tenant_id) — needed because goods_receipt_line.goods_receipt_id references it via composite FK, fixed in both the migration and the Drizzle source; (2) the admin.custom_field_definition CHECK-widen backfill hit a genuine chicken-and-egg ordering bug (widening the CHECK first fails validation against the still-old rows; backfilling first fails against the still-old CHECK) — fixed by dropping the CHECK entirely, backfilling unconstrained, then re-adding the CHECK once every row conformed; (3) the tolerance trigger's ::numeric cast on a corrupted (non-numeric) admin.setting_definition/tenant_setting catalog value had no defensive handling and would crash the trigger — fixed via a BEGIN/EXCEPTION WHEN invalid_text_representation wrapper falling back to 0% tolerance. None of these 3 touch purchasing's own tables directly — full detail in schema_docs/receiving.md.

Verification. Two layers, kept distinct. (1) A one-off, transactional (rolled-back) live-reproduction pass during the build itself — 32 guard assertions across 6 sections, all pass, including all 13 composite (col, tenant_id) FKs newly added or upgraded across this 3-schema build individually isolated for cross-tenant rejection (2 of the 13 are purchasing's own outbound pointers, vendor_invoice_match.goods_receipt_line_id and vendor_return_line.goods_receipt_line_id — both proven here), the BLOCKER 1 capped-write-back proof against purchase_order_line, and a 3-way-match test proving vendor_invoice_match still resolves PO line ↔ goods_receipt_line ↔ invoice line across the new schema boundary; ended in ROLLBACK, zero residue. (2) The persisted regression suites: apps/api/src/receiving/__tests__/receiving-schema.spec.ts (new file, 42/42, +2 added post-independent-verification for the strengthened trigger) covers the receiving side; apps/api/src/purchasing/__tests__/purchasing-schema.spec.ts was updated in place — 60 tests → 59 (net −1: −2 table-list count, −2 removed Section C purchase_receipt_line.inventory_movement_id tests, −2 removed Section F purchase_receipt idempotency tests, +1 new Section O3 isolating vendor_invoice_match.goods_receipt_line_id's own composite FK, +4 new Section P covering the 4 new UNIQUE(id, tenant_id) prerequisites) — all 59/59 passing. Disclosed gap in the persisted suite (not in the one-off live-reproduction, which did cover it): vendor_return_line.goods_receipt_line_id has no dedicated test in purchasing-schema.spec.ts — it's nullable and no pre-existing test in that file touched its predecessor column either, so there was nothing to update in place; flagged via an in-file comment rather than silently left untested. apps/api/src/inventory/__tests__/inventory-schema.spec.ts also gained 4 tests (new Section Q, the 4 sibling UNIQUE(id, tenant_id) constraints) — 55→59, all passing.

Real, disclosed discrepancy found (not fixed in this pass, out of scope). apps/api/src/admin/__tests__/admin-schema.spec.ts test (I2b) chk_custom_field_definition_entity_type accepts purchasing.purchase_receipt is now live-broken (the migration's own CHECK-widen replaced that accepted value with 'receiving.goods_receipt') — confirmed failing for real, not hypothetical, but admin-schema.spec.ts was outside this build's assigned scope; it needs the same one-line value update before the full apps/api suite is green.

Migration: packages/db/migrations/20260710090000_receiving_extraction.sql. purchasing schema files touched: vendor.ts (vendor/vendor_address both gained the new UNIQUE(id, tenant_id)), order.ts (purchase_order/purchase_order_line, same), invoice.ts (vendor_invoice_match's FK rename/retarget), credit_return.ts (vendor_return_line's FK rename/retarget). New files live under packages/db/src/schema/receiving/ (_schema.ts, receipt.ts, index.ts) — purchasing/receipt.ts no longer exists; the barrel purchasing/index.ts carries a comment noting the move instead of an export.

Next. Independent evidenced lock-gate verification (separate agent), then fix findings, run the full suite, rebuild+deploy docs, commit, and re-lock all 3 touched modules (purchasing, inventory, and the new receiving).

Service layer

No PurchasingService yet — schema-only this pass, same as every module's deferred service layer.

JSONB columns

vendor.blackout_config (v1 example shape), vendor.attributes, vendor_item.attributes (example shapes logged to OPEN_ITEMS), and decision_provenance on each FULL table (project-wide reason/evidence/confidence/memory_refs convention). (purchase_receipt.attributes MOVED with the table to receiving.goods_receipt.attributes, 2026-07-10.)

Open items carried forward (see OPEN_ITEMS.md — 9 purchasing rows)

Billing write-backs (Billing), search_vector+trgm (Search/pg_trgm), no PurchasingService, the reorder→PO service loop, the AI Invoice OCR pipeline, JSONB example shapes; plus two cross-references (agent spend-ceiling → identity/ai/pricing rows; over-receipt idempotency → pos/orders InventoryService rows). shipment_photo_ref MOVED with the table — it's now receiving.goods_receipt.shipment_photo_ref (2026-07-10), no longer one of purchasing's own open items; see receiving.md's own open items list. The 2026-07-10 Header/Line Remediation reopen (fixes #10/#12/#4) added zero new rows here — all 3 fixes closed clean with no deferral (zero live violations found, no data cleanup needed). The same-day Receiving module extraction likewise added zero new open-item rows to purchasing — the extraction closed the vendor_invoice_match/vendor_return_line bare-FK gaps rather than deferring anything new; any new open items the extraction itself introduced are tracked under receiving's own rows, not here — see PROJECT_DECISIONS #55.

Last modified: Jul 10, 2026, 6:26 PM PT
On this page
Esc