Skip to content

Discount handling β€” resolver status + persisted accept/reject (re-pointed)

Org discounts in the review-v2 flow are blanket auto-applied with no operator control:

  • resolveOrgDiscounts (discount-resolver.ts) matches customer-scoped + universal discount definitions (token matching + customer-prefix + seasonal window, then auto-picks one winner) and re-fires server-side on every SKU/customer change via POST /api/erp/orders/[id]/refresh-discounts, which overwrites extracted_orders.customLineItems wholesale. submit.ts re-resolves and merges item-level pins over that set.
  • The Totals tab (TotalsDetailPane) shows only read-only surfaces (WhereFourDiscountSummary, AppliedDiscountsCard) β€” no value clarity, no remove.
  • The only control is per-line DiscountMatchPanel pin/clear in the Items tab.

The original plan (ck-org-discounts-full-stack-plan) promised validated, opt-in discounts with explicit value visibility; only the matching shipped.

Both reviewers found the original fix direction broken: a Totals β€œremove” wired to DELETE /apply-discount clears a per-item pin in extracted_order_items, not the resolver’s extracted_orders.customLineItems, so it’d be cosmetic; and $allowancePinGateEnabled doesn’t govern resolver discounts. Decision: re-point from β€œmanual Totals buttons” to β€œresolver produces trustable, persisted, operator-adjudicable outcomes; UI surfaces only exceptions.”

Phase 1 β€” Read-only value clarity (ship now, no data-model risk)

Section titled β€œPhase 1 β€” Read-only value clarity (ship now, no data-model risk)”
  • In the Totals tab, render each resolved discount with its dollar value (forceSubtotal, signed) and a gross β†’ discount β†’ net breakdown so the operator sees the impact.
  • A discount with forceSubtotal == null renders as β€œvalue unknown” and is visually flagged (not silently shown as β€”).
  • Pure presentation. No endpoint or schema changes. Touches AppliedDiscountsCard.tsx, WhereFourDiscountSummary.tsx, TotalsDetailPane in ReviewCenterPane.tsx.

Phase 2 β€” Persisted accept/reject + resolver status (designed here, built after Phase 1)

Section titled β€œPhase 2 β€” Persisted accept/reject + resolver status (designed here, built after Phase 1)”
  • Add a persisted rejection set to the order so a removed discount stays removed across re-resolve. Drizzle schema change on extracted_orders (e.g. rejectedCustomLineDefinitionIds int[]), generated migration β€” never hand-written SQL (per AGENTS.md).
  • resolveOrgDiscounts emits a status per resolved discount: validated (in-window, customer-scoped, value known), needs_review (universal/loose match or out-of-window), unknown_value (forceSubtotal null). Status is data, not UI heuristic.
  • refresh-discounts and submit.ts subtract the rejected set before persisting/submitting customLineItems β€” both must honor rejections (today both re-resolve and clobber).
  • A null-value resolved discount is a resolver escalation (unknown_value), never auto-applied, never silently submitted.
  • Auto-apply validated discounts (visible + removable, value shown β€” Phase 1 surface).
  • Surface needs_review / unknown_value discounts for explicit operator accept/reject. A reject writes to the persisted rejection set (new small endpoint or extend the order PATCH), an accept pins it.
  • Keep manual adjudication at the line where the evidence (qty, price, extracted text, rowVersion, search) lives; the Totals tab shows the value/exception readout and links to the line.
  • Submit is blocked only by unresolved exceptions (needs_review/unknown not yet adjudicated), not by every discount β€” avoids accept-all fatigue.
  • apps/webapp/src/services/erp/wherefour/discount-resolver.ts (status emission, honor rejections)
  • apps/webapp/src/pages/api/erp/orders/[id]/refresh-discounts.ts (subtract rejected)
  • apps/webapp/src/pages/api/erp/orders/[id]/submit.ts (honor rejected at submit)
  • packages/db/src/schema/pdf-documents.ts (rejection set column) + generated migration
  • apps/webapp/src/components/integrations/AppliedDiscountsCard.tsx (value + exception/remove)
  • apps/webapp/src/components/integrations/WhereFourDiscountSummary.tsx (grossβ†’net)
  • apps/webapp/src/components/orders/review-v2/ReviewCenterPane.tsx (TotalsDetailPane)
  • apps/webapp/src/stores/pdfOrder/review-v2.ts (exception-aware submit gate)
  • Rewriting the resolver’s matching (token/prefix/seasonal) algorithm β€” keep matching; add status + honor rejections only.
  • The persisted-'0' unpriced-line round-trip (separate data-model issue).
  • Promo-context validation beyond in-window/customer-scoped/value-known (a later resolver-quality phase if needed).
  • Rejection key: customLineDefinitionId alone, or (definitionId, erpItemId) since a def can attach to multiple SKUs?
  • Does the rejection set live on extracted_orders (order-scoped) β€” confirmed yes β€” and does submit read the same source the UI mutates?
  • Status thresholds: exact rule for validated vs needs_review given the resolver only has token/prefix/season signals today.

Dual voices: Claude subagent (Plan) + Codex. Both ran independently against the code.

Dimension Claude Codex Consensus
───────────────────────────────────── ────── ───── ─────────
1. "Remove" primitive correct? NO NO CONFIRMED (broken)
2. Opt-in via pin-gate is right lever? NO NO CONFIRMED (wrong lever)
3. Right problem / scope? reframe reframe CONFIRMED (re-point to resolver)
4. Premise "ops want Totals control"? assumed assumed CONFIRMED (unvalidated)
5. Read-only value/gross→net slice OK? YES YES CONFIRMED (ship it)
6. 6-month trajectory sound as-is? NO NO CONFIRMED (regret risk)
  • CRITICAL β€” wrong primitive. Totals card renders extracted_orders.customLineItems (overwritten wholesale on every re-resolve + submit); planned remove wires to DELETE /apply-discount which clears a per-item pin in extracted_order_items. Removal cosmetic/transient.
  • HIGH β€” opt-in lever wrong. $allowancePinGateEnabled governs Items-flow allowance lines, not resolver Totals discounts; default-on = accept-all friction with no acceptance model.
  • HIGH β€” real product is persisted accept/reject honored at submit.
  • HIGH β€” leverage is resolver quality (status/validation), not manual buttons.
  • CONFIRMED SAFE β€” read-only value + grossβ†’net slice.

Re-point to resolver + reject model: ship read-only value/gross→net now (Phase 1); build persisted accept/reject + resolver status honored at submit, exceptions-only UI (Phase 2). Recorded above.


Dual voices: Claude subagent (Plan) + Codex, against the code.

Dimension Claude Codex Consensus
───────────────────────────────────────── ────── ───── ─────────
1. Reject key = definitionId only? YES YES CONFIRMED
2. Submit re-introduces rejected def? YES YES CONFIRMED (filter post-merge)
3. unknown_value block server-side? YES YES CONFIRMED (submit.ts, not just store)
4. Reject set as independent column? YES YES CONFIRMED
5. Concurrency safe without rowVersion? YES NO DISAGREE β†’ taste decision
6. int[] migration safe (notNull default)? YES YES CONFIRMED
  • F1 β€” reject key = definitionId only. Resolver seen is an order-scoped Set<number>; a def fires at most once per order. Composite (definitionId, erpItemId) would break when the winning SKU is removed (def re-binds to another SKU). Resolves the plan’s open question.
  • F2 (CRITICAL) β€” filter rejections AFTER the operator-pin merge in submit.ts (:690), and also subtract before persisting canonical customLineItems (:621) and in refresh-discounts (~:148). Submit re-resolves from scratch and merges line-pins by definitionId; a rejected def can re-enter via a stale line-pin. Order-level reject must win over line-pin.
  • F3 β€” reject set stays an independent column; never folded into customLineItems (which is overwritten wholesale every re-resolve).
  • F4 (CRITICAL) β€” enforce the unknown_value (null forceSubtotal) submit-block server-side in submit.ts (422, like the existing needsReview/staleMatch blocks), not only in the store gate (review-v2.ts only sees product lines, and a direct POST bypasses it). A null-amount discount to WhereFour = an undefined off-invoice deduction.
  • F5 β€” status is computable (scope==='customer_sku' + finite amount β†’ validated; sku_only β†’ needs_review; null forceSubtotal β†’ unknown_value). Drop β€œout-of-window” from the taxonomy (resolver filters those out today; emitting them is a matching change = out of scope). Add status to the customLineItems jsonb $type so the UI can read it after reload.
  • F6 β€” migration: integer('rejected_custom_line_definition_ids').array().notNull().default([]), generated via drizzle-kit (never hand-written). Non-null default backfills existing rows to []; verify the drizzle output dir before generating.
  • Concurrency model. Claude: reject endpoint and refresh write disjoint columns (reject col vs customLineItems col), so no SQL clobber and no rowVersion needed; submit re-filters against the live reject set so the ERP payload is always correct. Codex: refresh can still persist a stale customLineItems that transiently includes a just-rejected def (blind last-write-wins, no rowVersion on extracted_orders); wants optimistic concurrency or a single atomic read-rejects-write-filtered server write. Integrity is safe either way (submit re-filters); the disagreement is whether to also prevent the transient DB/UI staleness.
  • Resolver status unit cases: customer_sku+finiteβ†’validated; sku_onlyβ†’needs_review; null amountβ†’unknown_value (emitted, not dropped).
  • Reject persists through refresh (seed rejected set, re-resolve, assert absent).
  • Submit honors reject via re-resolve path AND degraded-fallback path; reject-beats-pin.
  • unknown_value non-rejected β†’ submit 422, no Temporal start; adjudicated β†’ proceeds.
  • Store gate: unresolved exception β†’ submit disabled (UX only; not the integrity test).

UI surface is small and low-risk after the re-point: read-only per-discount value + grossβ†’net math (follows existing AppliedDiscountsCard patterns + DESIGN.md tokens), plus visually-distinct badges for needs_review/unknown_value exceptions surfaced at the line. Main design call: make exceptions clearly distinct from auto-applied validated discounts (color/badge), and make β€œvalue unknown” unmistakable. Full dual-voice design review condensed given the minimal surface; revisit if the exception UX grows.

Phase 1 (ship now):

  • P1 β€” AppliedDiscountsCard / WhereFourDiscountSummary / TotalsDetailPane β€” per-discount signed value + grossβ†’net math; flag null value as β€œvalue unknown” (read-only).

Phase 2 (after Phase 1, in order):

  • P1 β€” schema β€” rejectedCustomLineDefinitionIds integer[] notNull default [] on extracted_orders; add status to customLineItems jsonb type; generate migration.
  • P1 β€” resolver β€” emit status (validated|needs_review|unknown_value); honor reject set.
  • P1 (CRITICAL) β€” submit.ts β€” subtract reject set AFTER the pin merge + before persisting; server-side 422 on non-rejected unknown_value.
  • P1 β€” refresh-discounts.ts β€” subtract reject set before persisting.
  • P2 β€” reject endpoint β€” column-disjoint write to the reject set (concurrency model per gate decision).
  • P2 β€” UI β€” surface needs_review/unknown exceptions for accept/reject at the line; exception-aware submit gate in review-v2.ts.
  • P2 β€” tests β€” per the test plan above.
Review Trigger Why Runs Status Findings
CEO Review /plan-ceo-review Scope & strategy 1 (via autoplan) re-pointed 5 confirmed; premise re-pointed by user
Eng Review /plan-eng-review Architecture & data integrity 1 (via autoplan) issues_open 2 critical (submit filter, server-side unknown_value block), 1 disagree (concurrency)
Design Review /plan-design-review UI/UX condensed clean minimal read-only + exception surface
Codex Review /codex Independent 2nd opinion 2 (CEO+Eng) confirmed agreed on all criticals
  • CODEX: confirmed both critical eng findings + the CEO re-point independently.
  • CROSS-MODEL: CEO β€” both re-pointed to resolver+reject model. Eng β€” both confirmed submit-filter + server-side unknown_value block; disagreed only on concurrency (rowVersion vs disjoint columns).
  • VERDICT: CEO re-pointed (user-approved premise). Eng issues are design-resolved (criticals have concrete fixes baked into the task list). APPROVED β€” Phase 1 ready to implement immediately; Phase 2 approved with disjoint-column concurrency.

Disjoint-column writes: the reject endpoint writes ONLY rejected_custom_line_definition_ids; refresh-discounts writes ONLY customLineItems; submit.ts always re-filters against the live reject set. No rowVersion on extracted_orders. Integrity holds at submit; transient DB staleness (a just-rejected discount lingering in customLineItems until the next refresh) is accepted as cosmetic.

NO UNRESOLVED DECISIONS