@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
package/src/server/store.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/validate.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
213
|
-
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
|
}
|