@12-apps/discounts 1.1.0 → 1.3.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 CHANGED
@@ -131,7 +131,7 @@ bundle, if a host wants one, stays the host's.
131
131
 
132
132
  ## The admin surface
133
133
 
134
- `createApiDiscounts({ store, copy })` returns five `WireRoute` descriptors —
134
+ `createApiDiscounts({ store, copy, logger })` returns five `WireRoute` descriptors —
135
135
  list, read, create, re-state, archive — each **declaring the permission it
136
136
  needs** (`discounts:read` / `discounts:write`), so a host's coverage gates can
137
137
  read the policy off the assembled table instead of scanning route files.
@@ -140,7 +140,11 @@ read the policy off the assembled table instead of scanning route files.
140
140
  import { createApiDiscounts } from "@12-apps/discounts/server";
141
141
  import { PT_BR_DISCOUNTS_SERVER_COPY } from "@12-apps/discounts/server/pt-BR";
142
142
 
143
- const { routes } = createApiDiscounts({ store, copy: PT_BR_DISCOUNTS_SERVER_COPY });
143
+ const { routes } = createApiDiscounts({
144
+ store,
145
+ copy: PT_BR_DISCOUNTS_SERVER_COPY,
146
+ logger: createFeatureLogger("discounts"),
147
+ });
144
148
  ```
145
149
 
146
150
  Or, through the contract:
@@ -152,7 +156,9 @@ import { discountsServerManifest } from "@12-apps/discounts/manifest/server";
152
156
  host.adoptServer({
153
157
  manifest: discountsManifest,
154
158
  server: discountsServerManifest,
155
- bindings: { http: { mountPath: "/api/admin/:tenantSlug", config: { store, copy } } },
159
+ bindings: {
160
+ http: { mountPath: "/api/admin/:tenantSlug", config: { store, copy, logger } },
161
+ },
156
162
  });
157
163
  ```
158
164
 
@@ -160,24 +166,239 @@ Delete is a **soft archive**, and that is a product decision: the orders that
160
166
  already redeemed a discount keep their snapshot, and its redemption counter
161
167
  stays readable for reporting.
162
168
 
169
+ ### Nothing it does is silent
170
+
171
+ `logger` is **required config, with no default**, for the reason `copy` is: the
172
+ default would be a no-op, and a no-op is the exact silence it exists to end.
173
+ Every route is wrapped, and there are three outcomes:
174
+
175
+ | outcome | level | what the line names |
176
+ |---|---|---|
177
+ | a write that succeeded | `info` | the verb, the discount id, the tenant |
178
+ | a refusal this package decided (400 / 404 / 422) | `warn` | the route, the status, and for a 422 the **field** a form paints red |
179
+ | anything thrown — and it is re-thrown unchanged | `error` | the route, the tenant, the cause's message |
180
+
181
+ Every line is a **string**. A host's logger is a Winston child in practice,
182
+ whose formatter runs `util.inspect(…, { depth: 5 })` over an extra argument —
183
+ which is how a provider error's retained response body, buyer name and CPF
184
+ included, reaches a third party. The cause is folded into the sentence instead.
185
+ A line names the tenant, the route, the discount id and the field; never the
186
+ body, never a coupon code, never a name an operator typed.
187
+
188
+ The `error` case **re-throws**. A uniqueness clash, a foreign target and a dead
189
+ connection are the store's to raise in the host's own error vocabulary; a
190
+ surface that folded one into a tidy response would turn a broken database into a
191
+ refusal nobody investigates.
192
+
193
+ A host adopting through `@12-apps/wiring/consumer` already has the logger the
194
+ binder built from this package's declared namespace — pass
195
+ `assembled.loggers["@12-apps/discounts"]`. A host on no wiring passes any
196
+ `createFeatureLogger`-shaped child of its own.
197
+
198
+ The evaluator is deliberately **not** wired to it. `evaluateCart` is a pure
199
+ function called once per cart render; a logger there would file a line per
200
+ keystroke on the storefront and would need a logger threaded through a browser
201
+ bundle. A rejected coupon is already a `DiscountRejection` in the result, which
202
+ is the host's to report if it wants to.
203
+
204
+ ## Letting a host table opt in — `DiscountableCollection`
205
+
206
+ A discount points at rows this package will never see: a store's categories, its
207
+ products, and — the day a host wants it — its suppliers or its shelves. Every
208
+ question about them ("which exist", "are they this tenant's", "what is this one
209
+ filed under") is answerable only by the host, so the host DECLARES the table:
210
+
211
+ ```ts
212
+ const categories: DiscountableCollection = {
213
+ targetType: "CATEGORY",
214
+ slug: "categories",
215
+ label: "Categorias", // host vocabulary, host language
216
+ nests: true,
217
+ ops: {
218
+ list: (clientId) => …, // the picker's rows
219
+ ownsAll: (clientId, ids) => …, // the cross-tenant guard
220
+ parents: (clientId) => …, // child → parent, for the ancestry walk
221
+ },
222
+ };
223
+
224
+ createApiDiscounts({ store, copy, logger, collections: [categories, products] });
225
+ ```
226
+
227
+ Three pieces of hand-written host code collapse into it:
228
+
229
+ | host code | becomes |
230
+ |---|---|
231
+ | a per-table cross-tenant `count` before every write | `ops.ownsAll`, run once per write for every dimension |
232
+ | the tenant's category tree loaded, and an ancestor walk with a cycle guard | `ops.parents` + the package's own `buildTargetPath` |
233
+ | an admin page side-loading two catalogs at `?pageSize=500` to feed a picker | `GET /discounts/targets`, one round trip |
234
+
235
+ Two things it fixes as a side effect. The ownership check now covers the ids
236
+ inside a **combo's slots** — a combo scope drops the top-level target pair and
237
+ carries its targets in the slots, so a host checking only the pair let a crafted
238
+ combo name another store's products. And the ancestry walk stops being a second
239
+ implementation: the host builds the cart lines this package prices, so both
240
+ sides now read the same `buildTargetPath`.
241
+
242
+ The check runs OUTSIDE the store's transaction, and that is enough for the leak
243
+ that matters: a catalog row's tenant never changes, so an id owned by this
244
+ tenant now cannot belong to another one by the time the write lands. The only
245
+ race left is a target deleted in between, whose outcome is a discount pointing
246
+ at a row that no longer exists — which the evaluator already tolerates.
247
+
248
+ `collections` is **optional**, and the omission is a real configuration: a host
249
+ that sells one undifferentiated thing has no dimension to register. What it
250
+ costs is stated rather than defaulted around — with nothing registered,
251
+ `GET /discounts/targets` answers `[]` and the cross-tenant check stays the
252
+ store's job.
253
+
254
+ ### Why this is not a wiring capability yet
255
+
256
+ `@12-apps/wiring`'s contract folder holds http, web, mcp, db, env, jobs, email,
257
+ notifications and permissions — there is no registration or extension concept,
258
+ so promoting this would mint a new capability on two instances that are less
259
+ alike than they look: `@12-apps/entity-lifecycle` registers a collection it
260
+ DRIVES, with write ops, while this registers a catalog dimension it only
261
+ QUERIES. Abstracting one contract over both risks fitting neither. It ships
262
+ here, shaped deliberately like its sibling so the family resemblance is visible,
263
+ and a third consumer is what would earn it a place in the contract.
264
+
265
+ `DISCOUNT_TARGET_TYPES` is closed at two today. That is a boundary, not a
266
+ principle: a third dimension is expressible the moment targets are stored **by
267
+ value** (`(target_type, target_id)`) instead of as a join table per member, and
268
+ the registration is shaped so that widening the array plus registering one more
269
+ collection is the whole change.
270
+
271
+ ## The schema travels too
272
+
273
+ The package ships a **composed Prisma partial** and its migrations
274
+ (`prisma/discounts.prisma`, `wiring.db`). A host syncs the partial into its
275
+ schema folder and discovers the migration structurally:
276
+
277
+ ```bash
278
+ pnpm --filter @12-apps/discounts prisma:sync # copy the partial
279
+ pnpm --filter @12-apps/discounts prisma:sync:check # drift gate, for CI
280
+ ```
281
+
282
+ Three tables — `discounts`, `discount_combo_slots`, `discount_targets` — and
283
+ what makes them shippable at all is that **nothing in them names a host table**:
284
+
285
+ | the origin had | the partial has | why |
286
+ |---|---|---|
287
+ | `client Client @relation(...)` | a scalar `client_id` | this package cannot know a host's tenant model's name |
288
+ | `discount_categories` + `discount_items`, one FK each | one `discount_targets`, keyed `(target_type, target_id)` | a partial naming `product_categories` only compiles inside a host that has that table |
289
+ | the scope's targets and a combo slot's, separately | one table, told apart by a nullable `slot_id` | they are the same fact; two tables need every reverse read, ownership check and cascade written twice |
290
+
291
+ Which collections are discountable is answered at RUNTIME by a
292
+ `DiscountableCollection` registration, not by the schema. That is the whole
293
+ move, and it is `@12-apps/entity-lifecycle`'s, applied here.
294
+
295
+ **The redemption snapshot stays with the host.** `order_discounts` is a child of
296
+ the host's own order, with a cascade, and its purpose is to freeze what a buyer
297
+ received on an order the host owns. Shipping the rule and leaving the receipt is
298
+ the clean cut.
299
+
300
+ **What the by-value target costs**, stated plainly: the schema no longer
301
+ guarantees that deleting a category cannot leave a rule pointing at a ghost.
302
+ Three things carry that instead — the evaluator already tolerates an unmatched
303
+ target (it covers nothing), `DiscountableOps.ownsAll` refuses a foreign or
304
+ missing id on every write, and a host that wants the constraint back may add it
305
+ in its own migration.
306
+
307
+ ### The migration adopts an existing table
308
+
309
+ Every statement is idempotent, because the first host to adopt this package
310
+ already HAS a `discounts` table from its own, earlier migration — and a package
311
+ migration sorts AFTER the host's by name, so a bare `CREATE TABLE` would fail
312
+ `migrate deploy` on every database that already has one. A fresh host gets
313
+ everything; an existing host gets only what it is missing (the three combo
314
+ columns, the two new tables, and the widened `type`/`scope`/value CHECKs);
315
+ replaying the whole folder is a no-op.
316
+
317
+ It deliberately does NOT move a host's existing target rows into
318
+ `discount_targets`. Only that host knows what its catalog tables are called, so
319
+ backfilling and dropping the old join tables is one migration in the host,
320
+ written once, sorting after this one.
321
+
322
+ `src/prisma/__tests__/migration.test.ts` applies the SQL to real Postgres
323
+ (PGlite) in all three situations — 17 cases, including the partial uniques, both
324
+ cascades, and the CHECK widening a schema diff cannot see.
325
+
326
+ ## The admin screens travel too — `@12-apps/discounts/react`
327
+
328
+ ```ts
329
+ // ONCE, at module scope. The members are component TYPES.
330
+ export const discounts = createWebDiscounts({
331
+ apiBase: `/api/admin/${slug}`,
332
+ copy: PT_BR_DISCOUNTS_WEB_COPY, // required, no defaults
333
+ locale: "pt-BR",
334
+ currency: "BRL",
335
+ currencyField: MyCurrencyField, // currency entry is a host decision
336
+ onError: reportToSentry, // required — see below
337
+ breadcrumb: [{ label: "Início", href: "/admin" }, { label: "Descontos" }],
338
+ });
339
+
340
+ // <discounts.Screen /> is the whole promotions admin.
341
+ ```
342
+
343
+ What that one call replaces: a server-driven grid with eight columns, five
344
+ filter pills and a CSV/JSON export; a create/edit form of fourteen inputs, four
345
+ of which appear only when another says so; the target pickers; two card
346
+ layouts; four confirmation popups; and every wire call between them.
347
+
348
+ **Build it once, at module scope.** The members are component TYPES, so
349
+ rebuilding per render gives React new types every time and remounts the whole
350
+ tree below — a form then cannot be typed into, and nothing says why.
351
+ `useWebDiscounts` is the memoised form for a genuinely dynamic mount path.
352
+
353
+ ### `onError` is required, and it is the browser twin of the server's logger
354
+
355
+ These screens already tell the OPERATOR what went wrong. That is not the same as
356
+ anybody knowing. A refused write, a catalog that would not load, a page read
357
+ that failed — each reaches a person only if a host routes it somewhere, and a
358
+ no-op default would make *nothing is broken* and *nothing is watching* look
359
+ identical. `context` is a stable dotted token (`discounts.list`,
360
+ `discounts.create`, `discounts.targets`) so a reporter can group on it.
361
+
362
+ ### One vocabulary, in English, on the wire
363
+
364
+ `DISCOUNT_WINDOW_STATES` are `RUNNING` / `SCHEDULED` / `ENDED`, and the words on
365
+ the pill come from the copy pack. They are read in three places that must agree
366
+ — the filter pill, the query the backend receives, and the badge on a row — and
367
+ the moment one of those is a word in a language, the set stops being a set. The
368
+ origin filtered on `Vigente`, so its wire protocol *was* its language.
369
+
370
+ `discountWindowState()` lives in the engine rather than in this surface for the
371
+ same reason: the badge and the backend filter must answer "has it ended" the
372
+ same way, and two implementations is how a grid shows a rule as running on a row
373
+ the filter excludes.
374
+
375
+ ### What a host still supplies, and why each one
376
+
377
+ | config | why it cannot be defaulted |
378
+ |---|---|
379
+ | `copy` | a default in one language reads as finished to the next host until a user sees it |
380
+ | `locale` + `currency` | "12,5" is not a translation of "12.5" — it decides PARSING as much as rendering |
381
+ | `currencyField` | masking, which side the symbol sits on, whether cents are typed — no neutral answer exists |
382
+ | `onError` | above |
383
+ | `breadcrumb` | the host owns its own information hierarchy |
384
+
385
+ ### Storybook
386
+
387
+ `pnpm --filter @12-apps/discounts dev` (port 6009). Every ported component has
388
+ stories, including the states nobody can produce on demand in a real
389
+ environment: a refused write, a backend that will not answer, a catalog that
390
+ failed while the grid loaded fine. The stories substitute a **transport**, not a
391
+ stubbed client, so they exercise the real path building and envelope unwrapping
392
+ and only the bytes are pretend.
393
+
163
394
  ## What the host still owns
164
395
 
165
- **The database.** A discount's rows relate to a host's own catalog and orders —
166
- targets point at its categories and items, redemptions at its orders and its
167
- buyers — so neither wiring `db` mode qualifies and the manifest declares none.
168
- The host owns the schema, the tenant scoping, the transactions and the
169
- uniqueness conflicts, and answers the `DiscountStore` port. What travels is the
170
- RULE, not the storage.
171
-
172
- Combos add three nullable columns to that table — `bundle_price_cents`,
173
- `free_units`, `max_combo_applications` — and one child table for the slots, each
174
- row carrying a quantity plus its own product and category id lists. `DiscountWrite`
175
- hands a store exactly that: the columns on `scalars`, the slots on
176
- `targets.comboRequirements`, already validated and narrowed to the scope. A host
177
- that does not sell combos adds nothing: the fields are optional on everything
178
- this package asks a host to BUILD (`DiscountRule`, `DiscountRecord`) and
179
- complete on everything it PRODUCES, so adopting the version that introduced them
180
- is a no-op until a `COMBO`-scoped row exists.
396
+ **Persistence itself.** The package owns the schema; the host owns the client,
397
+ the tenant scoping, the transactions and the uniqueness conflicts, and answers
398
+ the `DiscountStore` port. `toTargetRows` / `fromTargetRows` do the fold between
399
+ the id arrays this package speaks and the by-value rows the schema stores, so a
400
+ store stays persistence and nothing else — that mapping is exactly where a
401
+ dropped `slotId` produces a combo whose slots have silently merged.
181
402
 
182
403
  **Every sentence a human reads.** `DiscountRejectionCopy` (buyer-facing) and
183
404
  `DiscountsServerCopy` (operator-facing) are required config with **no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/discounts",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
6
6
  "exports": {
@@ -8,38 +8,78 @@
8
8
  "./pt-BR": "./src/engine/pt-BR.ts",
9
9
  "./server": "./src/server/index.ts",
10
10
  "./server/pt-BR": "./src/server/pt-BR.ts",
11
+ "./react": "./src/react/index.ts",
12
+ "./react/pt-BR": "./src/react/pt-BR.ts",
11
13
  "./manifest": "./src/manifest/index.ts",
12
14
  "./manifest/server": "./src/manifest/server.ts",
15
+ "./manifest/web": "./src/manifest/web.ts",
13
16
  "./package.json": "./package.json"
14
17
  },
15
18
  "scripts": {
19
+ "check-types": "tsc --noEmit",
16
20
  "clean": "rm -rf node_modules coverage dist",
21
+ "dev": "storybook dev -p 6009",
22
+ "lint": "eslint src --max-warnings 0",
23
+ "prisma:sync": "node scripts/sync-discounts-schema.mjs",
24
+ "prisma:sync:check": "node scripts/sync-discounts-schema.mjs --check",
25
+ "storybook:build": "storybook build",
17
26
  "test": "node ../../scripts/vitest-with-teardown.mjs run",
18
27
  "test:watch": "vitest watch",
19
- "lint": "eslint src --max-warnings 0",
20
- "check-types": "tsc --noEmit",
21
28
  "typecheck": "tsc --noEmit"
22
29
  },
23
30
  "dependencies": {
31
+ "@12-apps/app-shell": "^5.1.0",
32
+ "@12-apps/forms-core": "^1.20.0",
24
33
  "@12-apps/shared-helpers": "^2.0.2",
34
+ "@12-apps/ui": "^6.2.0",
35
+ "@mui/icons-material": "^6.5.0",
25
36
  "zod": "^4.3.5"
26
37
  },
27
38
  "peerDependencies": {
28
- "@12-apps/wiring": ">=1.3.0"
39
+ "@12-apps/wiring": ">=1.3.0",
40
+ "react": ">=19.0.0",
41
+ "react-dom": ">=19.0.0",
42
+ "react-router-dom": ">=7.0.0"
29
43
  },
30
44
  "peerDependenciesMeta": {
31
45
  "@12-apps/wiring": {
32
46
  "optional": true
47
+ },
48
+ "react": {
49
+ "optional": true
50
+ },
51
+ "react-dom": {
52
+ "optional": true
53
+ },
54
+ "react-router-dom": {
55
+ "optional": true
33
56
  }
34
57
  },
35
58
  "devDependencies": {
36
59
  "@12-apps/eslint-config": "^1.21.1",
37
60
  "@12-apps/typescript-config": "^1.20.1",
38
- "@12-apps/wiring": "^1.7.0",
61
+ "@12-apps/wiring": "^1.10.0",
62
+ "@electric-sql/pglite": "^0.2.17",
63
+ "@emotion/react": "^11.14.0",
64
+ "@emotion/styled": "^11.14.0",
65
+ "@mui/material": "^6.5.0",
66
+ "@storybook/addon-docs": "^9.1.10",
67
+ "@storybook/addon-links": "^9.1.10",
68
+ "@storybook/react-vite": "^9.1.10",
69
+ "@testing-library/react": "^16.1.0",
39
70
  "@types/node": "^22.10.6",
71
+ "@types/react": "19.2.2",
72
+ "@types/react-dom": "19.2.2",
40
73
  "eslint": "^9.39.1",
74
+ "eslint-plugin-storybook": "9.1.10",
41
75
  "eslint-plugin-test-flakiness": "^1.4.0",
76
+ "jsdom": "^25.0.1",
77
+ "react": "^19.2.0",
78
+ "react-dom": "^19.2.0",
79
+ "react-router-dom": "^7.0.0",
80
+ "storybook": "^9.1.10",
42
81
  "typescript": "^5.9.2",
82
+ "vite": "^6.4.1",
43
83
  "vitest": "^3.2.4"
44
84
  },
45
85
  "engines": {
@@ -58,6 +98,8 @@
58
98
  "files": [
59
99
  "src",
60
100
  "dist",
101
+ "scripts",
102
+ "prisma",
61
103
  "*.js",
62
104
  "*.mjs",
63
105
  "*.md",
@@ -68,6 +110,13 @@
68
110
  "!**/*.spec.*",
69
111
  "!**/*.stories.*",
70
112
  "!**/*.test-story.*",
71
- "!**/test-helpers.*"
72
- ]
113
+ "!**/test-helpers.*",
114
+ "!**/__stories__/**"
115
+ ],
116
+ "wiring": {
117
+ "db": {
118
+ "partial": "prisma/discounts.prisma",
119
+ "migrations": "prisma/migrations"
120
+ }
121
+ }
73
122
  }
@@ -0,0 +1,181 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @12-apps/discounts — CANONICAL Prisma model partial (plug-and-play).
3
+ //
4
+ // The single source of truth for the three tables a promotions surface owns. A
5
+ // host does NOT copy these models into its schema by hand: it uses Prisma's
6
+ // multi-file schema folder and SYNCS this file into it
7
+ // (`scripts/sync-discounts-schema.mjs`), then discovers the migrations beside
8
+ // it structurally.
9
+ //
10
+ // Host-agnostic by design, and this is the whole reason the shape differs from
11
+ // the origin's:
12
+ //
13
+ // - The TENANT is a by-value `client_id` scalar — no relation to the host's
14
+ // tenant model, whose name this package cannot know.
15
+ // - A TARGET is named BY VALUE via (target_type, target_id) — no FK into the
16
+ // host's catalog tables, so any collection plugs in with zero schema change
17
+ // here. That replaces one join table per dimension, which is what made the
18
+ // old shape unshippable: a partial literally naming `product_categories`
19
+ // and `menu_items` only compiles inside a host that has tables under those
20
+ // names. Which collections are discountable is answered at RUNTIME by a
21
+ // `DiscountableCollection` registration (`src/server/collections.ts`), not
22
+ // by the schema.
23
+ // - `type` / `scope` / `trigger` / `target_type` are Strings; the migration
24
+ // adds the CHECK constraints for the closed sets `src/engine/kinds.ts`
25
+ // declares. Those arrays and these CHECKs are two halves of ONE set.
26
+ //
27
+ // WHAT THE HOST STILL OWNS: the REDEMPTION snapshot (`order_discounts`). It is
28
+ // a child of the host's own order with a cascade, and its whole purpose is to
29
+ // freeze what a buyer received on an order the host owns. Shipping the rule and
30
+ // leaving the receipt behind is the clean cut.
31
+ //
32
+ // WHAT THE BY-VALUE TARGET COSTS, stated plainly: this schema no longer
33
+ // guarantees that deleting a category cannot leave a discount pointing at a
34
+ // ghost. Three things carry that instead — the evaluator already tolerates an
35
+ // unmatched target (it simply covers nothing), `DiscountableOps.ownsAll`
36
+ // refuses a foreign or missing id on every write, and a host that wants the
37
+ // constraint back may add it in its own migration.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ // The rule itself: what it takes off, from what, when, and how often.
41
+ //
42
+ // `usage_count` is the live redemption counter, incremented inside the host's
43
+ // PAID confirmation transaction by a predicated UPDATE. That predicate is what
44
+ // makes `usage_count <= usage_limit` an invariant the CHECK can assert; a plain
45
+ // read-then-write under READ COMMITTED would lose increments and break it.
46
+ model Discount {
47
+ id String @id @default(uuid())
48
+ // BY VALUE — no relation to the host's tenant model. Every read and every
49
+ // write carries it: a discount is a direct lever on what a buyer is charged,
50
+ // so a query that forgot its tenant would let one store reprice another's.
51
+ clientId String @map("client_id")
52
+ name String
53
+ // PERCENTAGE | FIXED_AMOUNT | BUNDLE_PRICE | FREE_UNITS (CHECK in migration).
54
+ type String
55
+ // Basis points off, 1..10000. Set iff type = PERCENTAGE.
56
+ percentOffBp Int? @map("percent_off_bp")
57
+ // Cents off. Set iff type = FIXED_AMOUNT. Clamped at application time to what
58
+ // is left, so it can never credit the buyer.
59
+ amountOffCents Int? @map("amount_off_cents")
60
+ // The matched combo group costs exactly this. Set iff type = BUNDLE_PRICE.
61
+ bundlePriceCents Int? @map("bundle_price_cents")
62
+ // The cheapest N units of the matched group are free. Set iff type =
63
+ // FREE_UNITS, and always fewer than the units one application requires — a
64
+ // combo that gives away everything it asks for sells nothing.
65
+ freeUnits Int? @map("free_units")
66
+ // How many times one cart may claim this combo. NULL = as often as it matches.
67
+ maxComboApplications Int? @map("max_combo_applications")
68
+ // ORDER | CATEGORY | ITEM | COMBO (CHECK in migration). ORDER carries no
69
+ // targets; CATEGORY/ITEM resolve through `discount_targets`; COMBO resolves by
70
+ // MATCHING the cart against its quantified slots.
71
+ scope String
72
+ // AUTOMATIC | CODE (CHECK in migration).
73
+ trigger String
74
+ // The coupon the buyer types, upper-cased and unmasked. Non-null iff
75
+ // trigger = CODE. Unique per tenant among LIVE rows only — see the note below.
76
+ code String?
77
+ // Active window, half-open [starts_at, ends_at): `ends_at` is EXCLUSIVE, so
78
+ // an operator setting midnight of the next day means exactly that. Null on
79
+ // either side = open-ended.
80
+ startsAt DateTime? @map("starts_at")
81
+ endsAt DateTime? @map("ends_at")
82
+ // Minimum PRE-DISCOUNT cart subtotal, in cents. Always compared against the
83
+ // untouched subtotal, never a running total — which is what makes the result
84
+ // independent of the order the discounts are applied in.
85
+ minSubtotalCents Int? @map("min_subtotal_cents")
86
+ // Redemptions allowed across every buyer, and per buyer. Null = no cap.
87
+ usageLimit Int? @map("usage_limit")
88
+ perBuyerLimit Int? @map("per_buyer_limit")
89
+ usageCount Int @default(0) @map("usage_count")
90
+ // When false this rule stacks with nothing: if it wins it is the only one
91
+ // applied to the order ("best single discount wins").
92
+ stackable Boolean @default(true)
93
+ // The operator's on/off switch, ORTHOGONAL to `archived_at` (deleted) and to
94
+ // the window (scheduled).
95
+ active Boolean @default(true)
96
+ createdAt DateTime @default(now()) @map("created_at")
97
+ updatedAt DateTime @updatedAt @map("updated_at")
98
+ // Change attribution: the host's user id, by value, nullable for system rows.
99
+ createdBy String? @map("created_by")
100
+ updatedBy String? @map("updated_by")
101
+ // Accent/case-insensitive search key over `name`, written by the host.
102
+ searchName String? @map("search_name")
103
+ // Soft delete. A non-null timestamp hides the rule from every live read while
104
+ // its redemption history and its counter survive. Null = live.
105
+ archivedAt DateTime? @map("archived_at")
106
+
107
+ targets DiscountTarget[]
108
+ comboSlots DiscountComboSlot[]
109
+
110
+ // NO @@unique here, and that is an honest declaration rather than an
111
+ // omission. The tenant uniques on (client_id, name) and (client_id, code)
112
+ // exist in the database as PARTIAL indexes, both `WHERE archived_at IS NULL`.
113
+ // Prisma cannot express a filtered unique, and declaring an unconditional one
114
+ // here would be a lie the generated client acts on: it would expose a
115
+ // `clientId_code` findUnique the index cannot serve.
116
+ //
117
+ // Partial because deleting is a SOFT archive: an unconditional unique lets a
118
+ // soft-deleted coupon squat its code forever, so an operator could never
119
+ // recreate a code they had deleted. Live rows still own one name and one code
120
+ // per tenant; archived rows leave the index.
121
+ @@index([clientId, active, archivedAt])
122
+ @@map("discounts")
123
+ }
124
+
125
+ // One quantified slot of a combo: "2 units of anything in Drinks".
126
+ //
127
+ // A child table rather than a JSON column, for the reason the target rows are
128
+ // rows: the slot's own targets need the same by-value shape and the same
129
+ // reverse read, and a JSON blob would put half the combo behind a scan.
130
+ //
131
+ // `position` is the operator's order, which is also the order a card describes
132
+ // the combo in — a stable order matters because two slots of the same size are
133
+ // otherwise indistinguishable in a UI that lists them.
134
+ model DiscountComboSlot {
135
+ id String @id @default(uuid())
136
+ discountId String @map("discount_id")
137
+ position Int
138
+ // Units of this slot ONE application consumes. Bounded by
139
+ // MAX_COMBO_SLOT_QUANTITY (CHECK in migration).
140
+ quantity Int
141
+
142
+ discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
143
+ targets DiscountTarget[]
144
+
145
+ @@unique([discountId, position])
146
+ @@map("discount_combo_slots")
147
+ }
148
+
149
+ // What a discount points at, BY VALUE.
150
+ //
151
+ // One table for both jobs, told apart by `slot_id`: NULL means a SCOPE target
152
+ // (what a CATEGORY- or ITEM-scoped rule covers), non-null means the target of
153
+ // one combo slot. One table because they are the same fact — "this rule cares
154
+ // about this row of this collection" — and two would need every reverse read,
155
+ // every ownership check and every cascade written twice.
156
+ //
157
+ // The uniques are PARTIAL (`WHERE slot_id IS NULL` / `IS NOT NULL`) and
158
+ // therefore live only in the migration, for the same reason the tenant uniques
159
+ // on `Discount` do: NULLs are distinct in Postgres, so one unconditional unique
160
+ // over a nullable column would not constrain the scope rows at all.
161
+ model DiscountTarget {
162
+ id String @id @default(uuid())
163
+ discountId String @map("discount_id")
164
+ // NULL = a scope target. Non-null = a target of that combo slot.
165
+ slotId String? @map("slot_id")
166
+ // CATEGORY | ITEM (CHECK in migration) — which host collection `targetId`
167
+ // names. Widening this set is a host registering one more
168
+ // `DiscountableCollection`, plus this CHECK.
169
+ targetType String @map("target_type")
170
+ // The host row's id. No FK: see the partial's header.
171
+ targetId String @map("target_id")
172
+
173
+ discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
174
+ slot DiscountComboSlot? @relation(fields: [slotId], references: [id], onDelete: Cascade)
175
+
176
+ // The reverse read a menu badge does: "which rules touch this row".
177
+ @@index([targetType, targetId])
178
+ @@index([discountId])
179
+ @@index([slotId])
180
+ @@map("discount_targets")
181
+ }