@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.
- package/README.md +91 -5
- 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/server/copy.ts +24 -0
- package/src/server/index.ts +3 -0
- package/src/server/mcp.ts +40 -6
- package/src/server/pt-BR.ts +9 -0
- package/src/server/store.ts +16 -0
- package/src/server/validate-combo.ts +161 -0
- package/src/server/validate.ts +47 -5
package/src/engine/types.ts
CHANGED
|
@@ -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,
|
package/src/server/copy.ts
CHANGED
|
@@ -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. */
|
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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,
|
package/src/server/pt-BR.ts
CHANGED
|
@@ -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
|
};
|
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
|
}
|