@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.
@@ -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
  }