Discount handling β resolver status + persisted accept/reject (re-pointed)
Problem
Section titled βProblemβ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 viaPOST /api/erp/orders/[id]/refresh-discounts, which overwritesextracted_orders.customLineItemswholesale.submit.tsre-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
DiscountMatchPanelpin/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.
Re-point (decided at autoplan premise gate)
Section titled βRe-point (decided at autoplan premise gate)β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 == nullrenders as βvalue unknownβ and is visually flagged (not silently shown asβ). - Pure presentation. No endpoint or schema changes. Touches
AppliedDiscountsCard.tsx,WhereFourDiscountSummary.tsx,TotalsDetailPaneinReviewCenterPane.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)βData model
Section titled βData modelβ- 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). resolveOrgDiscountsemits a status per resolved discount:validated(in-window, customer-scoped, value known),needs_review(universal/loose match or out-of-window),unknown_value(forceSubtotalnull). Status is data, not UI heuristic.
refresh-discountsandsubmit.tssubtract the rejected set before persisting/submittingcustomLineItemsβ 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.
UI (exceptions only)
Section titled βUI (exceptions only)β- Auto-apply
validateddiscounts (visible + removable, value shown β Phase 1 surface). - Surface
needs_review/unknown_valuediscounts 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.
Files in blast radius
Section titled βFiles in blast radiusβ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 migrationapps/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)
Out of scope
Section titled βOut of scopeβ- 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).
Open questions (for Eng review)
Section titled βOpen questions (for Eng review)β- Rejection key:
customLineDefinitionIdalone, 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
validatedvsneeds_reviewgiven the resolver only has token/prefix/season signals today.
CEO Review (autoplan Phase 1)
Section titled βCEO Review (autoplan Phase 1)βDual voices: Claude subagent (Plan) + Codex. Both ran independently against the code.
CEO consensus table
Section titled βCEO consensus tableβ 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)Confirmed findings (both models)
Section titled βConfirmed findings (both models)β- CRITICAL β wrong primitive. Totals card renders
extracted_orders.customLineItems(overwritten wholesale on every re-resolve + submit); planned remove wires toDELETE /apply-discountwhich clears a per-item pin inextracted_order_items. Removal cosmetic/transient. - HIGH β opt-in lever wrong.
$allowancePinGateEnabledgoverns 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.
Premise-gate decision (user)
Section titled βPremise-gate decision (user)β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.
Eng Review (autoplan Phase 3)
Section titled βEng Review (autoplan Phase 3)βDual voices: Claude subagent (Plan) + Codex, against the code.
Eng consensus table
Section titled βEng consensus tableβ 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 CONFIRMEDConfirmed findings
Section titled βConfirmed findingsβ- F1 β reject key =
definitionIdonly. Resolverseenis an order-scopedSet<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:621) and incustomLineItems(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 insubmit.ts(422, like the existing needsReview/staleMatch blocks), not only in the store gate (review-v2.tsonly sees product lines, and a direct POST bypasses it). A null-amount discount to WhereFour = an undefined off-invoice deduction. - F5 β
statusis 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). Addstatusto thecustomLineItemsjsonb$typeso 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.
DISAGREE (taste decision for gate)
Section titled βDISAGREE (taste decision for gate)β- 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
customLineItemsthat transiently includes a just-rejected def (blind last-write-wins, no rowVersion onextracted_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.
Test plan (Phase 2)
Section titled βTest plan (Phase 2)β- 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_valuenon-rejected β submit 422, no Temporal start; adjudicated β proceeds.- Store gate: unresolved exception β submit disabled (UX only; not the integrity test).
Design (autoplan Phase 2 β condensed)
Section titled βDesign (autoplan Phase 2 β condensed)β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.
Implementation Tasks (aggregated)
Section titled βImplementation Tasks (aggregated)β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 []onextracted_orders; addstatustocustomLineItemsjsonb 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.
GSTACK REVIEW REPORT
Section titled βGSTACK REVIEW REPORTβ| 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.
Concurrency decision (resolved at final gate)
Section titled βConcurrency decision (resolved at final gate)β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