@12-apps/discounts 1.2.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.
@@ -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 $$;
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /* global console, process */
3
+ /**
4
+ * Copy this package's Prisma model PARTIAL into the host's schema folder.
5
+ *
6
+ * node scripts/sync-discounts-schema.mjs [--check] [<host-schema-dir>]
7
+ *
8
+ * The partial is COPIED, never symlinked (the
9
+ * prisma-partials-are-copied-never-symlinked doctrine): `turbo prune` copies
10
+ * only what the dependency graph reaches, so a committed symlink dangles the
11
+ * moment the owning package is not a declared workspace dependency — and a
12
+ * SYMLINKED MIGRATION is silently skipped by Prisma (`readdir` +
13
+ * `isDirectory()` is false for a link), so a green deploy applies no schema.
14
+ *
15
+ * Only the schema partial. MIGRATIONS ARE NOT HANDLED HERE — a host discovers
16
+ * and copies them structurally, by looking for a `prisma/migrations` directory
17
+ * inside every installed `@12-apps/*` package.
18
+ *
19
+ * The host package that owns the schema folder MUST also declare this package
20
+ * as a dependency, so the source of the copy is present in every build context.
21
+ */
22
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const LABEL = '[discounts-schema]';
27
+ const RESYNC = 'pnpm --filter @12-apps/discounts prisma:sync';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ const SOURCE = join(HERE, '../prisma/discounts.prisma');
31
+
32
+ const args = process.argv.slice(2).filter((arg) => arg !== '--check');
33
+ const check = process.argv.includes('--check');
34
+ const hostSchemaDir =
35
+ args[0] ?? process.env.DISCOUNTS_HOST_SCHEMA_DIR ?? join(HERE, '../../prisma/prisma/schema');
36
+ const TARGET = join(hostSchemaDir, 'discounts.prisma');
37
+
38
+ const source = readFileSync(SOURCE, 'utf8');
39
+ const target = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : null;
40
+
41
+ if (source === target) {
42
+ console.log(`${LABEL} in sync.`);
43
+ } else if (check) {
44
+ console.error(
45
+ `${LABEL} DRIFT: ${TARGET} does not match the @12-apps/discounts partial. ` +
46
+ `Run "${RESYNC}" and commit the result.`,
47
+ );
48
+ process.exit(1);
49
+ } else {
50
+ mkdirSync(hostSchemaDir, { recursive: true });
51
+ copyFileSync(SOURCE, TARGET);
52
+ console.log(`${LABEL} copied ${SOURCE} -> ${TARGET}.`);
53
+ }
@@ -60,6 +60,26 @@ export const COMBO_ONLY_DISCOUNT_TYPES = ["BUNDLE_PRICE", "FREE_UNITS"] as const
60
60
  export const DISCOUNT_SCOPES = ["ORDER", "CATEGORY", "ITEM", "COMBO"] as const;
61
61
  export type DiscountScope = (typeof DISCOUNT_SCOPES)[number];
62
62
 
63
+ /**
64
+ * The DIMENSIONS a discount can be pointed at — the two scopes above that name
65
+ * rows in a host's catalog, as opposed to `ORDER` (which names nothing) and
66
+ * `COMBO` (which names its own slots).
67
+ *
68
+ * Split out from the scopes because they are the key of a different thing: a
69
+ * `DiscountableCollection` is a host's answer for ONE of these
70
+ * (`./server/collections`), and a combo slot names both of them at once, so
71
+ * neither list is a subset of the other in use even though it is here.
72
+ *
73
+ * This set is closed today and that is a boundary rather than a principle. A
74
+ * third dimension — "every item from this supplier", "every item on this
75
+ * shelf" — is expressible the moment targets are stored BY VALUE
76
+ * (`(target_type, target_id)`) rather than as a join table per member, and the
77
+ * registration seam is deliberately shaped so that widening this array plus
78
+ * registering one more collection is the whole change.
79
+ */
80
+ export const DISCOUNT_TARGET_TYPES = ["CATEGORY", "ITEM"] as const;
81
+ export type DiscountTargetType = (typeof DISCOUNT_TARGET_TYPES)[number];
82
+
63
83
  /**
64
84
  * What makes a discount fire: the engine picks `AUTOMATIC` ones up on its own,
65
85
  * while a `CODE` one only becomes a candidate when the buyer types its coupon.
@@ -67,6 +87,48 @@ export type DiscountScope = (typeof DISCOUNT_SCOPES)[number];
67
87
  export const DISCOUNT_TRIGGERS = ["AUTOMATIC", "CODE"] as const;
68
88
  export type DiscountTrigger = (typeof DISCOUNT_TRIGGERS)[number];
69
89
 
90
+ /**
91
+ * Where a rule sits relative to its window RIGHT NOW.
92
+ *
93
+ * A derived fact rather than a stored one — it is `startsAt`/`endsAt` compared
94
+ * against an instant, so storing it would mean a column that is wrong between
95
+ * the moment a promotion starts and the next time something wrote to it.
96
+ *
97
+ * The values are English wire tokens on purpose. They are read in three places
98
+ * that must agree — an admin grid's filter pill, the query the backend
99
+ * receives, and the badge on a row — and the moment one of those three is a
100
+ * word in a language, the set stops being a set: the origin host filtered on
101
+ * `Vigente`, so its pill values WERE its pt-BR labels, and a second host in
102
+ * another language could not use its own words without changing the wire.
103
+ * Labels come from the web copy pack; these do not.
104
+ */
105
+ export const DISCOUNT_WINDOW_STATES = ["RUNNING", "SCHEDULED", "ENDED"] as const;
106
+ export type DiscountWindowState = (typeof DISCOUNT_WINDOW_STATES)[number];
107
+
108
+ /**
109
+ * Which of the three a rule is in, at `now`.
110
+ *
111
+ * Domain logic rather than presentation, which is why it lives beside the
112
+ * vocabulary and not in the web surface: the badge on a row and the filter the
113
+ * backend applies must answer this the same way, and two implementations of
114
+ * "has it ended" is how a grid shows a rule as running on a row the filter
115
+ * excludes.
116
+ *
117
+ * The window is half-open `[startsAt, endsAt)` — `endsAt` is EXCLUSIVE, so a
118
+ * promotion ends the instant its end date begins.
119
+ */
120
+ export function discountWindowState(
121
+ rule: { startsAt: Date | string | null; endsAt: Date | string | null },
122
+ now: Date,
123
+ ): DiscountWindowState {
124
+ const instant = now.getTime();
125
+ const starts = rule.startsAt === null ? null : new Date(rule.startsAt).getTime();
126
+ const ends = rule.endsAt === null ? null : new Date(rule.endsAt).getTime();
127
+ if (starts !== null && starts > instant) return "SCHEDULED";
128
+ if (ends !== null && ends <= instant) return "ENDED";
129
+ return "RUNNING";
130
+ }
131
+
70
132
  /**
71
133
  * Why a discount the buyer explicitly asked for was not applied. Only ever
72
134
  * surfaced for a coupon the buyer typed or for a promo displaced by an
package/src/index.ts CHANGED
@@ -33,8 +33,11 @@ export {
33
33
  COMBO_ONLY_DISCOUNT_TYPES,
34
34
  DISCOUNT_REJECTION_REASONS,
35
35
  DISCOUNT_SCOPES,
36
+ DISCOUNT_TARGET_TYPES,
36
37
  DISCOUNT_TRIGGERS,
37
38
  DISCOUNT_TYPES,
39
+ DISCOUNT_WINDOW_STATES,
40
+ discountWindowState,
38
41
  MAX_COMBO_SLOTS,
39
42
  MAX_COMBO_SLOT_QUANTITY,
40
43
  MAX_PERCENT_OFF_BP,
@@ -42,8 +45,10 @@ export {
42
45
  normalizeDiscountCode,
43
46
  type DiscountRejectionReason,
44
47
  type DiscountScope,
48
+ type DiscountTargetType,
45
49
  type DiscountTrigger,
46
50
  type DiscountType,
51
+ type DiscountWindowState,
47
52
  } from "./engine/kinds";
48
53
 
49
54
  export type {
@@ -13,23 +13,41 @@
13
13
  * (`__tests__/manifest.test.ts`) — the same "fails in the package's own test
14
14
  * run" guarantee with zero runtime dependencies added.
15
15
  *
16
- * ## Two capabilities this package deliberately does NOT declare
17
- *
18
- * **`db`.** A discount's rows relate to a host's own catalog and orders:
19
- * targets point at its categories and items, redemptions at its orders and
20
- * buyers. A `composed` partial may in principle relate to host models, but one
21
- * shipped from here would only compile inside a host that already has those
22
- * tables under those names — and `isolated` is ruled out by the same
23
- * relations, which is exactly the qualification the wiring db contract states
24
- * ("a package qualifies only when its models carry no relation into host
25
- * tables"). What is portable is the RULE, not the storage: the host owns the
26
- * schema and answers `DiscountStore`.
27
- *
28
- * **`web`.** The admin grid, the form and the target pickers stay host
29
- * surfaces for now. They are three quarters product copy and host design
30
- * system, and a surface declared before its copy is host config is the exact
31
- * anti-pattern the copy-portability gate exists to refuse. The permission ids
32
- * and the tool schemas are enough for a host to build them against.
16
+ * ## The `db` capability, and what had to change to earn it
17
+ *
18
+ * This manifest used to declare none, on the reasoning that a discount's rows
19
+ * relate to a host's own catalog: a partial literally naming `product_categories`
20
+ * and `menu_items` only compiles inside a host that has tables under those
21
+ * names. That half was right. The half that was not is the conclusion — the
22
+ * shape was not a given.
23
+ *
24
+ * `@12-apps/entity-lifecycle` had already solved it in this repo and written
25
+ * the answer at the top of its own partial: name the record BY VALUE. Applied
26
+ * here, the two target join tables collapse into one `discount_targets` keyed
27
+ * `(target_type, target_id)`, the tenant becomes a scalar `client_id`, and
28
+ * WHICH collections are discountable becomes a runtime `DiscountableCollection`
29
+ * registration rather than a schema fact. Nothing in the partial names a host
30
+ * table, so `composed` qualifies on the contract's own terms.
31
+ *
32
+ * What stays behind is the REDEMPTION snapshot (`order_discounts`): a child of
33
+ * the host's order, with a cascade, whose whole purpose is to freeze what a
34
+ * buyer received on an order the host owns. Shipping the rule and leaving the
35
+ * receipt is the clean cut.
36
+ *
37
+ * ## The `web` capability, and what had to move first
38
+ *
39
+ * This manifest also used to refuse `web`, and the refusal was right at the
40
+ * time: the screens were three quarters product copy and host design system,
41
+ * and a surface declared before its copy is host config is the exact
42
+ * anti-pattern the copy-portability gate exists to refuse.
43
+ *
44
+ * Neither half holds now. The copy IS host config — `DiscountsWebCopy`,
45
+ * required and defaultless, the third such port here. And what was genuinely
46
+ * host-grown underneath the screens — the kebab, the card context, the two
47
+ * confirm hooks, the server-grid hook, the row export — moved into
48
+ * `@12-apps/ui` and `@12-apps/app-shell` first, which is the only reason this
49
+ * could follow rather than dragging a private copy of an admin framework along
50
+ * with it.
33
51
  */
34
52
 
35
53
  import type { PackageManifest } from "@12-apps/wiring";
@@ -55,5 +73,15 @@ export const discountsManifest = {
55
73
  * undeclared one, because it reads as finished.
56
74
  */
57
75
  observability: { namespace: "discounts" },
76
+ /**
77
+ * `composed`, not `isolated`. The models carry no relation into host tables,
78
+ * which is the qualification isolation asks for — but isolation costs the
79
+ * seam, and this is the one package where that cost is unpayable: the
80
+ * redemption counter is incremented INSIDE the host's PAID confirmation
81
+ * transaction, and a rule whose counter cannot move in the same transaction
82
+ * as the order that redeemed it is a rule that can be redeemed twice.
83
+ */
84
+ db: { partial: "prisma/discounts.prisma", migrations: "prisma/migrations" },
58
85
  server: ["http"],
86
+ web: ["surface", "areas"],
59
87
  } as const satisfies PackageManifest;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `@12-apps/discounts/manifest/web` — the browser capabilities.
3
+ *
4
+ * Two contributions. `surface` is the `createWeb*` convention the contract
5
+ * names: one config object in, an object of component types out, memoised by
6
+ * the host because the members are TYPES. `areas` is the declaration half — a
7
+ * route and a nav row this package SUGGESTS for a host's admin area, which the
8
+ * host is free to relabel, reorder or veto at its single call site.
9
+ *
10
+ * ## Why this capability exists now, and did not before
11
+ *
12
+ * The manifest used to refuse it, and said why:
13
+ *
14
+ * > The admin grid, the form and the target pickers stay host surfaces for now.
15
+ * > They are three quarters product copy and host design system, and a surface
16
+ * > declared before its copy is host config is the exact anti-pattern the
17
+ * > copy-portability gate exists to refuse.
18
+ *
19
+ * Both halves were true when written and neither is now. The copy IS host
20
+ * config — `DiscountsWebCopy`, required, no defaults, the third such port in
21
+ * this package. And the "host design system" is `@12-apps/ui`, a shared
22
+ * package these screens already read eighteen symbols from; what genuinely was
23
+ * host-grown underneath them — the kebab, the card context, the confirm hooks,
24
+ * the server-grid hook, the export — moved into `@12-apps/ui` and
25
+ * `@12-apps/app-shell` first, which is why this could follow.
26
+ *
27
+ * Behind its own subpath so a server bundle importing the shared manifest never
28
+ * resolves React.
29
+ */
30
+
31
+ import type { AnyWebManifest } from "@12-apps/wiring";
32
+
33
+ import { createWebDiscounts } from "../react/create-web-discounts";
34
+
35
+ export const discountsWebManifest = {
36
+ name: "@12-apps/discounts",
37
+ surface: { create: createWebDiscounts },
38
+ areas: [
39
+ {
40
+ area: "admin",
41
+ routes: [
42
+ {
43
+ path: "discounts",
44
+ screen: "Screen",
45
+ // The write privilege gates the ROUTE, not just the create button:
46
+ // the grid is an operator's view of what the store charges, and a
47
+ // read-only visitor has `discounts:read` for the storefront's sake
48
+ // rather than for this page's.
49
+ permission: "discounts:read",
50
+ },
51
+ ],
52
+ nav: [{ testId: "nav-discounts", path: "discounts", permission: "discounts:read" }],
53
+ },
54
+ ],
55
+ } as const satisfies AnyWebManifest;