@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 +240 -19
- package/package.json +56 -7
- package/prisma/discounts.prisma +181 -0
- package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
- package/scripts/sync-discounts-schema.mjs +53 -0
- package/src/engine/kinds.ts +62 -0
- package/src/index.ts +5 -0
- package/src/manifest/index.ts +52 -17
- package/src/manifest/web.ts +55 -0
- package/src/react/api.ts +185 -0
- package/src/react/copy.ts +239 -0
- package/src/react/create-web-discounts.tsx +162 -0
- package/src/react/discount-actions-menu.tsx +127 -0
- package/src/react/discount-card.tsx +92 -0
- package/src/react/discount-form-fields.tsx +174 -0
- package/src/react/discount-form.tsx +272 -0
- package/src/react/discount-list-card.tsx +199 -0
- package/src/react/discount-target-picker.tsx +159 -0
- package/src/react/discounts-screen.tsx +314 -0
- package/src/react/format.ts +126 -0
- package/src/react/index.ts +72 -0
- package/src/react/pt-BR.ts +132 -0
- package/src/react/row.ts +66 -0
- package/src/react/table-config.ts +238 -0
- package/src/react/transport.ts +115 -0
- package/src/react/use-discounts-data.ts +103 -0
- package/src/server/collections.ts +290 -0
- package/src/server/copy.ts +12 -0
- package/src/server/index.ts +25 -0
- package/src/server/logging.ts +135 -0
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +147 -34
- package/src/server/targets.ts +102 -0
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({
|
|
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: {
|
|
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
|
-
**
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|