@12-apps/prisma 6.2.0 → 6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/prisma",
3
- "version": "6.2.0",
3
+ "version": "6.3.0",
4
4
  "description": "Prisma host: the multi-file schema folder, the plugin migration seam, and the shared PrismaClient singleton with its audit / append-only extensions",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  "scripts": {
15
- "build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
15
+ "build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-discounts-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
16
16
  "clean": "rm -rf dist node_modules coverage",
17
17
  "test": "node ../../scripts/vitest-with-teardown.mjs run",
18
18
  "test:watch": "vitest watch",
@@ -20,7 +20,7 @@
20
20
  "lint": "pnpm run lint:files .",
21
21
  "lint:fix": "bash -c 'eslint \"${@:-.}\" --max-warnings 0 --fix' _",
22
22
  "typecheck": "tsc --noEmit",
23
- "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-auth-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
23
+ "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-auth-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-feature-flags-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-notifications-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-realtime-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-discounts-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
24
24
  "prisma:migrate": "prisma migrate dev",
25
25
  "prisma:push": "prisma db push",
26
26
  "prisma:studio": "prisma studio",
@@ -53,7 +53,9 @@
53
53
  "prisma:sync-audit": "node scripts/sync-audit-schema.mjs",
54
54
  "prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check",
55
55
  "prisma:sync-auth": "node scripts/sync-auth-schema.mjs",
56
- "prisma:sync-auth:check": "node scripts/sync-auth-schema.mjs --check"
56
+ "prisma:sync-auth:check": "node scripts/sync-auth-schema.mjs --check",
57
+ "prisma:sync-discounts": "node scripts/sync-discounts-schema.mjs",
58
+ "prisma:sync-discounts:check": "node scripts/sync-discounts-schema.mjs --check"
57
59
  },
58
60
  "dependencies": {
59
61
  "@electric-sql/pglite": "0.2.17",
@@ -64,21 +66,22 @@
64
66
  },
65
67
  "devDependencies": {
66
68
  "@12-apps/audit": "^5.0.2",
67
- "@12-apps/auth": "^2.7.0",
69
+ "@12-apps/auth": "^2.8.0",
70
+ "@12-apps/discounts": "^1.3.0",
68
71
  "@12-apps/entitlements": "^3.2.0",
69
72
  "@12-apps/entity-lifecycle": "^4.4.0",
70
73
  "@12-apps/eslint-config": "^1.21.1",
71
74
  "@12-apps/feature-flags": "^2.1.1",
72
- "@12-apps/jobs": "^4.3.1",
75
+ "@12-apps/jobs": "^4.4.0",
73
76
  "@12-apps/mcp": "^3.5.1",
74
77
  "@12-apps/notifications": "^4.2.0",
75
78
  "@12-apps/onboarding": "^2.1.0",
76
- "@12-apps/payments-backend": "^4.15.0",
77
- "@12-apps/product-research": "^2.2.0",
79
+ "@12-apps/payments-backend": "^4.16.0",
80
+ "@12-apps/product-research": "^2.3.0",
78
81
  "@12-apps/rbac": "^4.2.0",
79
- "@12-apps/realtime": "^2.2.0",
82
+ "@12-apps/realtime": "^2.3.0",
80
83
  "@12-apps/report-builder": "^5.4.1",
81
- "@12-apps/shift": "^3.2.0",
84
+ "@12-apps/shift": "^3.3.0",
82
85
  "@12-apps/typescript-config": "^1.20.1",
83
86
  "@types/node": "^22.10.6",
84
87
  "@vitest/coverage-v8": "^3.2.4",
@@ -0,0 +1,286 @@
1
+ -- @12-apps/discounts: the promotions tables, owned by the package and copied
2
+ -- into a host's migrations folder by its plugin-migration sync. Runs
3
+ -- identically on PostgreSQL and PGlite.
4
+ --
5
+ -- ── REPLAY-SAFE ON PURPOSE ────────────────────────────────────────────────
6
+ -- Every statement here is idempotent, because the first host to adopt this
7
+ -- package already HAS a `discounts` table created by its own, earlier
8
+ -- migration. A package migration is applied by name order, so this one sorts
9
+ -- AFTER the host's — and a bare `CREATE TABLE` would then fail
10
+ -- `prisma migrate deploy` on an existing database AND on a fresh one built from
11
+ -- the full folder. `prisma migrate resolve --applied` can only paper over the
12
+ -- first case, by hand, once per database. So this migration ADOPTS an existing
13
+ -- table instead of demanding a baseline:
14
+ --
15
+ -- * a fresh host gets the three tables, every index and every CHECK;
16
+ -- * a host that already has `discounts` gets only what it is missing — the
17
+ -- three combo columns, the two new tables, and the widened CHECKs;
18
+ -- * replaying the whole folder is a no-op.
19
+ --
20
+ -- What it deliberately does NOT do is MOVE a host's existing target rows. A
21
+ -- host adopting this package has `discount_categories` / `discount_items` rows
22
+ -- that belong in `discount_targets`, and only that host knows whether its
23
+ -- catalog tables are named what this package would have to guess. Backfilling
24
+ -- and dropping the old tables is one migration in the host, written once, and
25
+ -- it sorts after this one.
26
+ --
27
+ -- ── NO FOREIGN KEYS INTO HOST TABLES ──────────────────────────────────────
28
+ -- `client_id` and `target_id` are by-value scalars (the payments-backend
29
+ -- doctrine). The relations INTERNAL to these three tables ARE constrained, with
30
+ -- cascades, so a deleted rule can never leave orphan targets or orphan slots.
31
+ -- A host that wants referential integrity against its own catalog adds those
32
+ -- constraints in its own migration — and should first read the partial's note
33
+ -- on what the absence actually costs.
34
+
35
+ -- ─────────────────────────────────────────────────────────────────────────────
36
+ -- discounts — the rule itself.
37
+ -- ─────────────────────────────────────────────────────────────────────────────
38
+ CREATE TABLE IF NOT EXISTS "discounts" (
39
+ "id" TEXT NOT NULL,
40
+ "client_id" TEXT NOT NULL,
41
+ "name" TEXT NOT NULL,
42
+ "type" TEXT NOT NULL,
43
+ "percent_off_bp" INTEGER,
44
+ "amount_off_cents" INTEGER,
45
+ "bundle_price_cents" INTEGER,
46
+ "free_units" INTEGER,
47
+ "max_combo_applications" INTEGER,
48
+ "scope" TEXT NOT NULL,
49
+ "trigger" TEXT NOT NULL,
50
+ "code" TEXT,
51
+ "starts_at" TIMESTAMP(3),
52
+ "ends_at" TIMESTAMP(3),
53
+ "min_subtotal_cents" INTEGER,
54
+ "usage_limit" INTEGER,
55
+ "per_buyer_limit" INTEGER,
56
+ "usage_count" INTEGER NOT NULL DEFAULT 0,
57
+ "stackable" BOOLEAN NOT NULL DEFAULT true,
58
+ "active" BOOLEAN NOT NULL DEFAULT true,
59
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
60
+ "updated_at" TIMESTAMP(3) NOT NULL,
61
+ "created_by" TEXT,
62
+ "updated_by" TEXT,
63
+ "search_name" TEXT,
64
+ "archived_at" TIMESTAMP(3),
65
+
66
+ CONSTRAINT "discounts_pkey" PRIMARY KEY ("id")
67
+ );
68
+
69
+ -- The three combo columns, for a host whose `discounts` predates them.
70
+ ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "bundle_price_cents" INTEGER;
71
+ ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "free_units" INTEGER;
72
+ ALTER TABLE "discounts" ADD COLUMN IF NOT EXISTS "max_combo_applications" INTEGER;
73
+
74
+ -- One live name and one live coupon per tenant. PARTIAL on purpose: deleting is
75
+ -- a soft archive, and an unconditional unique lets a deleted coupon squat its
76
+ -- code forever, so the operator can never recreate a code they removed. NULLs
77
+ -- stay distinct, so the many automatic rules (all `code` NULL) never collide.
78
+ CREATE UNIQUE INDEX IF NOT EXISTS "discounts_client_id_name_key"
79
+ ON "discounts"("client_id", "name") WHERE "archived_at" IS NULL;
80
+ CREATE UNIQUE INDEX IF NOT EXISTS "discounts_client_id_code_key"
81
+ ON "discounts"("client_id", "code") WHERE "archived_at" IS NULL;
82
+
83
+ -- The storefront's "which rules could fire right now", and the admin list's
84
+ -- default active-first ordering.
85
+ CREATE INDEX IF NOT EXISTS "discounts_client_id_active_archived_at_idx"
86
+ ON "discounts"("client_id", "active", "archived_at");
87
+
88
+ -- ─────────────────────────────────────────────────────────────────────────────
89
+ -- discount_combo_slots — the quantified groups a COMBO matches.
90
+ -- ─────────────────────────────────────────────────────────────────────────────
91
+ CREATE TABLE IF NOT EXISTS "discount_combo_slots" (
92
+ "id" TEXT NOT NULL,
93
+ "discount_id" TEXT NOT NULL,
94
+ -- The operator's order, which is also the order a card describes the combo
95
+ -- in. Stable because two slots of the same size are otherwise
96
+ -- indistinguishable in a list.
97
+ "position" INTEGER NOT NULL,
98
+ "quantity" INTEGER NOT NULL,
99
+
100
+ CONSTRAINT "discount_combo_slots_pkey" PRIMARY KEY ("id")
101
+ );
102
+
103
+ CREATE UNIQUE INDEX IF NOT EXISTS "discount_combo_slots_discount_id_position_key"
104
+ ON "discount_combo_slots"("discount_id", "position");
105
+
106
+ -- ─────────────────────────────────────────────────────────────────────────────
107
+ -- discount_targets — what a rule points at, BY VALUE.
108
+ --
109
+ -- One table for both jobs, told apart by `slot_id`: NULL is a SCOPE target,
110
+ -- non-null is one combo slot's. They are the same fact — "this rule cares about
111
+ -- this row of this collection" — and two tables would need every reverse read,
112
+ -- every ownership check and every cascade written twice.
113
+ -- ─────────────────────────────────────────────────────────────────────────────
114
+ CREATE TABLE IF NOT EXISTS "discount_targets" (
115
+ "id" TEXT NOT NULL,
116
+ "discount_id" TEXT NOT NULL,
117
+ "slot_id" TEXT,
118
+ "target_type" TEXT NOT NULL,
119
+ "target_id" TEXT NOT NULL,
120
+
121
+ CONSTRAINT "discount_targets_pkey" PRIMARY KEY ("id")
122
+ );
123
+
124
+ -- PARTIAL uniques, and they have to be: NULLs are distinct in Postgres, so one
125
+ -- unconditional unique over `slot_id` would not constrain the scope rows at all
126
+ -- — a rule could name the same category twice and cover it twice.
127
+ CREATE UNIQUE INDEX IF NOT EXISTS "discount_targets_scope_key"
128
+ ON "discount_targets"("discount_id", "target_type", "target_id")
129
+ WHERE "slot_id" IS NULL;
130
+ CREATE UNIQUE INDEX IF NOT EXISTS "discount_targets_slot_key"
131
+ ON "discount_targets"("slot_id", "target_type", "target_id")
132
+ WHERE "slot_id" IS NOT NULL;
133
+
134
+ -- The reverse read a menu badge does: "which rules touch this row".
135
+ CREATE INDEX IF NOT EXISTS "discount_targets_target_type_target_id_idx"
136
+ ON "discount_targets"("target_type", "target_id");
137
+ CREATE INDEX IF NOT EXISTS "discount_targets_discount_id_idx"
138
+ ON "discount_targets"("discount_id");
139
+ CREATE INDEX IF NOT EXISTS "discount_targets_slot_id_idx"
140
+ ON "discount_targets"("slot_id");
141
+
142
+ -- ─────────────────────────────────────────────────────────────────────────────
143
+ -- Foreign keys — INTERNAL only.
144
+ -- ─────────────────────────────────────────────────────────────────────────────
145
+ DO $$ BEGIN
146
+ ALTER TABLE "discount_combo_slots" ADD CONSTRAINT "discount_combo_slots_discount_id_fkey"
147
+ FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
148
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
149
+
150
+ DO $$ BEGIN
151
+ ALTER TABLE "discount_targets" ADD CONSTRAINT "discount_targets_discount_id_fkey"
152
+ FOREIGN KEY ("discount_id") REFERENCES "discounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
153
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
154
+
155
+ -- Cascading from the SLOT as well as from the discount: removing one slot of a
156
+ -- combo must take its targets, or the next write would merge them into the slot
157
+ -- that replaced it.
158
+ DO $$ BEGIN
159
+ ALTER TABLE "discount_targets" ADD CONSTRAINT "discount_targets_slot_id_fkey"
160
+ FOREIGN KEY ("slot_id") REFERENCES "discount_combo_slots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
161
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
162
+
163
+ -- ─────────────────────────────────────────────────────────────────────────────
164
+ -- CHECK constraints — the String+CHECK house style. The closed sets here and
165
+ -- the arrays in `src/engine/kinds.ts` are two halves of ONE set: widening one
166
+ -- without the other is how a write starts failing at the database rather than
167
+ -- at the validator.
168
+ --
169
+ -- The three that a host may already have in a NARROWER form are dropped and
170
+ -- re-added rather than guarded, because the whole point is to widen them.
171
+ -- ─────────────────────────────────────────────────────────────────────────────
172
+ ALTER TABLE "discounts" DROP CONSTRAINT IF EXISTS "discounts_type_check";
173
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_type_check"
174
+ CHECK ("type" IN ('PERCENTAGE', 'FIXED_AMOUNT', 'BUNDLE_PRICE', 'FREE_UNITS'));
175
+
176
+ ALTER TABLE "discounts" DROP CONSTRAINT IF EXISTS "discounts_scope_check";
177
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_scope_check"
178
+ CHECK ("scope" IN ('ORDER', 'CATEGORY', 'ITEM', 'COMBO'));
179
+
180
+ -- Exactly one value column, chosen by the type. A percentage with no rate, or a
181
+ -- bundle with no price, would silently discount nothing.
182
+ ALTER TABLE "discounts" DROP CONSTRAINT IF EXISTS "discounts_value_check";
183
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_value_check"
184
+ CHECK (("type" = 'PERCENTAGE') = ("percent_off_bp" IS NOT NULL)
185
+ AND ("type" = 'FIXED_AMOUNT') = ("amount_off_cents" IS NOT NULL)
186
+ AND ("type" = 'BUNDLE_PRICE') = ("bundle_price_cents" IS NOT NULL)
187
+ AND ("type" = 'FREE_UNITS') = ("free_units" IS NOT NULL));
188
+
189
+ -- A combo REWARD only means something against a matched group: "the group costs
190
+ -- R$ 25" and "one of them is free" are both statements ABOUT a group.
191
+ DO $$ BEGIN
192
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_combo_reward_scope_check"
193
+ CHECK ("type" NOT IN ('BUNDLE_PRICE', 'FREE_UNITS') OR "scope" = 'COMBO');
194
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
195
+
196
+ DO $$ BEGIN
197
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_bundle_price_check"
198
+ CHECK ("bundle_price_cents" IS NULL OR "bundle_price_cents" > 0);
199
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
200
+
201
+ DO $$ BEGIN
202
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_free_units_check"
203
+ CHECK ("free_units" IS NULL OR "free_units" > 0);
204
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
205
+
206
+ -- A per-cart cap on a rule that is not a combo would be a number nothing reads.
207
+ DO $$ BEGIN
208
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_max_combo_applications_check"
209
+ CHECK ("max_combo_applications" IS NULL
210
+ OR ("max_combo_applications" > 0 AND "scope" = 'COMBO'));
211
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
212
+
213
+ DO $$ BEGIN
214
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_trigger_check"
215
+ CHECK ("trigger" IN ('AUTOMATIC', 'CODE'));
216
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
217
+
218
+ -- 0 bp is not a promotion; over 100% would credit the buyer.
219
+ DO $$ BEGIN
220
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_percent_off_bp_check"
221
+ CHECK ("percent_off_bp" IS NULL
222
+ OR ("percent_off_bp" > 0 AND "percent_off_bp" <= 10000));
223
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
224
+
225
+ DO $$ BEGIN
226
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_amount_off_cents_check"
227
+ CHECK ("amount_off_cents" IS NULL OR "amount_off_cents" > 0);
228
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
229
+
230
+ -- A CODE rule with no code can never be redeemed; an AUTOMATIC one with a code
231
+ -- would be redeemable two different ways.
232
+ DO $$ BEGIN
233
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_code_check"
234
+ CHECK (("trigger" = 'CODE') = ("code" IS NOT NULL));
235
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
236
+
237
+ -- An inverted window is always empty; whoever typed it meant something else.
238
+ DO $$ BEGIN
239
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_window_check"
240
+ CHECK ("ends_at" IS NULL OR "starts_at" IS NULL OR "ends_at" > "starts_at");
241
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
242
+
243
+ DO $$ BEGIN
244
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_min_subtotal_check"
245
+ CHECK ("min_subtotal_cents" IS NULL OR "min_subtotal_cents" > 0);
246
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
247
+
248
+ DO $$ BEGIN
249
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_usage_limit_check"
250
+ CHECK ("usage_limit" IS NULL OR "usage_limit" > 0);
251
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
252
+
253
+ DO $$ BEGIN
254
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_per_buyer_limit_check"
255
+ CHECK ("per_buyer_limit" IS NULL OR "per_buyer_limit" > 0);
256
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
257
+
258
+ DO $$ BEGIN
259
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_usage_count_check"
260
+ CHECK ("usage_count" >= 0);
261
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
262
+
263
+ -- The invariant the predicated PAID-time UPDATE exists to keep true.
264
+ DO $$ BEGIN
265
+ ALTER TABLE "discounts" ADD CONSTRAINT "discounts_usage_within_limit_check"
266
+ CHECK ("usage_limit" IS NULL OR "usage_count" <= "usage_limit");
267
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
268
+
269
+ -- A slot asking for zero units can never be filled; the upper bound is
270
+ -- MAX_COMBO_SLOT_QUANTITY, and a combo nobody can carry is not an offer.
271
+ DO $$ BEGIN
272
+ ALTER TABLE "discount_combo_slots" ADD CONSTRAINT "discount_combo_slots_quantity_check"
273
+ CHECK ("quantity" > 0 AND "quantity" <= 50);
274
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
275
+
276
+ DO $$ BEGIN
277
+ ALTER TABLE "discount_combo_slots" ADD CONSTRAINT "discount_combo_slots_position_check"
278
+ CHECK ("position" >= 0);
279
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
280
+
281
+ -- The closed set of discountable dimensions. Widening it is a host registering
282
+ -- one more DiscountableCollection, plus this line.
283
+ DO $$ BEGIN
284
+ ALTER TABLE "discount_targets" ADD CONSTRAINT "discount_targets_target_type_check"
285
+ CHECK ("target_type" IN ('CATEGORY', 'ITEM'));
286
+ EXCEPTION WHEN duplicate_object THEN NULL; END $$;
@@ -34,6 +34,7 @@
34
34
  "20260813180000_add_realtime_outbox",
35
35
  "20260819230000_auth_email_password",
36
36
  "20260820120000_add_user_feature_grants",
37
- "20260821120000_shift_kind_host_vocabulary"
37
+ "20260821120000_shift_kind_host_vocabulary",
38
+ "20260821140000_discounts_package_owned"
38
39
  ]
39
40
  }
@@ -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
+ }