@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.
- package/README.md +196 -16
- package/package.json +55 -6
- 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 +45 -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 +23 -0
- package/src/server/logging.ts +20 -3
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +74 -9
- package/src/server/targets.ts +102 -0
|
@@ -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
|
+
}
|
package/src/engine/kinds.ts
CHANGED
|
@@ -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 {
|
package/src/manifest/index.ts
CHANGED
|
@@ -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
|
-
* ##
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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;
|