@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 +135 -8
- package/package.json +5 -5
- package/src/engine/allocate.ts +20 -5
- package/src/engine/combo-match.ts +370 -0
- package/src/engine/combo-offer.ts +135 -0
- package/src/engine/eligibility.ts +18 -1
- package/src/engine/evaluate-passes.ts +165 -25
- package/src/engine/evaluate.ts +16 -3
- package/src/engine/kinds.ts +66 -6
- package/src/engine/preview.ts +10 -1
- package/src/engine/pt-BR.ts +8 -0
- package/src/engine/types.ts +74 -0
- package/src/index.ts +37 -0
- package/src/manifest/index.ts +7 -0
- package/src/server/copy.ts +24 -0
- package/src/server/index.ts +5 -0
- package/src/server/logging.ts +118 -0
- package/src/server/mcp.ts +40 -6
- package/src/server/pt-BR.ts +9 -0
- package/src/server/routes.ts +76 -28
- package/src/server/store.ts +16 -0
- package/src/server/validate-combo.ts +161 -0
- package/src/server/validate.ts +47 -5
|
@@ -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
|
-
|
|
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
|
|
7
|
-
* R9 (the payable floor, imposed here rather than on the finished total)
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
115
|
-
* it removed. Zero removes nothing and records nothing —
|
|
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
|
|
128
|
-
const
|
|
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
|
-
|
|
132
|
-
state
|
|
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:
|
|
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
|
|
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(
|
package/src/engine/evaluate.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
*
|
package/src/engine/kinds.ts
CHANGED
|
@@ -10,20 +10,54 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* How a discount computes its value.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 = [
|
|
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
|
|
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();
|
package/src/engine/preview.ts
CHANGED
|
@@ -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" &&
|
|
101
|
+
rule.trigger === "AUTOMATIC" &&
|
|
102
|
+
rule.scope !== "ORDER" &&
|
|
103
|
+
rule.scope !== "COMBO" &&
|
|
104
|
+
rule.minSubtotalCents === null
|
|
96
105
|
);
|
|
97
106
|
}
|
|
98
107
|
|
package/src/engine/pt-BR.ts
CHANGED
|
@@ -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.",
|