returns — Module Spec

1. Purpose

returns is the customer RMA (return merchandise authorization) module — module #27, schema-locked 2026-07-11. It owns the full return lifecycle: authorization (the RMA header and its per-line eligibility ceiling), physical receipt of returned goods (mirroring receiving.goods_receipt's own shape), the resolution of a return into an outcome (refund/store credit/replacement/repair/warranty credit/reject), and warranty/plant-guarantee claim tracking. It is the customer-facing counterpart to Receiving (module #22) — the same "physical dock event + posting trigger" structural insight, run in reverse: goods flow back in from a customer instead of in from a vendor.

This is Pass 2 of a 2-pass same-day session. Pass 1 (PROJECT_DECISIONS #60) reopened rewards/offers to fix a real, independent bug — offers.check_and_sync_offer_budget() had no magnitude check on a reversal, only a sign check, live-reproduced as exploitable — and, in fixing it, built proportional/partial reversal as a first-class capability for both modules via a new lazily-created cumulative-reversal-cap tracker table in each (loyalty_point_ledger_reversal_tracker / offer_redemption_reversal_tracker). Pass 2 (this module, PROJECT_DECISIONS #61) consumes that capability directly: returns never re-implements loyalty/offer math, exactly as it never re-implements refund math (pos.sale_refund stays the one source of truth for money) — it only links to 'reverse'-typed rows written using rewards/offers' own mechanism. The module's own design proposal (returns-module-design-proposal-2026-07-11.md) had originally scoped proportional clawback as out of v1 pending exactly this rewards/offers fix; because Pass 1 ran first, in the same session, this build implements it as real and working from day one, not deferred (Architect Decision 2).

returns had zero v1 precedent as a module — v1 handled refunds flatly via pos.sale_refund/sale_refund_line (unchanged, still owned by pos, not part of this module's count) with no RMA concept at all. See §8 for the full v1 comparison.

2. Ownership

Owns — 9 tables, 152 columns:

Table Cols Autonomy tier Role
return_authorization 29 FULL The RMA header — site/customer/source-sale/source-order reference, return type, status lifecycle, risk-tiering (risk_score/risk_tier/risk_factors), the genuine escalation-judgment surface
return_authorization_line 28 FULL Freezes the discount/tax allocation at RA-creation time (the module's central design decision, §4 DD-1); authorized_qty/received_qty/credited_qty; eligible_refund_cents — the frozen ceiling
return_source_line_tracker 10 NONE NEW — the aggregate-cap cache closing a real TOCTOU gap (§4 DD-2); no actor attribution, pure numeric counter
return_resolution 21 FULL The outcome header — refund/store_credit/replacement/repair/warranty_credit/reject; links to the rewards/offers reversal seam
return_resolution_line 8 NONE Append-only write-once financial fact — resolved qty/amount per RA line, capped and reconciled by trigger (§4 DD-3/DD-4)
return_receipt 14 LIGHT The receipt header — mirrors receiving.goods_receipt; attribution-only (received_by_actor_id/void columns), no review-workflow columns
return_receipt_line 16 NONE Mutable per-line disposition/condition — mirrors receiving.goods_receipt_line; posts inventory atomically (§4 DD-5); no actor attribution at all
return_reason 10 LIGHT Tenant-scoped reason catalog — mirrors inventory.stock_adjustment_reason exactly; created_by_actor_id only
warranty 16 NONE REVIVED v1 pos.guarantee — plant-guarantee/warranty instance + claim tracking; no actor column, matching v1's own shape verbatim
Total 152

Does NOT own:

  • The refund/payment fact itself (pos.sale_refund/sale_refund_line — unchanged, referenced not duplicated or absorbed; gained UNIQUE(id, tenant_id) as this build's own companion reopen, not moved or renamed).
  • Loyalty/offer point-and-discount math (rewards.loyalty_point_ledger, offers.offer_redemptionreturns only links to a 'reverse'-typed row each module writes using its own mechanism; see §4 DD-6).
  • Store credit / gift-card balances (no spendable-balance table exists anywhere in this codebase — return_resolution.store_credit_reference is a loose, unenforced text/uuid column pending a future billing.store_credit_account/store_credit_transaction pair; see §9).
  • The actual stock mutation (inventory.stockreturn_receipt_line's own trigger posts inventory.stock_movement/stock_movement_line, it never writes inventory.stock directly, mirroring Receiving's own DR-1 guard).
  • Tax calculation (tax.tax_calculation's reversal contract — calculation_type='reversal', source_type='sale_refund_line' — was already schema-complete and unfired; this module is its first real caller-in-waiting, no new tax schema needed).
  • Cached fraud/return-velocity analytics (return_authorization.risk_score is computed from live raw joins each time — no cached counter table exists; deferred to a future crm/reporting surface).

3. Layer & Dependencies

Tenant-scoped business layer, sitting downstream of pos/orders (what was sold) and upstream of inventory (what physically comes back), with a direct calling relationship into rewards/offers (what gets clawed back) — the first-ever business→rewards/offers-direction FK in this codebase (disclosed in the design proposal as a genuinely new precedent, permitted per SCHEMA_CONVENTIONS.md §13/§14 since rewards/offers are fully tenant-scoped like any business schema, not walled off behind consumer's SECURITY-DEFINER boundary).

Depends on:

  • platformtenant.id (every tenant_id column).
  • multi_locsite.id (return_authorization.site_id, return_receipt.site_id).
  • possale.id/sale_line.id (composite, riding this build's own new UNIQUE(id, tenant_id) prerequisite on both), sale_refund.id/sale_refund_line.id (composite, same new prerequisite).
  • ordersorder_header.id/order_line.id (composite, riding this build's own new UNIQUE(id, tenant_id) prerequisite on both).
  • inventorylot.id, stock_movement/stock_movement_line (the posting seam; stock_movement.source_module CHECK widened this build to add 'returns').
  • rewardsloyalty_point_ledger.id (composite, via return_resolution.loyalty_reversal_ledger_id).
  • offersoffer_redemption.id (composite, via return_resolution.offer_reversal_redemption_id).
  • crmcustomer.id (plain FK, matching the universal bare-FK convention for every crm.customer reference in this codebase — not a new gap).
  • identityactor.id (every *_actor_id column, bare, codebase-wide convention).
  • admin — read-only (no FK) for any tenant-configurable policy value, following the established tenant_setting/setting_definition pattern (no such value is actually consumed by this pass's triggers — the BLOCK-not-flag tolerance is a fixed policy, not tenant-configurable, see §4 DD-5).

Depended on by: none yet — returns has no known reverse dependents this pass; a future crm/reporting surface may eventually read return_authorization for fraud/return-velocity analytics (deferred, §9).

4. Key Design Decisions

DD-1 — Discount/tax allocation is frozen at RA-creation time, not re-derived later

pos.sale_line carries only per-SKU pricing-rule resolution (Pricing's Hard Contract 1) — there is no discount-allocation column of any kind, and it is write-once/append-only, off-limits to returns. A basket-wide offer redemption records exactly one lump discount_amount_applied_cents for the whole sale with zero per-line breakdown; rewards.loyalty_point_ledger has no sale_line_id at all. If the allocation isn't reconstructed and frozen at RA-creation time, it can never be correctly known later — the data was never computed or stored in the first place. return_authorization_line therefore freezes pre_discount_extended_price_cents, allocated_discount_cents, effective_unit_price_cents, allocated_tax_cents, and the resulting eligible_refund_cents ceiling at the moment the RA line is created, using a documented proportional-by-pre-discount-extended-price allocation policy — a genuinely new policy this codebase had never defined before. allocation_source (CHECK: line_scoped_offer/basket_allocated_offer/loyalty_allocated/none) discloses which reconstruction path produced the frozen figure, since only line_scoped_offer rests on solid per-line source data — the other two rest on the proportional-allocation assumption. Live-reproduced: 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).

DD-2 — return_source_line_tracker: the aggregate-cap cache, closing a real TOCTOU gap

Neither pos.sale_line nor orders.order_line can carry a returned_qty counter (both append-only). Without a dedicated cap, nothing stops multiple separate RMAs against the same source line from cumulatively over-returning it — the identical TOCTOU class this codebase already had to close twice before, via atomic row-locking UPDATE, not query-then-insert. return_source_line_tracker is a small, purpose-built cache (10 cols, no soft delete, no actor attribution) mirroring offers.offer_redemption_reversal_tracker's own shape: one row per source line, created lazily on first touch (INSERT ... ON CONFLICT (tenant_id, sale_line_id) WHERE sale_line_id IS NOT NULL DO NOTHING), snapshotting original_qty/original_charged_amount_cents, then capping total_authorized_qty/total_eligible_refund_cents atomically via returns.check_and_reserve_source_line() — the same UPDATE ... WHERE ... <= ceiling RETURNING shape offers.check_and_sync_offer_budget() already uses. Because this tracker (unlike Pass 1's single-nullable-column trackers) has two mutually exclusive nullable alternative columns (sale_line_id/order_line_id), its ON CONFLICT clause needs the WHERE predicate to match the correct partial unique index — a real bug in the first draft, caught and fixed during this build's own live-reproduction. Live-reproduced: a second 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, resolved exactly 2-of-3 succeeding (cumulative 4, correctly capped under 5) — the tracker's own total_authorized_qty confirmed at exactly 4, never 6.

DD-3 — return_resolution_line is HEADER-IS-TRUTH, mirroring purchasing.vendor_credit_line's own precedent (not LINES-ARE-TRUTH)

return_resolution_line is append-only (write-once financial fact, mirroring billing.ar_charge_line/purchasing.vendor_credit_line), and its cap trigger, returns.validate_and_apply_resolution_line(), sums resolved_amount_cents across every sibling row sharing the same return_authorization_line_id — regardless of which return_resolution header each belongs to — against that line's own eligible_refund_cents ceiling, race-free via an explicit SELECT ... FOR UPDATE row lock taken on the RA line before the SUM. This is the header-is-truth shape: the ceiling lives on return_authorization_line (a header-like row elsewhere), and every resolution-line write is validated against it — the same relationship purchasing.trg_vendor_credit_line_validate_against_credit already established (reject any line write that would push SUM(lines) over the parent's own stored ceiling). This is deliberately the opposite pattern from platform.subscription_invoice_line's own trigger, which instead syncs the header FROM SUM(lines) (LINES-ARE-TRUTH) — returns did not need that shape here because eligible_refund_cents is a value frozen once at RA-line-creation time (DD-1), not a running total the header itself must stay in sync with. The grouping key is deliberately return_authorization_line_id, not return_resolution_id — an RA line's ceiling is shared across possibly-multiple resolution events over time (e.g. partial store credit now, more later), so the header-scoped grouping a naive read of vendor_credit_line's own precedent would suggest is the wrong key here.

DD-4 — the sale_refund_line reconciliation, closing a duplicate-money-fact gap

return_resolution_line.resolved_amount_cents would otherwise be a drift-prone duplicate of pos.sale_refund_line's own authoritative refund amount for the resolution_type='refund' case — exactly the "duplicate money fact, unsynchronized" bug class this codebase has repeatedly had to close elsewhere. sale_refund_line_id (nullable composite FK → pos.sale_refund_line(id, tenant_id)) is populated only when the parent return_resolution.resolution_type='refund'; when populated, a trigger requires resolved_amount_cents to equal sale_refund_line.refunded_amount_minor_units exactly — sale_refund_line stays the one authoritative source for a true refund amount, and this column reconciles against it rather than restating an independent figure. For every non-refund resolution_type (store credit/replacement/repair/warranty credit), there is no sale_refund_line to reconcile against, so this column stays NULL and resolved_amount_cents is this table's own authoritative figure for that case.

DD-5 — return_receipt_line's tolerance policy is BLOCK, not flag — a deliberate deviation from Receiving's own default

returns.post_and_cap_return_receipt_line() derives the absorbable quantity itself (v_absorbed := LEAST(received_qty_requested, authorized_qty − received_qty), via SELECT ... FOR UPDATE on the target RA line) and posts inventory.stock_movement/stock_movement_line (movement_type='returned', source_module='returns') atomically in the same invocation, using the derived quantity, never the raw caller-supplied value — the same idempotent-posting structure receiving.goods_receipt_line's own trigger uses. Where it deliberately diverges: tolerance policy is BLOCK, not flag — 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 (Receiving's own tolerance default, appropriate there because the counterparty is a vetted vendor, not a walk-in customer). This is applied here as the corrected, second-round shape of Receiving's own trigger — which itself needed two independent verification passes to reach — built right from day one rather than repeating Receiving's own first, weaker draft. Live-reproduced: idempotent posting confirmed exactly one stock_movement row after a replayed/duplicate receipt attempt against an already-fully-received line, which is BLOCKED outright (not flagged); reversal_of_return_receipt_line_id (a self-referencing composite FK, mirroring goods_receipt_line's own precedent) resolves correctly.

DD-6 — received_qty/credited_qty are independent peer counters, not a chain — the mechanism the no-physical-receipt warranty path depends on

return_authorization_line.received_qty (maintained by DD-5's trigger, off return_receipt_line) and .credited_qty (maintained by DD-3's trigger, off return_resolution_line) are each independently capped against authorized_qty (chk_return_authorization_line_qty_rollup: received_qty <= authorized_qty AND credited_qty <= authorized_qty) but neither is derived from or gated by the other. This is deliberate, not an oversight: a return_type='warranty_claim' RA with physical_receipt_required=false gets no return_receipt/return_receipt_line row at all — a dead plant doesn't need to physically come back for the nursery to honor the guarantee. Resolution proceeds directly via resolution_type='replacement', and credited_qty reaches authorized_qty while received_qty stays 0 forever. If the two counters were instead a chain (credited_qty bounded by received_qty), this path would be structurally dead-ended. Live-reproduced: a warranty-claim RA with physical_receipt_required=false resolves via replacement with credited_qty reaching authorized_qty (1) while received_qty stays 0 — proving the independent-peer-counter design works as intended, not merely as documented.

DD-7 — Restocking fee reduces the refund directly and is never separately taxed

A genuinely new policy (grep -ri restock returned nothing codebase-wide before this build): restocking_fee_cents on return_authorization_line subtracts directly from the effective price to produce eligible_refund_cents — it is not itself a taxable line item. Live-reproduced: a $40.00 effective price minus a $5.00 restocking fee correctly yields $35.00 eligible refund (not $40 or $50).

DD-8 — Proportional loyalty/offer clawback is real, not deferred, because Pass 1 ran first in the same session

The design proposal's own Block 4 had scoped proportional clawback as out of v1 scope, since at design time rewards.sync_loyalty_account_balance() supported only exact-full-negation reversal and offers.check_and_sync_offer_budget() had no magnitude check at all — neither could safely support a partial return's proportional clawback. Pass 1 (PROJECT_DECISIONS #60), run immediately before this build in the same session, closed that exact gap in both modules. This build therefore implements proportional clawback as a real, working capability from day one (Architect Decision 2): return_resolution.loyalty_reversal_ledger_id/.offer_reversal_redemption_id link to 'reverse'-typed rows written using rewards/offers' own now-proportional mechanism — returns never re-implements loyalty/offer math, the same "calls the module that owns it" discipline it already applies to refund money (pos.sale_refund). Live-reproduced: 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); a second partial return (1 more, cumulative 3/5) correctly claws back an additional 20 points + $2.00 (cumulative 60/$6.00, proving no double-clawback); an over-clawback attempt (70 more, would total 130 > 100) is correctly rejected by Pass 1's own cumulative-cap tracker. The design's own originally-planned rewards/offers reopens (2 new partial-unique indexes, UNIQUE (reversed_ledger_id) WHERE entry_type='reverse' and its offers equivalent) are obsolete and were not applied — they would have been actively wrong given proportional reversal is now legal (multiple partial reversals against one original row).

DD-9 — Warranty revival: a capability gain, not a lossy migration

returns.warranty revives v1's pos.guarantee (16 cols, independently re-verified column-for-column against docs/old/schema/schema_modules/schema_pos.md) verbatim in shape — 13 columns unchanged, 2 cosmetic renames (variant_iditem_variant_id, guarantee_typewarranty_type), and one retarget: claimed_refund_idclaimed_via_resolution_id, now pointing at returns.return_resolution instead of pos.sale_refund. v1 assumed every guarantee claim resolves as a refund; this module's own differentiator flow (a dead-plant warranty claim resolves as a replacement, tracked as a warranty expense, never a refund) makes that assumption wrong on its face. return_resolution represents every outcome pos.sale_refund could plus four more (replacement/repair/warranty_credit/store_credit) v1 had no way to express — the sole reshaping case in this build, and it is a capability gain, not a loss.

DD-10 — Unreferenced ("blind") walk-in returns are structurally supported

return_authorization.source_sale_id/.source_order_id/.customer_id are all nullable, with item_variant_id as the identification fallback on the line and full risk-tiering (risk_score/risk_tier/risk_factors) available regardless — consistent with this codebase's own Phase 4 ALLOW decision for anonymous walk-in returns (PROJECT_DECISIONS #40). Live-reproduced: an RMA accepted with no source_sale_id/source_order_id/customer_id at all correctly risk-tiers (risk_score=75, risk_tier='high').

5. Autonomy / Agent-Authority Mapping

No new authority mechanism — returns is a pure consumer of identity.agent_duty_grant, matching every product module built since crm. The autonomy pack is applied selectively, by table, based on where a genuine human/agent judgment surface actually exists:

Table Autonomy pack? Rationale
return_authorization Full (created_by_actor_id/automation_source/review_status/review_reason/reviewed_by_actor_id/reviewed_at/decision_provenance) The genuine risk-tiering/escalation judgment surface — accepting or flagging an RMA, especially an unreferenced one, is a real decision an agent could draft and a human could review.
return_authorization_line Full (same 7-column pack) Per-line disposition/allocation judgment rides the same review seam as its header.
return_resolution Full (7-column pack) Choosing/approving the outcome (refund vs. store credit vs. replacement vs. reject) is the other genuine judgment surface in this module.
return_source_line_tracker None A pure numeric cumulative-cap counter, tenant-scoped RLS only — mirrors offers.offer_redemption_reversal_tracker's and rewards.loyalty_point_ledger_reversal_tracker's own precedent of carrying no *_actor_id column at all.
return_resolution_line None An append-only, write-once financial fact (created_at only) — mirrors billing.ar_charge_line/purchasing.vendor_credit_line's own precedent of no actor attribution on the line-grain money fact itself; provenance lives on the return_resolution header instead.
return_receipt Light (received_by_actor_id, voided_by_actor_id/void_reason) Attribution only, no review-workflow columns — mirrors receiving.goods_receipt_line's own framing that physically receiving/voiding goods is a human/system dock event, not an agent decision.
return_receipt_line None No actor attribution at all — the per-line disposition is a physical inspection outcome, mutable pre-finalization, mirroring goods_receipt_line's own low-agency framing taken one step further.
return_reason Light (created_by_actor_id only) A tenant-scoped catalog, mirrors inventory.stock_adjustment_reason's own minimal attribution.
warranty None No actor column at all — matches v1 pos.guarantee's own shape verbatim; a warranty instance is issued/tracked, not an agent-drafted decision surface in this pass.

return_source_line_tracker and return_resolution_line deliberately carry no actor attribution — both are mechanical, trigger-maintained facts (a running cap counter and a write-once financial line respectively), not judgment surfaces, mirroring offers/rewards' own tracker-table precedent (established one migration earlier, in the same session, by Pass 1) exactly.

6. Service Contract — ReturnsService

Does not exist yet. Schema + migration + tests only this pass — matching every other schema-only module's own established first-pass precedent (crm, inventory, pricing, pos, orders, purchasing, tax, billing, payments, admin, approvals, receiving, files, consumer, rewards, offers all shipped schema-only before their service layer). Downstream code queries returns.* directly via Drizzle until one is built. The 3 trigger functions in §4 (DD-2/DD-3/DD-5) already DB-enforce their respective caps atomically, regardless of what any future ReturnsService does or doesn't do — the binding remaining contract for that service is simply: never bypass them with a direct multi-step INSERT/UPDATE sequence that races the same guarantees, and always call RewardsService/OffersService's own .reverse()-shaped methods (once they exist) rather than constructing a 'reverse'-typed ledger/redemption row directly.

7. Cross-Module Seams

Direction Contract
returnspos return_authorization.source_sale_id → pos.sale(id,tenant_id); return_authorization_line.sale_line_id → pos.sale_line(id,tenant_id); return_resolution.pos_sale_refund_id → pos.sale_refund(id,tenant_id) (required when resolution_type='refund') and .replacement_sale_id → pos.sale(id,tenant_id) (exchanges); return_resolution_line.sale_refund_line_id → pos.sale_refund_line(id,tenant_id) (DD-4 reconciliation); warranty.sale_id/.sale_line_id → pos (composite). pos.sale_refund/sale_refund_line gained UNIQUE(id,tenant_id) as this build's own companion reopen — sale_refund stays referenced, never absorbed.
returnsorders return_authorization.source_order_id → orders.order_header(id,tenant_id); return_authorization_line.order_line_id → orders.order_line(id,tenant_id) — both composite, riding this build's own new UNIQUE(id,tenant_id) prerequisite on order_header/order_line.
returnsinventory return_receipt_line posts inventory.stock_movement/stock_movement_line (movement_type='returned', source_module='returns' — CHECK widened this build) atomically via returns.post_and_cap_return_receipt_line() (DD-5); never writes inventory.stock directly; return_receipt_line.lot_id → inventory.lot.
returnsrewards return_resolution.loyalty_reversal_ledger_id → rewards.loyalty_point_ledger(id,tenant_id) (composite) — a 'reverse'-typed row written using rewards' own now-proportional mechanism (DD-8); returns never computes point math itself.
returnsoffers return_resolution.offer_reversal_redemption_id → offers.offer_redemption(id,tenant_id) (composite) — same 'reverse'-typed-row pattern via offers' own proportional clawback mechanism (DD-8).
returnstax No schema seam added — tax.tax_calculation's calculation_type='reversal'/source_type='sale_refund_line' contract was already schema-complete and unfired; this module is its first real caller-in-waiting.
returnscrm return_authorization.customer_id → crm.customer (plain/bare FK, matching the universal convention for every crm.customer reference codebase-wide — not a new gap).
returnsfiles files.attachment.entity_type CHECK widened this build to add 'return_authorization' (return photos / warranty evidence); warranty.signature_ref stays a deferred forward-ref → files.file.id, folded into the existing Files FK-wiring bundle.
returnsapprovals approvals.approval_request.source_module CHECK widened this build to add 'returns'.
returnsidentity Every *_actor_id → identity.actor.id (bare, codebase-wide convention); consumes agent_duty_grant verbatim — no new authority mechanism.

5 companion reopens bundled into this same migration (all constraint/CHECK-shape only — zero column/table count impact on any of the 5): pos.sale_refund/sale_refund_line and orders.order_header/order_line each gained UNIQUE(id, tenant_id); inventory.stock_movement.source_module, approvals.approval_request.source_module, and files.attachment.entity_type CHECKs were each widened ('returns', 'returns', 'return_authorization' respectively).

8. v1 Comparison / Exclusions

returns had zero v1 precedent as a module — v1 handled refunds flatly via pos.sale_refund/sale_refund_line (unchanged today, still owned by pos, not part of this module's own table count) with no RMA/authorization concept at all. The only v1 table ever genuinely adjacent to this module was pos.guarantee (16 cols) — scoped once, deferred, and never built, held up historically in this codebase's own record as "the canonical example of the right approach" for vertical-specific extensibility. That single deferred table is now revived, verbatim in shape, as returns.warranty (DD-9): 13 columns unchanged, 2 cosmetic renames (variant_iditem_variant_id, guarantee_typewarranty_type), 1 retarget (claimed_refund_idclaimed_via_resolution_id, now pointing at returns.return_resolution instead of pos.sale_refund, since a warranty claim can resolve as a replacement, not only a refund — v1's refund-only assumption was wrong). Zero columns dropped.

v1: 0 tables / 0 cols as a module, + 1 deferred-never-built table (16 cols) → v2: 9 tables / 152 cols. Net: strictly additive — nothing merged away, nothing lost. The 8 tables with no v1 precedent at all (return_authorization, return_authorization_line, return_source_line_tracker, return_resolution, return_resolution_line, return_receipt, return_receipt_line, return_reason) are all genuinely new capability this codebase never had, not a restatement of anything that previously existed.

9. Deferred / Future Items

  1. ReturnsService — no service layer exists yet (§6), matching every other schema-only module's own established pattern.
  2. Store creditRESOLVED 2026-07-18 (Phase 3 stored-value build, PROJECT_DECISIONS #71), exactly as this item recommended: billing.store_credit_account/store_credit_transaction now exist (a billing reopen, reversing v1's own pos placement). return_resolution.store_credit_transaction_id is now a real composite FK, required for store_credit/warranty_credit resolutions; store_credit_reference deprecated in place.
  3. Restocking-fee taxabilityrestocking_fee_cents is stored and reduces the refund directly (DD-7), but whether the fee itself is subject to tax remains an undecided human product policy question.
  4. Cached customer-return-velocity/fraud counterreturn_authorization.risk_score is computed from live raw joins each time; no cached counter table exists. Deferred to a future crm/reporting surface.
  5. warranty.signature_ref → files.file.id — a plain uuid today, folded into the existing, already-logged Files FK-wiring bundle (the same bundle 8 other modules' forward-refs into files.file are waiting on).
  6. Gift card (bearer instrument) balance — explicitly out of returns' own scope; billing.gift_card now exists (Phase 3, 2026-07-18) and pos.sale_payment.gift_card_id is a real FK, but returns itself never references gift cards directly (a gift-card-purchase refund's clawback is a billing-internal concern, not a returns seam).
  7. return_source_line_tracker rows are never soft-deleted or cleaned up — a theoretical, not practical, concern, since both pos.sale_line and orders.order_line are append-only/soft-delete-only and will never be hard-purged.
  8. The anonymous-walk-in fraud-signal counter and store-credit table were both explicitly named as OPEN_ITEMS at design time. The store-credit table is now resolved (item 2 above, 2026-07-18); the anonymous-walk-in fraud-signal counter remains open.

See PROJECT_DECISIONS.md #61 for the complete build record — the full Section 4 self-audit, the NULL-in-CHECK sweep, the 14-sub-guard-plus-concurrency live-reproduction, and the regression-test inventory (apps/api/src/returns/__tests__/returns-schema.spec.ts, 31/31 passing; full apps/api suite 1083/1083). See PROJECT_DECISIONS.md #60 for the Pass 1 rewards/offers proportional-reversal fix this module's own DD-8 depends on.

Last modified: Jul 12, 2026, 10:49 PM PT
On this page
Esc