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-10 — purchase_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_lineadded 2026-07-10, minus 2 forpurchase_receipt/purchase_receipt_linemoving toreceivingthe same day) carrytenant_id NOT NULLFK →platform.tenant, no mixed-scope case. All 15 have RLS enabled with a permissive<table>_tenant_isolationpolicy oncurrent_setting('app.current_tenant_id')::uuid. Plaintenant_idindex on all 15. updated_attrigger-maintained viaplatform.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, noupdated_atat all, matchingtax.tax_calculation_jurisdiction's own precedent.- Soft delete (
deleted_at) on 14 of the 15 tables —vendor_credit_lineagain the exception, write-once with nodeleted_at; every UNIQUE (vendor.code,po_number,invoice_number,credit_number,return_number, thevendor_itemtriple, plus the 3 remainingline_numberpartial uniques added 2026-07-10 onvendor_invoice_line/vendor_return_line/purchase_order_template_line— a 4th,purchase_receipt_line's own, moved toreceiving.goods_receipt_linethe same day, see below) isWHERE deleted_at IS NULL. - Agent-as-actor — every
*_actor_idtargetsidentity.actor(retargeted from v1'sidentity.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_invoice—purchase_receiptMOVED toreceiving.goods_receipt2026-07-10, still FULL there). LIGHT =created_by_actor_id+automation_sourceonly, on the subordinate/mechanical tables (vendor_contact,vendor_address,vendor_invoice_line,vendor_invoice_match,vendor_credit,vendor_return,vendor_return_line—purchase_receipt_lineMOVED toreceiving.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, matchingtax.tax_calculation_jurisdiction's precedent). Note: purchasing's LIGHT carriesautomation_sourceuniformly — a deliberate, disclosed deviation from crm's lightercreated_by-only tier (provenance is useful on every purchasing row given the module's reorder/OCR/cost-flag agent surface). - Global-first —
vendor_addressusesshared.administrative_region/shared.countrynatural-key FKs (not v1'sus_state), allcurrency_codedefaults dropped (service-resolved),purchase_uom_id → shared.unit_of_measure.code. - v1 baseline vs. this build — v1's
schema_purchasing.mdspecified 16 tables / 341 cols. This build (2026-07-07 lock): 16 tables / 398 cols (+57), zero consolidation. The only removal isvendor.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_lineMOVED OUT to the newreceivingschema (−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 17b → shared.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 17b → shared.payment_terms_catalog, nullable), entity_id (NEW, Phase 4 Item 15 → platform.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_rollup — received_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_receipt → receiving.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_line → receiving.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 15 → platform.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_fkey → vendor_credit, vendor_credit_line_invoice_line_tenant_fkey → vendor_invoice_line, vendor_credit_line_return_line_tenant_fkey → vendor_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_line ↔ vendor_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_ordergainedchk_purchase_order_sent_requires_approval(a PO can't reachstatus IN ('sent','partial','received')withoutapproval_status='approved'recorded — the C8 financial-autonomy boundary) andchk_purchase_order_no_self_approval(approved_by_actor_idmust differ fromcreated_by_actor_idwhen both are set).- Corrective fix (same day): the first version of
chk_purchase_order_sent_requires_approvalhad a live NULL-bypass bug — a bareapproval_status = 'approved'comparison passes whenapproval_status IS NULL, since Postgres CHECK constraints treat NULL as satisfied. Found by this phase's own live-reproduction testing and fixed via corrective migration20260708160000, which explicitly guards withapproval_status IS NOT NULL. vendor_creditgained 2 CHECKs:chk_vendor_credit_fully_applied_requires_actor_atandchk_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_idandvendor_invoice.entity_id(both nullableuuidFK → newplatform.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: noentity_idonpurchase_order_line,vendor_invoice_line,purchase_receipt, orvendoritself — a line item's entity is always inherited via its header's FK, andvendor(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_invoiceboth 0 rows), zero backfill risk. - Item 17b — Payment Terms enum→catalog (additive-interim).
vendor.payment_terms_idandpurchase_order.payment_terms_id(both nullableuuidFK → newshared.payment_terms_catalog, 10 seeded rows incl.2_10_net_30—net_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 oldvendor.payment_termsCHECK-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 cleanvendor.payment_termsequivalent) 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_rollup — received_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_idupgraded from a bare FK to compositevendor_invoice_match_vendor_invoice_line_id_tenant_fkey (vendor_invoice_line_id, tenant_id) → vendor_invoice_line(id, tenant_id). The prerequisitevendor_invoice_line_id_tenant_id_uniquealready existed — added in the FIRST Header/Line Remediation batch's own Purchasing reopen (fix #4'svendor_credit_linebuild, 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_idDELIBERATELY remains bare —purchase_receipt_linehas noUNIQUE(id, tenant_id)today, out of this fix's own explicit named scope (onlyvendor_invoice_line_idwas requested); disclosed via a code comment ininvoice.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(renamedreceiving.goods_receipt_line) gainedUNIQUE(id, tenant_id)as part of that extraction, and this column was itself renamedgoods_receipt_line_idand 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_matchhad ZERO live rows in eithervendor_invoice_line/purchase_receipt_lineat 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 + thepurchase_receipt_line_idscope-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_receipt → receiving.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_line → receiving.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.