@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 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({ campaignId: campaign.id, count: 10_000, codeLength: 10 });
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
- Both maps accept plain SQL identifiers only (`[A-Za-z_][A-Za-z0-9_]*`); a
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