@12-apps/discounts 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/package.json +73 -0
- package/src/engine/allocate.ts +133 -0
- package/src/engine/eligibility.ts +160 -0
- package/src/engine/evaluate-passes.ts +217 -0
- package/src/engine/evaluate.ts +339 -0
- package/src/engine/kinds.ts +87 -0
- package/src/engine/preview.ts +164 -0
- package/src/engine/pt-BR.ts +26 -0
- package/src/engine/rejection-copy.ts +69 -0
- package/src/engine/types.ts +147 -0
- package/src/index.ts +69 -0
- package/src/manifest/index.ts +52 -0
- package/src/manifest/server.ts +23 -0
- package/src/server/contribution.ts +42 -0
- package/src/server/copy.ts +64 -0
- package/src/server/index.ts +54 -0
- package/src/server/mcp.ts +197 -0
- package/src/server/pt-BR.ts +26 -0
- package/src/server/routes.ts +200 -0
- package/src/server/search.ts +45 -0
- package/src/server/store.ts +101 -0
- package/src/server/validate.ts +296 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { allocateByLargestRemainder, maxDiscountableCents, rawAmountCents } from "./allocate";
|
|
2
|
+
import type { AppliedDiscount, DiscountCartLine, DiscountRule } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* APPLICATION core of the discount evaluator (FUT-245): rules R4 (a discount's
|
|
6
|
+
* base is what is still discountable), R6 (the three narrowest-first passes),
|
|
7
|
+
* R9 (the payable floor, imposed here rather than on the finished total) and the
|
|
8
|
+
* plumbing that turns one set of candidates into one complete
|
|
9
|
+
* "what if we applied exactly these" {@link Outcome}, which R8 in `./evaluate.ts`
|
|
10
|
+
* then compares against the alternatives.
|
|
11
|
+
*
|
|
12
|
+
* Split out of `./evaluate.ts` purely for the 400-line file gate — the same
|
|
13
|
+
* reason `order-confirm-payment.ts` sits beside `order-confirm.ts`. Read it as
|
|
14
|
+
* the middle of that file, not as a separate concept. Pure: no database, no
|
|
15
|
+
* clock, no I/O.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** R6's three passes, narrowest scope first. */
|
|
19
|
+
const SCOPE_PASSES = ["ITEM", "CATEGORY", "ORDER"] as const;
|
|
20
|
+
|
|
21
|
+
/** A rule that survived R2, carried together with its R3 covered set. */
|
|
22
|
+
export interface Candidate {
|
|
23
|
+
rule: DiscountRule;
|
|
24
|
+
covered: ReadonlySet<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The mutable working set of one outcome: what is left on each line so far. */
|
|
28
|
+
export interface EvaluationState {
|
|
29
|
+
remaining: Map<string, number>;
|
|
30
|
+
applied: AppliedDiscount[];
|
|
31
|
+
/** R1 gross of the whole cart — the fixed reference R9's floor measures from. */
|
|
32
|
+
subtotalCents: number;
|
|
33
|
+
/** `Σ applied[].amountCents` so far, kept beside it so R9's headroom is O(1). */
|
|
34
|
+
appliedTotalCents: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One complete run over a candidate set, ready to be compared with another. */
|
|
38
|
+
export interface Outcome {
|
|
39
|
+
state: EvaluationState;
|
|
40
|
+
candidates: readonly Candidate[];
|
|
41
|
+
totalCents: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** R1 — a line's gross value, floored at zero for defensive inputs. */
|
|
45
|
+
export function lineGrossCents(line: DiscountCartLine): number {
|
|
46
|
+
return Math.max(0, line.unitPriceCents * line.quantity);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function freshState(lines: readonly DiscountCartLine[]): EvaluationState {
|
|
50
|
+
return {
|
|
51
|
+
remaining: new Map(lines.map((line) => [line.lineId, lineGrossCents(line)])),
|
|
52
|
+
applied: [],
|
|
53
|
+
subtotalCents: lines.reduce((sum, line) => sum + lineGrossCents(line), 0),
|
|
54
|
+
appliedTotalCents: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Deterministic total order on rules: oldest first, then lowest id. */
|
|
59
|
+
export function compareRuleOrder(a: DiscountRule, b: DiscountRule): number {
|
|
60
|
+
const byAge = a.createdAt.getTime() - b.createdAt.getTime();
|
|
61
|
+
if (byAge !== 0) return byAge;
|
|
62
|
+
if (a.id === b.id) return 0;
|
|
63
|
+
return a.id < b.id ? -1 : 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** R4 — what is still discountable on each of a candidate's covered lines. */
|
|
67
|
+
function remainingFor(state: EvaluationState, covered: ReadonlySet<string>): Map<string, number> {
|
|
68
|
+
const remaining = new Map<string, number>();
|
|
69
|
+
for (const lineId of covered) {
|
|
70
|
+
remaining.set(lineId, Math.max(0, state.remaining.get(lineId) ?? 0));
|
|
71
|
+
}
|
|
72
|
+
return remaining;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sumValues(values: ReadonlyMap<string, number>): number {
|
|
76
|
+
let total = 0;
|
|
77
|
+
for (const cents of values.values()) total += cents;
|
|
78
|
+
return total;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function chargeAllocation(state: EvaluationState, allocation: ReadonlyMap<string, number>): void {
|
|
82
|
+
for (const [lineId, cents] of allocation) {
|
|
83
|
+
state.remaining.set(lineId, (state.remaining.get(lineId) ?? 0) - cents);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toApplied(rule: DiscountRule, amountCents: number): AppliedDiscount {
|
|
88
|
+
return {
|
|
89
|
+
discountId: rule.id,
|
|
90
|
+
name: rule.name,
|
|
91
|
+
code: rule.code,
|
|
92
|
+
type: rule.type,
|
|
93
|
+
scope: rule.scope,
|
|
94
|
+
percentOffBp: rule.percentOffBp,
|
|
95
|
+
amountOffCents: rule.amountOffCents,
|
|
96
|
+
amountCents,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* R9 — the cents this outcome may still give away: everything above the payable
|
|
102
|
+
* floor that earlier discounts have not already taken.
|
|
103
|
+
*
|
|
104
|
+
* Measured against the SUBTOTAL and the running applied total rather than
|
|
105
|
+
* against the per-line remainders, because the floor is a property of the ORDER
|
|
106
|
+
* (one charge, one provider) and not of any line. On a cart whose gross is zero
|
|
107
|
+
* this is zero, so a free cart stays free instead of being handed a phantom cent.
|
|
108
|
+
*/
|
|
109
|
+
function headroomCents(state: EvaluationState): number {
|
|
110
|
+
return maxDiscountableCents(state.subtotalCents) - state.appliedTotalCents;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Apply one candidate to the running state (R4 → R5 → R9 → R7) and return what
|
|
115
|
+
* it removed. Zero removes nothing and records nothing — R5's `ZERO_VALUE` case,
|
|
116
|
+
* because an `R$ 0,00` line is not a promotion.
|
|
117
|
+
*
|
|
118
|
+
* The R9 cap sits BETWEEN the raw amount and the allocation on purpose: the
|
|
119
|
+
* clamped amount is the one that gets split across the lines, so the per-line
|
|
120
|
+
* parts still sum to what was actually applied and the double-entry invariant
|
|
121
|
+
* survives a clamp. A discount whose headroom has run out is simply never
|
|
122
|
+
* applied, and `./evaluate.ts` reports it as `ZERO_VALUE` — it removed nothing,
|
|
123
|
+
* which is exactly what that reason means, and inventing a second "the cart is
|
|
124
|
+
* already free" reason would say nothing more to a buyer.
|
|
125
|
+
*/
|
|
126
|
+
function applyOne(state: EvaluationState, candidate: Candidate): number {
|
|
127
|
+
const remaining = remainingFor(state, candidate.covered);
|
|
128
|
+
const rawCents = rawAmountCents(candidate.rule, sumValues(remaining));
|
|
129
|
+
const amountCents = Math.min(rawCents, headroomCents(state));
|
|
130
|
+
if (amountCents <= 0) return 0;
|
|
131
|
+
chargeAllocation(state, allocateByLargestRemainder(amountCents, remaining));
|
|
132
|
+
state.applied.push(toApplied(candidate.rule, amountCents));
|
|
133
|
+
state.appliedTotalCents += amountCents;
|
|
134
|
+
return amountCents;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* R6's within-pass ordering: biggest saving first, measured against the state
|
|
139
|
+
* at PASS ENTRY (not re-measured mid-pass, or the order would depend on
|
|
140
|
+
* itself), then the canonical rule order. Biggest-first is the buyer-favourable
|
|
141
|
+
* order whenever a later clamp bites.
|
|
142
|
+
*
|
|
143
|
+
* The score is the PRE-R9 raw amount deliberately. R9's headroom is one budget
|
|
144
|
+
* shared by the whole outcome, so it is spent to the last cent whichever order
|
|
145
|
+
* the pass runs in — capping the scores would only flatten distinct promotions
|
|
146
|
+
* into a tie and hand the choice of which one to NAME to the id tie-break. The
|
|
147
|
+
* A-vs-B comparison in R8 is a different question and does compare capped
|
|
148
|
+
* totals; see `runOutcome`.
|
|
149
|
+
*/
|
|
150
|
+
function orderPassCandidates(
|
|
151
|
+
state: EvaluationState,
|
|
152
|
+
candidates: readonly Candidate[],
|
|
153
|
+
): Candidate[] {
|
|
154
|
+
const scored = candidates.map((candidate) => ({
|
|
155
|
+
candidate,
|
|
156
|
+
amountCents: rawAmountCents(candidate.rule, sumValues(remainingFor(state, candidate.covered))),
|
|
157
|
+
}));
|
|
158
|
+
scored.sort((a, b) => {
|
|
159
|
+
const byAmount = b.amountCents - a.amountCents;
|
|
160
|
+
return byAmount !== 0 ? byAmount : compareRuleOrder(a.candidate.rule, b.candidate.rule);
|
|
161
|
+
});
|
|
162
|
+
return scored.map((entry) => entry.candidate);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runPass(state: EvaluationState, candidates: readonly Candidate[]): void {
|
|
166
|
+
for (const candidate of orderPassCandidates(state, candidates)) {
|
|
167
|
+
applyOne(state, candidate);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** R6 — the three fixed passes, narrowest scope first. */
|
|
172
|
+
function runPasses(state: EvaluationState, candidates: readonly Candidate[]): void {
|
|
173
|
+
for (const scope of SCOPE_PASSES) {
|
|
174
|
+
runPass(
|
|
175
|
+
state,
|
|
176
|
+
candidates.filter((candidate) => candidate.rule.scope === scope),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Run one candidate set against a pristine cart.
|
|
183
|
+
*
|
|
184
|
+
* `totalCents` is the sum of the amounts that were ACTUALLY applied — already
|
|
185
|
+
* capped by R9 — which is what makes it safe for R8 to compare two outcomes
|
|
186
|
+
* with. Comparing pre-clamp figures could crown an outcome that looks larger and
|
|
187
|
+
* then delivers less than the one it beat.
|
|
188
|
+
*/
|
|
189
|
+
export function runOutcome(
|
|
190
|
+
lines: readonly DiscountCartLine[],
|
|
191
|
+
candidates: readonly Candidate[],
|
|
192
|
+
): Outcome {
|
|
193
|
+
const state = freshState(lines);
|
|
194
|
+
runPasses(state, candidates);
|
|
195
|
+
return { state, candidates, totalCents: state.appliedTotalCents };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* R8's outcome B — every exclusive discount evaluated ALONE against the
|
|
200
|
+
* pristine cart, best saving wins, ties broken by the canonical rule order.
|
|
201
|
+
* Each one is a full {@link runOutcome}, so every candidate is scored on its
|
|
202
|
+
* post-R9 value and the winner is the one that really removes the most cents.
|
|
203
|
+
*/
|
|
204
|
+
export function outcomeBestSingle(
|
|
205
|
+
lines: readonly DiscountCartLine[],
|
|
206
|
+
exclusives: readonly Candidate[],
|
|
207
|
+
): { candidate: Candidate; outcome: Outcome } | null {
|
|
208
|
+
const scored = exclusives.map((candidate) => ({
|
|
209
|
+
candidate,
|
|
210
|
+
outcome: runOutcome(lines, [candidate]),
|
|
211
|
+
}));
|
|
212
|
+
scored.sort((a, b) => {
|
|
213
|
+
const byAmount = b.outcome.totalCents - a.outcome.totalCents;
|
|
214
|
+
return byAmount !== 0 ? byAmount : compareRuleOrder(a.candidate.rule, b.candidate.rule);
|
|
215
|
+
});
|
|
216
|
+
return scored[0] ?? null;
|
|
217
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { maxDiscountableCents } from "./allocate";
|
|
2
|
+
import { normalizeDiscountCode } from "./kinds";
|
|
3
|
+
import { buildLineIndex, coveredLineIds, screenRule } from "./eligibility";
|
|
4
|
+
import {
|
|
5
|
+
compareRuleOrder,
|
|
6
|
+
lineGrossCents,
|
|
7
|
+
outcomeBestSingle,
|
|
8
|
+
runOutcome,
|
|
9
|
+
type Candidate,
|
|
10
|
+
type Outcome,
|
|
11
|
+
} from "./evaluate-passes";
|
|
12
|
+
import type { DiscountRejectionReason } from "./kinds";
|
|
13
|
+
import type { LineIndex } from "./eligibility";
|
|
14
|
+
import type {
|
|
15
|
+
DiscountCartLine,
|
|
16
|
+
DiscountEvaluation,
|
|
17
|
+
DiscountEvaluationInput,
|
|
18
|
+
DiscountLineAdjustment,
|
|
19
|
+
DiscountRejection,
|
|
20
|
+
DiscountRule,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The discount evaluator (FUT-245): given a priced cart, the tenant's discount
|
|
25
|
+
* rules and an instant, decide which discounts apply, how much each removes and
|
|
26
|
+
* how that money is charged back to the individual lines.
|
|
27
|
+
*
|
|
28
|
+
* PURE: no database, no I/O, no clock, no randomness. `now` is injected, the
|
|
29
|
+
* rules arrive pre-loaded from whatever the host reads them out of, and the same input
|
|
30
|
+
* always produces byte-identical output — including the ORDER of `applied[]`
|
|
31
|
+
* and `rejections[]`, which the storefront renders.
|
|
32
|
+
*
|
|
33
|
+
* THE RULE SET (cited by number from the tests and from the sibling modules):
|
|
34
|
+
*
|
|
35
|
+
* R1 — Line gross is `unitPriceCents * quantity`, with `unitPriceCents` exactly
|
|
36
|
+
* the cart's composed unit price (base/variation + paid extras).
|
|
37
|
+
* `subtotalCents` is their sum. An empty cart short-circuits to a zero
|
|
38
|
+
* evaluation, with `EMPTY_CART` for any coupon the buyer supplied.
|
|
39
|
+
* R2 — Eligibility screen (`./eligibility.ts`). A failing AUTOMATIC discount is
|
|
40
|
+
* dropped silently; a failing CODE discount the buyer typed is reported.
|
|
41
|
+
* R3 — Covered set (`./eligibility.ts`).
|
|
42
|
+
* R4 — A discount's base is what is STILL discountable on its covered lines, so
|
|
43
|
+
* a later discount never discounts money an earlier one already removed.
|
|
44
|
+
* R5 — Raw amount (`./allocate.ts`).
|
|
45
|
+
* R6 — Scope precedence `ITEM → CATEGORY → ORDER`, in three passes
|
|
46
|
+
* (`./evaluate-passes.ts`). Narrowest first, so an order-wide percentage is
|
|
47
|
+
* computed on the already item-discounted amount and an order-wide fixed
|
|
48
|
+
* amount is clamped to what is actually left.
|
|
49
|
+
* R7 — Allocation across lines by largest remainder (`./allocate.ts`).
|
|
50
|
+
* R8 — Stacking: the stackable discounts applied together ("outcome A") versus
|
|
51
|
+
* the best single exclusive one ("outcome B"); more cents off wins, a tie
|
|
52
|
+
* goes to A. Both outcomes are built under R9, so the comparison is between
|
|
53
|
+
* what each would REALLY remove, never between two pre-clamp fantasies. A
|
|
54
|
+
* coupon the buyer actually typed always wins: exclusive ⇒ it forces B,
|
|
55
|
+
* stackable ⇒ it forces A.
|
|
56
|
+
* R9 — Payable floor: `totalCents = subtotalCents - discountTotalCents`, and a
|
|
57
|
+
* discounted order never lands below `MIN_PAYABLE_TOTAL_CENTS` (R$ 0,01).
|
|
58
|
+
*
|
|
59
|
+
* ─── R9 CONTRADICTS THE FUT-235 SPEC ON PURPOSE. DO NOT "FIX" IT BACK. ───
|
|
60
|
+
*
|
|
61
|
+
* The written spec (rule R9, unit cases C15 and C16) says a 100%-off percentage
|
|
62
|
+
* and an oversized `FIXED_AMOUNT` must both land the order on EXACTLY ZERO. The
|
|
63
|
+
* product owner overrode that after the payment path was re-read: `totalCents`
|
|
64
|
+
* is the only number ever sent to the payment provider, and a provider has no
|
|
65
|
+
* zero-amount branch, and a R$ 0,00 charge is REJECTED — the order would strand
|
|
66
|
+
* in `AWAITING_PAYMENT` with its stock never consumed and its coupon never
|
|
67
|
+
* counted. Given the choice between teaching the payment path to settle an order
|
|
68
|
+
* nobody pays for and keeping one cent payable, the owner chose the cent. The
|
|
69
|
+
* spec is wrong on this point and this code is right; a later reader restoring
|
|
70
|
+
* "must reach zero" would be re-introducing a stranded-order bug, not a fix.
|
|
71
|
+
*
|
|
72
|
+
* The floor is imposed DURING application, in `./evaluate-passes.ts`: each
|
|
73
|
+
* discount's amount is capped to `subtotalCents - alreadyApplied -
|
|
74
|
+
* MIN_PAYABLE_TOTAL_CENTS` BEFORE it is allocated across its lines, and one that
|
|
75
|
+
* lands at or below zero is not applied at all (rejected `ZERO_VALUE`). It is
|
|
76
|
+
* never a post-hoc subtraction from the finished total, because that would break
|
|
77
|
+
* the double-entry invariant below — and with it the `orders_subtotal_check`
|
|
78
|
+
* CHECK, which refuses any order row where the three numbers disagree.
|
|
79
|
+
*
|
|
80
|
+
* The floor only bites when there is money to floor: a cart whose gross is
|
|
81
|
+
* already zero (empty, or only free items) stays at zero and simply gets no
|
|
82
|
+
* discount. `totalCents` can therefore never exceed `subtotalCents` either.
|
|
83
|
+
*
|
|
84
|
+
* Double-entry invariant, asserted across the whole unit matrix:
|
|
85
|
+
* `Σ applied[].amountCents === discountTotalCents === Σ lines[].discountCents`.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/** The stacking decision of R8: who won, and who was displaced by whom. */
|
|
89
|
+
interface OutcomeChoice {
|
|
90
|
+
outcome: Outcome;
|
|
91
|
+
displaced: readonly Candidate[];
|
|
92
|
+
supersededBy: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Dedupe by id (first wins — adversarial input must not be counted twice) and
|
|
97
|
+
* put the rules in their canonical order, so the evaluation cannot depend on
|
|
98
|
+
* the order the host's loader happened to return them in.
|
|
99
|
+
*/
|
|
100
|
+
function canonicalRules(rules: readonly DiscountRule[]): DiscountRule[] {
|
|
101
|
+
const byId = new Map<string, DiscountRule>();
|
|
102
|
+
for (const rule of rules) {
|
|
103
|
+
if (!byId.has(rule.id)) byId.set(rule.id, rule);
|
|
104
|
+
}
|
|
105
|
+
return [...byId.values()].sort(compareRuleOrder);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* R8 — choose between stacking everything stackable and applying the single
|
|
110
|
+
* best exclusive discount.
|
|
111
|
+
*
|
|
112
|
+
* A coupon the buyer explicitly typed is never silently discarded: an exclusive
|
|
113
|
+
* coupon IS exclusivity, so it forces outcome B on its own terms; a stackable
|
|
114
|
+
* coupon is already inside outcome A, so A is forced. Otherwise the outcome
|
|
115
|
+
* that removes more cents wins, and a tie goes to A — the additive result is
|
|
116
|
+
* the one the promotions advertised, and it shows the buyer more of them.
|
|
117
|
+
*/
|
|
118
|
+
function selectOutcome(
|
|
119
|
+
lines: readonly DiscountCartLine[],
|
|
120
|
+
candidates: readonly Candidate[],
|
|
121
|
+
couponCode: string | null,
|
|
122
|
+
): OutcomeChoice {
|
|
123
|
+
const stackables = candidates.filter((candidate) => candidate.rule.stackable);
|
|
124
|
+
const coupon =
|
|
125
|
+
couponCode === null
|
|
126
|
+
? undefined
|
|
127
|
+
: candidates.find((candidate) => candidate.rule.code === couponCode);
|
|
128
|
+
const stack = runOutcome(lines, stackables);
|
|
129
|
+
if (coupon) {
|
|
130
|
+
if (coupon.rule.stackable) return { outcome: stack, displaced: [], supersededBy: null };
|
|
131
|
+
const forced = runOutcome(lines, [coupon]);
|
|
132
|
+
// "Always applied" presupposes there is something to apply: a coupon that
|
|
133
|
+
// computes to zero (R5), or that finds no headroom under the payable floor
|
|
134
|
+
// (R9, e.g. a one-cent cart), was never applied, so it has displaced nothing,
|
|
135
|
+
// and charging the buyer full price for typing a worthless code would be a
|
|
136
|
+
// punishment. Fall back to the stack — the coupon still gets its own
|
|
137
|
+
// ZERO_VALUE rejection, so the buyer is told what happened. `forced` is a
|
|
138
|
+
// post-floor total, so this reads what really happened, not what was asked.
|
|
139
|
+
if (forced.totalCents === 0) return { outcome: stack, displaced: [], supersededBy: null };
|
|
140
|
+
return { outcome: forced, displaced: stackables, supersededBy: coupon.rule.id };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const best = outcomeBestSingle(
|
|
144
|
+
lines,
|
|
145
|
+
candidates.filter((candidate) => !candidate.rule.stackable),
|
|
146
|
+
);
|
|
147
|
+
if (best === null || best.outcome.totalCents <= stack.totalCents) {
|
|
148
|
+
return { outcome: stack, displaced: [], supersededBy: null };
|
|
149
|
+
}
|
|
150
|
+
return { outcome: best.outcome, displaced: stackables, supersededBy: best.candidate.rule.id };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toRejection(rule: DiscountRule, reason: DiscountRejectionReason): DiscountRejection {
|
|
154
|
+
const rejection: DiscountRejection = { discountId: rule.id, code: rule.code, reason };
|
|
155
|
+
if (reason === "MIN_SUBTOTAL_NOT_MET" && rule.minSubtotalCents !== null) {
|
|
156
|
+
return { ...rejection, minSubtotalCents: rule.minSubtotalCents };
|
|
157
|
+
}
|
|
158
|
+
return rejection;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type Screened =
|
|
162
|
+
| { kind: "candidate"; candidate: Candidate }
|
|
163
|
+
| { kind: "rejected"; rejection: DiscountRejection }
|
|
164
|
+
| { kind: "skipped" };
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* R2 for one rule. A CODE rule the buyer did not type is not a candidate and
|
|
168
|
+
* not a rejection — it simply is not in play. A failing AUTOMATIC rule is
|
|
169
|
+
* dropped silently: the buyer must never be shown a promotion they cannot have.
|
|
170
|
+
*/
|
|
171
|
+
function screenOneRule(
|
|
172
|
+
rule: DiscountRule,
|
|
173
|
+
input: DiscountEvaluationInput,
|
|
174
|
+
context: { subtotalCents: number; couponCode: string | null; index: LineIndex },
|
|
175
|
+
): Screened {
|
|
176
|
+
if (rule.trigger === "CODE" && rule.code !== context.couponCode) return { kind: "skipped" };
|
|
177
|
+
const covered = coveredLineIds(rule, input.lines, context.index);
|
|
178
|
+
const reason = screenRule(rule, {
|
|
179
|
+
now: input.now,
|
|
180
|
+
subtotalCents: context.subtotalCents,
|
|
181
|
+
hasEligibleItems: covered.size > 0,
|
|
182
|
+
});
|
|
183
|
+
if (reason === null) return { kind: "candidate", candidate: { rule, covered } };
|
|
184
|
+
if (rule.trigger !== "CODE") return { kind: "skipped" };
|
|
185
|
+
return { kind: "rejected", rejection: toRejection(rule, reason) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface ScreenResult {
|
|
189
|
+
candidates: Candidate[];
|
|
190
|
+
rejections: DiscountRejection[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function screenCandidates(
|
|
194
|
+
input: DiscountEvaluationInput,
|
|
195
|
+
subtotalCents: number,
|
|
196
|
+
couponCode: string | null,
|
|
197
|
+
): ScreenResult {
|
|
198
|
+
const rules = canonicalRules(input.rules);
|
|
199
|
+
const context = { subtotalCents, couponCode, index: buildLineIndex(input.lines) };
|
|
200
|
+
const candidates: Candidate[] = [];
|
|
201
|
+
const rejections: DiscountRejection[] = [];
|
|
202
|
+
for (const rule of rules) {
|
|
203
|
+
const screened = screenOneRule(rule, input, context);
|
|
204
|
+
if (screened.kind === "candidate") candidates.push(screened.candidate);
|
|
205
|
+
else if (screened.kind === "rejected") rejections.push(screened.rejection);
|
|
206
|
+
}
|
|
207
|
+
if (couponCode !== null && !rules.some((rule) => rule.code === couponCode)) {
|
|
208
|
+
rejections.push({ discountId: null, code: couponCode, reason: "UNKNOWN_CODE" });
|
|
209
|
+
}
|
|
210
|
+
return { candidates, rejections };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A CODE candidate that survived the screen but removed nothing — the buyer
|
|
215
|
+
* typed a real, live coupon and got R$ 0,00 off, which needs saying. Candidates
|
|
216
|
+
* an exclusive winner displaced are excluded: they have their own, more
|
|
217
|
+
* informative `NOT_STACKABLE` reason.
|
|
218
|
+
*
|
|
219
|
+
* "Removed nothing" now covers one more case than R5's: a coupon that computed a
|
|
220
|
+
* real amount but found no headroom left under the R9 floor, because everything
|
|
221
|
+
* above R$ 0,01 was already discounted. The reason is the same because the
|
|
222
|
+
* outcome is the same — the coupon took nothing off — and the buyer-facing copy
|
|
223
|
+
* ("Este cupom não vale para os itens do seu carrinho") stays true.
|
|
224
|
+
*/
|
|
225
|
+
function zeroValueRejections(
|
|
226
|
+
candidates: readonly Candidate[],
|
|
227
|
+
choice: OutcomeChoice,
|
|
228
|
+
): DiscountRejection[] {
|
|
229
|
+
const appliedIds = new Set(choice.outcome.state.applied.map((entry) => entry.discountId));
|
|
230
|
+
const displacedIds = new Set(choice.displaced.map(({ rule }) => rule.id));
|
|
231
|
+
return candidates
|
|
232
|
+
.filter(
|
|
233
|
+
({ rule }) =>
|
|
234
|
+
rule.trigger === "CODE" && !appliedIds.has(rule.id) && !displacedIds.has(rule.id),
|
|
235
|
+
)
|
|
236
|
+
.map(({ rule }) => ({ discountId: rule.id, code: rule.code, reason: "ZERO_VALUE" as const }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Whoever an EXCLUSIVE winner pushed aside is reported, so the storefront can
|
|
241
|
+
* say "o cupom X substituiu a promoção Y" instead of silently showing one
|
|
242
|
+
* promotion fewer than the menu advertised. Nothing is reported when the stack
|
|
243
|
+
* wins: nothing was displaced, the additive result simply was the better one.
|
|
244
|
+
*/
|
|
245
|
+
function notStackableRejections(choice: OutcomeChoice): DiscountRejection[] {
|
|
246
|
+
const { supersededBy } = choice;
|
|
247
|
+
if (supersededBy === null) return [];
|
|
248
|
+
return choice.displaced.map(({ rule }) => ({
|
|
249
|
+
discountId: rule.id,
|
|
250
|
+
code: rule.code,
|
|
251
|
+
reason: "NOT_STACKABLE" as const,
|
|
252
|
+
supersededByDiscountId: supersededBy,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function toAdjustment(line: DiscountCartLine, outcome: Outcome): DiscountLineAdjustment {
|
|
257
|
+
const gross = lineGrossCents(line);
|
|
258
|
+
const lineNetCents = Math.max(0, outcome.state.remaining.get(line.lineId) ?? gross);
|
|
259
|
+
return {
|
|
260
|
+
lineId: line.lineId,
|
|
261
|
+
lineGrossCents: gross,
|
|
262
|
+
discountCents: gross - lineNetCents,
|
|
263
|
+
lineNetCents,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function emptyEvaluation(couponCode: string | null): DiscountEvaluation {
|
|
268
|
+
return {
|
|
269
|
+
subtotalCents: 0,
|
|
270
|
+
discountTotalCents: 0,
|
|
271
|
+
totalCents: 0,
|
|
272
|
+
applied: [],
|
|
273
|
+
lines: [],
|
|
274
|
+
rejections:
|
|
275
|
+
couponCode === null
|
|
276
|
+
? []
|
|
277
|
+
: [{ discountId: null, code: couponCode, reason: "EMPTY_CART" as const }],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildEvaluation(args: {
|
|
282
|
+
lines: readonly DiscountCartLine[];
|
|
283
|
+
subtotalCents: number;
|
|
284
|
+
choice: OutcomeChoice;
|
|
285
|
+
candidates: readonly Candidate[];
|
|
286
|
+
screenRejections: readonly DiscountRejection[];
|
|
287
|
+
}): DiscountEvaluation {
|
|
288
|
+
const outcome = args.choice.outcome;
|
|
289
|
+
const applied = outcome.state.applied;
|
|
290
|
+
// R9 — a belt-and-braces re-clamp, NOT where the floor is enforced: the
|
|
291
|
+
// per-application cap in `./evaluate-passes.ts` already guarantees this bound,
|
|
292
|
+
// so this line is a no-op on every input the engine can currently produce. It
|
|
293
|
+
// stays because a future rule that breaks the guarantee should surface as a
|
|
294
|
+
// wrong-but-safe number (and a failing invariant assertion in the unit matrix)
|
|
295
|
+
// rather than as a charge the provider refuses.
|
|
296
|
+
const summed = applied.reduce((sum, entry) => sum + entry.amountCents, 0);
|
|
297
|
+
const discountTotalCents = Math.min(
|
|
298
|
+
Math.max(summed, 0),
|
|
299
|
+
maxDiscountableCents(args.subtotalCents),
|
|
300
|
+
);
|
|
301
|
+
return {
|
|
302
|
+
subtotalCents: args.subtotalCents,
|
|
303
|
+
discountTotalCents,
|
|
304
|
+
totalCents: args.subtotalCents - discountTotalCents,
|
|
305
|
+
applied,
|
|
306
|
+
lines: args.lines.map((line) => toAdjustment(line, outcome)),
|
|
307
|
+
rejections: [
|
|
308
|
+
...args.screenRejections,
|
|
309
|
+
...zeroValueRejections(args.candidates, args.choice),
|
|
310
|
+
...notStackableRejections(args.choice),
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function normalizeCoupon(raw: string | null): string | null {
|
|
316
|
+
if (raw === null) return null;
|
|
317
|
+
const normalized = normalizeDiscountCode(raw);
|
|
318
|
+
return normalized.length === 0 ? null : normalized;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Evaluate a cart against a tenant's discounts. Pure: same input, same output,
|
|
323
|
+
* no clock, no I/O. See the numbered rule set R1..R9 above.
|
|
324
|
+
*/
|
|
325
|
+
export function evaluateDiscounts(input: DiscountEvaluationInput): DiscountEvaluation {
|
|
326
|
+
const couponCode = normalizeCoupon(input.couponCode);
|
|
327
|
+
if (input.lines.length === 0) return emptyEvaluation(couponCode);
|
|
328
|
+
|
|
329
|
+
const subtotalCents = input.lines.reduce((sum, line) => sum + lineGrossCents(line), 0);
|
|
330
|
+
const screened = screenCandidates(input, subtotalCents, couponCode);
|
|
331
|
+
const choice = selectOutcome(input.lines, screened.candidates, couponCode);
|
|
332
|
+
return buildEvaluation({
|
|
333
|
+
lines: input.lines,
|
|
334
|
+
subtotalCents,
|
|
335
|
+
choice,
|
|
336
|
+
candidates: screened.candidates,
|
|
337
|
+
screenRejections: screened.rejections,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The discount vocabulary (FUT-235) — a constants-only module with no imports
|
|
3
|
+
* at all, so every surface can hold it: an offline tool generator, a browser
|
|
4
|
+
* bundle, a validator, a worker. Nothing here reaches a database or a clock.
|
|
5
|
+
*
|
|
6
|
+
* A host stores `type`, `scope` and `trigger` as plain strings, and typically
|
|
7
|
+
* pins them with CHECK constraints. Where it does, these arrays and those
|
|
8
|
+
* CHECKs are the two halves of ONE closed set: widening one without the other
|
|
9
|
+
* is how a write starts failing at the database instead of at the validator.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How a discount computes its value. `PERCENTAGE` reads `percentOffBp`,
|
|
14
|
+
* `FIXED_AMOUNT` reads `amountOffCents`; the DB CHECK enforces that exactly one
|
|
15
|
+
* of the two columns is set.
|
|
16
|
+
*/
|
|
17
|
+
export const DISCOUNT_TYPES = ["PERCENTAGE", "FIXED_AMOUNT"] as const;
|
|
18
|
+
export type DiscountType = (typeof DISCOUNT_TYPES)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a discount is allowed to touch. `ORDER` carries no targets; `CATEGORY`
|
|
22
|
+
* and `ITEM` are resolved through their join tables. The evaluator applies them
|
|
23
|
+
* narrowest-first (R6) so an order-wide promo can never give away money an
|
|
24
|
+
* item-level promo already removed.
|
|
25
|
+
*/
|
|
26
|
+
export const DISCOUNT_SCOPES = ["ORDER", "CATEGORY", "ITEM"] as const;
|
|
27
|
+
export type DiscountScope = (typeof DISCOUNT_SCOPES)[number];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What makes a discount fire: the engine picks `AUTOMATIC` ones up on its own,
|
|
31
|
+
* while a `CODE` one only becomes a candidate when the buyer types its coupon.
|
|
32
|
+
*/
|
|
33
|
+
export const DISCOUNT_TRIGGERS = ["AUTOMATIC", "CODE"] as const;
|
|
34
|
+
export type DiscountTrigger = (typeof DISCOUNT_TRIGGERS)[number];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Why a discount the buyer explicitly asked for was not applied. Only ever
|
|
38
|
+
* surfaced for a coupon the buyer typed or for a promo displaced by an
|
|
39
|
+
* exclusive one — a failing AUTOMATIC discount is silently skipped, because a
|
|
40
|
+
* buyer must not be shown promotions they cannot have.
|
|
41
|
+
*/
|
|
42
|
+
export const DISCOUNT_REJECTION_REASONS = [
|
|
43
|
+
"UNKNOWN_CODE",
|
|
44
|
+
"INACTIVE",
|
|
45
|
+
"NOT_STARTED",
|
|
46
|
+
"EXPIRED",
|
|
47
|
+
"MIN_SUBTOTAL_NOT_MET",
|
|
48
|
+
"USAGE_LIMIT_REACHED",
|
|
49
|
+
"BUYER_LIMIT_REACHED",
|
|
50
|
+
"NO_ELIGIBLE_ITEMS",
|
|
51
|
+
"ZERO_VALUE",
|
|
52
|
+
"NOT_STACKABLE",
|
|
53
|
+
"EMPTY_CART",
|
|
54
|
+
] as const;
|
|
55
|
+
export type DiscountRejectionReason = (typeof DISCOUNT_REJECTION_REASONS)[number];
|
|
56
|
+
|
|
57
|
+
/** 100% in basis points — the ceiling the DB CHECK also enforces. */
|
|
58
|
+
export const MAX_PERCENT_OFF_BP = 10_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The smallest amount a discounted order is allowed to be worth: one cent.
|
|
62
|
+
*
|
|
63
|
+
* This exists for a PAYMENT reason, not a pricing one. An order's total is the
|
|
64
|
+
* only number ever handed to a payment provider, and providers reject a
|
|
65
|
+
* zero-amount charge — so an order discounted to nothing sits awaiting a
|
|
66
|
+
* payment that can never be raised: stock never consumed, coupon never
|
|
67
|
+
* counted, buyer looking at a charge nobody can settle. Keeping one cent
|
|
68
|
+
* payable is cheaper than teaching a payment path to settle an order nobody
|
|
69
|
+
* pays for.
|
|
70
|
+
*
|
|
71
|
+
* It is enforced DURING application (see R9 in `./evaluate.ts`), never as a
|
|
72
|
+
* post-hoc trim of the final total: a discount is capped to the cents actually
|
|
73
|
+
* available above this floor before it is allocated, so
|
|
74
|
+
* `Σ applied[].amountCents === discountTotalCents === Σ lines[].discountCents`
|
|
75
|
+
* still holds to the cent. Trimming the total afterwards would break exactly
|
|
76
|
+
* that identity, and the `orders_subtotal_check` CHECK would reject the row.
|
|
77
|
+
*
|
|
78
|
+
* A cart whose gross is already zero is NOT raised to one cent — the floor caps
|
|
79
|
+
* discounts, it never invents money. See `maxDiscountableCents` in
|
|
80
|
+
* `./allocate.ts`, which is the single place the floor is turned into a number.
|
|
81
|
+
*/
|
|
82
|
+
export const MIN_PAYABLE_TOTAL_CENTS = 1;
|
|
83
|
+
|
|
84
|
+
/** Codes are compared case- and whitespace-insensitively. */
|
|
85
|
+
export function normalizeDiscountCode(raw: string): string {
|
|
86
|
+
return raw.trim().toUpperCase();
|
|
87
|
+
}
|