returns — module #27, the customer RMA (return merchandise authorization) layer
9 tables, 153 columns (152→153, 2026-07-18, Phase 3 stored-value build — return_resolution.store_credit_transaction_id, PROJECT_DECISIONS #71) — schema-locked 2026-07-11 (PROJECT_DECISIONS #61), built as Pass 2 of a same-day 2-pass session (Pass 1, PROJECT_DECISIONS #60, reopened rewards/offers to add proportional/cumulative-cap partial-reversal — a prerequisite this module's own clawback seam consumes). returns owns the buy-side-mirror-image of a sale: RMA authorization, the frozen discount/tax allocation a return refunds against, an aggregate-cap tracker preventing over-return across multiple separate RMAs against one source line, the physical return-receipt/inventory-posting event, the resolution outcome (refund/store credit/replacement/repair/warranty credit/reject), and a revived plant-warranty-claim instance table. Migration: packages/db/migrations/20260711040000_returns_module.sql. The current 153-column count is confirmed across Drizzle and the live DB; the Inventory Core companion adds no Returns column.
Inventory Core companion reopen — 2026-07-15 task run (PROJECT_DECISIONS #75).
trg_return_receipt_line_post_and_capnow firesAFTER INSERT, so ordinary-caller RLS, FKs, and row constraints validate the receipt line before the privileged posting wrapper executes. The wrapper then re-reads the persisted line and locks/derives its authoritative receipt and authorization facts.returns_invariant_ownerhas only the required SELECT/UPDATE policies onreturn_receipt_line; current Returns policy count is 17. This companion change adds no Returns table or column.
returns is the first module built entirely under the corrected, 3-lens-adversarially-verified design (returns-module-design-proposal-2026-07-11.md) — the design draft itself was NOT built as originally scoped: Architect Decision 2 (per PROJECT_DECISIONS #61) deliberately deviates from the design's own Block 4, which had deferred proportional loyalty/offer clawback as out-of-scope for v1 because rewards/offers' reversal triggers, at design time, supported only exact-full-negation (rewards) or unvalidated-magnitude (offers) reversal. Pass 1 (#60), run immediately before this build in the same session, closed that exact gap — so this build implements proportional clawback as a real, working capability from day one, not a deferred placeholder.
Depends on platform (tenant), identity (actor), multi_loc (site), crm (customer — bare FK, matching the universal codebase-wide convention for every reference to crm.customer), pos (sale/sale_line/sale_refund/sale_refund_line — the latter two gained a new prerequisite UNIQUE(id, tenant_id) in this same migration, both were PK-only before), orders (order_header/order_line — same new prerequisite, both were PK-only before), inventory (item_variant/lot/stock_movement/stock_movement_line — read/write via the posting trigger, no new prerequisite needed), rewards (loyalty_point_ledger — the first-ever business-layer→rewards direction FK in this codebase; every prior FK between the two schemas ran the other way), and offers (offer_redemption — same first-ever-direction note). returns never re-implements refund, loyalty, or offer math: pos.sale_refund stays the one source of truth for refunded money, and return_resolution.loyalty_reversal_ledger_id/.offer_reversal_redemption_id link to 'reverse'-typed rows written using rewards/offers' own (now-proportional) mechanisms.
PROJECT_DECISIONS entry: #61 (and its companion Pass-1 entry, #60, for the rewards/offers proportional-reversal fix this module's clawback seam depends on).
Groups: Authorization (return_authorization, return_authorization_line, return_source_line_tracker) / Resolution (return_resolution, return_resolution_line) / Physical Receipt (return_receipt, return_receipt_line) / Catalogs (return_reason, warranty).
Global rules for this schema:
- Uniform tenant-scoping — all 9 tables carry
tenant_id NOT NULLFK →platform.tenant, standard RLS<table>_tenant_isolationpolicy (FOR ALL TO authenticated,USING/WITH CHECKbothtenant_id = current_setting('app.current_tenant_id')::uuid), a plaintenant_idindex. Cross-tenantINSERTis live-rejected with42501(live-reproduced at lock gate). No nullabletenant_idanywhere in this module (Section 4 Item C: N/A). updated_attrigger-maintained viaplatform.set_updated_at()(shared, reused) on 8 of 9 tables — every table exceptreturn_resolution_line, the one genuinely append-only fact table.- Soft delete (
deleted_at) on 7 of 9 tables —return_reason,warranty,return_authorization,return_authorization_line,return_resolution,return_receipt,return_receipt_line. Not onreturn_source_line_tracker(a small mutable counter row with no lifecycle to soft-delete, mirroringoffers.offer_redemption_reversal_tracker's own precedent exactly) and not onreturn_resolution_line(append-only, mirroringbilling.ar_charge_line's own precedent). Every_number-style uniqueness constraint (rma_number) and every soft-deletable catalog uniqueness (return_reason.code) isWHERE deleted_at IS NULL. - Append-only, 1 table:
return_resolution_line— a write-once financial fact, mirroringbilling.ar_charge_line/purchasing.vendor_credit_lineexactly (REVOKEUPDATE/DELETE+platform.reject_append_only_mutation()reused verbatim). - Agent-as-actor — every
*_actor_idtargetsidentity.actor, bare FK (codebase-wide convention). - Autonomy tiers, non-uniform by design.
return_authorizationandreturn_authorization_lineboth get the FULL pack (created_by_actor_id+automation_source+ the full review seam —review_status/review_reason/reviewed_by_actor_id/reviewed_at— +decision_provenance); this is the genuine risk-tiering/escalation judgment surface for the module, duplicated onto the line table because a line-level allocation decision can itself need review independent of the header.return_resolutionalso gets the FULL pack — the refund/store-credit/replacement/repair/warranty-credit/reject decision is exactly the kind of judgment this codebase reserves FULL for.return_reasonandwarrantycarry onlycreated_by_actor_id(noautomation_source, no review seam) — plain catalog/revived-v1 shapes, mirroringinventory.stock_adjustment_reason's and v1pos.guarantee's own precedents respectively.return_source_line_trackerandreturn_resolution_linecarry zero autonomy columns at all — deterministic, maintained-counter/ledger-fact shapes with no judgment surface of their own.return_receiptgets LIGHT (received_by_actor_id/voided_by_actor_id/void_reason— attribution only, noautomation_source, no review seam) andreturn_receipt_linegets NONE (zero actor attribution at all) — a deliberately lighter pairing thanreceiving.goods_receipt(FULL)/goods_receipt_line(LIGHT): the RMA-approval judgment already happened upstream on the FULLreturn_authorization/return_authorization_line, so the physical receiving event itself needs no independent review seam duplicated onto it. - Composite
(col, tenant_id)FK convention, total on every cross-schema reference that can support it. The 2 disclosed, deliberate exceptions match the rest of the codebase:customer_id → crm.customerstays a plain bare FK (the universal convention —crm.customeritself has never been given theUNIQUE(id, tenant_id)prerequisite, and all 22-plus live FKs anywhere in this codebase targeting it are plain; this design does not introduce a new gap), and every*_actor_id → identity.actorstays bare (codebase-wide actor-FK convention). - The
rewards/offersseam is the first-ever business-layer FK pointing INTO either schema in this codebase. Every prior FK betweenreturns' siblings andrewards/offersran the other direction (rewards.loyalty_point_ledger.sale_id/offers.offer_redemption.sale_id/.sale_line_idare composite FKs FROM those schemas INTOpos).return_resolution.loyalty_reversal_ledger_id/.offer_reversal_redemption_idare the reverse — permitted perSCHEMA_CONVENTIONS.md§13/§14 (rewards/offersare fully tenant-scoped, same as any business schema; onlyconsumer's own core identity tables get the SECURITY-DEFINER wall), disclosed here as the non-obvious precedent it is. - Schema-bootstrap GRANTs. A brand-new schema needs its own bootstrap —
GRANT USAGE ON SCHEMA returns,GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES, andALTER DEFAULT PRIVILEGES— the same 3-statement template every prior new-schema build in this codebase (approvals,receiving,consumer/rewards/offers,files) used. - Money never re-derived where an owning module already computed it.
return_authorization_linefreezes a per-unit discount/tax allocation at RA-creation time (§ below) rather than re-queryingpos/offers/rewardslater;return_resolution_line.resolved_amount_centsreconciles againstpos.sale_refund_line's own authoritative amount when a refund is involved, never restating an independent figure for that case.
Cross-Phase / Cross-Module Foreign Keys (returns)
| Column | Target | Notes |
|---|---|---|
*.tenant_id (all 9 tables) |
platform.tenant |
NOT NULL |
return_authorization.site_id |
multi_loc.site (id, tenant_id) |
NOT NULL, composite |
return_authorization.source_sale_id |
pos.sale (id, tenant_id) |
nullable, composite — NULL for an unreferenced/anonymous walk-in return |
return_authorization.source_order_id |
orders.order_header (id, tenant_id) |
nullable, composite |
return_authorization.customer_id |
crm.customer.id |
nullable, bare (universal codebase-wide convention for every crm.customer reference) |
return_authorization.warranty_id |
returns.warranty (id, tenant_id) |
nullable, composite — gated by chk_return_authorization_warranty_requires_type |
return_authorization.reason_id, return_authorization_line.reason_id |
returns.return_reason (id, tenant_id) |
nullable, composite (same-schema, composite for consistency, mirroring receiving.goods_receipt_line.goods_receipt_id's own same-schema-composite precedent) |
return_authorization_line.return_authorization_id |
returns.return_authorization (id, tenant_id) |
NOT NULL, composite |
return_authorization_line.sale_line_id |
pos.sale_line (id, tenant_id) |
nullable, composite |
return_authorization_line.order_line_id |
orders.order_line (id, tenant_id) |
nullable, composite |
return_authorization_line.item_variant_id |
inventory.item_variant (id, tenant_id) |
nullable, composite — the no-receipt/unreferenced fallback, mirrors pos.sale_refund_line's own fallback shape |
return_source_line_tracker.sale_line_id |
pos.sale_line (id, tenant_id) |
nullable, composite — exactly one of sale_line_id/order_line_id set, CHECK-enforced |
return_source_line_tracker.order_line_id |
orders.order_line (id, tenant_id) |
nullable, composite |
return_resolution.return_authorization_id |
returns.return_authorization (id, tenant_id) |
NOT NULL, composite |
return_resolution.pos_sale_refund_id |
pos.sale_refund (id, tenant_id) |
nullable, composite — required when resolution_type='refund' (CHECK) |
return_resolution.replacement_sale_id |
pos.sale (id, tenant_id) |
nullable, composite — the exchange-as-two-transactions path |
return_resolution.loyalty_reversal_ledger_id |
rewards.loyalty_point_ledger (id, tenant_id) |
nullable, composite — first-ever business→rewards direction FK in this codebase; links to a 'reverse'-typed row written via rewards's own proportional mechanism (Pass 1, #60) |
return_resolution.offer_reversal_redemption_id |
offers.offer_redemption (id, tenant_id) |
nullable, composite — same first-ever-direction note, into offers |
return_resolution_line.return_resolution_id |
returns.return_resolution (id, tenant_id) |
NOT NULL, composite |
return_resolution_line.return_authorization_line_id |
returns.return_authorization_line (id, tenant_id) |
NOT NULL, composite — the resolution-line cap trigger's own grouping key |
return_resolution_line.sale_refund_line_id |
pos.sale_refund_line (id, tenant_id) |
nullable, composite — populated only when the parent resolution is a refund; reconciliation trigger requires exact equality when set |
return_receipt.return_authorization_id |
returns.return_authorization (id, tenant_id) |
NOT NULL, composite |
return_receipt.site_id |
multi_loc.site (id, tenant_id) |
NOT NULL, composite |
return_receipt_line.return_receipt_id |
returns.return_receipt (id, tenant_id) |
NOT NULL, composite |
return_receipt_line.return_authorization_line_id |
returns.return_authorization_line (id, tenant_id) |
NOT NULL, composite |
return_receipt_line.lot_id |
inventory.lot (id, tenant_id) |
nullable, composite |
return_receipt_line.stock_movement_id |
inventory.stock_movement (id, tenant_id) |
nullable, composite — the posting seam, reusing receiving's mechanism |
return_receipt_line.stock_movement_line_id |
inventory.stock_movement_line (id, tenant_id) |
nullable, composite — line-grain movement linkage |
return_receipt_line.reversal_of_return_receipt_line_id |
returns.return_receipt_line (id, tenant_id) |
nullable, self-referencing composite — mirrors goods_receipt_line.reversal_of_goods_receipt_line_id exactly |
warranty.item_variant_id |
inventory.item_variant (id, tenant_id) |
composite |
warranty.sale_id |
pos.sale (id, tenant_id) |
composite |
warranty.sale_line_id |
pos.sale_line (id, tenant_id) |
composite |
warranty.claimed_via_resolution_id |
returns.return_resolution (id, tenant_id) |
nullable, composite — retargeted from v1's claimed_refund_id → pos.sale_refund (Block 2's one capability gain: a warranty claim can now resolve as a replacement, not just a refund) |
*.created_by_actor_id, .reviewed_by_actor_id, .received_by_actor_id, .voided_by_actor_id |
identity.actor |
nullable, bare (codebase-wide actor-FK convention) |
returns.return_reason (10 cols) — tenant-scoped catalog, mirrors inventory.stock_adjustment_reason exactly
A plain reason-code catalog, FK'd from return_authorization.reason_id/return_authorization_line.reason_id rather than a CHECK-enum — the same "real catalog table, not a hardcoded list" pattern this codebase uses everywhere a reason needs to be tenant-configurable and orderable in a UI.
Tenant-scoped. RLS enabled —
return_reason_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). No autonomy pack beyondcreated_by_actor_id.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
code |
text | NOT NULL | — | UNIQUE per (tenant_id) WHERE not deleted |
name |
text | NOT NULL | — | |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
UI ordering |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
Indexes (3): PK on id; return_reason_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for return_authorization/return_authorization_line's own composite reason_id FKs); return_reason_tenant_id_code_unique UNIQUE(tenant_id,code) WHERE deleted_at IS NULL.
returns.warranty (16 cols) — REVIVED v1 pos.guarantee, warranty claim instance tracking
Inspected 2026-07-18, Phase 2 (Nursery Vertical Extraction, PROJECT_DECISIONS #70), 'V7' of that phase's own extraction plan — confirmed already fully vertical-neutral. Nothing extracted; this note is documentation-language-only.
Revived from v1's fully-specified-but-never-built pos.guarantee (16 cols), long held up in this codebase's own history as "the canonical example of the right approach" for vertical-specific extensibility — not because the table itself is plant-specific in design, but because a nursery-vertical case (plant_guarantee) is handled as one CHECK-constrained enum value on a single generic warranty table, alongside manufacturer_warranty/extended_warranty, rather than requiring its own dedicated vertical-only table or column shape. 13 columns carry over verbatim, 2 are cosmetic renames (variant_id→item_variant_id, guarantee_type→warranty_type), and 1 is retargeted (Block 2's one capability gain): v1's claimed_refund_id → pos.sale_refund becomes claimed_via_resolution_id → returns.return_resolution, since v1 wrongly assumed every guarantee claim resolves as a refund — this module's own differentiator flow (a dead-plant warranty claim resolving as a replacement, never a refund) makes that assumption wrong on its face, and return_resolution represents every outcome pos.sale_refund could plus 4 more (replacement/repair/warranty_credit/store_credit). Zero columns dropped.
Tenant-scoped. RLS enabled —
warranty_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). No autonomy pack — a straight revival of v1's pre-autonomy-era shape, matching the codebase's own "verbatim revival, don't retrofit unrelated conventions" practice for this class of table.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
item_variant_id |
UUID | NOT NULL | — | Renamed from v1's variant_id; composite FK → inventory.item_variant |
sale_id |
UUID | NOT NULL | — | Composite FK → pos.sale — the guarantee is issued at time of sale |
sale_line_id |
UUID | NOT NULL | — | Composite FK → pos.sale_line |
customer_id |
UUID | nullable | — | Plain FK → crm.customer |
warranty_type |
text | NOT NULL | — | Renamed from v1's guarantee_type |
issued_date |
date | NOT NULL | — | |
expires_date |
date | nullable | — | |
terms_snapshot |
jsonb | nullable | — | Frozen copy of inventory.item_variant.guarantee_terms at issuance, e.g. {"duration_days":365,"type":"plant_guarantee"} |
signature_ref |
text | nullable | — | Deferred Files forward-ref → files.file.id, folded into the existing Files FK-wiring bundle (retargeted from pos.guarantee's own identical, pre-existing gap) |
status |
text | NOT NULL | 'active' |
CHECK IN (active,claimed,expired,void) |
claimed_via_resolution_id |
UUID | nullable | — | Retargeted from v1's claimed_refund_id → pos.sale_refund; composite FK → returns.return_resolution |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (1): chk_warranty_status.
Indexes (4): PK on id; warranty_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for return_authorization.warranty_id's own composite FK); warranty_tenant_id_item_variant_id_idx on (tenant_id,item_variant_id); warranty_tenant_id_status_idx on (tenant_id,status) WHERE status='active' — the active-warranty lookup, confirmed valid against this table's own CHECK enum (Section 4 Item E).
returns.return_authorization (29 cols, FULL autonomy) — the RMA header
One row per return-merchandise-authorization event. This is the genuine risk-tiering/escalation judgment surface of the module — every column past risk_factors is the standard FULL autonomy pack.
Tenant-scoped. RLS enabled —
return_authorization_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
site_id |
UUID | NOT NULL | — | Composite FK → multi_loc.site |
rma_number |
text | NOT NULL | — | UNIQUE per (tenant_id) WHERE not deleted — the exceptionless "every _number header column gets this constraint" codebase convention |
source_sale_id |
UUID | nullable | — | Composite FK → pos.sale; NULL for an unreferenced/anonymous walk-in return |
source_order_id |
UUID | nullable | — | Composite FK → orders.order_header |
customer_id |
UUID | nullable | — | Plain FK → crm.customer; NULL for an anonymous walk-in |
return_type |
text | NOT NULL | — | CHECK IN (standard,warranty_claim,unreferenced) |
channel |
text | NOT NULL | — | CHECK IN (pos,online) |
original_channel |
text | nullable | — | CHECK IN (pos,online) — buy-online-return-in-store (BORIS) tracking |
status |
text | NOT NULL | 'draft' |
CHECK IN (draft,authorized,partially_received,fully_received,resolved,cancelled,expired,closed) |
expiry_date |
date | nullable | — | |
physical_receipt_required |
boolean | NOT NULL | true |
false for warranty claims resolving with no physical return (the no-receipt path, see warranty above) |
warranty_id |
UUID | nullable | — | Composite FK → returns.warranty. CHECK chk_return_authorization_warranty_requires_type: warranty_id IS NULL OR return_type = 'warranty_claim' — NULL-safe by construction |
reason_id |
UUID | nullable | — | Composite FK → returns.return_reason |
policy_reference |
text | nullable | — | Descriptive only, no dedicated policy table exists |
risk_score |
numeric | nullable | — | Computed from live raw joins each time — no cached fraud-signal counter exists yet (deferred to a future crm/reporting surface) |
risk_tier |
text | nullable | — | CHECK IN (low,medium,high) |
risk_factors |
jsonb | nullable | — | e.g. {"return_velocity_30d": 3, "days_since_purchase": 2, "anonymous": false} |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor — FULL-pack |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | Project-wide reason/evidence/confidence/memory_refs convention |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (6): chk_return_authorization_return_type; chk_return_authorization_channel; chk_return_authorization_original_channel; chk_return_authorization_status; chk_return_authorization_risk_tier; chk_return_authorization_warranty_requires_type (detailed above).
Indexes (4): PK on id; return_authorization_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for return_authorization_line/return_resolution/return_receipt's own composite FKs back to this header); return_authorization_tenant_id_rma_number_unique UNIQUE(tenant_id,rma_number) WHERE not deleted; return_authorization_tenant_id_status_idx on (tenant_id,status) partial for the open-RMA sweep.
Autonomy: FULL — the escalation/risk-tiering judgment surface, riding identity.agent_duty_grant verbatim (no new authority mechanism was built for this module).
Unreferenced/blind return, live-reproduced (per #61): an RA accepted with NO source_sale_id/source_order_id/customer_id at all, falling back to return_authorization_line.item_variant_id, with full risk-tiering (risk_score=75, risk_tier='high') — anonymous walk-in returns are structurally supported, consistent with this codebase's own Phase 4 ALLOW decision (PROJECT_DECISIONS #40).
returns.return_authorization_line (28 cols, FULL autonomy) — freezes the discount/tax allocation at RA-creation time
The module's central design decision, per PROJECT_DECISIONS #61's Architect Decision 1: pos.sale_line carries pure per-SKU pricing-rule resolution with no discount/allocation column of any kind (write-once, append-only, Pricing's Hard Contract 1), and a basket-wide offers/rewards discount has zero per-line breakdown anywhere in the system — if this table doesn't reconstruct-and-freeze the allocation at RA-creation time, it can never correctly know it later. allocation_source discloses which reconstruction path produced the frozen figures, since only line_scoped_offer rests on solid, directly-attributable data — the other two paths rest on a proportional-by-pre-discount-extended-price allocation policy this design newly defines.
Tenant-scoped. RLS enabled —
return_authorization_line_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | |
return_authorization_id |
UUID | NOT NULL | — | Composite FK → returns.return_authorization |
sale_line_id |
UUID | nullable | — | Composite FK → pos.sale_line |
order_line_id |
UUID | nullable | — | Composite FK → orders.order_line |
item_variant_id |
UUID | nullable | — | No-receipt/unreferenced fallback, mirrors pos.sale_refund_line's own fallback shape |
reason_id |
UUID | nullable | — | Composite FK → returns.return_reason |
requested_disposition |
text | nullable | — | CHECK IN (restock,scrap,repair,quarantine,return_to_vendor) |
authorized_qty |
numeric | NOT NULL | — | Never trust this column alone to bound total returns of a source line — capped in aggregate against the source line via return_source_line_tracker (below), across potentially multiple separate RMAs |
received_qty |
numeric | NOT NULL | 0 |
CHECK chk_return_authorization_line_qty_rollup: received_qty <= authorized_qty AND credited_qty <= authorized_qty — 2 independent peer counters, not a chain (mirrors purchasing.purchase_order_line's own several-independent-bounded-counters shape). Maintained by returns.post_and_cap_return_receipt_line() (below), never a caller-supplied value |
credited_qty |
numeric | NOT NULL | 0 |
Peer counter to received_qty (see CHECK above) — this is what lets credited_qty reach authorized_qty while received_qty stays 0 forever on the no-physical-receipt warranty path. Maintained by returns.validate_and_apply_resolution_line() (below) |
restocking_fee_cents |
bigint | NOT NULL | 0 |
No codebase precedent existed before this module (grep -ri restock returned nothing codebase-wide); invented from scratch, taxability left undecided (OPEN_ITEMS) |
pre_discount_extended_price_cents |
bigint | NOT NULL | — | Frozen at RA-creation time |
allocated_discount_cents |
bigint | NOT NULL | 0 |
Frozen at RA-creation time per the proportional-by-pre-discount-extended-price allocation policy |
effective_unit_price_cents |
bigint | NOT NULL | — | (pre_discount_extended_price_cents − allocated_discount_cents) / authorized_qty |
allocated_tax_cents |
bigint | NOT NULL | 0 |
Frozen proportional tax share |
eligible_refund_cents |
bigint | NOT NULL | — | The frozen ceiling this line can ever resolve up to, in aggregate across every return_resolution_line referencing it — effective_unit_price_cents minus restocking_fee_cents, never separately taxed |
allocation_source |
text | NOT NULL | — | CHECK IN (line_scoped_offer,basket_allocated_offer,loyalty_allocated,none) — discloses which reconstruction path produced allocated_discount_cents |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor — FULL-pack |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (3): chk_return_authorization_line_requested_disposition; chk_return_authorization_line_qty_rollup (detailed above); chk_return_authorization_line_allocation_source.
Indexes (3): PK on id; return_authorization_line_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for return_resolution_line/return_receipt_line's own composite FKs back to this line); return_authorization_line_tenant_id_return_authorization_id_idx on (tenant_id,return_authorization_id).
Triggers (2): set_updated_at (standard) and trg_return_authorization_line_check_and_reserve (BEFORE INSERT) → returns.check_and_reserve_source_line() — see "The aggregate-cap seam" below.
Autonomy: FULL — duplicated from the header, since a line-level allocation decision can itself need independent review.
Discount allocation, live-reproduced (per #61, Architect Decision 1): a $150 basket (2 lines, $100 + $50) with a 20%-off basket-wide offer — returning the $100 line correctly refunds $80, not $100 (allocated_discount_cents = 10000/15000 × 3000 = 2000; effective_unit_price_cents = (10000−2000)/1 = 8000). Restocking fee arithmetic: a $40.00 effective price minus a $5.00 restocking fee correctly yields $35.00 eligible refund (not $40 or $50) — the fee reduces the refund directly and is never separately taxed.
Disclosed limitation, not a gap unique to this table: the per-line derivation formulas above (allocated_discount_cents, effective_unit_price_cents, eligible_refund_cents, the restocking-fee arithmetic) are not themselves DB-enforced — only non-negativity (chk_return_authorization_line_money_nonneg) and the aggregate ceiling against the original source line (return_source_line_tracker) exist as backstops, so a caller could in principle store a wrong-but-in-bounds combination today. This matches the codebase-wide convention already established on every other line-level pricing table (pos.sale_line, orders.order_line — both non-negativity-only, no derivation CHECK anywhere), not a regression specific to returns. Flagged at lock-gate verification (PROJECT_DECISIONS #61 addendum) precisely because no ReturnsService exists yet to compute these correctly — whoever builds it must implement and unit-test this arithmetic directly, since the DB will not catch a formula error.
returns.return_source_line_tracker (10 cols, NEW) — the aggregate-cap cache, no autonomy pack
Closes a real, live-verification-caught gap (Lens A Finding 1 of the design's own 3-lens adversarial pass, per PROJECT_DECISIONS #61): pos.sale_line/orders.order_line are append-only and structurally off-limits for a maintained counter column, so "remaining returnable quantity" cannot live as a column on the source line itself. This table is a small, purpose-built cache mirroring offers.offer_redemption_reversal_tracker's own shape exactly — one row per source line, created lazily on first touch, capping the running total atomically. It exists specifically to prevent cumulative over-return across MULTIPLE separate RMAs against the same source line — a gap a naive single-RA-scoped check would miss entirely.
Tenant-scoped. RLS enabled —
return_source_line_tracker_tenant_isolation.updated_at: trigger-maintained viaplatform.set_updated_at(). No soft delete — one row per source line, created lazily, never removed (mirrorsoffers.offer/rewards.loyalty_account's own cached-running-total shape, not a soft-deletable business record). No autonomy pack — a deterministic maintained-counter shape.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | |
sale_line_id |
UUID | nullable | — | Composite FK → pos.sale_line. Exactly one of sale_line_id/order_line_id set |
order_line_id |
UUID | nullable | — | Composite FK → orders.order_line |
original_qty |
numeric | NOT NULL | — | Snapshotted from the source line at first-touch (row created on the first return_authorization_line insert referencing this source line) |
original_charged_amount_cents |
bigint | NOT NULL | — | Snapshotted at first-touch |
total_authorized_qty |
numeric | NOT NULL | 0 |
Running total, atomically capped against original_qty |
total_eligible_refund_cents |
bigint | NOT NULL | 0 |
Running total, atomically capped against original_charged_amount_cents |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
CHECK constraints (1): chk_return_source_line_tracker_exactly_one_source — (sale_line_id IS NOT NULL) != (order_line_id IS NOT NULL), an explicit XOR-shaped disjunction, NULL-safe by construction.
Indexes (3): PK on id; return_source_line_tracker_tenant_id_sale_line_id_unique UNIQUE(tenant_id,sale_line_id) WHERE sale_line_id IS NOT NULL; return_source_line_tracker_tenant_id_order_line_id_unique UNIQUE(tenant_id,order_line_id) WHERE order_line_id IS NOT NULL — both partial uniques double as the ON CONFLICT target for the lazy-create insert and as one-row-per-source-line enforcement.
Trigger function: returns.check_and_reserve_source_line(), fired BEFORE INSERT on return_authorization_line as trg_return_authorization_line_check_and_reserve. Lazily creates the tracker row (INSERT ... ON CONFLICT (tenant_id, sale_line_id) WHERE sale_line_id IS NOT NULL DO NOTHING, or the order_line_id equivalent), then caps total_authorized_qty/total_eligible_refund_cents atomically via the same UPDATE ... WHERE total + NEW <= ceiling ... RETURNING shape offers.check_and_sync_offer_budget() already established — zero rows returned means the cap was exceeded (or the row didn't resolve), and the whole insert is rejected. A real bug was caught and fixed during this build's own live-reproduction pass: the first draft's ON CONFLICT clause omitted the WHERE predicate needed to match a partial unique index — unlike Pass 1's own single-nullable-column trackers (rewards.loyalty_point_ledger_reversal_tracker/offers.offer_redemption_reversal_tracker, each keyed on exactly one non-nullable FK column), this tracker has two mutually-exclusive nullable alternative columns (sale_line_id/order_line_id), so the ON CONFLICT target has to specify which partial index it's matching.
Aggregate over-return cap, live-reproduced (per #61): a second, separate RMA against an already-fully-returned line is rejected; a genuine 3-way concurrent race (3 backgrounded psql processes, real simultaneous connections) against a fresh 5-unit line, each requesting qty=2 — exactly 2 of 3 succeeded (cumulative 4, correctly capped under 5), 1 rejected; the tracker's own total_authorized_qty confirmed at exactly 4, never 6.
returns.return_resolution (21 cols, FULL autonomy) — the outcome header
One row per resolved-outcome event against an RA: refund, store credit, replacement, repair, warranty credit, or reject. pos_sale_refund_id/replacement_sale_id reference execution that already happened elsewhere — this table never duplicates money, only links to and reconciles against it. loyalty_reversal_ledger_id/offer_reversal_redemption_id link to 'reverse'-typed rows written using rewards/offers' own proportional mechanism (Pass 1, #60) — returns never re-implements loyalty/offer math, exactly as it never re-implements refund math.
Tenant-scoped. RLS enabled —
return_resolution_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | |
return_authorization_id |
UUID | NOT NULL | — | Composite FK → returns.return_authorization |
resolution_type |
text | NOT NULL | — | CHECK IN (refund,store_credit,replacement,repair,warranty_credit,reject) |
pos_sale_refund_id |
UUID | nullable | — | Composite FK → pos.sale_refund. CHECK: resolution_type != 'refund' OR pos_sale_refund_id IS NOT NULL — NULL-safe by construction |
replacement_sale_id |
UUID | nullable | — | Composite FK → pos.sale — the exchange-as-two-transactions path |
store_credit_reference |
text | nullable | — | DEPRECATED IN PLACE (2026-07-18, Phase 3 stored-value build, PROJECT_DECISIONS #71) — the loose free-text placeholder this column always was; superseded by store_credit_transaction_id below. 0 non-NULL rows at deprecation. Drop-trigger: after ReturnsService ships reading only the new column |
store_credit_transaction_id |
UUID | nullable | — | NEW 2026-07-18. Composite FK → billing.store_credit_transaction (id, tenant_id) — the real 'issue'-typed ledger entry this resolution created (link-don't-reimplement, same direction as loyalty_reversal_ledger_id). CHECK: resolution_type NOT IN ('store_credit','warranty_credit') OR store_credit_transaction_id IS NOT NULL — required for those two outcomes, mirroring pos_sale_refund_id's own enforced pattern |
loyalty_reversal_ledger_id |
UUID | nullable | — | Composite FK → rewards.loyalty_point_ledger — first-ever business→rewards direction FK |
offer_reversal_redemption_id |
UUID | nullable | — | Composite FK → offers.offer_redemption — first-ever business→offers direction FK |
status |
text | NOT NULL | 'pending' |
CHECK IN (pending,approved,executed,rejected,cancelled) |
rejection_reason |
text | nullable | — | |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor — FULL-pack |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (3): chk_return_resolution_resolution_type; chk_return_resolution_status; chk_return_resolution_refund_requires_sale_refund (detailed above).
Indexes (3): PK on id; return_resolution_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for return_resolution_line's own composite FK); return_resolution_tenant_id_return_authorization_id_idx on (tenant_id,return_authorization_id).
Autonomy: FULL — the refund/store-credit/replacement/repair/warranty-credit/reject decision.
sale_refund stays referenced, not absorbed — live-reproduced (per #61): every resolution's pos_sale_refund_id points at a genuine pos.sale_refund row created independently, confirmed live.
returns.return_resolution_line (8 cols, APPEND-ONLY, no autonomy pack)
Write-once financial fact, mirroring billing.ar_charge_line/purchasing.vendor_credit_line exactly — no created_by_actor_id/automation_source of its own (attribution lives on the parent return_resolution header, which is FULL).
Tenant-scoped, append-only (REVOKE
UPDATE/DELETE+platform.reject_append_only_mutation()reused verbatim). RLS enabled —return_resolution_line_tenant_isolation. Noupdated_at/deleted_at.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | |
return_resolution_id |
UUID | NOT NULL | — | Composite FK → returns.return_resolution |
return_authorization_line_id |
UUID | NOT NULL | — | Composite FK → returns.return_authorization_line — the cap trigger's own grouping key, shared across possibly-multiple resolution events over time (e.g. partial store credit now, more later) |
sale_refund_line_id |
UUID | nullable | — | Composite FK → pos.sale_refund_line, populated only when the parent return_resolution.resolution_type='refund' |
resolved_qty |
numeric | NOT NULL | — | |
resolved_amount_cents |
bigint | NOT NULL | — | Capped per the trigger below; when sale_refund_line_id is populated, must equal sale_refund_line.refunded_amount_minor_units for the same row (reconciliation, not restatement) |
created_at |
timestamptz | NOT NULL | now() |
Indexes (2): PK on id; return_resolution_line_tenant_id_return_authorization_line_id_idx on (tenant_id,return_authorization_line_id) — the trigger's own aggregation key. No UNIQUE(id, tenant_id) — this table is not itself an FK target from any other table in this module.
Trigger function: returns.validate_and_apply_resolution_line(), fired BEFORE INSERT as the module's cap-and-reconcile-and-maintain trigger, doing 4 things in one invocation:
- An explicit NULL guard — rejects
resolved_qty IS NULL OR resolved_amount_cents IS NULLoutright before any comparison runs. Added at lock-gate verification: the twoIF ... > ... THEN RAISEcap checks below are plain comparisons, not the NULL-safeUPDATE ... WHERE ... RETURNINGidiomcheck_and_reserve_source_line()uses — Postgres treatsIF <NULL>as false, so without this guard a NULL input would silently skip both caps. Column-levelNOT NULLon both operands already made this unreachable in practice, but the guard makes the trigger's own logic independently correct rather than relying on that coincidence (see PROJECT_DECISIONS #61 addendum). - The cap — sums
resolved_amount_centsacross every siblingreturn_resolution_linerow sharing the samereturn_authorization_line_id, regardless of whichreturn_resolutionheader each belongs to, against that line's own frozeneligible_refund_centsceiling. Race-free via an explicitSELECT ... FOR UPDATErow lock taken on the RA line before theSUM, serializing concurrent resolution-line inserts against the same line — the correct analogue ofpurchasing.vendor_credit_line's own grouping-by-child-FK cap logic, re-keyed to the field that actually needs capping. sale_refund_linereconciliation — whensale_refund_line_idis populated, requiresresolved_amount_centsto exactly equalsale_refund_line.refunded_amount_minor_unitsfor that row, keepingsale_refund_linethe one authoritative source for a true refund amount rather than letting this table restate an independent, drift-prone figure.credited_qtymaintenance — atomically writes back toreturn_authorization_line.credited_qty(capped againstauthorized_qty, the peer counter toreceived_qty), in the same invocation, mirroring the capped-write-back patternreceiving.trg_goods_receipt_line_check_over_receipt_tolerance()established forpurchase_order_line.received_qty.
Proportional clawback, live-reproduced (per #61, Architect Decision 2 — the capability this whole 2-pass session exists for): buy 5 shrubs with 100 loyalty points earned + a $10 line-scoped offer redeemed → return 2 of 5 → exactly 40 points clawed back (2/5 of 100) and exactly $4.00 of offer budget released (2/5 of $10), via the Pass-1-built proportional mechanism; a second partial return (1 more, cumulative 3/5) correctly clawed back an ADDITIONAL 20 points + $2.00, cumulative 60 points/$6.00 — proving no double-clawback; an over-clawback attempt (70 more, would total 130 > 100) was correctly rejected by Pass 1's own cumulative-cap tracker.
returns.return_receipt (14 cols, LIGHT autonomy pack) — mirrors receiving.goods_receipt
The physical return-receipt header — one row per physical delivery event of returned goods against an RA. Carries LIGHT attribution only (received_by_actor_id/voided_by_actor_id/void_reason — no created_by_actor_id, no automation_source, no review seam) — a lighter footprint than receiving.goods_receipt's own FULL pack, since the RMA-approval judgment already happened upstream on the FULL return_authorization.
Tenant-scoped. RLS enabled —
return_receipt_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | |
return_authorization_id |
UUID | NOT NULL | — | Composite FK → returns.return_authorization |
site_id |
UUID | NOT NULL | — | Composite FK → multi_loc.site |
received_at |
timestamptz | NOT NULL | now() |
|
received_by_actor_id |
UUID | nullable | — | FK → identity.actor |
idempotency_key |
text | nullable | — | NULL-safe UNIQUE per tenant_id — dedup for a future ReturnsService |
status |
text | NOT NULL | 'draft' |
CHECK IN (draft,posted,voided) |
voided_at |
timestamptz | nullable | — | Void attribution |
voided_by_actor_id |
UUID | nullable | — | FK → identity.actor |
void_reason |
text | nullable | — | |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (1): chk_return_receipt_status.
Indexes (3): PK on id; return_receipt_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for return_receipt_line's own composite FK); return_receipt_tenant_id_idempotency_key_unique UNIQUE(tenant_id,idempotency_key) WHERE idempotency_key IS NOT NULL AND deleted_at IS NULL (NULL-safe, matches receiving.goods_receipt's own idempotency pattern).
returns.return_receipt_line (16 cols, no autonomy pack, mutable) — mirrors receiving.goods_receipt_line
Mutable — disposition/condition can be revised pre-finalization, mirroring goods_receipt_line. This is the corrected, second-round shape of receiving's own posting trigger (which needed 2 independent verification passes to get right) — applied here from day one, not repeating receiving's own first, weaker draft.
Tenant-scoped, mutable. RLS enabled —
return_receipt_line_tenant_isolation.updated_at: trigger-maintained viaplatform.set_updated_at(). Soft delete:deleted_at.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | |
return_receipt_id |
UUID | NOT NULL | — | Composite FK → returns.return_receipt |
return_authorization_line_id |
UUID | NOT NULL | — | Composite FK → returns.return_authorization_line |
received_qty |
numeric | NOT NULL | — | Caller-supplied request — the trigger derives the actual absorbed amount, never trusting this raw value directly |
disposition |
text | NOT NULL | — | CHECK IN (restock,scrap,repair,quarantine,return_to_vendor) — the actual outcome, vs. the RA line's requested one |
condition_assessment |
jsonb | nullable | — | e.g. {"grade": "B", "notes": "pot cracked, plant healthy"} |
lot_id |
UUID | nullable | — | Composite FK → inventory.lot |
unit_cost_cents |
bigint | NOT NULL | — | |
return_cost_override_cents |
bigint | nullable | — | The "original lot no longer exists" case |
stock_movement_id |
UUID | nullable | — | Composite FK → inventory.stock_movement |
stock_movement_line_id |
UUID | nullable | — | Composite FK → inventory.stock_movement_line — the real line-grain movement linkage |
reversal_of_return_receipt_line_id |
UUID | nullable | — | Self-referencing composite FK → returns.return_receipt_line, mirrors goods_receipt_line exactly |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
CHECK constraints (1): chk_return_receipt_line_disposition.
Indexes (3): PK on id; return_receipt_line_id_tenant_id_unique UNIQUE(id,tenant_id) (prerequisite for this table's own self-referencing reversal FK); return_receipt_line_tenant_id_return_receipt_id_idx on (tenant_id,return_receipt_id). No dedicated index on lot_id/stock_movement_id/stock_movement_line_id/reversal_of_return_receipt_line_id — a disclosed gap, matching receiving.goods_receipt_line's own identical carried-forward gap, not asserted otherwise.
Trigger function: returns.post_and_cap_return_receipt_line(), fired AFTER INSERT as trg_return_receipt_line_post_and_cap. The persisted line has already passed ordinary-caller RLS, FKs, and row constraints. The definer wrapper re-reads it by ID, locks and derives the authoritative receipt/authorization/source facts, calculates the absorbable quantity (v_absorbed = LEAST(received_qty request, authorized_qty − received_qty already posted)), posts inventory.stock_movement/stock_movement_line (movement_type='returned', source_module='returns') atomically using that derived quantity, and writes the derived links/counters back through the narrow owner policy. It never trusts caller-supplied tenant or source attribution as authority.
Tolerance policy: BLOCK, not flag — a deliberate, disclosed deviation from receiving.goods_receipt_line's own "flag" default: a customer physically returning goods twice against the same authorization is a materially more dangerous default to leave un-gated pending review than a vendor's over-shipment, so an over-tolerance receipt attempt here is rejected outright rather than merely flagged for later review.
Idempotent inventory posting, live-reproduced (per #61): a return_receipt_line insert posts a real inventory.stock_movement; a replayed/duplicate receipt attempt against an already-fully-received line is BLOCKED (not flagged), confirmed exactly 1 stock_movement row exists afterward — never double-posted. The reversal_of_return_receipt_line_id self-referencing FK (mirroring goods_receipt_line's own precedent) resolves correctly.
The seams
The discount-allocation freeze (return_authorization_line's reason for existing). pos.sale_line carries pure per-SKU pricing-rule resolution — no discount/allocation column exists there at all (write-once, append-only, Pricing's Hard Contract 1). A basket-wide offers/rewards discount has zero per-line breakdown anywhere in the codebase before this module: offers.check_and_sync_offer_budget()'s own trigger body literally gives up attributing a basket-wide discount to a line (RAISE EXCEPTION ... 'has no sale_line_id to verify cost against'), and rewards.loyalty_point_ledger has no sale_line_id column at all. returns never touches pos.sale_line and never re-derives this later — it reconstructs and freezes a per-unit eligible-refund ceiling once, at RA-creation time, using the documented proportional-by-pre-discount-extended-price allocation policy, and discloses which reconstruction path was used per line via allocation_source.
The aggregate-cap seam (return_source_line_tracker). pos.sale_line/orders.order_line are append-only and off-limits for a maintained counter column — the same structural constraint offers/rewards already solved once via a dedicated tracker table. return_source_line_tracker mirrors offers.offer_redemption_reversal_tracker's shape exactly, generalized to 2 mutually-exclusive nullable source columns instead of 1 non-nullable one (the real ON CONFLICT-predicate bug this generalization introduced, and its fix, are documented under the tracker's own table section above).
The proportional loyalty/offer clawback seam. return_resolution.loyalty_reversal_ledger_id/.offer_reversal_redemption_id link to 'reverse'-typed rows written using rewards/offers' own now-proportional mechanism (Pass 1, PROJECT_DECISIONS #60) — returns never re-implements loyalty/offer math, exactly as it never re-implements refund math. This capability was originally deferred by the design proposal's own Block 4 as out of scope for v1, since at design time rewards.sync_loyalty_account_balance() hard-enforced exact-full-negation-only and offers.check_and_sync_offer_budget() had no magnitude check at all on a reversal. Pass 1 closed both gaps in the same session, immediately before this build, so this module implements proportional clawback as a real, working capability from day one (Architect Decision 2) — not the deferred placeholder the original design proposed.
The no-physical-receipt warranty path. A return_type='warranty_claim' RA with physical_receipt_required=false never gets a return_receipt/return_receipt_line row at all. Its return_authorization_line.received_qty stays 0 forever — fine, since received_qty/credited_qty are independent peer counters (chk_return_authorization_line_qty_rollup), not a chain. Resolution proceeds directly via return_resolution.resolution_type='replacement'; credited_qty reaches authorized_qty via returns.validate_and_apply_resolution_line()'s own write-back while received_qty never leaves 0. Live-reproduced (per #61): a return_type='warranty_claim' RA with physical_receipt_required=false gets NO return_receipt at all, resolves directly via resolution_type='replacement', and credited_qty reached authorized_qty (1) while received_qty stayed 0 forever.
The inventory-posting seam. Both return_source_line_tracker's aggregate cap and returns.post_and_cap_return_receipt_line()'s derive-and-post trigger reuse this codebase's own proven atomic patterns verbatim — the UPDATE ... WHERE ... <= ceiling RETURNING shape (offers.check_and_sync_offer_budget()'s own precedent) and the derive-then-write-back-atomically shape (receiving's own corrected, second-round trigger). returns never writes inventory.stock directly — only inventory.stock_movement/stock_movement_line, exactly like every other module that posts inventory movements.
Cross-tenant RLS rejection, live-reproduced (per #61): a crafted cross-tenant return_authorization insert (claiming a different tenant_id) is rejected with 42501.
Companion reopens (5, bundled into this same migration — zero column/table impact on any of the 5)
Per PROJECT_DECISIONS #61: pos.sale_refund/sale_refund_line and orders.order_header/order_line each gained UNIQUE(id, tenant_id) (all 4 were PK-only before this build — the prerequisite for returns' own composite FKs into them). inventory.stock_movement.source_module, approvals.approval_request.source_module, and files.attachment.entity_type CHECKs were each widened to accept a returns-related value ('returns', 'returns', and 'return_authorization' respectively). All 5 reopens are constraint/CHECK-shape only — pos, orders, inventory, approvals, and files are all re-locked with zero table/column count change.
The design's own originally-planned rewards/offers reopens are obsolete and were NOT applied. The design proposal (pre-Pass-1) had planned 2 new partial-unique indexes on rewards/offers (UNIQUE (reversed_ledger_id) WHERE entry_type='reverse' and the offers equivalent) to close same-row double-reversal. Pass 1 (#60) already built the correct, more general mechanism (the cumulative-cap tracker tables), and the design's own proposed unique constraints would have been actively wrong given proportional reversal is now legal — a unique constraint on the reference column itself would reject a second legitimate partial reversal outright.
returns — Design Patterns Summary
The real bug found and fixed during this build's own live-reproduction pass
return_source_line_tracker's lazy-creation INSERT ... ON CONFLICT clause, in its first draft, omitted the WHERE predicate needed to match a partial unique index. Unlike Pass 1's own single-nullable-column trackers (each keyed on exactly one non-nullable FK column), this tracker has 2 mutually-exclusive nullable alternative columns (sale_line_id/order_line_id), so ON CONFLICT needed an explicit WHERE sale_line_id IS NOT NULL (or the order_line_id equivalent) to correctly target the matching partial index. Found and fixed during this build's own live-reproduction pass, not by a separate design-phase verification lens.
NULL-in-CHECK sweep
Every new CHECK constraint across all 9 tables references only NOT NULL columns or uses an explicit IS NULL/IS NOT NULL-disjunctive form — confirmed none is reachable via a NULL-bypass (per PROJECT_DECISIONS #61). Notable examples: chk_return_authorization_warranty_requires_type (warranty_id IS NULL OR return_type = 'warranty_claim'); chk_return_source_line_tracker_exactly_one_source ((sale_line_id IS NOT NULL) != (order_line_id IS NOT NULL)); chk_return_resolution_refund_requires_sale_refund (resolution_type != 'refund' OR pos_sale_refund_id IS NOT NULL).
Column-count reconciliation
| Table | Cols |
|---|---|
return_authorization |
29 |
return_authorization_line |
28 |
return_source_line_tracker |
10 |
return_resolution |
21 |
return_resolution_line |
8 |
return_receipt |
14 |
return_receipt_line |
16 |
return_reason |
10 |
warranty |
16 |
| Total | 152 |
Confirmed identical across the Drizzle schema files, the migration, and the live DB via information_schema.columns, schema returns — zero drift (PROJECT_DECISIONS #61's own Section 4 Item A: "column drift: zero, 152/152 confirmed live").
Service layer
No ReturnsService yet — schema-only this pass, matching every other module's own established schema-only-at-build-time pattern. The aggregate cap (return_source_line_tracker), the resolution-line cap/reconciliation/write-back (returns.validate_and_apply_resolution_line()), and the inventory-posting derive-and-cap (returns.post_and_cap_return_receipt_line()) are, however, not waiting on that service layer to be correct — all 3 triggers enforce their own invariants directly and atomically on every relevant insert, regardless of whether any service ever calls it via raw SQL or through a future ReturnsService method.
JSONB columns
return_authorization.risk_factors— e.g.{"return_velocity_30d": 3, "days_since_purchase": 2, "anonymous": false}.return_receipt_line.condition_assessment— e.g.{"grade": "B", "notes": "pot cracked, plant healthy"}.warranty.terms_snapshot— frozen copy ofinventory.item_variant.guarantee_termsat issuance, e.g.{"duration_days":365,"type":"plant_guarantee"}.return_authorization.decision_provenance,return_authorization_line.decision_provenance,return_resolution.decision_provenance— the project-wide reason/evidence/confidence/memory_refs convention, same shape as every other FULL-tier table'sdecision_provenancecolumn codebase-wide.
Triggers, full list (3 new functions + reused shared triggers)
| Trigger | Table | Fires | Function |
|---|---|---|---|
set_updated_at |
8 of 9 tables (all except return_resolution_line) |
BEFORE UPDATE |
platform.set_updated_at() (shared, reused) |
trg_return_resolution_line_append_only |
return_resolution_line |
BEFORE UPDATE OR DELETE |
platform.reject_append_only_mutation() (shared, reused) |
trg_return_authorization_line_check_and_reserve |
return_authorization_line |
BEFORE INSERT |
returns.check_and_reserve_source_line() (NEW) |
trg_return_resolution_line_validate_against_authorization_line |
return_resolution_line |
BEFORE INSERT |
returns.validate_and_apply_resolution_line() (NEW) |
trg_return_receipt_line_post_and_cap |
return_receipt_line |
AFTER INSERT |
returns.post_and_cap_return_receipt_line() (Inventory Core companion) |
Regression tests
apps/api/src/returns/__tests__/returns-schema.spec.ts — new file, 31/31 passing, covering table/RLS/column-count existence, discount allocation, sequential + concurrent aggregate cap, proportional clawback + over-clawback rejection, idempotent posting + BLOCK-not-flag over-receipt, warranty no-receipt flow + the conditional-warranty CHECK, restocking fee arithmetic, unreferenced return, cross-tenant RLS, and all 5 companion-reopen constraint/CHECK confirmations (PROJECT_DECISIONS #61). Full apps/api suite: 1083/1083 passing, confirmed serially via --runInBand — one pre-existing, already-disclosed, unrelated cross-file concurrency flake in admin-tenants.spec.ts intermittently reproduces under parallel Jest workers, not caused by this build.
cd apps/api && npx jest src/returns/__tests__/returns-schema.spec.ts --forceExit
Open items carried forward
No ReturnsService yet — the accept/resolve/void workflow and any future AI risk-tiering agent surfaces are still to be designed at the service layer. return_resolution.store_credit_reference is now deprecated in place (2026-07-18) — billing.store_credit_account/store_credit_transaction exist and store_credit_transaction_id is the real, enforced pointer (see the table section above). Restocking-fee taxability is undecided — a human product decision, not a schema gap (restocking_fee_cents is stored either way). return_authorization.risk_score is computed from live raw joins each time — no cached customer-return-velocity/fraud-signal counter exists yet, deferred to a future crm/reporting surface. warranty.signature_ref remains a deferred Files forward-ref (plain text, no FK), folded into the existing Files FK-wiring bundle already logged in OPEN_ITEMS. crm.customer stays a bare (non-composite) FK from return_authorization.customer_id — matches the universal existing convention, not a gap introduced by this module. return_source_line_tracker rows are never soft-deleted or cleaned up — a theoretical, not practical, orphan concern, since pos.sale_line/orders.order_line are both append-only/soft-delete-only and will never be hard-purged. No dedicated index exists yet on return_receipt_line.lot_id/.stock_movement_id/.stock_movement_line_id/.reversal_of_return_receipt_line_id — matches receiving.goods_receipt_line's own identical carried-forward gap; add if a future query pattern needs it.
Inventory Core companion reopen — 2026-07-15 task run
The receipt-line posting trigger is now a narrow protected schema path. returns.post_and_cap_return_receipt_line() is SECURITY DEFINER, owned by NOLOGIN/non-BYPASSRLS returns_invariant_owner, uses search_path=pg_catalog, and derives tenant, receipt identity, source timestamp, variant, capped quantity, cost, actor, and idempotency from locked Returns rows. It calls the private Inventory primitive, which creates exactly one posting_integrity='complete' header and one child line while updating stock atomically. PUBLIC and all ordinary/executor-role EXECUTE paths are revoked. source_module='returns' remains present in both live PostgreSQL and Drizzle.