@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 +21 -0
- package/README.md +298 -0
- package/dist/index.cjs +1928 -0
- package/dist/index.d.cts +755 -0
- package/dist/index.d.ts +755 -0
- package/dist/index.mjs +1897 -0
- package/package.json +86 -0
- package/prisma/fragment.prisma +122 -0
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
|