@12-apps/discounts 1.0.0 → 1.2.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 CHANGED
@@ -11,7 +11,7 @@ No database, no framework, no clock, and **no baked user copy**.
11
11
 
12
12
  | entry | what it is |
13
13
  |---|---|
14
- | `@12-apps/discounts` | the engine: `evaluateDiscounts`, `previewItemDiscount`, the vocabulary, the rejection copy port |
14
+ | `@12-apps/discounts` | the engine: `evaluateDiscounts`, `previewItemDiscount`, `comboOffersForItem`, `matchCombo`, the vocabulary, the rejection copy port |
15
15
  | `@12-apps/discounts/pt-BR` | the pt-BR rejection pack, imported by hand |
16
16
  | `@12-apps/discounts/server` | `createApiDiscounts`, the `DiscountStore` port, the permission contribution, the MCP tools |
17
17
  | `@12-apps/discounts/server/pt-BR` | the pt-BR pack for the admin surface's sentences |
@@ -21,10 +21,11 @@ No database, no framework, no clock, and **no baked user copy**.
21
21
  ## The model
22
22
 
23
23
  A discount is a **percentage** (basis points, 1..10000) or a **fixed amount**
24
- (integer cents), scoped to the whole **order**, to **categories**, or to
25
- specific **items**, fired either **automatically** when its conditions hold or
26
- by the buyer typing a **coupon code**. Money is integer cents throughout; a
27
- percentage is basis points, so "12,5%" needs no decimal column anywhere.
24
+ (integer cents), scoped to the whole **order**, to **categories**, to specific
25
+ **items**, or to a **combo**, fired either **automatically** when its conditions
26
+ hold or by the buyer typing a **coupon code**. Money is integer cents
27
+ throughout; a percentage is basis points, so "12,5%" needs no decimal column
28
+ anywhere.
28
29
 
29
30
  The evaluator is a pure function of `{ lines, rules, couponCode, now }`:
30
31
 
@@ -53,9 +54,84 @@ Three properties it holds to the cent, and the suite pins each:
53
54
  strike through *before* there is a cart — from the same rules, so a card and a
54
55
  cart cannot quote different numbers.
55
56
 
57
+ ## Combos
58
+
59
+ A **combo** is a discount scoped to a group the merchant defines: a list of
60
+ **slots**, each naming products and/or categories and how many units it needs.
61
+ The evaluator matches those slots against the cart's **units**, repeatedly, and
62
+ prices each match.
63
+
64
+ ```ts
65
+ // "1 pipoca grande + 2 refrigerantes por R$ 25,00"
66
+ {
67
+ scope: "COMBO",
68
+ type: "BUNDLE_PRICE",
69
+ bundlePriceCents: 2_500,
70
+ comboRequirements: [
71
+ { menuItemIds: ["popcorn-lg"], categoryIds: [], quantity: 1 },
72
+ { menuItemIds: [], categoryIds: ["drinks"], quantity: 2 },
73
+ ],
74
+ }
75
+
76
+ // "3 hambúrgueres pelo preço de 2" — one slot of three, the cheapest one free
77
+ {
78
+ scope: "COMBO",
79
+ type: "FREE_UNITS",
80
+ freeUnits: 1,
81
+ comboRequirements: [{ menuItemIds: ["burger"], categoryIds: [], quantity: 3 }],
82
+ }
83
+ ```
84
+
85
+ Four rewards, and the type picks which column is read:
86
+
87
+ | type | column | the group's discount |
88
+ |---|---|---|
89
+ | `BUNDLE_PRICE` | `bundlePriceCents` | its value above the bundle price |
90
+ | `FREE_UNITS` | `freeUnits` | its N cheapest units |
91
+ | `PERCENTAGE` | `percentOffBp` | that rate off it |
92
+ | `FIXED_AMOUNT` | `amountOffCents` | that many cents off it |
93
+
94
+ The last two are the ordinary columns, doing the ordinary thing against a group
95
+ instead of against a line. The first two only mean something against a matched
96
+ group, so they are legal at `COMBO` scope and nowhere else — the write path
97
+ refuses the combination and a host's CHECK constraint should too.
98
+
99
+ Every reward is **per application**. A cart of seven burgers takes "3 for the
100
+ price of 2" twice and pays full price for the seventh; `maxComboApplications`
101
+ caps that when a merchant wants it capped.
102
+
103
+ Four decisions worth knowing before you wire it up:
104
+
105
+ - **Combos run first.** The pass order is `COMBO → ITEM → CATEGORY → ORDER`.
106
+ - **What a combo consumed is opaque to `ITEM` and `CATEGORY`.** A combo price is
107
+ a number the merchant set deliberately; a component-targeted promotion
108
+ stacking on top of it is a double discount. So a line of five burgers with
109
+ three inside a combo offers exactly two burgers, at full price, to an
110
+ item-level promotion. **`ORDER` is not blocked** and applies to the combo
111
+ price, because an order-wide promise is about the basket, not the components.
112
+ - **One pool.** Every combo draws from the same units, so two combos can never
113
+ both be paid for the same burger. The richer one claims them first.
114
+ - **A combo is not badged.** Its price does not exist until the other components
115
+ are in the cart, so `previewItemDiscount` skips it. `comboOffersForItem` is
116
+ what a card can honestly show instead — that the item takes part in a combo,
117
+ as a label rather than a price.
118
+
119
+ A combo the cart cannot assemble is rejected `COMBO_NOT_MATCHED`, which is the
120
+ one rejection reason this package refuses to coarsen: a buyer one soda short
121
+ can finish the combo, and telling them so is the difference between a dead end
122
+ and a sale.
123
+
124
+ ### What a combo is NOT, here
125
+
126
+ It is a **pricing rule**, not a catalog entity. There is no `Combo` product with
127
+ its own menu card, its own single cart line and its own order snapshot — that is
128
+ a host table with foreign keys into the host's own catalog, which is exactly why
129
+ this package declares no `db` capability. What travels is the rule; the sellable
130
+ bundle, if a host wants one, stays the host's.
131
+
56
132
  ## The admin surface
57
133
 
58
- `createApiDiscounts({ store, copy })` returns five `WireRoute` descriptors —
134
+ `createApiDiscounts({ store, copy, logger })` returns five `WireRoute` descriptors —
59
135
  list, read, create, re-state, archive — each **declaring the permission it
60
136
  needs** (`discounts:read` / `discounts:write`), so a host's coverage gates can
61
137
  read the policy off the assembled table instead of scanning route files.
@@ -64,7 +140,11 @@ read the policy off the assembled table instead of scanning route files.
64
140
  import { createApiDiscounts } from "@12-apps/discounts/server";
65
141
  import { PT_BR_DISCOUNTS_SERVER_COPY } from "@12-apps/discounts/server/pt-BR";
66
142
 
67
- const { routes } = createApiDiscounts({ store, copy: PT_BR_DISCOUNTS_SERVER_COPY });
143
+ const { routes } = createApiDiscounts({
144
+ store,
145
+ copy: PT_BR_DISCOUNTS_SERVER_COPY,
146
+ logger: createFeatureLogger("discounts"),
147
+ });
68
148
  ```
69
149
 
70
150
  Or, through the contract:
@@ -76,7 +156,9 @@ import { discountsServerManifest } from "@12-apps/discounts/manifest/server";
76
156
  host.adoptServer({
77
157
  manifest: discountsManifest,
78
158
  server: discountsServerManifest,
79
- bindings: { http: { mountPath: "/api/admin/:tenantSlug", config: { store, copy } } },
159
+ bindings: {
160
+ http: { mountPath: "/api/admin/:tenantSlug", config: { store, copy, logger } },
161
+ },
80
162
  });
81
163
  ```
82
164
 
@@ -84,6 +166,41 @@ Delete is a **soft archive**, and that is a product decision: the orders that
84
166
  already redeemed a discount keep their snapshot, and its redemption counter
85
167
  stays readable for reporting.
86
168
 
169
+ ### Nothing it does is silent
170
+
171
+ `logger` is **required config, with no default**, for the reason `copy` is: the
172
+ default would be a no-op, and a no-op is the exact silence it exists to end.
173
+ Every route is wrapped, and there are three outcomes:
174
+
175
+ | outcome | level | what the line names |
176
+ |---|---|---|
177
+ | a write that succeeded | `info` | the verb, the discount id, the tenant |
178
+ | a refusal this package decided (400 / 404 / 422) | `warn` | the route, the status, and for a 422 the **field** a form paints red |
179
+ | anything thrown — and it is re-thrown unchanged | `error` | the route, the tenant, the cause's message |
180
+
181
+ Every line is a **string**. A host's logger is a Winston child in practice,
182
+ whose formatter runs `util.inspect(…, { depth: 5 })` over an extra argument —
183
+ which is how a provider error's retained response body, buyer name and CPF
184
+ included, reaches a third party. The cause is folded into the sentence instead.
185
+ A line names the tenant, the route, the discount id and the field; never the
186
+ body, never a coupon code, never a name an operator typed.
187
+
188
+ The `error` case **re-throws**. A uniqueness clash, a foreign target and a dead
189
+ connection are the store's to raise in the host's own error vocabulary; a
190
+ surface that folded one into a tidy response would turn a broken database into a
191
+ refusal nobody investigates.
192
+
193
+ A host adopting through `@12-apps/wiring/consumer` already has the logger the
194
+ binder built from this package's declared namespace — pass
195
+ `assembled.loggers["@12-apps/discounts"]`. A host on no wiring passes any
196
+ `createFeatureLogger`-shaped child of its own.
197
+
198
+ The evaluator is deliberately **not** wired to it. `evaluateCart` is a pure
199
+ function called once per cart render; a logger there would file a line per
200
+ keystroke on the storefront and would need a logger threaded through a browser
201
+ bundle. A rejected coupon is already a `DiscountRejection` in the result, which
202
+ is the host's to report if it wants to.
203
+
87
204
  ## What the host still owns
88
205
 
89
206
  **The database.** A discount's rows relate to a host's own catalog and orders —
@@ -93,6 +210,16 @@ The host owns the schema, the tenant scoping, the transactions and the
93
210
  uniqueness conflicts, and answers the `DiscountStore` port. What travels is the
94
211
  RULE, not the storage.
95
212
 
213
+ Combos add three nullable columns to that table — `bundle_price_cents`,
214
+ `free_units`, `max_combo_applications` — and one child table for the slots, each
215
+ row carrying a quantity plus its own product and category id lists. `DiscountWrite`
216
+ hands a store exactly that: the columns on `scalars`, the slots on
217
+ `targets.comboRequirements`, already validated and narrowed to the scope. A host
218
+ that does not sell combos adds nothing: the fields are optional on everything
219
+ this package asks a host to BUILD (`DiscountRule`, `DiscountRecord`) and
220
+ complete on everything it PRODUCES, so adopting the version that introduced them
221
+ is a no-op until a `COMBO`-scoped row exists.
222
+
96
223
  **Every sentence a human reads.** `DiscountRejectionCopy` (buyer-facing) and
97
224
  `DiscountsServerCopy` (operator-facing) are required config with **no
98
225
  defaults** — `createApiDiscounts` throws at construction naming every missing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
6
6
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "typecheck": "tsc --noEmit"
22
22
  },
23
23
  "dependencies": {
24
- "@12-apps/shared-helpers": "^2.0.1",
24
+ "@12-apps/shared-helpers": "^2.0.2",
25
25
  "zod": "^4.3.5"
26
26
  },
27
27
  "peerDependencies": {
@@ -33,9 +33,9 @@
33
33
  }
34
34
  },
35
35
  "devDependencies": {
36
- "@12-apps/eslint-config": "^1.21.0",
37
- "@12-apps/typescript-config": "^1.20.0",
38
- "@12-apps/wiring": "^1.5.0",
36
+ "@12-apps/eslint-config": "^1.21.1",
37
+ "@12-apps/typescript-config": "^1.20.1",
38
+ "@12-apps/wiring": "^1.10.0",
39
39
  "@types/node": "^22.10.6",
40
40
  "eslint": "^9.39.1",
41
41
  "eslint-plugin-test-flakiness": "^1.4.0",
@@ -22,14 +22,29 @@ import type { DiscountRule } from "./types";
22
22
  * drifting negative. `FIXED_AMOUNT` is clamped to the base, so "R$ 20 off" on a
23
23
  * base of R$ 12 removes R$ 12 and never issues a credit. A result of 0 means
24
24
  * the discount must NOT be applied — an `R$ 0,00` line is not a promotion.
25
+ *
26
+ * The two COMBO reward types return 0 here, and that is deliberate rather than
27
+ * a gap: `BUNDLE_PRICE` and `FREE_UNITS` are not functions of a base at all.
28
+ * One needs to know which UNITS the combo matched (the cheapest of them are the
29
+ * free ones) and the other needs to know how many TIMES it matched, and a
30
+ * single `baseCents` has thrown both facts away. `applicationReward` in
31
+ * `./combo-match.ts` is where they are computed, per application, and it calls
32
+ * back into this function for the two types that ARE a function of a base.
33
+ *
34
+ * Returning 0 rather than throwing keeps the failure the safe one: a combo
35
+ * reward that somehow reached this path removes nothing, which the evaluator
36
+ * reports as `ZERO_VALUE`, instead of a wrong number reaching a charge.
25
37
  */
26
38
  export function rawAmountCents(rule: DiscountRule, baseCents: number): number {
27
39
  if (baseCents <= 0) return 0;
28
- const raw =
29
- rule.type === "PERCENTAGE"
30
- ? Math.floor((baseCents * (rule.percentOffBp ?? 0)) / MAX_PERCENT_OFF_BP)
31
- : (rule.amountOffCents ?? 0);
32
- return Math.max(0, Math.min(raw, baseCents));
40
+ if (rule.type === "PERCENTAGE") {
41
+ const raw = Math.floor((baseCents * (rule.percentOffBp ?? 0)) / MAX_PERCENT_OFF_BP);
42
+ return Math.max(0, Math.min(raw, baseCents));
43
+ }
44
+ if (rule.type === "FIXED_AMOUNT") {
45
+ return Math.max(0, Math.min(rule.amountOffCents ?? 0, baseCents));
46
+ }
47
+ return 0;
33
48
  }
34
49
 
35
50
  /**
@@ -0,0 +1,370 @@
1
+ import { rawAmountCents } from "./allocate";
2
+ import type { ComboRequirement, DiscountCartLine, DiscountRule } from "./types";
3
+
4
+ /**
5
+ * COMBO matching (FUT-268): which UNITS of a cart satisfy a combo's slots, how
6
+ * many times, and how many cents that is worth.
7
+ *
8
+ * This is the one part of the engine that counts UNITS rather than lines. A
9
+ * discount scoped to an item takes a percentage off a whole line and never
10
+ * needs to know it holds five burgers; a combo does — "3 for the price of 2"
11
+ * on a line of five applies once and leaves two burgers at full price, and the
12
+ * cents that fall out of that are not a function of the line's total.
13
+ *
14
+ * Pure, like the rest of the engine: no database, no clock, no I/O. The same
15
+ * pool and the same rule always produce the same match, down to WHICH units
16
+ * were taken, because every ordering below has an explicit final tie-break.
17
+ *
18
+ * ## The matching rule, stated once
19
+ *
20
+ * One APPLICATION fills every slot. A slot is filled from the units its
21
+ * targets accept, MOST EXPENSIVE FIRST, and slots are filled MOST CONSTRAINED
22
+ * FIRST. The combo then applies again against whatever units are left, until a
23
+ * slot can no longer be filled or `maxComboApplications` is reached.
24
+ *
25
+ * Most-expensive-first is the buyer-favourable reading and it is the same
26
+ * instinct as R6's biggest-first ordering. On a `FREE_UNITS` combo it makes the
27
+ * free unit the dearest one the deal can reach; on a `BUNDLE_PRICE` combo the
28
+ * buyer pays the bundle price either way, so pulling the expensive units INTO
29
+ * the bundle leaves the cheap ones outside it at full price, which is again the
30
+ * cheaper cart.
31
+ *
32
+ * Most-constrained-first is a HEURISTIC, and the honest name for what it is: a
33
+ * greedy assignment, not a maximum bipartite matching. It is exact whenever the
34
+ * slots are disjoint — which every combo a merchant can describe in a sentence
35
+ * is — and it repairs the one overlap that occurs in practice, a specific item
36
+ * slot next to a broad category slot that also accepts it ("1 large popcorn + 2
37
+ * sodas", where the popcorn is itself filed under a targeted category). Filling
38
+ * the narrow slot first stops the broad one from eating its only candidate. A
39
+ * deliberately pathological spec — three slots overlapping three ways — can
40
+ * still leave an application unmatched that a full matching would have found.
41
+ *
42
+ * That residue is bounded and it is bounded in the safe direction: the cost is
43
+ * a combo firing fewer times than it could, never a wrong price, because
44
+ * whatever the assignment picks is a real set of units the buyer really has.
45
+ * The admin surface does NOT detect overlap — it caps the slot COUNT
46
+ * (`MAX_COMBO_SLOTS`), which is a limit on how pathological a spec can get and
47
+ * not a check that one is not. Trading an exact matching for that was
48
+ * deliberate: the exact algorithm is a maximum bipartite matching re-run per
49
+ * application, on a hot path a whole menu page walks, to buy correctness only
50
+ * on specs no merchant writes.
51
+ */
52
+
53
+ /**
54
+ * The units a combo may still consume, plus the lines they belong to.
55
+ *
56
+ * It is a POOL rather than a plain line list because combos share it: two
57
+ * combos that both want the same burgers must not both be paid for them, so
58
+ * the combo pass builds one pool, and each match deducts what it took. See
59
+ * `runPass` in `./evaluate-passes.ts`.
60
+ */
61
+ export interface ComboPool {
62
+ /** lineId -> units still available. */
63
+ readonly unitsByLine: ReadonlyMap<string, number>;
64
+ /** Every line of the cart by id — the matcher needs prices and targeting. */
65
+ readonly linesById: ReadonlyMap<string, DiscountCartLine>;
66
+ }
67
+
68
+ /** What one combo took from the cart, and what it is worth. */
69
+ export interface ComboMatch {
70
+ /** How many times the slots were filled. Always >= 1 on a real match. */
71
+ applications: number;
72
+ /** lineId -> units consumed across every application. */
73
+ consumedUnitsByLine: ReadonlyMap<string, number>;
74
+ /** lineId -> GROSS cents of those units. This is the allocation base. */
75
+ consumedCentsByLine: ReadonlyMap<string, number>;
76
+ /** Gross value of the whole matched group: the sum of the map above. */
77
+ groupCents: number;
78
+ /**
79
+ * Cents this combo removes, summed over its applications and before the
80
+ * cart-level clamps (R4's remainders and R9's payable floor) are applied.
81
+ */
82
+ rewardCents: number;
83
+ }
84
+
85
+ /** lineId -> a count of units. */
86
+ type UnitTally = Map<string, number>;
87
+
88
+ /** One slot with its target lists turned into sets, prepared once per match. */
89
+ interface PreparedSlot {
90
+ quantity: number;
91
+ menuItemIds: ReadonlySet<string>;
92
+ categoryIds: ReadonlySet<string>;
93
+ }
94
+
95
+ /** One candidate line for a slot, carried with the facts the sort needs. */
96
+ interface SlotCandidate {
97
+ lineId: string;
98
+ units: number;
99
+ line: DiscountCartLine;
100
+ }
101
+
102
+ /** A pristine pool: every line's full quantity available. */
103
+ export function freshComboPool(lines: readonly DiscountCartLine[]): ComboPool {
104
+ return {
105
+ unitsByLine: new Map(lines.map((line) => [line.lineId, Math.max(0, line.quantity)])),
106
+ linesById: new Map(lines.map((line) => [line.lineId, line])),
107
+ };
108
+ }
109
+
110
+ function prepareSlots(requirements: readonly ComboRequirement[]): PreparedSlot[] {
111
+ return requirements.map((requirement) => ({
112
+ quantity: Math.trunc(requirement.quantity),
113
+ menuItemIds: new Set(requirement.menuItemIds),
114
+ categoryIds: new Set(requirement.categoryIds),
115
+ }));
116
+ }
117
+
118
+ /**
119
+ * Whether one slot accepts one line: its BASE item, its chosen VARIATION, or
120
+ * any category on its path. Exactly the reach `coveredLineIds` gives an
121
+ * `ITEM`- or `CATEGORY`-scoped discount, so a merchant does not have to learn
122
+ * a second targeting model for combos.
123
+ */
124
+ function slotAccepts(slot: PreparedSlot, line: DiscountCartLine): boolean {
125
+ if (slot.menuItemIds.has(line.menuItemId)) return true;
126
+ if (line.variationMenuItemId !== null && slot.menuItemIds.has(line.variationMenuItemId)) {
127
+ return true;
128
+ }
129
+ return line.categoryPath.some((categoryId) => slot.categoryIds.has(categoryId));
130
+ }
131
+
132
+ /** Every available unit a slot accepts, dearest first, then by line id. */
133
+ function candidatesFor(
134
+ slot: PreparedSlot,
135
+ available: ReadonlyMap<string, number>,
136
+ linesById: ReadonlyMap<string, DiscountCartLine>,
137
+ ): SlotCandidate[] {
138
+ return [...available.entries()]
139
+ .map(([lineId, units]) => ({ lineId, units, line: linesById.get(lineId) }))
140
+ .filter(
141
+ (entry): entry is SlotCandidate =>
142
+ entry.units > 0 && entry.line !== undefined && slotAccepts(slot, entry.line),
143
+ )
144
+ .sort((a, b) => {
145
+ const byPrice = b.line.unitPriceCents - a.line.unitPriceCents;
146
+ if (byPrice !== 0) return byPrice;
147
+ return a.lineId < b.lineId ? -1 : 1;
148
+ });
149
+ }
150
+
151
+ /** Take one slot's units, dearest first. Null when the cart is short. */
152
+ function takeSlot(
153
+ slot: PreparedSlot,
154
+ available: ReadonlyMap<string, number>,
155
+ linesById: ReadonlyMap<string, DiscountCartLine>,
156
+ ): UnitTally | null {
157
+ const taken: UnitTally = new Map();
158
+ let needed = slot.quantity;
159
+ for (const candidate of candidatesFor(slot, available, linesById)) {
160
+ if (needed <= 0) break;
161
+ const take = Math.min(needed, candidate.units);
162
+ taken.set(candidate.lineId, take);
163
+ needed -= take;
164
+ }
165
+ return needed > 0 ? null : taken;
166
+ }
167
+
168
+ /** How many units are available to a slot right now — the constraint metric. */
169
+ function supplyFor(
170
+ slot: PreparedSlot,
171
+ available: ReadonlyMap<string, number>,
172
+ linesById: ReadonlyMap<string, DiscountCartLine>,
173
+ ): number {
174
+ return candidatesFor(slot, available, linesById).reduce(
175
+ (total, candidate) => total + candidate.units,
176
+ 0,
177
+ );
178
+ }
179
+
180
+ /** Most constrained first, ties broken by the merchant's declaration order. */
181
+ function orderSlots(
182
+ slots: readonly PreparedSlot[],
183
+ available: ReadonlyMap<string, number>,
184
+ linesById: ReadonlyMap<string, DiscountCartLine>,
185
+ ): PreparedSlot[] {
186
+ return slots
187
+ .map((slot, index) => ({ slot, index, supply: supplyFor(slot, available, linesById) }))
188
+ .sort((a, b) => a.supply - b.supply || a.index - b.index)
189
+ .map((entry) => entry.slot);
190
+ }
191
+
192
+ function addUnits(target: UnitTally, source: ReadonlyMap<string, number>): void {
193
+ for (const [lineId, units] of source) {
194
+ target.set(lineId, (target.get(lineId) ?? 0) + units);
195
+ }
196
+ }
197
+
198
+ function subtractUnits(target: UnitTally, source: ReadonlyMap<string, number>): void {
199
+ for (const [lineId, units] of source) {
200
+ target.set(lineId, Math.max(0, (target.get(lineId) ?? 0) - units));
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Fill every slot once, against a private copy of what is available.
206
+ *
207
+ * The copy is what makes an application ALL-OR-NOTHING: a combo that fills two
208
+ * of its three slots and then runs short must consume nothing at all, or the
209
+ * buyer would be charged for a bundle they were never given.
210
+ */
211
+ function fillOneApplication(
212
+ slots: readonly PreparedSlot[],
213
+ available: ReadonlyMap<string, number>,
214
+ linesById: ReadonlyMap<string, DiscountCartLine>,
215
+ ): UnitTally | null {
216
+ const left: UnitTally = new Map(available);
217
+ const taken: UnitTally = new Map();
218
+ for (const slot of orderSlots(slots, left, linesById)) {
219
+ const slotTake = takeSlot(slot, left, linesById);
220
+ if (slotTake === null) return null;
221
+ addUnits(taken, slotTake);
222
+ subtractUnits(left, slotTake);
223
+ }
224
+ return taken;
225
+ }
226
+
227
+ function tallyCents(
228
+ units: ReadonlyMap<string, number>,
229
+ linesById: ReadonlyMap<string, DiscountCartLine>,
230
+ ): Map<string, number> {
231
+ const cents = new Map<string, number>();
232
+ for (const [lineId, count] of units) {
233
+ const line = linesById.get(lineId);
234
+ if (line !== undefined) cents.set(lineId, count * Math.max(0, line.unitPriceCents));
235
+ }
236
+ return cents;
237
+ }
238
+
239
+ function sumValues(values: ReadonlyMap<string, number>): number {
240
+ let total = 0;
241
+ for (const cents of values.values()) total += cents;
242
+ return total;
243
+ }
244
+
245
+ /** The unit prices of one application, one entry per unit. */
246
+ function unitPricesOf(
247
+ units: ReadonlyMap<string, number>,
248
+ linesById: ReadonlyMap<string, DiscountCartLine>,
249
+ ): number[] {
250
+ return [...units.entries()].flatMap(([lineId, count]) => {
251
+ const line = linesById.get(lineId);
252
+ if (line === undefined) return [];
253
+ return Array.from({ length: count }, () => Math.max(0, line.unitPriceCents));
254
+ });
255
+ }
256
+
257
+ /** The `FREE_UNITS` reward: the N CHEAPEST units of one application. */
258
+ function cheapestUnitsCents(
259
+ units: ReadonlyMap<string, number>,
260
+ linesById: ReadonlyMap<string, DiscountCartLine>,
261
+ freeUnits: number,
262
+ ): number {
263
+ if (freeUnits <= 0) return 0;
264
+ return unitPricesOf(units, linesById)
265
+ .sort((a, b) => a - b)
266
+ .slice(0, freeUnits)
267
+ .reduce((total, cents) => total + cents, 0);
268
+ }
269
+
270
+ /**
271
+ * What ONE application removes.
272
+ *
273
+ * Per application rather than per match, because that is what each reward
274
+ * means: "R$ 5 off the combo" twice is R$ 10, and "one free" twice is two free
275
+ * units, one from each group. Only `PERCENTAGE` would read the same either
276
+ * way, and it goes through the same path so there is one rule to read.
277
+ */
278
+ function applicationReward(
279
+ rule: DiscountRule,
280
+ units: ReadonlyMap<string, number>,
281
+ linesById: ReadonlyMap<string, DiscountCartLine>,
282
+ ): number {
283
+ const groupCents = sumValues(tallyCents(units, linesById));
284
+ if (groupCents <= 0) return 0;
285
+ if (rule.type === "BUNDLE_PRICE") {
286
+ return Math.max(0, groupCents - Math.max(0, rule.bundlePriceCents ?? 0));
287
+ }
288
+ if (rule.type === "FREE_UNITS") {
289
+ return cheapestUnitsCents(units, linesById, rule.freeUnits ?? 0);
290
+ }
291
+ return rawAmountCents(rule, groupCents);
292
+ }
293
+
294
+ /**
295
+ * The application ceiling.
296
+ *
297
+ * Bounded by the pool's own size whatever the rule says, which is what makes
298
+ * the loop below provably terminate: every application consumes at least one
299
+ * unit (a zero-quantity slot is refused before we get here), so there can never
300
+ * be more applications than there are units in the cart.
301
+ *
302
+ * Only null/undefined mean UNCAPPED. A stored zero means "at most zero times",
303
+ * so the combo simply does not fire — the admin surface refuses that value, and
304
+ * a row that carries one anyway should cost the merchant a promotion nobody
305
+ * sees rather than an uncapped giveaway nobody authorised.
306
+ */
307
+ function applicationCap(rule: DiscountRule, pool: ComboPool): number {
308
+ const totalUnits = sumValues(pool.unitsByLine);
309
+ const declared = rule.maxComboApplications;
310
+ if (declared === null || declared === undefined) return totalUnits;
311
+ return Math.min(Math.trunc(declared), totalUnits);
312
+ }
313
+
314
+ /**
315
+ * Match one combo rule against a pool. Null when the cart cannot fill its
316
+ * slots even once — which the screen reports as `COMBO_NOT_MATCHED`.
317
+ */
318
+ export function matchCombo(rule: DiscountRule, pool: ComboPool): ComboMatch | null {
319
+ const slots = prepareSlots(rule.comboRequirements ?? []);
320
+ if (slots.length === 0 || slots.some((slot) => slot.quantity <= 0)) return null;
321
+
322
+ const left: UnitTally = new Map(pool.unitsByLine);
323
+ const consumedUnits: UnitTally = new Map();
324
+ const cap = applicationCap(rule, pool);
325
+ let rewardCents = 0;
326
+ let applications = 0;
327
+ while (applications < cap) {
328
+ const taken = fillOneApplication(slots, left, pool.linesById);
329
+ if (taken === null) break;
330
+ rewardCents += applicationReward(rule, taken, pool.linesById);
331
+ addUnits(consumedUnits, taken);
332
+ subtractUnits(left, taken);
333
+ applications += 1;
334
+ }
335
+ if (applications === 0) return null;
336
+
337
+ const consumedCentsByLine = tallyCents(consumedUnits, pool.linesById);
338
+ return {
339
+ applications,
340
+ consumedUnitsByLine: consumedUnits,
341
+ consumedCentsByLine,
342
+ groupCents: sumValues(consumedCentsByLine),
343
+ rewardCents,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * R3 for a `COMBO`-scoped rule: the lines a pristine cart would let it touch.
349
+ *
350
+ * Answered by MATCHING rather than by intersecting target lists, because a
351
+ * combo's coverage is not "every line it names" — a cart holding the popcorn
352
+ * and no soda names one of the slots and covers nothing, and reporting it as
353
+ * covered would let it through the screen only to remove zero.
354
+ */
355
+ export function comboCoveredLineIds(
356
+ rule: DiscountRule,
357
+ lines: readonly DiscountCartLine[],
358
+ ): ReadonlySet<string> {
359
+ const match = matchCombo(rule, freshComboPool(lines));
360
+ return match === null ? new Set<string>() : new Set(match.consumedUnitsByLine.keys());
361
+ }
362
+
363
+ /**
364
+ * Whether any of a combo's slots would accept this line — the question a menu
365
+ * card asks ("is this item part of a combo?"), which is strictly weaker than
366
+ * whether the combo MATCHES. Used by `./combo-offer.ts`.
367
+ */
368
+ export function comboRuleAcceptsLine(rule: DiscountRule, line: DiscountCartLine): boolean {
369
+ return prepareSlots(rule.comboRequirements ?? []).some((slot) => slotAccepts(slot, line));
370
+ }