@12-apps/discounts 1.0.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.
@@ -0,0 +1,296 @@
1
+ import {
2
+ MAX_PERCENT_OFF_BP,
3
+ normalizeDiscountCode,
4
+ type DiscountScope,
5
+ type DiscountTrigger,
6
+ type DiscountType,
7
+ } from "../engine/kinds";
8
+ import type { DiscountsServerCopy } from "./copy";
9
+
10
+ /**
11
+ * The write-time rule set for a discount (FUT-244).
12
+ *
13
+ * Every rule here has a twin somewhere else, and that is the point:
14
+ *
15
+ * - most of them can also be expressed as database CHECK constraints. The
16
+ * database is the thing that cannot be bypassed; this layer exists so the
17
+ * operator gets a sentence naming the FIELD instead of a constraint name;
18
+ * - the "at least one target" rule has NO twin in SQL — a CHECK cannot see
19
+ * another table's rows — so this module is its only enforcement point;
20
+ * - an admin form re-states them client-side for instant feedback. A form is
21
+ * a convenience, never an authority: an MCP tool call and any direct REST
22
+ * caller land here, so the server re-validates everything.
23
+ *
24
+ * Money is integer cents and the percentage is basis points throughout, so
25
+ * nothing downstream has to know about percent-shaped floats.
26
+ *
27
+ * The sentences are NOT here — {@link DiscountsServerCopy} carries them, and
28
+ * this module names the key. The FIELD each failure reports against is the
29
+ * package's own vocabulary and stays here, because a form binds to it.
30
+ */
31
+
32
+ /** One discount as the admin writes it. Targets are ignored for `ORDER` scope. */
33
+ export interface DiscountWriteInput {
34
+ name: string;
35
+ type: DiscountType;
36
+ /** 1..10000. Required iff `type === "PERCENTAGE"`. */
37
+ percentOffBp: number | null;
38
+ /** Cents, > 0. Required iff `type === "FIXED_AMOUNT"`. */
39
+ amountOffCents: number | null;
40
+ scope: DiscountScope;
41
+ trigger: DiscountTrigger;
42
+ /** Raw code as typed; normalized here. Required iff `trigger === "CODE"`. */
43
+ code: string | null;
44
+ /** Calendar dates (`YYYY-MM-DD`) as the form sends them, or null. */
45
+ startsAt: string | null;
46
+ endsAt: string | null;
47
+ minSubtotalCents: number | null;
48
+ usageLimit: number | null;
49
+ perBuyerLimit: number | null;
50
+ stackable: boolean;
51
+ active: boolean;
52
+ /** Target category ids — read only when `scope === "CATEGORY"`. */
53
+ categoryIds: readonly string[];
54
+ /** Target menu-item ids — read only when `scope === "ITEM"`. */
55
+ menuItemIds: readonly string[];
56
+ }
57
+
58
+ /**
59
+ * A rejected write: 422, carrying the FORM field the operator must fix and the
60
+ * copy key whose sentence says so.
61
+ *
62
+ * A class rather than a returned union because the rules are asserted down a
63
+ * call chain and every one of them aborts the write; the routes catch it and
64
+ * turn it into `{ error, issues: { <field>: error } }`, which is the shape a
65
+ * form reads its per-input errors out of.
66
+ */
67
+ export class DiscountValidationError extends Error {
68
+ /** Always 422 — every rule here is "the operator can fix this and retry". */
69
+ readonly status = 422;
70
+ /** The form input to paint, e.g. `percentOff`, `code`, `targets`. */
71
+ readonly field: string;
72
+
73
+ constructor(field: string, message: string) {
74
+ super(message);
75
+ this.name = "DiscountValidationError";
76
+ this.field = field;
77
+ }
78
+ }
79
+
80
+ function invalid(field: string, message: string): DiscountValidationError {
81
+ return new DiscountValidationError(field, message);
82
+ }
83
+
84
+ /** The value half: exactly one of the two columns, inside its legal range. */
85
+ function assertValue(input: DiscountWriteInput, copy: DiscountsServerCopy): void {
86
+ if (input.type === "PERCENTAGE") {
87
+ const bp = input.percentOffBp;
88
+ if (bp === null || !Number.isInteger(bp) || bp <= 0 || bp > MAX_PERCENT_OFF_BP) {
89
+ throw invalid("percentOff", copy.invalidPercent);
90
+ }
91
+ return;
92
+ }
93
+ const cents = input.amountOffCents;
94
+ if (cents === null || !Number.isInteger(cents) || cents <= 0) {
95
+ throw invalid("amountOff", copy.invalidAmount);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The code half. A CODE discount without a code can never be redeemed, and an
101
+ * AUTOMATIC one carrying a code would be redeemable two different ways — so the
102
+ * relationship is an equivalence, not a "required if".
103
+ */
104
+ function assertTrigger(input: DiscountWriteInput, copy: DiscountsServerCopy): string | null {
105
+ if (input.trigger !== "CODE") return null;
106
+ const code = normalizeDiscountCode(input.code ?? "");
107
+ if (code.length === 0) throw invalid("code", copy.codeRequired);
108
+ return code;
109
+ }
110
+
111
+ /**
112
+ * The targets. This is the rule with no DB twin: a CHECK constraint cannot
113
+ * count rows in another table, so a CATEGORY-scoped discount with an empty
114
+ * target list would be accepted by the database and then silently cover
115
+ * NOTHING — a promotion that exists, looks live in the list, and never fires.
116
+ */
117
+ function assertTargets(input: DiscountWriteInput, copy: DiscountsServerCopy): void {
118
+ if (input.scope === "CATEGORY" && input.categoryIds.length === 0) {
119
+ throw invalid("targets", copy.categoryTargetRequired);
120
+ }
121
+ if (input.scope === "ITEM" && input.menuItemIds.length === 0) {
122
+ throw invalid("targets", copy.itemTargetRequired);
123
+ }
124
+ }
125
+
126
+ /** A calendar date at UTC midnight, or null. The wire format is `YYYY-MM-DD`. */
127
+ function toDate(value: string | null, field: string, copy: DiscountsServerCopy): Date | null {
128
+ if (value === null || value === "") return null;
129
+ const parsed = new Date(`${value}T00:00:00.000Z`);
130
+ if (Number.isNaN(parsed.getTime())) throw invalid(field, copy.invalidDate);
131
+ return parsed;
132
+ }
133
+
134
+ /**
135
+ * The window, half-open `[startsAt, endsAt)`. `endsAt` is EXCLUSIVE, so equal
136
+ * bounds are an EMPTY window rather than a one-instant one — an operator who
137
+ * typed the same date twice meant something else, and an always-empty promotion
138
+ * is worse than a rejected form.
139
+ */
140
+ function assertWindow(
141
+ input: DiscountWriteInput,
142
+ copy: DiscountsServerCopy,
143
+ ): { startsAt: Date | null; endsAt: Date | null } {
144
+ const startsAt = toDate(input.startsAt, "startsAt", copy);
145
+ const endsAt = toDate(input.endsAt, "endsAt", copy);
146
+ if (startsAt !== null && endsAt !== null && endsAt.getTime() <= startsAt.getTime()) {
147
+ throw invalid("endsAt", copy.endsBeforeStarts);
148
+ }
149
+ return { startsAt, endsAt };
150
+ }
151
+
152
+ /** A cap of zero is not a cap, it is a discount that can never be used. */
153
+ function assertPositiveOrNull(value: number | null, field: string, message: string): void {
154
+ if (value === null) return;
155
+ if (!Number.isInteger(value) || value <= 0) throw invalid(field, message);
156
+ }
157
+
158
+ function assertLimits(input: DiscountWriteInput, copy: DiscountsServerCopy): void {
159
+ assertPositiveOrNull(input.minSubtotalCents, "minSubtotal", copy.invalidMinSubtotal);
160
+ assertPositiveOrNull(input.usageLimit, "usageLimit", copy.invalidUsageLimit);
161
+ assertPositiveOrNull(input.perBuyerLimit, "perBuyerLimit", copy.invalidPerBuyerLimit);
162
+ }
163
+
164
+ /** The scalar columns of a discount row, validated and normalized. */
165
+ export interface DiscountScalars {
166
+ name: string;
167
+ type: DiscountType;
168
+ percentOffBp: number | null;
169
+ amountOffCents: number | null;
170
+ scope: DiscountScope;
171
+ trigger: DiscountTrigger;
172
+ code: string | null;
173
+ startsAt: Date | null;
174
+ endsAt: Date | null;
175
+ minSubtotalCents: number | null;
176
+ usageLimit: number | null;
177
+ perBuyerLimit: number | null;
178
+ stackable: boolean;
179
+ active: boolean;
180
+ }
181
+
182
+ /** The target ids of one write, de-duplicated and narrowed to the scope. */
183
+ export interface DiscountTargets {
184
+ categoryIds: string[];
185
+ menuItemIds: string[];
186
+ }
187
+
188
+ /**
189
+ * Validate one write and fold it into the exact column values a host persists.
190
+ *
191
+ * The unused half of each either/or pair is forced to NULL rather than passed
192
+ * through: a PERCENTAGE discount that kept a leftover `amountOffCents` from the
193
+ * form's other branch would violate the "exactly one value column" constraint
194
+ * at the database, and the operator would see a 500 for a form they filled in
195
+ * correctly. Same for a code left behind by flipping the trigger back to
196
+ * AUTOMATIC.
197
+ */
198
+ export function toDiscountScalars(
199
+ input: DiscountWriteInput,
200
+ copy: DiscountsServerCopy,
201
+ ): DiscountScalars {
202
+ assertValue(input, copy);
203
+ const code = assertTrigger(input, copy);
204
+ assertTargets(input, copy);
205
+ const { startsAt, endsAt } = assertWindow(input, copy);
206
+ assertLimits(input, copy);
207
+
208
+ const isPercentage = input.type === "PERCENTAGE";
209
+ return {
210
+ name: input.name.trim(),
211
+ type: input.type,
212
+ percentOffBp: isPercentage ? input.percentOffBp : null,
213
+ amountOffCents: isPercentage ? null : input.amountOffCents,
214
+ scope: input.scope,
215
+ trigger: input.trigger,
216
+ code,
217
+ startsAt,
218
+ endsAt,
219
+ minSubtotalCents: input.minSubtotalCents,
220
+ usageLimit: input.usageLimit,
221
+ perBuyerLimit: input.perBuyerLimit,
222
+ stackable: input.stackable,
223
+ active: input.active,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * The validated wire body as the routes receive it — every optional field is
229
+ * `T | null | undefined` because the wire lets a client omit what does not
230
+ * apply to its branch (an AUTOMATIC discount sends no `code`).
231
+ *
232
+ * Declared structurally rather than as `z.infer<typeof createDiscountBody>` so
233
+ * this module stays free of the schema module, which imports it back.
234
+ */
235
+ export interface DiscountWriteBody {
236
+ name: string;
237
+ type: DiscountType;
238
+ percentOffBp?: number | null;
239
+ amountOffCents?: number | null;
240
+ scope: DiscountScope;
241
+ trigger: DiscountTrigger;
242
+ code?: string | null;
243
+ startsAt?: string | null;
244
+ endsAt?: string | null;
245
+ minSubtotalCents?: number | null;
246
+ usageLimit?: number | null;
247
+ perBuyerLimit?: number | null;
248
+ stackable: boolean;
249
+ active: boolean;
250
+ categoryIds?: string[];
251
+ menuItemIds?: string[];
252
+ }
253
+
254
+ /**
255
+ * Fold the wire body into the write input. Both writes go through it so
256
+ * "omitted" and "explicitly null" collapse to the same thing in exactly one
257
+ * place — a discount is always saved WHOLE, so an absent field means "no
258
+ * value", never "leave the stored one alone".
259
+ */
260
+ export function toDiscountWriteInput(body: DiscountWriteBody): DiscountWriteInput {
261
+ // One helper rather than a dozen inline `??`: each of those is a branch the
262
+ // complexity gate counts, and the rule they encode is a single sentence.
263
+ const orNull = <T,>(value: T | null | undefined): T | null => value ?? null;
264
+ const orEmpty = (value: string[] | undefined): string[] => value ?? [];
265
+ return {
266
+ name: body.name,
267
+ type: body.type,
268
+ percentOffBp: orNull(body.percentOffBp),
269
+ amountOffCents: orNull(body.amountOffCents),
270
+ scope: body.scope,
271
+ trigger: body.trigger,
272
+ code: orNull(body.code),
273
+ startsAt: orNull(body.startsAt),
274
+ endsAt: orNull(body.endsAt),
275
+ minSubtotalCents: orNull(body.minSubtotalCents),
276
+ usageLimit: orNull(body.usageLimit),
277
+ perBuyerLimit: orNull(body.perBuyerLimit),
278
+ stackable: body.stackable,
279
+ active: body.active,
280
+ categoryIds: orEmpty(body.categoryIds),
281
+ menuItemIds: orEmpty(body.menuItemIds),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * The target ids this scope actually stores. An ORDER-scoped discount carries
287
+ * none — dropping them here (instead of trusting the caller) is what keeps a
288
+ * scope change from leaving orphan join rows that would quietly re-narrow the
289
+ * discount if the scope were ever flipped back.
290
+ */
291
+ export function targetsForScope(input: DiscountWriteInput): DiscountTargets {
292
+ return {
293
+ categoryIds: input.scope === "CATEGORY" ? [...new Set(input.categoryIds)] : [],
294
+ menuItemIds: input.scope === "ITEM" ? [...new Set(input.menuItemIds)] : [],
295
+ };
296
+ }