@withwiz/coupon 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 withwiz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,298 @@
1
+ # @withwiz/coupon
2
+
3
+ Multi-tenant, concurrency-safe coupon domain core for Prisma + Node.js.
4
+ Drop-in for Next.js / serverless / any Node backend. No UI, no payment glue,
5
+ no emitter side effects — pure coupon lifecycle.
6
+
7
+ > The package owns coupon lifecycle and nothing else. It merges its schema
8
+ > into the database you already have through Prisma, and leaves UI, payment
9
+ > and mail to you.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pnpm install @withwiz/coupon
15
+ # peer deps
16
+ pnpm install @prisma/client
17
+ ```
18
+
19
+ Peer dependencies:
20
+
21
+ - `@prisma/client` >= 5 < 8
22
+ - `typescript` >= 5 (optional)
23
+
24
+ Runtime dependencies (bundled): `zod`, `nanoid`, `@paralleldrive/cuid2`.
25
+
26
+ Node.js >= 18.17.
27
+
28
+ ## Quick Start
29
+
30
+ ```ts
31
+ import { createCouponClient } from "@withwiz/coupon";
32
+ import { PrismaClient } from "@prisma/client";
33
+
34
+ const prisma = new PrismaClient();
35
+
36
+ const coupon = createCouponClient({
37
+ prisma,
38
+ defaultTenantId: "tlog",
39
+ events: {
40
+ async onEvent(e) {
41
+ // Fan out to queue, webhook, analytics, etc.
42
+ if (e.type === "coupon.redeemed") {
43
+ // await fetch(...); await bullQueue.add(...);
44
+ }
45
+ },
46
+ },
47
+ });
48
+
49
+ // 1) Admin creates a campaign and issues 10,000 codes
50
+ const campaign = await coupon.campaigns.create({
51
+ name: "SPRING2026",
52
+ discount: { kind: "PERCENT", percent: 10, maxDiscount: 5000 },
53
+ startsAt: new Date("2026-04-01"),
54
+ endsAt: new Date("2026-04-30"),
55
+ maxCoupons: 10_000,
56
+ });
57
+ await coupon.campaigns.issue({ campaignId: campaign.id, count: 10_000, codeLength: 10 });
58
+
59
+ // 2) Customer applies a code at checkout (non-destructive preview)
60
+ const preview = await coupon.validate({
61
+ code: "ABC123",
62
+ userId: "u_42",
63
+ subtotal: 45000,
64
+ currency: "KRW",
65
+ context: { plan: "PREMIUM" },
66
+ });
67
+ if (!preview.ok) throw new Error(preview.reason); // NOT_FOUND / EXPIRED / ...
68
+
69
+ // 3) Customer confirms order — piggyback on consumer's transaction
70
+ await prisma.$transaction(async (tx) => {
71
+ const order = await tx.order.create({ data: { /* ... */ } });
72
+ await coupon.redeem({
73
+ code: "ABC123",
74
+ userId: "u_42",
75
+ orderRef: order.id,
76
+ subtotal: 45000,
77
+ currency: "KRW",
78
+ context: { plan: "PREMIUM" },
79
+ tx, // SAME transaction as the order write
80
+ });
81
+ });
82
+
83
+ // 4) Admin reads live summary
84
+ const s = await coupon.campaigns.summary({ id: campaign.id });
85
+ ```
86
+
87
+ ## Schema Setup
88
+
89
+ Copy the contents of `prisma/fragment.prisma` from this package into your
90
+ `schema.prisma` and run your normal Prisma migration workflow:
91
+
92
+ ```bash
93
+ # After copying the fragment, regenerate and migrate:
94
+ npx prisma generate
95
+ npx prisma migrate dev --name add_coupon
96
+ ```
97
+
98
+ Field names, indexes and the `@@unique` constraints must be preserved for the
99
+ concurrency guarantees and invariants to hold. Model names, on the other hand,
100
+ are yours to change — see below.
101
+
102
+ ### Renaming the models
103
+
104
+ `Campaign` is a common name, so a consumer schema may already own it. Rename
105
+ any of the five models in your copy of the fragment and tell the client about
106
+ it through `models`:
107
+
108
+ ```prisma
109
+ model CouponCampaign { // was: Campaign
110
+ // ...same fields, indexes and constraints
111
+ }
112
+ ```
113
+
114
+ ```ts
115
+ const coupon = createCouponClient({
116
+ prisma,
117
+ models: { campaign: "couponCampaign" }, // the Prisma delegate name
118
+ });
119
+ ```
120
+
121
+ `models` takes delegate names — what you call on the client
122
+ (`prisma.couponCampaign`), which is the model name with a lowercase first
123
+ letter. Any key you leave out keeps its default (`coupon`, `campaign`,
124
+ `couponIssuance`, `couponRedemption`, `couponAuditLog`).
125
+
126
+ `redeem()` takes a `SELECT ... FOR UPDATE` row lock on the coupon table, and
127
+ raw SQL needs the physical table name rather than the delegate name. It is
128
+ derived by capitalizing the delegate name, which is Prisma's own default. If
129
+ your schema uses `@@map`, declare the real name:
130
+
131
+ ```prisma
132
+ model Coupon {
133
+ // ...
134
+ @@map("coupons")
135
+ }
136
+ ```
137
+
138
+ ```ts
139
+ const coupon = createCouponClient({
140
+ prisma,
141
+ tables: { coupon: "coupons" },
142
+ });
143
+ ```
144
+
145
+ Both maps accept plain SQL identifiers only (`[A-Za-z_][A-Za-z0-9_]*`); a
146
+ malformed name raises `InvalidModelNameError` when the client is created, and
147
+ a name that matches no delegate raises it on the first call that needs it.
148
+
149
+ ## Errors
150
+
151
+ All errors extend `CouponError` and carry a stable string `code`.
152
+
153
+ | Class | `code` |
154
+ |---|---|
155
+ | `ValidationError` | `VALIDATION_ERROR` |
156
+ | `InvalidModelNameError` | `INVALID_MODEL_NAME` |
157
+ | `InvalidDiscountPolicyError` | `INVALID_DISCOUNT_POLICY` |
158
+ | `InvalidDateRangeError` | `INVALID_DATE_RANGE` |
159
+ | `CurrencyMismatchError` | `CURRENCY_MISMATCH` |
160
+ | `CouponNotFoundError` | `NOT_FOUND` |
161
+ | `CouponArchivedError` | `ARCHIVED` |
162
+ | `CouponInactiveError` | `PAUSED` |
163
+ | `CouponExpiredError` | `EXPIRED` or `NOT_STARTED` |
164
+ | `CouponExhaustedError` | `EXHAUSTED` |
165
+ | `CouponAlreadyRedeemedError` | `ALREADY_REDEEMED` |
166
+ | `PerUserLimitExceededError` | `PER_USER_LIMIT` |
167
+ | `IneligibleUserError` | `INELIGIBLE_USER` |
168
+ | `IneligiblePlanError` | `INELIGIBLE_PLAN` |
169
+ | `IneligibleProductError` | `INELIGIBLE_PRODUCT` |
170
+ | `IneligibleCategoryError` | `INELIGIBLE_CATEGORY` |
171
+ | `MinimumOrderNotMetError` | `MIN_ORDER_NOT_MET` |
172
+ | `CampaignClosedError` | `CAMPAIGN_CLOSED` |
173
+ | `CampaignCapacityExceededError` | `CAMPAIGN_CAPACITY_EXCEEDED` |
174
+ | `TenantMismatchError` | `TENANT_MISMATCH` |
175
+
176
+ ## CouponRejectReason
177
+
178
+ `validate()` returns `{ ok: false, reason }` where `reason` is a closed
179
+ union of 13 literals:
180
+
181
+ ```
182
+ 'NOT_FOUND' | 'NOT_STARTED' | 'EXPIRED' | 'EXHAUSTED'
183
+ | 'PER_USER_LIMIT' | 'MIN_ORDER_NOT_MET'
184
+ | 'INELIGIBLE_USER' | 'INELIGIBLE_PLAN' | 'INELIGIBLE_PRODUCT' | 'INELIGIBLE_CATEGORY'
185
+ | 'PAUSED' | 'ARCHIVED' | 'TENANT_MISMATCH'
186
+ ```
187
+
188
+ Each reason maps 1:1 to an error class `code` emitted by `redeem()` on the
189
+ corresponding failure path.
190
+
191
+ ## Events
192
+
193
+ `CouponEvent` union:
194
+
195
+ ```
196
+ coupon.created | coupon.issued | coupon.issued.bulk
197
+ | coupon.redeemed | coupon.redeem.rejected
198
+ | coupon.paused | coupon.resumed | coupon.archived
199
+ | campaign.created | campaign.closed
200
+ ```
201
+
202
+ Events fire **after** the transactional mutation commits. If your handler
203
+ throws, the business result is preserved and a `logger.warn` is emitted.
204
+
205
+ ```ts
206
+ createCouponClient({
207
+ prisma,
208
+ events: {
209
+ async onEvent(e) {
210
+ switch (e.type) {
211
+ case "coupon.redeemed":
212
+ // enqueue webhook / analytics / email
213
+ break;
214
+ }
215
+ },
216
+ },
217
+ });
218
+ ```
219
+
220
+ ## Multi-tenant
221
+
222
+ Every public method requires `tenantId`, either via:
223
+
224
+ - explicit `tenantId` on the call args, or
225
+ - `config.defaultTenantId` passed to `createCouponClient`.
226
+
227
+ If neither is provided, calls throw `ValidationError`. Cross-tenant lookups
228
+ (e.g. tenant B querying tenant A's code) return `null` / `NOT_FOUND` to
229
+ prevent enumeration attacks.
230
+
231
+ For single-tenant applications, set `defaultTenantId: "default"` at init and
232
+ never touch tenantId again.
233
+
234
+ ## Security Notes
235
+
236
+ - Coupon codes are **case-normalized to upper-case** before storage and
237
+ lookup. `welcome10` and `WELCOME10` are the same code.
238
+ - `code` length cap is 64. Control chars, NUL, whitespace and homoglyph
239
+ variants are rejected at input.
240
+ - `metadata` object is guarded against prototype pollution (`__proto__` /
241
+ `constructor` / `prototype` keys rejected).
242
+ - `issueBulk.count` and `campaigns.issue.count` are capped at 10,000.
243
+ - `models` / `tables` entries are checked against `[A-Za-z_][A-Za-z0-9_]*`
244
+ before the coupon table name reaches the row-lock query — the only place an
245
+ identifier is interpolated into SQL. Row values always travel as bound
246
+ parameters.
247
+ - Cursor pagination defaults to `limit=50`, max `limit=200`. Every list sorts
248
+ by `id` as a secondary key, so rows sharing a `createdAt` (anything issued
249
+ in one `issueBulk` call) still page deterministically — no duplicates and
250
+ no dropped rows.
251
+ - No `$executeRawUnsafe` / `$queryRawUnsafe` is used anywhere in this
252
+ package.
253
+
254
+ ## Development
255
+
256
+ Tests run against a real Postgres — there are no mocks. Start the bundled
257
+ database and point `DATABASE_URL` at it:
258
+
259
+ ```bash
260
+ docker compose up -d # postgres on host port 13011
261
+ export DATABASE_URL="postgresql://postgres:postgres@localhost:13011/coupon_test?schema=public"
262
+
263
+ pnpm install --frozen-lockfile
264
+ pnpm run prisma:generate && pnpm run db:push
265
+ pnpm run typecheck && pnpm run check:isolation
266
+ pnpm run build && pnpm run test:coverage
267
+ pnpm run test:concurrency:x3 # reruns the race suite 3x
268
+ ```
269
+
270
+ `examples/consumer` is a standalone app that installs this package by path and
271
+ merges `prisma/fragment.prisma` into its own schema. It uses the same database
272
+ on a separate `consumer_sample` schema and runs in CI, which is what keeps the
273
+ package honest about working outside this repo:
274
+
275
+ ```bash
276
+ cd examples/consumer
277
+ export DATABASE_URL="postgresql://postgres:postgres@localhost:13011/coupon_test?schema=consumer_sample"
278
+ pnpm install --frozen-lockfile
279
+ pnpm run prisma:generate && pnpm run db:push && pnpm run test
280
+ ```
281
+
282
+ CI provisions its own Postgres on the same port 13011, so the workflow sets
283
+ `DATABASE_URL` itself and does not use `docker-compose.yml`.
284
+
285
+ ## Non-goals / Future
286
+
287
+ - UI components, admin dashboards, React hooks
288
+ - Stripe / PayPal / PG integration
289
+ - Email / SMS / push
290
+ - Edge runtime (Cloudflare Workers / Vercel Edge)
291
+ - i18n: error messages are English-only. Translate on the consumer side.
292
+
293
+ A future minor version may add an optional `batchKey` to `issueBulk()` for
294
+ caller-supplied idempotency.
295
+
296
+ ## License
297
+
298
+ MIT