@12-apps/discounts 1.0.0 → 1.1.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.
@@ -0,0 +1,135 @@
1
+ import { comboRuleAcceptsLine } from "./combo-match";
2
+ import { compareRuleOrder } from "./evaluate-passes";
3
+ import type { DiscountType } from "./kinds";
4
+ import type { ComboRequirement, DiscountCartLine, DiscountRule } from "./types";
5
+
6
+ /**
7
+ * "Which combos is this item part of?" — the menu-card question, answered from
8
+ * the same rules the cart is priced with.
9
+ *
10
+ * `previewItemDiscount` cannot answer it. A badge is a PRICE, and a combo has
11
+ * none until the other components are in the cart, so combos are excluded
12
+ * there. But a card that says nothing at all about a combo is how a merchant's
13
+ * best promotion goes unsold: the buyer has to already know the bundle exists
14
+ * to assemble it.
15
+ *
16
+ * So this returns a LABELLING fact, not a price: these are the live combos a
17
+ * slot of which this item can fill. A host renders "Leve 3, pague 2" or "Faz
18
+ * parte de um combo" from it, in its own words — the merchant's `name` is the
19
+ * only string here, and it is already their copy in their own language.
20
+ *
21
+ * Pure and copy-free, like the rest of the engine. Loaded once for a whole
22
+ * catalog request, never per product.
23
+ *
24
+ * ## What it deliberately does not do
25
+ *
26
+ * - **No cart.** It never says the combo would MATCH — that needs the other
27
+ * components, and a card cannot see them. `comboRuleAcceptsLine` is the
28
+ * weaker "a slot would accept this item", which is exactly what a card can
29
+ * honestly claim.
30
+ * - **No coupon combos.** A `CODE`-triggered combo is not advertised, on the
31
+ * same reasoning the badge uses: a price the buyer has not unlocked yet is
32
+ * not this item's price, and a promotion nobody can reach from the card is
33
+ * noise on it.
34
+ * - **No redemption caps.** `usageLimit` IS honoured (an exhausted combo is
35
+ * over), but `perBuyerLimit` is not, for the reason `./preview.ts` sets out
36
+ * at length: a catalog listing is anonymous, so the buyer's count is not
37
+ * knowable here, and hiding a first-purchase promotion from first-time
38
+ * buyers is the worse error.
39
+ */
40
+
41
+ /**
42
+ * One combo an item takes part in, flattened to what a card needs.
43
+ *
44
+ * The reward is carried as the raw columns rather than as a rendered phrase
45
+ * because the phrase is a SENTENCE, and sentences are host config everywhere
46
+ * else in this package. A host reads `type` and the one column that goes with
47
+ * it, and writes its own line.
48
+ */
49
+ export interface ComboOffer {
50
+ discountId: string;
51
+ /** The merchant's own name for the promotion — already their copy. */
52
+ name: string;
53
+ type: DiscountType;
54
+ /** Non-null iff `type === "BUNDLE_PRICE"`: what the group costs. */
55
+ bundlePriceCents: number | null;
56
+ /** Non-null iff `type === "FREE_UNITS"`: how many of the group are free. */
57
+ freeUnits: number | null;
58
+ /** Non-null iff `type === "PERCENTAGE"`: basis points off the group. */
59
+ percentOffBp: number | null;
60
+ /** Non-null iff `type === "FIXED_AMOUNT"`: cents off the group. */
61
+ amountOffCents: number | null;
62
+ /** The slots, so a card can say what else the buyer has to add. */
63
+ requirements: readonly ComboRequirement[];
64
+ }
65
+
66
+ export interface ComboOffersInput {
67
+ /** The BASE (grouping) menu-item id the card renders. */
68
+ menuItemId: string;
69
+ /** The variation whose price the card shows, when it shows one. */
70
+ variationMenuItemId?: string | null;
71
+ /** The item's own category id followed by its ancestors, nearest first. */
72
+ categoryPath: readonly string[];
73
+ /** The tenant's live discount rules, loaded once for the whole menu. */
74
+ rules: readonly DiscountRule[];
75
+ /** Evaluation instant. Injected — this module never calls `new Date()`. */
76
+ now: Date;
77
+ }
78
+
79
+ /** The synthetic one-unit line a slot is tested against. */
80
+ const OFFER_LINE_ID = "offer";
81
+
82
+ function toOfferLine(input: ComboOffersInput): DiscountCartLine {
83
+ return {
84
+ lineId: OFFER_LINE_ID,
85
+ menuItemId: input.menuItemId,
86
+ variationMenuItemId: input.variationMenuItemId ?? null,
87
+ categoryPath: input.categoryPath,
88
+ quantity: 1,
89
+ // A card asks whether a slot ACCEPTS this item, which no price enters into.
90
+ unitPriceCents: 0,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Live enough to advertise: switched on, inside its window, and not exhausted.
96
+ *
97
+ * Deliberately NOT `screenRule`: that function's remaining predicates are all
98
+ * cart facts (`minSubtotalCents` against a subtotal, `hasEligibleItems` against
99
+ * a covered set), and a card has no cart to answer them from. Feeding it
100
+ * zeroes would reject every combo carrying a minimum basket, which is precisely
101
+ * the sort a merchant advertises.
102
+ */
103
+ function isAdvertisable(rule: DiscountRule, now: Date): boolean {
104
+ if (rule.scope !== "COMBO" || rule.trigger !== "AUTOMATIC" || !rule.active) return false;
105
+ if (rule.startsAt !== null && now.getTime() < rule.startsAt.getTime()) return false;
106
+ if (rule.endsAt !== null && now.getTime() >= rule.endsAt.getTime()) return false;
107
+ return rule.usageLimit === null || rule.usageCount < rule.usageLimit;
108
+ }
109
+
110
+ function toOffer(rule: DiscountRule): ComboOffer {
111
+ return {
112
+ discountId: rule.id,
113
+ name: rule.name,
114
+ type: rule.type,
115
+ bundlePriceCents: rule.bundlePriceCents ?? null,
116
+ freeUnits: rule.freeUnits ?? null,
117
+ percentOffBp: rule.percentOffBp,
118
+ amountOffCents: rule.amountOffCents,
119
+ requirements: rule.comboRequirements ?? [],
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Every live combo one of whose slots this item can fill, in the evaluator's
125
+ * canonical rule order so a card and a cart never disagree about which combo
126
+ * comes first. Empty when the item is in none — the common case, and the one a
127
+ * caller should render as nothing at all.
128
+ */
129
+ export function comboOffersForItem(input: ComboOffersInput): readonly ComboOffer[] {
130
+ const line = toOfferLine(input);
131
+ return input.rules
132
+ .filter((rule) => isAdvertisable(rule, input.now) && comboRuleAcceptsLine(rule, line))
133
+ .sort(compareRuleOrder)
134
+ .map(toOffer);
135
+ }
@@ -1,3 +1,4 @@
1
+ import { comboCoveredLineIds } from "./combo-match";
1
2
  import type { DiscountRejectionReason } from "./kinds";
2
3
  import type { DiscountCartLine, DiscountRule } from "./types";
3
4
 
@@ -90,6 +91,15 @@ function addAll(target: Set<string>, lineIds: readonly string[] | undefined): vo
90
91
  * discount on a top-level category automatically reaches items filed under its
91
92
  * subcategories; `ITEM` covers every line whose base or variation menu item is
92
93
  * targeted.
94
+ *
95
+ * `COMBO` is the one scope the reverse index cannot answer, because its
96
+ * coverage is not a set intersection but a MATCH: whether the cart holds enough
97
+ * units to fill every slot at once. It is delegated to `./combo-match.ts`, and
98
+ * the answer here is deliberately the PRISTINE one — what the whole cart could
99
+ * fill — because this is the screen, and the screen's job is to tell a combo
100
+ * that can never fire ("no soda in the cart") from one that merely lost its
101
+ * units to a combo that ran first. The second is not a screen failure; it is a
102
+ * combo that removed nothing, and `./evaluate.ts` reports it as `ZERO_VALUE`.
93
103
  */
94
104
  export function coveredLineIds(
95
105
  rule: DiscountRule,
@@ -97,6 +107,7 @@ export function coveredLineIds(
97
107
  index: LineIndex,
98
108
  ): ReadonlySet<string> {
99
109
  if (rule.scope === "ORDER") return new Set(lines.map((line) => line.lineId));
110
+ if (rule.scope === "COMBO") return comboCoveredLineIds(rule, lines);
100
111
  const byCategory = rule.scope === "CATEGORY";
101
112
  const targets = byCategory ? rule.targetCategoryIds : rule.targetMenuItemIds;
102
113
  const bucket = byCategory ? index.byCategory : index.byMenuItem;
@@ -147,6 +158,11 @@ function screenLimits(rule: DiscountRule, ctx: ScreenContext): DiscountRejection
147
158
  * The coupon-match predicate is NOT here: a CODE-triggered rule the buyer did
148
159
  * not type is not a rejection, it is simply not a candidate, and the caller
149
160
  * drops it silently.
161
+ *
162
+ * The last predicate splits by scope for one reason only: a combo that could
163
+ * not be assembled is the one failure in this set the buyer can DO something
164
+ * about, so it earns its own sentence rather than the shared "not for the items
165
+ * in your cart". See `DISCOUNT_REJECTION_REASONS` in `./kinds.ts`.
150
166
  */
151
167
  export function screenRule(
152
168
  rule: DiscountRule,
@@ -156,5 +172,6 @@ export function screenRule(
156
172
  if (scheduling !== null) return scheduling;
157
173
  const limits = screenLimits(rule, ctx);
158
174
  if (limits !== null) return limits;
159
- return ctx.hasEligibleItems ? null : "NO_ELIGIBLE_ITEMS";
175
+ if (ctx.hasEligibleItems) return null;
176
+ return rule.scope === "COMBO" ? "COMBO_NOT_MATCHED" : "NO_ELIGIBLE_ITEMS";
160
177
  }
@@ -1,10 +1,12 @@
1
1
  import { allocateByLargestRemainder, maxDiscountableCents, rawAmountCents } from "./allocate";
2
+ import { freshComboPool, matchCombo, type ComboMatch } from "./combo-match";
2
3
  import type { AppliedDiscount, DiscountCartLine, DiscountRule } from "./types";
3
4
 
4
5
  /**
5
6
  * 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
7
+ * base is what is still discountable), R6 (the four narrowest-first passes),
8
+ * R9 (the payable floor, imposed here rather than on the finished total), R10
9
+ * (combos: the shared unit pool and the opacity of what they consumed) and the
8
10
  * plumbing that turns one set of candidates into one complete
9
11
  * "what if we applied exactly these" {@link Outcome}, which R8 in `./evaluate.ts`
10
12
  * then compares against the alternatives.
@@ -13,10 +15,44 @@ import type { AppliedDiscount, DiscountCartLine, DiscountRule } from "./types";
13
15
  * reason `order-confirm-payment.ts` sits beside `order-confirm.ts`. Read it as
14
16
  * the middle of that file, not as a separate concept. Pure: no database, no
15
17
  * clock, no I/O.
18
+ *
19
+ * ─── R10, THE COMBO RULES, IN FULL ───
20
+ *
21
+ * A combo is the only candidate whose base is not "the lines it covers". It
22
+ * consumes UNITS, and two consequences follow that the other three scopes never
23
+ * raise:
24
+ *
25
+ * 1. **One pool, shared by every combo.** Two combos that both want the same
26
+ * three burgers must not both be paid for them. The state therefore carries
27
+ * a unit pool that each match DEDUCTS from, so the second combo sees only
28
+ * what the first left. Without it a cart of three burgers could satisfy
29
+ * "3 for the price of 2" and "buy 3 get a free soda" on the same three
30
+ * units, and the merchant would pay for both.
31
+ *
32
+ * 2. **What a combo consumed is OPAQUE to `ITEM` and `CATEGORY`.** A combo
33
+ * price is a number the merchant set deliberately for a specific group; a
34
+ * component-targeted promotion stacking on top of it is the double discount
35
+ * the epic exists to refuse. So the units inside a combo are locked at their
36
+ * post-combo value and the two middle passes see only the units OUTSIDE it —
37
+ * a line of five burgers with three in a combo offers exactly two burgers at
38
+ * full price to an item-level promotion.
39
+ *
40
+ * `ORDER` is deliberately NOT blocked, and applies to the combo PRICE: an
41
+ * order-wide "10% off everything" is a statement about the basket, not about
42
+ * the components, and a buyer who was promised it on the whole cart would be
43
+ * right to be surprised by a combo silently opting out of it.
44
+ *
45
+ * The two together are what make R4's promise still true at the UNIT level:
46
+ * money already given away on a unit cannot be given away again.
16
47
  */
17
48
 
18
- /** R6's three passes, narrowest scope first. */
19
- const SCOPE_PASSES = ["ITEM", "CATEGORY", "ORDER"] as const;
49
+ /**
50
+ * R6's four passes, narrowest scope first. `COMBO` leads because it is the
51
+ * narrowest thing a merchant can price — a named group of units — and because
52
+ * the opacity in R10 only means anything if the combo has already claimed its
53
+ * units by the time the item pass runs.
54
+ */
55
+ const SCOPE_PASSES = ["COMBO", "ITEM", "CATEGORY", "ORDER"] as const;
20
56
 
21
57
  /** A rule that survived R2, carried together with its R3 covered set. */
22
58
  export interface Candidate {
@@ -32,6 +68,12 @@ export interface EvaluationState {
32
68
  subtotalCents: number;
33
69
  /** `Σ applied[].amountCents` so far, kept beside it so R9's headroom is O(1). */
34
70
  appliedTotalCents: number;
71
+ /** R10 — units no combo has claimed yet. Depleted as combos match. */
72
+ comboPool: Map<string, number>;
73
+ /** R10 — per line, the NET value sitting inside a combo. Opaque to ITEM/CATEGORY. */
74
+ comboLockedCents: Map<string, number>;
75
+ /** The cart by line id: the combo matcher needs unit prices and targeting. */
76
+ linesById: ReadonlyMap<string, DiscountCartLine>;
35
77
  }
36
78
 
37
79
  /** One complete run over a candidate set, ready to be compared with another. */
@@ -41,17 +83,31 @@ export interface Outcome {
41
83
  totalCents: number;
42
84
  }
43
85
 
86
+ /** What one candidate would take, and from where, measured against the state. */
87
+ interface ApplicationPlan {
88
+ /** lineId -> the cents this candidate may take from that line. */
89
+ base: Map<string, number>;
90
+ /** R5's raw value against that base, before R9's floor. */
91
+ rawCents: number;
92
+ /** The combo match behind the plan, or null for the other three scopes. */
93
+ match: ComboMatch | null;
94
+ }
95
+
44
96
  /** R1 — a line's gross value, floored at zero for defensive inputs. */
45
97
  export function lineGrossCents(line: DiscountCartLine): number {
46
98
  return Math.max(0, line.unitPriceCents * line.quantity);
47
99
  }
48
100
 
49
101
  function freshState(lines: readonly DiscountCartLine[]): EvaluationState {
102
+ const pool = freshComboPool(lines);
50
103
  return {
51
104
  remaining: new Map(lines.map((line) => [line.lineId, lineGrossCents(line)])),
52
105
  applied: [],
53
106
  subtotalCents: lines.reduce((sum, line) => sum + lineGrossCents(line), 0),
54
107
  appliedTotalCents: 0,
108
+ comboPool: new Map(pool.unitsByLine),
109
+ comboLockedCents: new Map(),
110
+ linesById: pool.linesById,
55
111
  };
56
112
  }
57
113
 
@@ -63,29 +119,98 @@ export function compareRuleOrder(a: DiscountRule, b: DiscountRule): number {
63
119
  return a.id < b.id ? -1 : 1;
64
120
  }
65
121
 
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
122
  function sumValues(values: ReadonlyMap<string, number>): number {
76
123
  let total = 0;
77
124
  for (const cents of values.values()) total += cents;
78
125
  return total;
79
126
  }
80
127
 
128
+ /**
129
+ * R4 + R10 — what is still discountable on each of a candidate's covered lines.
130
+ *
131
+ * `ITEM` and `CATEGORY` are additionally denied whatever a combo has locked, so
132
+ * they reach only the units the combo did not take. `ORDER` reads the plain
133
+ * remainder, which is the combo's net price plus everything else.
134
+ */
135
+ function discountableFor(state: EvaluationState, candidate: Candidate): Map<string, number> {
136
+ const respectsCombos = candidate.rule.scope !== "ORDER";
137
+ const base = new Map<string, number>();
138
+ for (const lineId of candidate.covered) {
139
+ const remaining = Math.max(0, state.remaining.get(lineId) ?? 0);
140
+ const locked = respectsCombos ? (state.comboLockedCents.get(lineId) ?? 0) : 0;
141
+ base.set(lineId, Math.max(0, remaining - locked));
142
+ }
143
+ return base;
144
+ }
145
+
146
+ /**
147
+ * R10 — a combo's plan: match against what the pool still holds, then take the
148
+ * consumed units' GROSS value as the allocation base.
149
+ *
150
+ * The base is the consumed value rather than the whole line, because a combo
151
+ * that took three of a line's five burgers must charge its discount to those
152
+ * three. Allocating over the whole line would spread the money across units the
153
+ * combo never claimed, and the locked-value arithmetic in `lockComboValue`
154
+ * would then hold back the wrong number.
155
+ */
156
+ function comboPlan(state: EvaluationState, rule: DiscountRule): ApplicationPlan {
157
+ const match = matchCombo(rule, {
158
+ unitsByLine: state.comboPool,
159
+ linesById: state.linesById,
160
+ });
161
+ if (match === null) return { base: new Map(), rawCents: 0, match: null };
162
+ const base = new Map<string, number>();
163
+ for (const [lineId, grossCents] of match.consumedCentsByLine) {
164
+ base.set(lineId, Math.min(grossCents, Math.max(0, state.remaining.get(lineId) ?? 0)));
165
+ }
166
+ return { base, rawCents: Math.min(match.rewardCents, sumValues(base)), match };
167
+ }
168
+
169
+ function planFor(state: EvaluationState, candidate: Candidate): ApplicationPlan {
170
+ if (candidate.rule.scope === "COMBO") return comboPlan(state, candidate.rule);
171
+ const base = discountableFor(state, candidate);
172
+ return { base, rawCents: rawAmountCents(candidate.rule, sumValues(base)), match: null };
173
+ }
174
+
81
175
  function chargeAllocation(state: EvaluationState, allocation: ReadonlyMap<string, number>): void {
82
176
  for (const [lineId, cents] of allocation) {
83
177
  state.remaining.set(lineId, (state.remaining.get(lineId) ?? 0) - cents);
84
178
  }
85
179
  }
86
180
 
87
- function toApplied(rule: DiscountRule, amountCents: number): AppliedDiscount {
88
- return {
181
+ /** R10 — the units are spent: no later combo may be paid for them again. */
182
+ function depleteComboPool(state: EvaluationState, match: ComboMatch): void {
183
+ for (const [lineId, units] of match.consumedUnitsByLine) {
184
+ state.comboPool.set(lineId, Math.max(0, (state.comboPool.get(lineId) ?? 0) - units));
185
+ }
186
+ }
187
+
188
+ /**
189
+ * R10 — lock the consumed units at what the buyer will actually pay for them.
190
+ *
191
+ * `gross - allocated` is the combo's own net, so an item-level promotion is
192
+ * left with exactly `(quantity - consumed) x unitPrice` on that line: the
193
+ * units outside the combo, at full price. Locking the GROSS instead would hide
194
+ * the combo's discount from the order pass too, and locking nothing would let
195
+ * the item pass discount the bundle price a second time.
196
+ */
197
+ function lockComboValue(
198
+ state: EvaluationState,
199
+ match: ComboMatch,
200
+ allocation: ReadonlyMap<string, number>,
201
+ ): void {
202
+ for (const [lineId, grossCents] of match.consumedCentsByLine) {
203
+ const netCents = Math.max(0, grossCents - (allocation.get(lineId) ?? 0));
204
+ state.comboLockedCents.set(lineId, (state.comboLockedCents.get(lineId) ?? 0) + netCents);
205
+ }
206
+ }
207
+
208
+ function toApplied(
209
+ rule: DiscountRule,
210
+ amountCents: number,
211
+ match: ComboMatch | null,
212
+ ): AppliedDiscount {
213
+ const applied: AppliedDiscount = {
89
214
  discountId: rule.id,
90
215
  name: rule.name,
91
216
  code: rule.code,
@@ -95,6 +220,8 @@ function toApplied(rule: DiscountRule, amountCents: number): AppliedDiscount {
95
220
  amountOffCents: rule.amountOffCents,
96
221
  amountCents,
97
222
  };
223
+ if (match === null) return applied;
224
+ return { ...applied, comboApplications: match.applications };
98
225
  }
99
226
 
100
227
  /**
@@ -111,9 +238,9 @@ function headroomCents(state: EvaluationState): number {
111
238
  }
112
239
 
113
240
  /**
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.
241
+ * Apply one candidate to the running state (R4 → R5 → R9 → R7, plus R10 for a
242
+ * combo) and return what it removed. Zero removes nothing and records nothing —
243
+ * R5's `ZERO_VALUE` case, because an `R$ 0,00` line is not a promotion.
117
244
  *
118
245
  * The R9 cap sits BETWEEN the raw amount and the allocation on purpose: the
119
246
  * clamped amount is the one that gets split across the lines, so the per-line
@@ -122,14 +249,23 @@ function headroomCents(state: EvaluationState): number {
122
249
  * applied, and `./evaluate.ts` reports it as `ZERO_VALUE` — it removed nothing,
123
250
  * which is exactly what that reason means, and inventing a second "the cart is
124
251
  * already free" reason would say nothing more to a buyer.
252
+ *
253
+ * A combo that removes nothing consumes nothing either: the pool and the locks
254
+ * are only touched past the early return. That matters — a combo clamped to
255
+ * zero by the payable floor must not lock its units away from the item pass,
256
+ * having given the buyer nothing in exchange.
125
257
  */
126
258
  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));
259
+ const plan = planFor(state, candidate);
260
+ const amountCents = Math.min(plan.rawCents, headroomCents(state));
130
261
  if (amountCents <= 0) return 0;
131
- chargeAllocation(state, allocateByLargestRemainder(amountCents, remaining));
132
- state.applied.push(toApplied(candidate.rule, amountCents));
262
+ const allocation = allocateByLargestRemainder(amountCents, plan.base);
263
+ chargeAllocation(state, allocation);
264
+ if (plan.match !== null) {
265
+ depleteComboPool(state, plan.match);
266
+ lockComboValue(state, plan.match, allocation);
267
+ }
268
+ state.applied.push(toApplied(candidate.rule, amountCents, plan.match));
133
269
  state.appliedTotalCents += amountCents;
134
270
  return amountCents;
135
271
  }
@@ -146,6 +282,10 @@ function applyOne(state: EvaluationState, candidate: Candidate): number {
146
282
  * into a tie and hand the choice of which one to NAME to the id tie-break. The
147
283
  * A-vs-B comparison in R8 is a different question and does compare capped
148
284
  * totals; see `runOutcome`.
285
+ *
286
+ * Combos are scored the same way and against the same pass-entry pool, so two
287
+ * combos competing for one set of units are ranked by what each is worth on the
288
+ * FULL cart, and the richer one claims its units first.
149
289
  */
150
290
  function orderPassCandidates(
151
291
  state: EvaluationState,
@@ -153,7 +293,7 @@ function orderPassCandidates(
153
293
  ): Candidate[] {
154
294
  const scored = candidates.map((candidate) => ({
155
295
  candidate,
156
- amountCents: rawAmountCents(candidate.rule, sumValues(remainingFor(state, candidate.covered))),
296
+ amountCents: planFor(state, candidate).rawCents,
157
297
  }));
158
298
  scored.sort((a, b) => {
159
299
  const byAmount = b.amountCents - a.amountCents;
@@ -168,7 +308,7 @@ function runPass(state: EvaluationState, candidates: readonly Candidate[]): void
168
308
  }
169
309
  }
170
310
 
171
- /** R6 — the three fixed passes, narrowest scope first. */
311
+ /** R6 — the four fixed passes, narrowest scope first. */
172
312
  function runPasses(state: EvaluationState, candidates: readonly Candidate[]): void {
173
313
  for (const scope of SCOPE_PASSES) {
174
314
  runPass(
@@ -37,12 +37,16 @@ import type {
37
37
  * `subtotalCents` is their sum. An empty cart short-circuits to a zero
38
38
  * evaluation, with `EMPTY_CART` for any coupon the buyer supplied.
39
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`).
40
+ * dropped silently; a failing CODE discount the buyer typed is reported. A
41
+ * combo the cart cannot assemble is reported as `COMBO_NOT_MATCHED` rather
42
+ * than `NO_ELIGIBLE_ITEMS`, because it is the one failure here a buyer can
43
+ * act on.
44
+ * R3 — Covered set (`./eligibility.ts`). For a combo this is a MATCH against the
45
+ * pristine cart, not a target intersection.
42
46
  * R4 — A discount's base is what is STILL discountable on its covered lines, so
43
47
  * a later discount never discounts money an earlier one already removed.
44
48
  * R5 — Raw amount (`./allocate.ts`).
45
- * R6 — Scope precedence `ITEM → CATEGORY → ORDER`, in three passes
49
+ * R6 — Scope precedence `COMBO → ITEM → CATEGORY → ORDER`, in four passes
46
50
  * (`./evaluate-passes.ts`). Narrowest first, so an order-wide percentage is
47
51
  * computed on the already item-discounted amount and an order-wide fixed
48
52
  * amount is clamped to what is actually left.
@@ -55,6 +59,15 @@ import type {
55
59
  * stackable ⇒ it forces A.
56
60
  * R9 — Payable floor: `totalCents = subtotalCents - discountTotalCents`, and a
57
61
  * discounted order never lands below `MIN_PAYABLE_TOTAL_CENTS` (R$ 0,01).
62
+ * R10 — Combos (`./combo-match.ts`, `./evaluate-passes.ts`). A `COMBO`-scoped
63
+ * rule matches UNITS against quantified slots, repeatedly, and prices each
64
+ * application. Every combo draws from ONE shared unit pool, so two combos
65
+ * can never both be paid for the same burger; and the units a combo
66
+ * consumed are OPAQUE to the `ITEM` and `CATEGORY` passes, so a
67
+ * component-targeted promotion cannot stack on top of a bundle price the
68
+ * merchant set deliberately. `ORDER` is not blocked and applies to the
69
+ * combo price, because an order-wide promotion is a statement about the
70
+ * basket rather than about the components.
58
71
  *
59
72
  * ─── R9 CONTRADICTS THE FUT-235 SPEC ON PURPOSE. DO NOT "FIX" IT BACK. ───
60
73
  *
@@ -10,20 +10,54 @@
10
10
  */
11
11
 
12
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.
13
+ * How a discount computes its value. Each type reads exactly ONE value column
14
+ * and the DB CHECK enforces that the other three are NULL:
15
+ *
16
+ * | type | column | means |
17
+ * |---|---|---|
18
+ * | `PERCENTAGE` | `percentOffBp` | basis points off its base |
19
+ * | `FIXED_AMOUNT` | `amountOffCents` | cents off its base |
20
+ * | `BUNDLE_PRICE` | `bundlePriceCents` | the matched group costs exactly this |
21
+ * | `FREE_UNITS` | `freeUnits` | the cheapest N units of the group are free |
22
+ *
23
+ * The last two are COMBO rewards and are only legal on a `COMBO`-scoped rule,
24
+ * because neither can be computed without a matched group to price: "the group
25
+ * costs R$ 25" and "one of them is free" are both statements ABOUT a group.
26
+ * `toDiscountScalars` refuses the combination, and a host's CHECK should too.
27
+ *
28
+ * The first two are legal at EVERY scope, `COMBO` included — "10% off when you
29
+ * buy the bundle" is a perfectly ordinary promotion, and it reads its base from
30
+ * the matched group like the other two.
16
31
  */
17
- export const DISCOUNT_TYPES = ["PERCENTAGE", "FIXED_AMOUNT"] as const;
32
+ export const DISCOUNT_TYPES = [
33
+ "PERCENTAGE",
34
+ "FIXED_AMOUNT",
35
+ "BUNDLE_PRICE",
36
+ "FREE_UNITS",
37
+ ] as const;
18
38
  export type DiscountType = (typeof DISCOUNT_TYPES)[number];
19
39
 
40
+ /** The two types that only mean something against a matched combo group. */
41
+ export const COMBO_ONLY_DISCOUNT_TYPES = ["BUNDLE_PRICE", "FREE_UNITS"] as const;
42
+
20
43
  /**
21
44
  * 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
45
+ * and `ITEM` are resolved through their join tables; `COMBO` is resolved by
46
+ * MATCHING the cart against a list of quantified slots (see
47
+ * {@link ComboRequirement} in `./types`). The evaluator applies them
23
48
  * narrowest-first (R6) so an order-wide promo can never give away money an
24
49
  * item-level promo already removed.
50
+ *
51
+ * `COMBO` is the narrowest of the four and runs FIRST, because a combo price is
52
+ * a number the merchant set deliberately for a specific group of units, and
53
+ * everything else is an adjustment around it (R10).
54
+ *
55
+ * Appended rather than inserted: these values are stored as plain strings and
56
+ * typically pinned by a CHECK constraint, so the ARRAY's order is not a
57
+ * contract but its membership is. The pass order lives in
58
+ * `./evaluate-passes.ts`, which names the scopes explicitly.
25
59
  */
26
- export const DISCOUNT_SCOPES = ["ORDER", "CATEGORY", "ITEM"] as const;
60
+ export const DISCOUNT_SCOPES = ["ORDER", "CATEGORY", "ITEM", "COMBO"] as const;
27
61
  export type DiscountScope = (typeof DISCOUNT_SCOPES)[number];
28
62
 
29
63
  /**
@@ -38,6 +72,15 @@ export type DiscountTrigger = (typeof DISCOUNT_TRIGGERS)[number];
38
72
  * surfaced for a coupon the buyer typed or for a promo displaced by an
39
73
  * exclusive one — a failing AUTOMATIC discount is silently skipped, because a
40
74
  * buyer must not be shown promotions they cannot have.
75
+ *
76
+ * `COMBO_NOT_MATCHED` is deliberately NOT folded into `NO_ELIGIBLE_ITEMS`,
77
+ * even though the two are the same predicate ("R3 covered nothing"), because
78
+ * the rest of this set is coarse for a reason that does not hold here: those
79
+ * reasons are merged when there is nothing the buyer could do differently. A
80
+ * buyer one soda short of a combo CAN act — adding it is the entire point of
81
+ * advertising the combo — so the sentence is worth its own key. See the pt-BR
82
+ * pack, where the scheduling reasons still collapse into one and this one
83
+ * does not.
41
84
  */
42
85
  export const DISCOUNT_REJECTION_REASONS = [
43
86
  "UNKNOWN_CODE",
@@ -48,6 +91,7 @@ export const DISCOUNT_REJECTION_REASONS = [
48
91
  "USAGE_LIMIT_REACHED",
49
92
  "BUYER_LIMIT_REACHED",
50
93
  "NO_ELIGIBLE_ITEMS",
94
+ "COMBO_NOT_MATCHED",
51
95
  "ZERO_VALUE",
52
96
  "NOT_STACKABLE",
53
97
  "EMPTY_CART",
@@ -81,6 +125,22 @@ export const MAX_PERCENT_OFF_BP = 10_000;
81
125
  */
82
126
  export const MIN_PAYABLE_TOTAL_CENTS = 1;
83
127
 
128
+ /**
129
+ * The most slots one combo may declare, and the most units one slot may ask
130
+ * for. Both are FORM sanity rather than engine limits — the matcher is linear
131
+ * in slots x lines and would not care — and they exist for the two reasons the
132
+ * rest of this file's bounds do: an operator who typed 500 into a quantity
133
+ * field made a mistake worth a sentence, and a host that pins these as CHECK
134
+ * constraints needs a number to pin.
135
+ *
136
+ * Sized from what a combo IS rather than from what a database could hold: the
137
+ * spoken examples are two and three slots ("sandwich + soda", "popcorn + two
138
+ * sodas"), and a promotion nobody can describe in a sentence is not one a buyer
139
+ * will understand on a card.
140
+ */
141
+ export const MAX_COMBO_SLOTS = 8;
142
+ export const MAX_COMBO_SLOT_QUANTITY = 50;
143
+
84
144
  /** Codes are compared case- and whitespace-insensitively. */
85
145
  export function normalizeDiscountCode(raw: string): string {
86
146
  return raw.trim().toUpperCase();
@@ -15,6 +15,12 @@ import type { DiscountCartLine, DiscountRule } from "./types";
15
15
  * item's price;
16
16
  * - `ORDER` scope is excluded — an order-wide promo is not an item price and
17
17
  * would badge the whole menu;
18
+ * - `COMBO` scope is excluded, for the same reason `minSubtotalCents` is: a
19
+ * combo price only exists once the OTHER components are in the cart, so
20
+ * striking a single item's price through with it would advertise a number
21
+ * this item alone can never reach. What a card CAN honestly say about a combo
22
+ * is that the item takes part in one, which is `comboOffersForItem` in
23
+ * `./combo-offer.ts` — a label, not a price;
18
24
  * - a `minSubtotalCents` threshold is excluded — a card promising a price that
19
25
  * only materializes above some basket total is a lie;
20
26
  * - `stackable` is ignored: this previews ONE item at ONE price, and which
@@ -92,7 +98,10 @@ const PREVIEW_LINE_ID = "preview";
92
98
 
93
99
  function isBadgeable(rule: DiscountRule): boolean {
94
100
  return (
95
- rule.trigger === "AUTOMATIC" && rule.scope !== "ORDER" && rule.minSubtotalCents === null
101
+ rule.trigger === "AUTOMATIC" &&
102
+ rule.scope !== "ORDER" &&
103
+ rule.scope !== "COMBO" &&
104
+ rule.minSubtotalCents === null
96
105
  );
97
106
  }
98
107
 
@@ -9,6 +9,13 @@ import { MIN_SUBTOTAL_TOKEN, type DiscountRejectionCopy } from "./rejection-copy
9
9
  * Deliberately coarser than the reason set: switched off, not started yet and
10
10
  * expired all read the same, because the difference only leaks how the
11
11
  * merchant schedules promotions and the buyer could not act on it either way.
12
+ *
13
+ * `COMBO_NOT_MATCHED` is the one place that coarsening stops, and for the same
14
+ * test read the other way: a buyer one soda short of the combo can finish it,
15
+ * so telling them WHICH kind of failure this was is the difference between a
16
+ * dead end and a sale. It does not name the missing item — the evaluator does
17
+ * not report one, because "which item is missing" has no single answer when a
18
+ * slot accepts a whole category.
12
19
  */
13
20
  export const PT_BR_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
14
21
  UNKNOWN_CODE: "Cupom inválido ou expirado.",
@@ -19,6 +26,7 @@ export const PT_BR_DISCOUNT_REJECTION_COPY: DiscountRejectionCopy = {
19
26
  USAGE_LIMIT_REACHED: "Este cupom já atingiu o limite de uso.",
20
27
  BUYER_LIMIT_REACHED: "Você já usou este cupom.",
21
28
  NO_ELIGIBLE_ITEMS: "Este cupom não vale para os itens do seu carrinho.",
29
+ COMBO_NOT_MATCHED: "Seu carrinho ainda não tem todos os itens deste combo.",
22
30
  ZERO_VALUE: "Este cupom não vale para os itens do seu carrinho.",
23
31
  NOT_STACKABLE: "Outra promoção já aplicada é melhor que este cupom.",
24
32
  EMPTY_CART: "Seu carrinho está vazio.",