@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,54 @@
1
+ /**
2
+ * `@12-apps/discounts/server` — the admin CRUD surface.
3
+ *
4
+ * Behind its own subpath so a browser bundle that only prices a cart (the
5
+ * package root) never resolves zod or the search engine. The wiring manifest
6
+ * for these capabilities is `@12-apps/discounts/manifest/server`.
7
+ */
8
+
9
+ export {
10
+ createApiDiscounts,
11
+ type DiscountRoute,
12
+ type DiscountsActor,
13
+ type DiscountsApiConfig,
14
+ } from "./routes";
15
+
16
+ export { missingServerCopy, type DiscountsServerCopy } from "./copy";
17
+
18
+ export {
19
+ DISCOUNTS_PERMISSIONS,
20
+ DISCOUNTS_READ,
21
+ DISCOUNTS_WRITE,
22
+ } from "./contribution";
23
+
24
+ export type {
25
+ DiscountListInput,
26
+ DiscountPage,
27
+ DiscountRecord,
28
+ DiscountStore,
29
+ DiscountWrite,
30
+ } from "./store";
31
+
32
+ export {
33
+ DiscountValidationError,
34
+ targetsForScope,
35
+ toDiscountScalars,
36
+ toDiscountWriteInput,
37
+ type DiscountScalars,
38
+ type DiscountTargets,
39
+ type DiscountWriteBody,
40
+ type DiscountWriteInput,
41
+ } from "./validate";
42
+
43
+ export { discountSearchConfig } from "./search";
44
+
45
+ export {
46
+ createDiscountBody,
47
+ createDiscountListQuery,
48
+ discountCollectionParams,
49
+ discountItemParams,
50
+ discountSchema,
51
+ DISCOUNTS_MCP_TOOLS,
52
+ listDiscountsQuery,
53
+ updateDiscountBody,
54
+ } from "./mcp";
@@ -0,0 +1,197 @@
1
+ import { z } from "zod";
2
+
3
+ import {
4
+ createSearchInput,
5
+ createSearchOutput,
6
+ searchConfigSchema,
7
+ type SearchConfig,
8
+ } from "@12-apps/shared-helpers/search";
9
+ import type { WireMcpTool } from "@12-apps/wiring";
10
+
11
+ import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES, MAX_PERCENT_OFF_BP } from "../engine/kinds";
12
+ import { discountSearchConfig } from "./search";
13
+
14
+ /**
15
+ * The discounts tool surface — authored ONCE, beside the route descriptors it
16
+ * proxies to, and reused as the runtime validators. The advertised schema and
17
+ * what the handler accepts are the same objects, so they cannot drift.
18
+ *
19
+ * Every enum comes from `../engine/kinds` rather than being spelled out as
20
+ * literals: those same arrays back a host's CHECK constraints, so a value an
21
+ * agent can send is by construction a value the database will store.
22
+ *
23
+ * Paths are RELATIVE to the http capability's mount — the wiring consumer
24
+ * prefixes the adoption's `mountPath`, so a tool's URL and its route's URL
25
+ * cannot disagree.
26
+ */
27
+
28
+ /** Path params for the tenant-scoped discounts collection. */
29
+ export const discountCollectionParams = z.object({
30
+ tenantSlug: z.string().min(1),
31
+ });
32
+
33
+ /** Path params for a single discount. */
34
+ export const discountItemParams = z.object({
35
+ tenantSlug: z.string().min(1),
36
+ id: z.string().min(1),
37
+ });
38
+
39
+ /**
40
+ * A calendar date (`YYYY-MM-DD`), read at UTC midnight by the API.
41
+ *
42
+ * No custom message: a sentence here would be a default in one language, and
43
+ * the schema's own path is what a client renders. The SEMANTIC failure — a
44
+ * date that is not a date — is reported by `toDiscountScalars` with the host's
45
+ * `invalidDate` copy and the field to paint.
46
+ */
47
+ const calendarDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
48
+
49
+ /**
50
+ * A discount as the API returns it (JSON-serialized: dates become ISO
51
+ * strings). The two join tables are flattened into id arrays.
52
+ */
53
+ export const discountSchema = z.object({
54
+ id: z.string(),
55
+ name: z.string(),
56
+ type: z.enum(DISCOUNT_TYPES),
57
+ /** Basis points off (1..10000). Non-null iff `type = PERCENTAGE`. */
58
+ percentOffBp: z.number().int().nullable(),
59
+ /** Cents off. Non-null iff `type = FIXED_AMOUNT`. */
60
+ amountOffCents: z.number().int().nullable(),
61
+ scope: z.enum(DISCOUNT_SCOPES),
62
+ trigger: z.enum(DISCOUNT_TRIGGERS),
63
+ code: z.string().nullable(),
64
+ startsAt: z.string().nullable(),
65
+ endsAt: z.string().nullable(),
66
+ minSubtotalCents: z.number().int().nullable(),
67
+ usageLimit: z.number().int().nullable(),
68
+ perBuyerLimit: z.number().int().nullable(),
69
+ usageCount: z.number().int(),
70
+ stackable: z.boolean(),
71
+ active: z.boolean(),
72
+ categoryIds: z.array(z.string()),
73
+ menuItemIds: z.array(z.string()),
74
+ createdAt: z.string(),
75
+ });
76
+
77
+ /**
78
+ * The list query, from the same config the store's engine reads.
79
+ *
80
+ * A host that tunes its page-size ceilings by environment, or that adds a pill
81
+ * of its own vocabulary, builds its own with this factory and hands the result
82
+ * to BOTH `createApiDiscounts({ listQuery })` and the adoption's
83
+ * `mcpOverrides.listDiscounts.query` — one schema, advertised and enforced.
84
+ */
85
+ export function createDiscountListQuery<E extends Record<string, z.ZodTypeAny>>(
86
+ config: SearchConfig,
87
+ extraShape?: E,
88
+ ) {
89
+ return createSearchInput(discountSearchConfig, config, extraShape);
90
+ }
91
+
92
+ /**
93
+ * The package's own list query: the search config's DEFAULTS, not the
94
+ * environment's. This module is imported by an offline tool generator and by
95
+ * browsers; reading `process.env` at module scope would make the advertised
96
+ * surface depend on where it was generated.
97
+ */
98
+ export const listDiscountsQuery = createDiscountListQuery(searchConfigSchema.parse({}));
99
+
100
+ /**
101
+ * The write body, shared by create and update: a discount is always saved
102
+ * WHOLE (the form re-states every field), so there is no partial-patch shape
103
+ * 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.
107
+ */
108
+ const discountWriteShape = {
109
+ name: z.string().min(2).max(120),
110
+ type: z.enum(DISCOUNT_TYPES),
111
+ percentOffBp: z.number().int().min(1).max(MAX_PERCENT_OFF_BP).nullable().optional(),
112
+ amountOffCents: z.number().int().positive().nullable().optional(),
113
+ scope: z.enum(DISCOUNT_SCOPES),
114
+ trigger: z.enum(DISCOUNT_TRIGGERS),
115
+ code: z.string().min(1).max(64).nullable().optional(),
116
+ startsAt: calendarDateSchema.nullable().optional(),
117
+ endsAt: calendarDateSchema.nullable().optional(),
118
+ minSubtotalCents: z.number().int().positive().nullable().optional(),
119
+ usageLimit: z.number().int().positive().nullable().optional(),
120
+ perBuyerLimit: z.number().int().positive().nullable().optional(),
121
+ stackable: z.boolean(),
122
+ active: z.boolean(),
123
+ categoryIds: z.array(z.string().min(1)).optional(),
124
+ menuItemIds: z.array(z.string().min(1)).optional(),
125
+ };
126
+
127
+ /** Body for creating a discount. */
128
+ export const createDiscountBody = z.object(discountWriteShape);
129
+
130
+ /** Body for updating a discount (every field re-stated). */
131
+ export const updateDiscountBody = z.object(discountWriteShape);
132
+
133
+ /** `{ data: { id } }` acknowledgement for update/delete. */
134
+ const discountIdResponse = z.object({ data: z.object({ id: z.string() }) });
135
+
136
+ /**
137
+ * The tools, one per route descriptor. `annotations` travel WITH the tool —
138
+ * the package knows perfectly well that `listDiscounts` only reads and that
139
+ * `deleteDiscount` is the destructive one, and a host's policy table would
140
+ * otherwise restate all five by hand.
141
+ */
142
+ export const DISCOUNTS_MCP_TOOLS: readonly WireMcpTool<z.ZodType>[] = [
143
+ {
144
+ operationId: "listDiscounts",
145
+ method: "GET",
146
+ path: "/discounts",
147
+ 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).",
149
+ tags: ["discounts"],
150
+ params: discountCollectionParams,
151
+ query: listDiscountsQuery,
152
+ response: createSearchOutput(discountSchema),
153
+ annotations: { readOnly: true },
154
+ },
155
+ {
156
+ operationId: "getDiscount",
157
+ method: "GET",
158
+ path: "/discounts/{id}",
159
+ summary: "Read one of a store's discounts by id (admin).",
160
+ tags: ["discounts"],
161
+ params: discountItemParams,
162
+ response: z.object({ data: discountSchema }),
163
+ annotations: { readOnly: true },
164
+ },
165
+ {
166
+ operationId: "createDiscount",
167
+ method: "POST",
168
+ path: "/discounts",
169
+ 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.",
171
+ tags: ["discounts"],
172
+ params: discountCollectionParams,
173
+ body: createDiscountBody,
174
+ response: z.object({ data: discountSchema }),
175
+ },
176
+ {
177
+ operationId: "updateDiscount",
178
+ method: "PATCH",
179
+ path: "/discounts/{id}",
180
+ summary: "Update a store's discount (every field is re-stated, targets included).",
181
+ tags: ["discounts"],
182
+ params: discountItemParams,
183
+ body: updateDiscountBody,
184
+ response: discountIdResponse,
185
+ },
186
+ {
187
+ operationId: "deleteDiscount",
188
+ method: "DELETE",
189
+ path: "/discounts/{id}",
190
+ summary:
191
+ "Delete a store's discount (admin). Soft-delete: the discount stops applying and leaves the list, while the orders that already redeemed it keep their snapshot and its redemption counter is preserved.",
192
+ tags: ["discounts"],
193
+ params: discountItemParams,
194
+ response: discountIdResponse,
195
+ annotations: { destructive: true },
196
+ },
197
+ ];
@@ -0,0 +1,26 @@
1
+ import type { DiscountsServerCopy } from "./copy";
2
+
3
+ /**
4
+ * The pt-BR pack — a NAMED export a host passes by hand
5
+ * (`copy: PT_BR_DISCOUNTS_SERVER_COPY`), never a default. The filename is what
6
+ * exempts this file from the copy-portability gate: Portuguese may ship, it
7
+ * may not be silent.
8
+ *
9
+ * Every sentence names what the operator must change, because these are form
10
+ * errors: the route reports them with the offending FIELD alongside, so an
11
+ * admin form can paint that input rather than only flashing a banner.
12
+ */
13
+ export const PT_BR_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
14
+ invalidQuery: "Parâmetros de consulta inválidos.",
15
+ notFound: "Desconto não encontrado.",
16
+ invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
17
+ invalidAmount: "Informe um valor de desconto maior que zero.",
18
+ codeRequired: "Informe o código do cupom que o cliente vai digitar.",
19
+ categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
20
+ itemTargetRequired: "Selecione ao menos um produto para este desconto.",
21
+ invalidDate: "Data inválida. Use o formato AAAA-MM-DD.",
22
+ endsBeforeStarts: "A data de término deve ser posterior à data de início.",
23
+ invalidMinSubtotal: "O pedido mínimo deve ser maior que zero (ou deixe em branco).",
24
+ invalidUsageLimit: "O limite de usos deve ser maior que zero (ou deixe em branco).",
25
+ invalidPerBuyerLimit: "O limite por cliente deve ser maior que zero (ou deixe em branco).",
26
+ };
@@ -0,0 +1,200 @@
1
+ import type { z } from "zod";
2
+
3
+ import type { WireRequest, WireResponse, WireRoute } from "@12-apps/wiring";
4
+
5
+ import { missingServerCopy, type DiscountsServerCopy } from "./copy";
6
+ import { listDiscountsQuery } from "./mcp";
7
+ import { DISCOUNTS_READ, DISCOUNTS_WRITE } from "./contribution";
8
+ import type { DiscountListInput, DiscountRecord, DiscountStore } from "./store";
9
+ import {
10
+ DiscountValidationError,
11
+ targetsForScope,
12
+ toDiscountScalars,
13
+ toDiscountWriteInput,
14
+ type DiscountWriteBody,
15
+ } from "./validate";
16
+
17
+ /**
18
+ * The admin discounts HTTP surface (FUT-244), as framework-neutral wiring
19
+ * descriptors: tenant-scoped CRUD over the promotions an operator manages.
20
+ *
21
+ * What is NOT here is as deliberate as what is. There is no lifecycle or
22
+ * approval branch (FUT-235 D7) — a pricing rule with a live redemption counter
23
+ * is not versioned catalog content, so a write applies immediately or fails.
24
+ * There is no authentication, no tenant resolution and no RBAC: each
25
+ * descriptor DECLARES the permission it needs and the host's guard answers it,
26
+ * because which tier a route requires is host policy, and the actor arrives
27
+ * already resolved.
28
+ *
29
+ * Delete is a SOFT archive rather than a row removal, and that is a product
30
+ * decision rather than an implementation detail: the orders that already
31
+ * redeemed this discount keep their snapshot, and its redemption counter stays
32
+ * readable for reporting.
33
+ */
34
+
35
+ /** Whoever the host resolved: the tenant this request may act inside. */
36
+ export interface DiscountsActor {
37
+ /** The tenant's id — every store call is scoped to it. */
38
+ clientId: string;
39
+ }
40
+
41
+ export type DiscountRoute = WireRoute<DiscountsActor>;
42
+
43
+ export interface DiscountsApiConfig {
44
+ /** Where the rows are — see {@link DiscountStore}. */
45
+ store: DiscountStore;
46
+ /** Every sentence this surface answers a human with. Required, no defaults. */
47
+ copy: DiscountsServerCopy;
48
+ /**
49
+ * The schema the list query is validated against. Optional ONLY because a
50
+ * mechanical default is portable: the package's own, built from the search
51
+ * config's defaults. A host that tunes page-size ceilings by environment, or
52
+ * that adds a pill of its own vocabulary, passes the schema it also
53
+ * advertises through `mcpOverrides.listDiscounts.query` — one object, so the
54
+ * advertised surface and the enforced one cannot drift.
55
+ */
56
+ listQuery?: z.ZodType<DiscountListInput>;
57
+ }
58
+
59
+ function ok(body: unknown): WireResponse {
60
+ return { status: 200, body };
61
+ }
62
+
63
+ /** The `{ error, issues }` shape a form reads its per-input errors out of. */
64
+ function fieldError(error: DiscountValidationError): WireResponse {
65
+ return {
66
+ status: error.status,
67
+ body: { error: error.message, issues: { [error.field]: error.message } },
68
+ };
69
+ }
70
+
71
+ /** The id acknowledgement update and delete answer with. */
72
+ function acknowledged(id: string): WireResponse {
73
+ return ok({ data: { id } });
74
+ }
75
+
76
+ /**
77
+ * Fold a validated body into what the store persists.
78
+ *
79
+ * Both writes go through it, so the rules run exactly once per write and a
80
+ * second caller cannot half-apply them.
81
+ */
82
+ function writeFrom(body: unknown, copy: DiscountsServerCopy) {
83
+ const input = toDiscountWriteInput(body as DiscountWriteBody);
84
+ return { scalars: toDiscountScalars(input, copy), targets: targetsForScope(input) };
85
+ }
86
+
87
+ /**
88
+ * Run a write, turning this package's own validation failure into the 422 the
89
+ * operator can act on. Everything else — a uniqueness clash, a foreign target,
90
+ * a stale id — is the STORE's to raise, in the host's own error vocabulary,
91
+ * and travels untouched to the host's error mapping.
92
+ */
93
+ async function attempt(run: () => Promise<WireResponse>): Promise<WireResponse> {
94
+ try {
95
+ return await run();
96
+ } catch (error) {
97
+ if (error instanceof DiscountValidationError) return fieldError(error);
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ function listRoute(config: DiscountsApiConfig): DiscountRoute {
103
+ const schema = config.listQuery ?? (listDiscountsQuery as z.ZodType<DiscountListInput>);
104
+ return {
105
+ method: "GET",
106
+ path: "/discounts",
107
+ permission: DISCOUNTS_READ,
108
+ handle: async ({ actor, query }: WireRequest<DiscountsActor>) => {
109
+ const parsed = schema.safeParse(query);
110
+ if (!parsed.success) {
111
+ return { status: 400, body: { error: config.copy.invalidQuery } };
112
+ }
113
+ // Returned as-is rather than wrapped: the page IS the `{ data,
114
+ // pagination }` envelope the advertised response describes, and wrapping
115
+ // it again would nest it under a second `data`.
116
+ return ok(await config.store.list(actor.clientId, parsed.data));
117
+ },
118
+ };
119
+ }
120
+
121
+ function readRoute(config: DiscountsApiConfig): DiscountRoute {
122
+ return {
123
+ method: "GET",
124
+ path: "/discounts/:id",
125
+ permission: DISCOUNTS_READ,
126
+ handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
127
+ const record = await config.store.get(actor.clientId, String(params.id));
128
+ if (record === null) return { status: 404, body: { error: config.copy.notFound } };
129
+ return ok({ data: record as DiscountRecord });
130
+ },
131
+ };
132
+ }
133
+
134
+ function createRoute(config: DiscountsApiConfig): DiscountRoute {
135
+ return {
136
+ method: "POST",
137
+ path: "/discounts",
138
+ permission: DISCOUNTS_WRITE,
139
+ handle: ({ actor, body }: WireRequest<DiscountsActor>) =>
140
+ attempt(async () =>
141
+ ok({ data: await config.store.create(actor.clientId, writeFrom(body, config.copy)) }),
142
+ ),
143
+ };
144
+ }
145
+
146
+ function updateRoute(config: DiscountsApiConfig): DiscountRoute {
147
+ return {
148
+ method: "PATCH",
149
+ path: "/discounts/:id",
150
+ permission: DISCOUNTS_WRITE,
151
+ handle: ({ actor, params, body }: WireRequest<DiscountsActor>) =>
152
+ attempt(async () => {
153
+ const id = String(params.id);
154
+ await config.store.update(actor.clientId, id, writeFrom(body, config.copy));
155
+ return acknowledged(id);
156
+ }),
157
+ };
158
+ }
159
+
160
+ function archiveRoute(config: DiscountsApiConfig): DiscountRoute {
161
+ return {
162
+ method: "DELETE",
163
+ path: "/discounts/:id",
164
+ permission: DISCOUNTS_WRITE,
165
+ handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
166
+ const id = String(params.id);
167
+ await config.store.archive(actor.clientId, id);
168
+ return acknowledged(id);
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The five descriptors, config asserted at construction.
175
+ *
176
+ * Copy is checked HERE rather than at first request, because a missing
177
+ * sentence is a wiring mistake and it should fail where the wiring is written
178
+ * — the report-builder doctrine: required fields, no defaults, asserted at
179
+ * assembly.
180
+ */
181
+ export function createApiDiscounts(config: DiscountsApiConfig): {
182
+ routes: readonly DiscountRoute[];
183
+ } {
184
+ const missing = missingServerCopy(config.copy);
185
+ if (missing.length > 0) {
186
+ throw new Error(
187
+ `@12-apps/discounts: createApiDiscounts is missing copy for ${missing.join(", ")} — ` +
188
+ "every sentence this surface answers a human with is host config, with no defaults.",
189
+ );
190
+ }
191
+ return {
192
+ routes: [
193
+ listRoute(config),
194
+ readRoute(config),
195
+ createRoute(config),
196
+ updateRoute(config),
197
+ archiveRoute(config),
198
+ ],
199
+ };
200
+ }
@@ -0,0 +1,45 @@
1
+ import { defineSearchConfig } from "@12-apps/shared-helpers/search";
2
+
3
+ import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
4
+
5
+ /**
6
+ * The admin discounts-list search contract (FUT-244): the backend equivalent of
7
+ * a promotions grid's search box, pills and sort. Search is case-insensitive
8
+ * over the discount name AND its coupon code, because an operator hunting for
9
+ * `BEMVINDO10` types the code, not the promotion's marketing name.
10
+ *
11
+ * The three closed sets come from `../engine/kinds` rather than being spelled
12
+ * out again here: the same arrays back a host's CHECK constraints and the MCP
13
+ * enums, so a value this config would accept is by construction a value the
14
+ * database will store.
15
+ *
16
+ * The grid's fourth pill — the VIGÊNCIA one (running / scheduled / ended) — is
17
+ * deliberately ABSENT. It is a comparison between two nullable columns and the
18
+ * current instant, which `filterableFields` cannot express at all, so it rides
19
+ * as a separate `window` parameter the store resolves into its own predicate.
20
+ * The parameter's VALUES are host words, which is why the store owns them: see
21
+ * `DiscountListInput` in `./store`.
22
+ */
23
+ export const discountSearchConfig = defineSearchConfig({
24
+ searchableFields: ["name", "code"],
25
+ searchMode: "insensitive",
26
+ filterableFields: {
27
+ type: { type: "string", operators: ["in"], allowedValues: [...DISCOUNT_TYPES] },
28
+ scope: { type: "string", operators: ["in"], allowedValues: [...DISCOUNT_SCOPES] },
29
+ trigger: { type: "string", operators: ["in"], allowedValues: [...DISCOUNT_TRIGGERS] },
30
+ active: { type: "boolean", operators: ["eq"] },
31
+ },
32
+ sortableFields: [
33
+ "name",
34
+ "code",
35
+ "type",
36
+ "scope",
37
+ "startsAt",
38
+ "endsAt",
39
+ "usageCount",
40
+ "createdAt",
41
+ ],
42
+ // Newest first: a promotions list is a working queue, and the thing an
43
+ // operator just created is the thing they want to see.
44
+ defaultSort: { field: "createdAt", direction: "desc" },
45
+ });
@@ -0,0 +1,101 @@
1
+ import type { PaginatedResult, ParsedSearchInput } from "@12-apps/shared-helpers/search";
2
+
3
+ import type { DiscountScope, DiscountTrigger, DiscountType } from "../engine/kinds";
4
+ import type { DiscountScalars, DiscountTargets } from "./validate";
5
+
6
+ /**
7
+ * The persistence seam — the ONE thing this package does not do.
8
+ *
9
+ * A discount's rows relate to a host's own catalog and orders: targets point
10
+ * at its categories and items, redemptions at its orders and its buyers. A
11
+ * shipped Prisma partial would therefore only compile inside a host that
12
+ * already has those tables under those names, which is why the manifest
13
+ * declares no `db` capability and this interface exists instead. The host
14
+ * owns the schema, the tenant scoping, the transactions and the uniqueness
15
+ * conflicts; the package owns the rules and the wire.
16
+ *
17
+ * Everything here is `clientId`-first for the same reason the origin
18
+ * implementation is: a discount is a direct lever on what a buyer is charged,
19
+ * so a query that forgot its tenant would not merely leak a row — it would let
20
+ * one store edit another store's prices.
21
+ */
22
+
23
+ /** One discount as the admin API returns it, before JSON serialization. */
24
+ export interface DiscountRecord {
25
+ id: string;
26
+ name: string;
27
+ type: DiscountType | string;
28
+ percentOffBp: number | null;
29
+ amountOffCents: number | null;
30
+ scope: DiscountScope | string;
31
+ trigger: DiscountTrigger | string;
32
+ code: string | null;
33
+ startsAt: Date | null;
34
+ endsAt: Date | null;
35
+ minSubtotalCents: number | null;
36
+ usageLimit: number | null;
37
+ perBuyerLimit: number | null;
38
+ usageCount: number;
39
+ stackable: boolean;
40
+ active: boolean;
41
+ /**
42
+ * The join tables flattened into plain id arrays: a form's target pickers
43
+ * bind to ids, and a client has no business knowing there are two joins.
44
+ */
45
+ categoryIds: string[];
46
+ menuItemIds: string[];
47
+ createdAt: Date;
48
+ }
49
+
50
+ /**
51
+ * What a write persists: the validated columns and the scope-narrowed targets.
52
+ *
53
+ * The store receives them ALREADY folded, never the raw body — every rule
54
+ * (`toDiscountScalars`) has run, the unused half of each either/or pair is
55
+ * NULL, and the targets are de-duplicated. So a store implementation is
56
+ * persistence and nothing else, and the rules cannot be half-applied by a
57
+ * second caller that forgot one.
58
+ */
59
+ export interface DiscountWrite {
60
+ scalars: DiscountScalars;
61
+ targets: DiscountTargets;
62
+ }
63
+
64
+ /**
65
+ * The list query as the store receives it: the shared search engine's parsed
66
+ * input, widened so a host can carry pills of its OWN vocabulary through.
67
+ *
68
+ * The widening is the point. A promotions grid usually wants a validity pill
69
+ * — running / scheduled / ended — which compares two nullable columns against
70
+ * "now" and no `filterableField` can express; its VALUES are words a host
71
+ * chose, in a language this package does not have. So the host extends the
72
+ * advertised query schema (an `mcpOverrides` entry, the same object it hands
73
+ * `createApiDiscounts` as `listQuery`), and reads its own key back out here.
74
+ */
75
+ export type DiscountListInput = ParsedSearchInput & Readonly<Record<string, unknown>>;
76
+
77
+ /** One backend-paginated page of the admin discounts grid. */
78
+ export type DiscountPage = PaginatedResult<DiscountRecord>;
79
+
80
+ export interface DiscountStore {
81
+ /** One page of this tenant's LIVE discounts, filtered/sorted/paginated. */
82
+ list(clientId: string, input: DiscountListInput): Promise<DiscountPage>;
83
+ /** One LIVE discount owned by `clientId`, or null (stale, foreign, archived). */
84
+ get(clientId: string, id: string): Promise<DiscountRecord | null>;
85
+ /**
86
+ * Persist a new discount and its targets. Implementations raise their own
87
+ * host error for a uniqueness clash (name or coupon code within the tenant)
88
+ * and for a target id that belongs to another tenant — both are database
89
+ * facts this package cannot see, and both must reach the operator naming the
90
+ * offending field.
91
+ */
92
+ create(clientId: string, write: DiscountWrite): Promise<DiscountRecord>;
93
+ /** Re-state a discount whole. Raises the host's not-found for a stale id. */
94
+ update(clientId: string, id: string, write: DiscountWrite): Promise<void>;
95
+ /**
96
+ * SOFT-delete. The row survives: its redemption snapshots are order history
97
+ * and its counter is reporting history, so removing it would either orphan
98
+ * redemptions or reset a number the store may still be asked to explain.
99
+ */
100
+ archive(clientId: string, id: string): Promise<void>;
101
+ }