@withwiz/coupon 0.1.0 → 0.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 +67 -2
- package/dist/index.cjs +238 -52
- package/dist/index.d.cts +52 -1
- package/dist/index.d.ts +52 -1
- package/dist/index.mjs +238 -53
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -54,7 +54,12 @@ const campaign = await coupon.campaigns.create({
|
|
|
54
54
|
endsAt: new Date("2026-04-30"),
|
|
55
55
|
maxCoupons: 10_000,
|
|
56
56
|
});
|
|
57
|
-
await coupon.campaigns.issue({
|
|
57
|
+
await coupon.campaigns.issue({
|
|
58
|
+
campaignId: campaign.id,
|
|
59
|
+
count: 10_000,
|
|
60
|
+
codeLength: 10,
|
|
61
|
+
maxRedemptions: 1, // single-use codes; omit for unlimited
|
|
62
|
+
});
|
|
58
63
|
|
|
59
64
|
// 2) Customer applies a code at checkout (non-destructive preview)
|
|
60
65
|
const preview = await coupon.validate({
|
|
@@ -142,10 +147,38 @@ const coupon = createCouponClient({
|
|
|
142
147
|
});
|
|
143
148
|
```
|
|
144
149
|
|
|
145
|
-
|
|
150
|
+
### Tables outside `public` with a driver adapter
|
|
151
|
+
|
|
152
|
+
The row lock is raw SQL, so it does not inherit the schema from your Prisma
|
|
153
|
+
datasource. With a driver adapter (Prisma 7, e.g. `@prisma/adapter-pg`) an
|
|
154
|
+
unqualified table name resolves through the connection's `search_path`, which
|
|
155
|
+
is usually `public`. If the coupon tables live in another schema, name it:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const coupon = createCouponClient({ prisma, schema: "billing" });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Without it, `redeem()` cannot see the coupon row and fails with `NOT_FOUND`.
|
|
162
|
+
|
|
163
|
+
`models`, `tables` and `schema` accept plain SQL identifiers only (`[A-Za-z_][A-Za-z0-9_]*`); a
|
|
146
164
|
malformed name raises `InvalidModelNameError` when the client is created, and
|
|
147
165
|
a name that matches no delegate raises it on the first call that needs it.
|
|
148
166
|
|
|
167
|
+
## Discount duration
|
|
168
|
+
|
|
169
|
+
A policy may declare how many billing cycles it applies to:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
discount: { kind: "PERCENT", percent: 20, duration: { kind: "REPEATING", months: 3 } }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`ONCE` (the default when `duration` is absent), `REPEATING` (1 to 36 months) or
|
|
176
|
+
`FOREVER`. The package stores and validates it and returns it inside
|
|
177
|
+
`coupon.discount` and `discount.appliedPolicy`; `resolveDuration(policy)` gives the
|
|
178
|
+
effective value. Redemption still happens once. To discount later cycles of a
|
|
179
|
+
subscription, keep the policy and call `calculateDiscount({ subtotal, currency, policy })`
|
|
180
|
+
for each cycle on your side.
|
|
181
|
+
|
|
149
182
|
## Errors
|
|
150
183
|
|
|
151
184
|
All errors extend `CouponError` and carry a stable string `code`.
|
|
@@ -250,6 +283,28 @@ never touch tenantId again.
|
|
|
250
283
|
no dropped rows.
|
|
251
284
|
- No `$executeRawUnsafe` / `$queryRawUnsafe` is used anywhere in this
|
|
252
285
|
package.
|
|
286
|
+
- **`userId` must come from your authenticated session, never from the
|
|
287
|
+
request body.** Eligibility (`allowedUserIds`), `maxRedemptionsPerUser` and
|
|
288
|
+
the duplicate-order guard all key on it. A coupon that sets
|
|
289
|
+
`maxRedemptionsPerUser` refuses anonymous `validate()` / `redeem()` calls
|
|
290
|
+
with `ValidationError`, so the cap cannot be bypassed by omitting the user.
|
|
291
|
+
- The idempotency index is `(couponId, redeemedByUserId, orderRef)`. Postgres
|
|
292
|
+
treats `NULL` as distinct, so **pass `orderRef` on every redeem** if you
|
|
293
|
+
need double-submit protection; without it, a coupon that has no
|
|
294
|
+
`maxRedemptionsPerUser` can be redeemed repeatedly by the same user.
|
|
295
|
+
- Caller-supplied strings are bounded: ids and cursors 64 chars, user / actor
|
|
296
|
+
ids and `orderRef` 128 chars, `metadata` 16 KiB of JSON, eligibility lists
|
|
297
|
+
1,000 entries of 128 chars. Oversized input throws `ValidationError`
|
|
298
|
+
before it reaches the database.
|
|
299
|
+
- Only a genuine unique-constraint conflict (Prisma `P2002`) is translated
|
|
300
|
+
into `CouponAlreadyRedeemedError` / "code already exists". Any other
|
|
301
|
+
database failure propagates unchanged, so the rejection audit log never
|
|
302
|
+
records a connection error as `ALREADY_REDEEMED`. The original Prisma
|
|
303
|
+
error is attached as `error.cause`, never placed in `error.details`.
|
|
304
|
+
- Generated codes use a cryptographic RNG (`nanoid`) over a 31-character
|
|
305
|
+
alphabet. The default length of 8 gives roughly 2^40 combinations, which is
|
|
306
|
+
adequate **only behind rate limiting** on your `validate` / `redeem`
|
|
307
|
+
endpoints; raise `codeLength` for high-value or long-lived coupons.
|
|
253
308
|
|
|
254
309
|
## Development
|
|
255
310
|
|
|
@@ -279,6 +334,16 @@ pnpm install --frozen-lockfile
|
|
|
279
334
|
pnpm run prisma:generate && pnpm run db:push && pnpm run test
|
|
280
335
|
```
|
|
281
336
|
|
|
337
|
+
`examples/consumer-prisma7` runs the same kind of checks on Prisma 7 with
|
|
338
|
+
`@prisma/adapter-pg`, a renamed `CouponCampaign` model and a non-`public` schema:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
cd examples/consumer-prisma7
|
|
342
|
+
export DATABASE_URL="postgresql://postgres:postgres@localhost:13011/coupon_test?schema=consumer_prisma7"
|
|
343
|
+
pnpm install --frozen-lockfile
|
|
344
|
+
pnpm run prisma:generate && pnpm run db:push && pnpm run test
|
|
345
|
+
```
|
|
346
|
+
|
|
282
347
|
CI provisions its own Postgres on the same port 13011, so the workflow sets
|
|
283
348
|
`DATABASE_URL` itself and does not use `docker-compose.yml`.
|
|
284
349
|
|