@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.
@@ -10,6 +10,38 @@ import type {
10
10
  * `./evaluate.ts` and whatever loads a host's rows agree on.
11
11
  */
12
12
 
13
+ /**
14
+ * One SLOT of a combo: what may fill it, and how many units it needs for ONE
15
+ * application of the combo.
16
+ *
17
+ * "1 large popcorn + 2 sodas" is two slots — `{ menuItemIds: ["popcorn-lg"],
18
+ * quantity: 1 }` and `{ categoryIds: ["sodas"], quantity: 2 }`. "3 burgers for
19
+ * the price of 2" is ONE slot of quantity 3, whose rule carries
20
+ * `type: "FREE_UNITS"`, `freeUnits: 1`.
21
+ *
22
+ * A slot is satisfied from either list — an item id OR a category — because a
23
+ * merchant thinks in both at once ("a soda" is a category, "the large popcorn"
24
+ * is an item). At least one of the two must be non-empty; a slot naming
25
+ * neither can never be filled, and `toDiscountScalars` refuses it rather than
26
+ * storing a combo that silently never fires.
27
+ */
28
+ export interface ComboRequirement {
29
+ /**
30
+ * Menu-item ids that fill this slot, matched against a line's BASE item OR
31
+ * its chosen variation — the same reach an `ITEM`-scoped discount has, so
32
+ * "a Coke" still fills the slot when the buyer picked "Coke zero can".
33
+ */
34
+ menuItemIds: readonly string[];
35
+ /**
36
+ * Category ids that fill this slot, matched against a line's category PATH,
37
+ * so a slot naming a top-level category is filled by its subcategories' items
38
+ * — the same reach a `CATEGORY`-scoped discount has.
39
+ */
40
+ categoryIds: readonly string[];
41
+ /** Units this slot needs per application. An integer >= 1. */
42
+ quantity: number;
43
+ }
44
+
13
45
  /**
14
46
  * One discount as the evaluator sees it: a plain value object with every
15
47
  * stored and every counted fact already resolved. The evaluator never touches
@@ -24,11 +56,42 @@ export interface DiscountRule {
24
56
  percentOffBp: number | null;
25
57
  /** Cents off, > 0. Non-null iff `type === "FIXED_AMOUNT"`. */
26
58
  amountOffCents: number | null;
59
+ /**
60
+ * What the matched combo group costs, in cents. Non-null iff
61
+ * `type === "BUNDLE_PRICE"`. The discount is the group's gross value minus
62
+ * this, per application.
63
+ */
64
+ bundlePriceCents?: number | null;
65
+ /**
66
+ * How many units of the matched group are free. Non-null iff
67
+ * `type === "FREE_UNITS"`. The CHEAPEST units of each application are the
68
+ * free ones, which is the buyer-favourable reading of "3 for the price of 2"
69
+ * and the one every merchant means by it.
70
+ */
71
+ freeUnits?: number | null;
27
72
  scope: DiscountScope;
28
73
  /** Target category ids. Non-empty iff `scope === "CATEGORY"`. */
29
74
  targetCategoryIds: readonly string[];
30
75
  /** Target menu-item ids. Non-empty iff `scope === "ITEM"`. */
31
76
  targetMenuItemIds: readonly string[];
77
+ /**
78
+ * The combo's slots, in the merchant's own declaration order. Non-empty iff
79
+ * `scope === "COMBO"`; ignored at every other scope.
80
+ *
81
+ * OPTIONAL, unlike the two target arrays above, and that asymmetry is the
82
+ * point: every discount has a scope and a type, so those fields are facts
83
+ * about all of them, while a combo is a CAPABILITY a host opts into. A host
84
+ * that does not sell combos never stores a `COMBO`-scoped rule, so it never
85
+ * has a value to put here, and requiring one would have made adding combos a
86
+ * breaking change for every adopter that does not want them.
87
+ */
88
+ comboRequirements?: readonly ComboRequirement[];
89
+ /**
90
+ * How many times this combo may apply to ONE cart; null or omitted means as
91
+ * often as the cart can fill its slots. A cart with six burgers takes "3 for
92
+ * the price of 2" twice unless this says otherwise.
93
+ */
94
+ maxComboApplications?: number | null;
32
95
  trigger: DiscountTrigger;
33
96
  /** Normalized (trimmed, upper-cased) code. Non-null iff `trigger === "CODE"`. */
34
97
  code: string | null;
@@ -110,6 +173,17 @@ export interface AppliedDiscount {
110
173
  amountOffCents: number | null;
111
174
  /** Cents removed from the order by THIS discount. Always > 0. */
112
175
  amountCents: number;
176
+ /**
177
+ * How many times a `COMBO`-scoped discount applied to this cart; absent for
178
+ * every other scope.
179
+ *
180
+ * It is on the SNAPSHOT rather than merely in the evaluation because it is
181
+ * the one combo fact an order cannot re-derive later: the rule's slots may
182
+ * have been re-stated, and the line quantities the combo consumed are not
183
+ * recoverable from the per-line cents alone. A receipt saying "Combo leve 3
184
+ * pague 2 (2x)" needs this number.
185
+ */
186
+ comboApplications?: number;
113
187
  }
114
188
 
115
189
  /** Per-line outcome. `Σ discountCents` equals the order's `discountTotalCents`. */
package/src/index.ts CHANGED
@@ -12,6 +12,14 @@
12
12
  * browser bundle that only prices a cart never resolves it. The wiring
13
13
  * manifests are at `@12-apps/discounts/manifest` and `./manifest/server`.
14
14
  *
15
+ * COMBOS (FUT-268) live here too, as a SCOPE rather than as a catalog entity:
16
+ * a `COMBO`-scoped rule matches quantified slots against the cart's units and
17
+ * prices the group ("1 large popcorn + 2 sodas for R$ 25", "3 burgers for the
18
+ * price of 2"). What is NOT here — and cannot be, for the same reason the
19
+ * database is not — is a combo as a SELLABLE product: its own menu card, its
20
+ * own cart line, its own order snapshot. That is a host catalog entity with
21
+ * foreign keys into host tables. The pricing RULE travels; the product does not.
22
+ *
15
23
  * The DATABASE is deliberately NOT here, and the manifest declares no `db`
16
24
  * capability. A discount's rows relate to a host's own catalog and orders —
17
25
  * targets point at categories and items, redemptions at orders and buyers —
@@ -22,10 +30,13 @@
22
30
  */
23
31
 
24
32
  export {
33
+ COMBO_ONLY_DISCOUNT_TYPES,
25
34
  DISCOUNT_REJECTION_REASONS,
26
35
  DISCOUNT_SCOPES,
27
36
  DISCOUNT_TRIGGERS,
28
37
  DISCOUNT_TYPES,
38
+ MAX_COMBO_SLOTS,
39
+ MAX_COMBO_SLOT_QUANTITY,
29
40
  MAX_PERCENT_OFF_BP,
30
41
  MIN_PAYABLE_TOTAL_CENTS,
31
42
  normalizeDiscountCode,
@@ -37,6 +48,7 @@ export {
37
48
 
38
49
  export type {
39
50
  AppliedDiscount,
51
+ ComboRequirement,
40
52
  DiscountCartLine,
41
53
  DiscountEvaluation,
42
54
  DiscountEvaluationInput,
@@ -53,6 +65,31 @@ export {
53
65
  type ItemDiscountPreviewInput,
54
66
  } from "./engine/preview";
55
67
 
68
+ /**
69
+ * The COMBO half of the catalog surface (FUT-268). A combo has no single-item
70
+ * price to badge, so a card advertises PARTICIPATION instead — see
71
+ * `./engine/combo-offer.ts` for why that is the only honest thing a card can
72
+ * say about one.
73
+ */
74
+ export {
75
+ comboOffersForItem,
76
+ type ComboOffer,
77
+ type ComboOffersInput,
78
+ } from "./engine/combo-offer";
79
+
80
+ /**
81
+ * The combo matcher, exported because a host that wants to EXPLAIN a combo —
82
+ * "aplicado 2x", "faltam 2 refrigerantes" — needs the same match the evaluator
83
+ * priced, and re-deriving it host-side is how a receipt and a cart start
84
+ * disagreeing about what was in the bundle.
85
+ */
86
+ export {
87
+ freshComboPool,
88
+ matchCombo,
89
+ type ComboMatch,
90
+ type ComboPool,
91
+ } from "./engine/combo-match";
92
+
56
93
  export {
57
94
  discountRejectionMessage,
58
95
  missingRejectionCopy,
@@ -40,6 +40,22 @@ export interface DiscountsServerCopy {
40
40
  readonly invalidUsageLimit: string;
41
41
  /** 422 — a per-buyer redemption cap of zero or less. */
42
42
  readonly invalidPerBuyerLimit: string;
43
+ /** 422 — a BUNDLE_PRICE or FREE_UNITS reward on a discount that is not a combo. */
44
+ readonly comboScopeRequired: string;
45
+ /** 422 — a combo with no slots at all, or more than `MAX_COMBO_SLOTS`. */
46
+ readonly invalidComboSlots: string;
47
+ /** 422 — a combo slot naming neither a product nor a category. */
48
+ readonly comboTargetRequired: string;
49
+ /** 422 — a slot asking for zero units, or more than `MAX_COMBO_SLOT_QUANTITY`. */
50
+ readonly invalidComboQuantity: string;
51
+ /** 422 — a BUNDLE_PRICE combo without a positive price in cents. */
52
+ readonly invalidBundlePrice: string;
53
+ /** 422 — a FREE_UNITS combo without a positive count of free units. */
54
+ readonly invalidFreeUnits: string;
55
+ /** 422 — a combo giving away every unit it asks for, which sells nothing. */
56
+ readonly freeUnitsExceedCombo: string;
57
+ /** 422 — a per-cart combo cap of zero or less. */
58
+ readonly invalidMaxComboApplications: string;
43
59
  }
44
60
 
45
61
  const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
@@ -55,6 +71,14 @@ const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
55
71
  "invalidMinSubtotal",
56
72
  "invalidUsageLimit",
57
73
  "invalidPerBuyerLimit",
74
+ "comboScopeRequired",
75
+ "invalidComboSlots",
76
+ "comboTargetRequired",
77
+ "invalidComboQuantity",
78
+ "invalidBundlePrice",
79
+ "invalidFreeUnits",
80
+ "freeUnitsExceedCombo",
81
+ "invalidMaxComboApplications",
58
82
  ];
59
83
 
60
84
  /** Every key present and non-blank — checked at assembly, like the rest. */
@@ -40,9 +40,12 @@ export {
40
40
  type DiscountWriteInput,
41
41
  } from "./validate";
42
42
 
43
+ export { comboRequirementsForScope, toComboColumns, type ComboColumns } from "./validate-combo";
44
+
43
45
  export { discountSearchConfig } from "./search";
44
46
 
45
47
  export {
48
+ comboRequirementSchema,
46
49
  createDiscountBody,
47
50
  createDiscountListQuery,
48
51
  discountCollectionParams,
package/src/server/mcp.ts CHANGED
@@ -8,7 +8,14 @@ import {
8
8
  } from "@12-apps/shared-helpers/search";
9
9
  import type { WireMcpTool } from "@12-apps/wiring";
10
10
 
11
- import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES, MAX_PERCENT_OFF_BP } from "../engine/kinds";
11
+ import {
12
+ DISCOUNT_SCOPES,
13
+ DISCOUNT_TRIGGERS,
14
+ DISCOUNT_TYPES,
15
+ MAX_COMBO_SLOTS,
16
+ MAX_COMBO_SLOT_QUANTITY,
17
+ MAX_PERCENT_OFF_BP,
18
+ } from "../engine/kinds";
12
19
  import { discountSearchConfig } from "./search";
13
20
 
14
21
  /**
@@ -46,6 +53,18 @@ export const discountItemParams = z.object({
46
53
  */
47
54
  const calendarDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
48
55
 
56
+ /**
57
+ * One combo slot: what fills it, and how many units of it one application
58
+ * needs. Both target lists default to empty and at least one must be
59
+ * non-empty — a rule `./validate-combo.ts` enforces with the host's own
60
+ * sentence, because a Zod refinement here could only answer in a Zod path.
61
+ */
62
+ export const comboRequirementSchema = z.object({
63
+ menuItemIds: z.array(z.string().min(1)).default([]),
64
+ categoryIds: z.array(z.string().min(1)).default([]),
65
+ quantity: z.number().int().min(1).max(MAX_COMBO_SLOT_QUANTITY),
66
+ });
67
+
49
68
  /**
50
69
  * A discount as the API returns it (JSON-serialized: dates become ISO
51
70
  * strings). The two join tables are flattened into id arrays.
@@ -58,6 +77,16 @@ export const discountSchema = z.object({
58
77
  percentOffBp: z.number().int().nullable(),
59
78
  /** Cents off. Non-null iff `type = FIXED_AMOUNT`. */
60
79
  amountOffCents: z.number().int().nullable(),
80
+ /**
81
+ * The COMBO half. OPTIONAL in the RESPONSE, unlike in the body, because the
82
+ * record is assembled by a host's store: an adopter that has not added the
83
+ * columns still answers a valid discount, and one that has says so. See
84
+ * `DiscountRecord` in `./store`.
85
+ */
86
+ bundlePriceCents: z.number().int().nullable().optional(),
87
+ freeUnits: z.number().int().nullable().optional(),
88
+ maxComboApplications: z.number().int().nullable().optional(),
89
+ comboRequirements: z.array(comboRequirementSchema).optional(),
61
90
  scope: z.enum(DISCOUNT_SCOPES),
62
91
  trigger: z.enum(DISCOUNT_TRIGGERS),
63
92
  code: z.string().nullable(),
@@ -101,15 +130,20 @@ export const listDiscountsQuery = createDiscountListQuery(searchConfigSchema.par
101
130
  * The write body, shared by create and update: a discount is always saved
102
131
  * WHOLE (the form re-states every field), so there is no partial-patch shape
103
132
  * to diverge from. The cross-field rules — a rate iff PERCENTAGE, a code iff
104
- * CODE, a target iff CATEGORY/ITEM, `endsAt` strictly after `startsAt` — are
105
- * enforced in `./validate`, where they produce a sentence naming the field
106
- * instead of a Zod path.
133
+ * CODE, a target iff CATEGORY/ITEM, a slot list iff COMBO, a combo reward ONLY
134
+ * at COMBO scope, `endsAt` strictly after `startsAt` — are enforced in
135
+ * `./validate` and `./validate-combo`, where they produce a sentence naming the
136
+ * field instead of a Zod path.
107
137
  */
108
138
  const discountWriteShape = {
109
139
  name: z.string().min(2).max(120),
110
140
  type: z.enum(DISCOUNT_TYPES),
111
141
  percentOffBp: z.number().int().min(1).max(MAX_PERCENT_OFF_BP).nullable().optional(),
112
142
  amountOffCents: z.number().int().positive().nullable().optional(),
143
+ bundlePriceCents: z.number().int().positive().nullable().optional(),
144
+ freeUnits: z.number().int().positive().nullable().optional(),
145
+ comboRequirements: z.array(comboRequirementSchema).max(MAX_COMBO_SLOTS).optional(),
146
+ maxComboApplications: z.number().int().positive().nullable().optional(),
113
147
  scope: z.enum(DISCOUNT_SCOPES),
114
148
  trigger: z.enum(DISCOUNT_TRIGGERS),
115
149
  code: z.string().min(1).max(64).nullable().optional(),
@@ -145,7 +179,7 @@ export const DISCOUNTS_MCP_TOOLS: readonly WireMcpTool<z.ZodType>[] = [
145
179
  method: "GET",
146
180
  path: "/discounts",
147
181
  summary:
148
- "List a store's discounts and promotions (admin). Backend filter (`q` over name and coupon code, `type_in` PERCENTAGE|FIXED_AMOUNT, `scope_in` ORDER|CATEGORY|ITEM, `trigger_in` AUTOMATIC|CODE, `active`), `sort` (field:asc|desc), and pagination (`page`/`pageSize`). Returns the page plus `pagination` (total, pageCount, hasNextPage).",
182
+ "List a store's discounts and promotions (admin). Backend filter (`q` over name and coupon code, `type_in` PERCENTAGE|FIXED_AMOUNT|BUNDLE_PRICE|FREE_UNITS, `scope_in` ORDER|CATEGORY|ITEM|COMBO, `trigger_in` AUTOMATIC|CODE, `active`), `sort` (field:asc|desc), and pagination (`page`/`pageSize`). Returns the page plus `pagination` (total, pageCount, hasNextPage).",
149
183
  tags: ["discounts"],
150
184
  params: discountCollectionParams,
151
185
  query: listDiscountsQuery,
@@ -167,7 +201,7 @@ export const DISCOUNTS_MCP_TOOLS: readonly WireMcpTool<z.ZodType>[] = [
167
201
  method: "POST",
168
202
  path: "/discounts",
169
203
  summary:
170
- "Create a discount for a store (admin). A percentage is basis points (1..10000); a fixed amount is integer cents. A CODE-triggered discount needs a coupon code, unique within the store; a CATEGORY/ITEM-scoped one needs at least one target.",
204
+ "Create a discount for a store (admin). A percentage is basis points (1..10000); a fixed amount is integer cents. A CODE-triggered discount needs a coupon code, unique within the store; a CATEGORY/ITEM-scoped one needs at least one target. A COMBO-scoped one is a bundle: `comboRequirements` lists its slots (products and/or categories, with a per-slot `quantity`), and the reward is either `BUNDLE_PRICE` (what the whole matched group costs, in cents) or `FREE_UNITS` (how many of its cheapest units are free — \"3 for the price of 2\" is one slot of quantity 3 with `freeUnits` 1), or an ordinary percentage/fixed amount off the group.",
171
205
  tags: ["discounts"],
172
206
  params: discountCollectionParams,
173
207
  body: createDiscountBody,
@@ -23,4 +23,13 @@ export const PT_BR_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
23
23
  invalidMinSubtotal: "O pedido mínimo deve ser maior que zero (ou deixe em branco).",
24
24
  invalidUsageLimit: "O limite de usos deve ser maior que zero (ou deixe em branco).",
25
25
  invalidPerBuyerLimit: "O limite por cliente deve ser maior que zero (ou deixe em branco).",
26
+ comboScopeRequired: "Preço de combo e itens grátis só valem para descontos do tipo combo.",
27
+ invalidComboSlots: "Um combo precisa de pelo menos um grupo de itens.",
28
+ comboTargetRequired: "Selecione ao menos um produto ou categoria para cada grupo do combo.",
29
+ invalidComboQuantity: "Informe uma quantidade maior que zero para cada grupo do combo.",
30
+ invalidBundlePrice: "Informe o preço do combo, maior que zero.",
31
+ invalidFreeUnits: "Informe quantos itens saem de graça, maior que zero.",
32
+ freeUnitsExceedCombo: "O combo precisa cobrar por pelo menos um item.",
33
+ invalidMaxComboApplications:
34
+ "O limite de combos por carrinho deve ser maior que zero (ou deixe em branco).",
26
35
  };
@@ -1,6 +1,7 @@
1
1
  import type { PaginatedResult, ParsedSearchInput } from "@12-apps/shared-helpers/search";
2
2
 
3
3
  import type { DiscountScope, DiscountTrigger, DiscountType } from "../engine/kinds";
4
+ import type { ComboRequirement } from "../engine/types";
4
5
  import type { DiscountScalars, DiscountTargets } from "./validate";
5
6
 
6
7
  /**
@@ -44,6 +45,21 @@ export interface DiscountRecord {
44
45
  */
45
46
  categoryIds: string[];
46
47
  menuItemIds: string[];
48
+ /**
49
+ * The COMBO half (FUT-268), OPTIONAL because this record is built by the
50
+ * HOST's store and combos are a capability a host opts into. A host that has
51
+ * not added the columns keeps compiling and keeps answering the same record
52
+ * it always did; one that has, fills them in.
53
+ *
54
+ * The asymmetry with {@link DiscountWrite} is deliberate and runs the other
55
+ * way for the same reason: what this package PRODUCES is always complete, so
56
+ * a store is handed every combo column whether it stores them or not, while
57
+ * what a host BUILDS may predate the feature.
58
+ */
59
+ bundlePriceCents?: number | null;
60
+ freeUnits?: number | null;
61
+ maxComboApplications?: number | null;
62
+ comboRequirements?: ComboRequirement[];
47
63
  createdAt: Date;
48
64
  }
49
65
 
@@ -0,0 +1,161 @@
1
+ import {
2
+ COMBO_ONLY_DISCOUNT_TYPES,
3
+ MAX_COMBO_SLOTS,
4
+ MAX_COMBO_SLOT_QUANTITY,
5
+ } from "../engine/kinds";
6
+ import type { ComboRequirement } from "../engine/types";
7
+ import type { DiscountsServerCopy } from "./copy";
8
+ import { DiscountValidationError, type DiscountWriteInput } from "./validate";
9
+
10
+ /**
11
+ * The write-time rule set for the COMBO half of a discount (FUT-268).
12
+ *
13
+ * Split from `./validate.ts` for the 400-line file gate, and read as part of
14
+ * it: the same doctrine applies unchanged. Every rule here has a twin as a
15
+ * database CHECK where SQL can express it, this layer exists so the operator
16
+ * gets a sentence naming the FIELD, and the sentences themselves live in
17
+ * {@link DiscountsServerCopy} rather than here.
18
+ *
19
+ * Two of these rules have no SQL twin at all, for the same reason the "at least
20
+ * one target" rule does not: they count rows in another table. A CHECK cannot
21
+ * see whether a combo has any slots, nor add their quantities up to compare the
22
+ * total against `free_units`. This module is their only enforcement point, and
23
+ * both failures are silent ones — a combo with no slots never fires, and a
24
+ * combo giving away as many units as it requires charges the buyer nothing for
25
+ * a group the merchant meant to sell.
26
+ */
27
+
28
+ function invalid(field: string, message: string): DiscountValidationError {
29
+ return new DiscountValidationError(field, message);
30
+ }
31
+
32
+ /** The combo columns of a discount row, validated and narrowed to the type. */
33
+ export interface ComboColumns {
34
+ bundlePriceCents: number | null;
35
+ freeUnits: number | null;
36
+ maxComboApplications: number | null;
37
+ }
38
+
39
+ function isPositiveInteger(value: number | null): value is number {
40
+ return value !== null && Number.isInteger(value) && value > 0;
41
+ }
42
+
43
+ /**
44
+ * A combo reward only means something against a matched group, so the two
45
+ * types that express one are legal at exactly one scope. Checked BEFORE the
46
+ * value columns, so an operator who picked "bundle price" on an order-wide
47
+ * discount is told the real mistake rather than being asked for a price the
48
+ * discount could never use.
49
+ */
50
+ function assertComboScope(input: DiscountWriteInput, copy: DiscountsServerCopy): void {
51
+ const isComboReward = COMBO_ONLY_DISCOUNT_TYPES.some((type) => type === input.type);
52
+ if (isComboReward && input.scope !== "COMBO") {
53
+ throw invalid("scope", copy.comboScopeRequired);
54
+ }
55
+ }
56
+
57
+ /** The slot list itself: present, and small enough to be a describable offer. */
58
+ function assertSlotCount(requirements: readonly ComboRequirement[], copy: DiscountsServerCopy): void {
59
+ if (requirements.length === 0 || requirements.length > MAX_COMBO_SLOTS) {
60
+ throw invalid("comboRequirements", copy.invalidComboSlots);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * One slot. A slot naming neither an item nor a category can never be filled,
66
+ * so the combo would exist, look live in the list, and never fire — the same
67
+ * failure `assertTargets` exists to refuse one level up.
68
+ */
69
+ function assertSlot(requirement: ComboRequirement, copy: DiscountsServerCopy): void {
70
+ const quantity = requirement.quantity;
71
+ if (!Number.isInteger(quantity) || quantity <= 0 || quantity > MAX_COMBO_SLOT_QUANTITY) {
72
+ throw invalid("comboRequirements", copy.invalidComboQuantity);
73
+ }
74
+ if (requirement.menuItemIds.length === 0 && requirement.categoryIds.length === 0) {
75
+ throw invalid("comboRequirements", copy.comboTargetRequired);
76
+ }
77
+ }
78
+
79
+ /** How many units ONE application of this combo takes out of the cart. */
80
+ function unitsPerApplication(requirements: readonly ComboRequirement[]): number {
81
+ return requirements.reduce((total, requirement) => total + requirement.quantity, 0);
82
+ }
83
+
84
+ /**
85
+ * The `FREE_UNITS` count, which is the one number in a combo that is bounded by
86
+ * another: "take 3, one free" is a promotion, "take 3, three free" is a
87
+ * giveaway the merchant almost certainly did not mean and that the payable
88
+ * floor would then have to rescue at checkout.
89
+ */
90
+ function assertFreeUnits(input: DiscountWriteInput, copy: DiscountsServerCopy): number {
91
+ const freeUnits = input.freeUnits;
92
+ if (!isPositiveInteger(freeUnits)) throw invalid("freeUnits", copy.invalidFreeUnits);
93
+ if (freeUnits >= unitsPerApplication(input.comboRequirements)) {
94
+ throw invalid("freeUnits", copy.freeUnitsExceedCombo);
95
+ }
96
+ return freeUnits;
97
+ }
98
+
99
+ function assertBundlePrice(input: DiscountWriteInput, copy: DiscountsServerCopy): number {
100
+ if (!isPositiveInteger(input.bundlePriceCents)) {
101
+ throw invalid("bundlePrice", copy.invalidBundlePrice);
102
+ }
103
+ return input.bundlePriceCents;
104
+ }
105
+
106
+ /**
107
+ * The slots and the application cap — everything that describes the combo
108
+ * rather than its reward. Only runs at `COMBO` scope: at every other scope the
109
+ * fields are ignored on the way in and dropped on the way out
110
+ * ({@link comboRequirementsForScope}), exactly as `ORDER` drops its targets.
111
+ */
112
+ function assertComboSpec(input: DiscountWriteInput, copy: DiscountsServerCopy): void {
113
+ assertSlotCount(input.comboRequirements, copy);
114
+ for (const requirement of input.comboRequirements) assertSlot(requirement, copy);
115
+ const cap = input.maxComboApplications;
116
+ if (cap !== null && !isPositiveInteger(cap)) {
117
+ throw invalid("maxComboApplications", copy.invalidMaxComboApplications);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Validate the combo half of one write and fold it into its columns.
123
+ *
124
+ * The unused reward column is forced to NULL rather than passed through, for
125
+ * the reason the scalar folder gives at length: a `BUNDLE_PRICE` combo that
126
+ * kept a leftover `freeUnits` from the form's other branch would violate the
127
+ * "exactly one value column" constraint at the database, and the operator would
128
+ * see a 500 for a form they filled in correctly.
129
+ */
130
+ export function toComboColumns(
131
+ input: DiscountWriteInput,
132
+ copy: DiscountsServerCopy,
133
+ ): ComboColumns {
134
+ assertComboScope(input, copy);
135
+ if (input.scope !== "COMBO") {
136
+ return { bundlePriceCents: null, freeUnits: null, maxComboApplications: null };
137
+ }
138
+ assertComboSpec(input, copy);
139
+ return {
140
+ bundlePriceCents: input.type === "BUNDLE_PRICE" ? assertBundlePrice(input, copy) : null,
141
+ freeUnits: input.type === "FREE_UNITS" ? assertFreeUnits(input, copy) : null,
142
+ maxComboApplications: input.maxComboApplications,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The slots this scope actually stores, de-duplicated within each slot.
148
+ *
149
+ * Dropping them at every other scope (instead of trusting the caller) is what
150
+ * keeps a scope change from leaving orphan slot rows that would quietly turn
151
+ * the discount back into a combo if the scope were ever flipped back — the same
152
+ * argument `targetsForScope` makes about its two id arrays.
153
+ */
154
+ export function comboRequirementsForScope(input: DiscountWriteInput): ComboRequirement[] {
155
+ if (input.scope !== "COMBO") return [];
156
+ return input.comboRequirements.map((requirement) => ({
157
+ menuItemIds: [...new Set(requirement.menuItemIds)],
158
+ categoryIds: [...new Set(requirement.categoryIds)],
159
+ quantity: requirement.quantity,
160
+ }));
161
+ }
@@ -5,7 +5,9 @@ import {
5
5
  type DiscountTrigger,
6
6
  type DiscountType,
7
7
  } from "../engine/kinds";
8
+ import type { ComboRequirement } from "../engine/types";
8
9
  import type { DiscountsServerCopy } from "./copy";
10
+ import { comboRequirementsForScope, toComboColumns } from "./validate-combo";
9
11
 
10
12
  /**
11
13
  * The write-time rule set for a discount (FUT-244).
@@ -37,6 +39,10 @@ export interface DiscountWriteInput {
37
39
  percentOffBp: number | null;
38
40
  /** Cents, > 0. Required iff `type === "FIXED_AMOUNT"`. */
39
41
  amountOffCents: number | null;
42
+ /** Cents the matched group costs. Required iff `type === "BUNDLE_PRICE"`. */
43
+ bundlePriceCents: number | null;
44
+ /** Free units of the group. Required iff `type === "FREE_UNITS"`. */
45
+ freeUnits: number | null;
40
46
  scope: DiscountScope;
41
47
  trigger: DiscountTrigger;
42
48
  /** Raw code as typed; normalized here. Required iff `trigger === "CODE"`. */
@@ -53,6 +59,10 @@ export interface DiscountWriteInput {
53
59
  categoryIds: readonly string[];
54
60
  /** Target menu-item ids — read only when `scope === "ITEM"`. */
55
61
  menuItemIds: readonly string[];
62
+ /** The combo's slots — read only when `scope === "COMBO"`. */
63
+ comboRequirements: readonly ComboRequirement[];
64
+ /** Cap on how often one cart may take the combo; null = uncapped. */
65
+ maxComboApplications: number | null;
56
66
  }
57
67
 
58
68
  /**
@@ -81,7 +91,12 @@ function invalid(field: string, message: string): DiscountValidationError {
81
91
  return new DiscountValidationError(field, message);
82
92
  }
83
93
 
84
- /** The value half: exactly one of the two columns, inside its legal range. */
94
+ /**
95
+ * The value half: exactly one of the FOUR value columns, inside its legal
96
+ * range. The two combo rewards are asserted in `./validate-combo.ts`, which
97
+ * also refuses them at any scope but `COMBO`; the two that read a plain base
98
+ * are asserted here.
99
+ */
85
100
  function assertValue(input: DiscountWriteInput, copy: DiscountsServerCopy): void {
86
101
  if (input.type === "PERCENTAGE") {
87
102
  const bp = input.percentOffBp;
@@ -90,6 +105,7 @@ function assertValue(input: DiscountWriteInput, copy: DiscountsServerCopy): void
90
105
  }
91
106
  return;
92
107
  }
108
+ if (input.type !== "FIXED_AMOUNT") return;
93
109
  const cents = input.amountOffCents;
94
110
  if (cents === null || !Number.isInteger(cents) || cents <= 0) {
95
111
  throw invalid("amountOff", copy.invalidAmount);
@@ -113,6 +129,9 @@ function assertTrigger(input: DiscountWriteInput, copy: DiscountsServerCopy): st
113
129
  * count rows in another table, so a CATEGORY-scoped discount with an empty
114
130
  * target list would be accepted by the database and then silently cover
115
131
  * NOTHING — a promotion that exists, looks live in the list, and never fires.
132
+ *
133
+ * `COMBO` has the same rule against a different table — its slots — and it
134
+ * lives in `./validate-combo.ts` beside the rest of the combo shape.
116
135
  */
117
136
  function assertTargets(input: DiscountWriteInput, copy: DiscountsServerCopy): void {
118
137
  if (input.scope === "CATEGORY" && input.categoryIds.length === 0) {
@@ -167,6 +186,9 @@ export interface DiscountScalars {
167
186
  type: DiscountType;
168
187
  percentOffBp: number | null;
169
188
  amountOffCents: number | null;
189
+ bundlePriceCents: number | null;
190
+ freeUnits: number | null;
191
+ maxComboApplications: number | null;
170
192
  scope: DiscountScope;
171
193
  trigger: DiscountTrigger;
172
194
  code: string | null;
@@ -179,10 +201,18 @@ export interface DiscountScalars {
179
201
  active: boolean;
180
202
  }
181
203
 
182
- /** The target ids of one write, de-duplicated and narrowed to the scope. */
204
+ /**
205
+ * The targets of one write, de-duplicated and narrowed to the scope.
206
+ *
207
+ * The combo slots ride here rather than with the scalars because that is what
208
+ * they ARE: rows in a child table, like the two id arrays beside them, not
209
+ * columns on the discount row. A store persisting this writes three join
210
+ * tables and one row.
211
+ */
183
212
  export interface DiscountTargets {
184
213
  categoryIds: string[];
185
214
  menuItemIds: string[];
215
+ comboRequirements: ComboRequirement[];
186
216
  }
187
217
 
188
218
  /**
@@ -204,13 +234,16 @@ export function toDiscountScalars(
204
234
  assertTargets(input, copy);
205
235
  const { startsAt, endsAt } = assertWindow(input, copy);
206
236
  assertLimits(input, copy);
237
+ const combo = toComboColumns(input, copy);
207
238
 
208
- const isPercentage = input.type === "PERCENTAGE";
209
239
  return {
210
240
  name: input.name.trim(),
211
241
  type: input.type,
212
- percentOffBp: isPercentage ? input.percentOffBp : null,
213
- amountOffCents: isPercentage ? null : input.amountOffCents,
242
+ percentOffBp: input.type === "PERCENTAGE" ? input.percentOffBp : null,
243
+ amountOffCents: input.type === "FIXED_AMOUNT" ? input.amountOffCents : null,
244
+ bundlePriceCents: combo.bundlePriceCents,
245
+ freeUnits: combo.freeUnits,
246
+ maxComboApplications: combo.maxComboApplications,
214
247
  scope: input.scope,
215
248
  trigger: input.trigger,
216
249
  code,
@@ -237,6 +270,8 @@ export interface DiscountWriteBody {
237
270
  type: DiscountType;
238
271
  percentOffBp?: number | null;
239
272
  amountOffCents?: number | null;
273
+ bundlePriceCents?: number | null;
274
+ freeUnits?: number | null;
240
275
  scope: DiscountScope;
241
276
  trigger: DiscountTrigger;
242
277
  code?: string | null;
@@ -249,6 +284,8 @@ export interface DiscountWriteBody {
249
284
  active: boolean;
250
285
  categoryIds?: string[];
251
286
  menuItemIds?: string[];
287
+ comboRequirements?: ComboRequirement[];
288
+ maxComboApplications?: number | null;
252
289
  }
253
290
 
254
291
  /**
@@ -267,6 +304,8 @@ export function toDiscountWriteInput(body: DiscountWriteBody): DiscountWriteInpu
267
304
  type: body.type,
268
305
  percentOffBp: orNull(body.percentOffBp),
269
306
  amountOffCents: orNull(body.amountOffCents),
307
+ bundlePriceCents: orNull(body.bundlePriceCents),
308
+ freeUnits: orNull(body.freeUnits),
270
309
  scope: body.scope,
271
310
  trigger: body.trigger,
272
311
  code: orNull(body.code),
@@ -279,6 +318,8 @@ export function toDiscountWriteInput(body: DiscountWriteBody): DiscountWriteInpu
279
318
  active: body.active,
280
319
  categoryIds: orEmpty(body.categoryIds),
281
320
  menuItemIds: orEmpty(body.menuItemIds),
321
+ comboRequirements: body.comboRequirements ?? [],
322
+ maxComboApplications: orNull(body.maxComboApplications),
282
323
  };
283
324
  }
284
325
 
@@ -292,5 +333,6 @@ export function targetsForScope(input: DiscountWriteInput): DiscountTargets {
292
333
  return {
293
334
  categoryIds: input.scope === "CATEGORY" ? [...new Set(input.categoryIds)] : [],
294
335
  menuItemIds: input.scope === "ITEM" ? [...new Set(input.menuItemIds)] : [],
336
+ comboRequirements: comboRequirementsForScope(input),
295
337
  };
296
338
  }