@withwiz/coupon
v0.2.0
Published
Multi-tenant, concurrency-safe coupon domain core for Prisma + Node.js applications
Downloads
297
Maintainers
Readme
@withwiz/coupon
Multi-tenant, concurrency-safe coupon domain core for Prisma + Node.js. Drop-in for Next.js / serverless / any Node backend. No UI, no payment glue, no emitter side effects — pure coupon lifecycle.
The package owns coupon lifecycle and nothing else. It merges its schema into the database you already have through Prisma, and leaves UI, payment and mail to you.
Install
pnpm install @withwiz/coupon
# peer deps
pnpm install @prisma/clientPeer dependencies:
@prisma/client>= 5 < 8typescript>= 5 (optional)
Runtime dependencies (bundled): zod, nanoid, @paralleldrive/cuid2.
Node.js >= 18.17.
Quick Start
import { createCouponClient } from "@withwiz/coupon";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const coupon = createCouponClient({
prisma,
defaultTenantId: "tlog",
events: {
async onEvent(e) {
// Fan out to queue, webhook, analytics, etc.
if (e.type === "coupon.redeemed") {
// await fetch(...); await bullQueue.add(...);
}
},
},
});
// 1) Admin creates a campaign and issues 10,000 codes
const campaign = await coupon.campaigns.create({
name: "SPRING2026",
discount: { kind: "PERCENT", percent: 10, maxDiscount: 5000 },
startsAt: new Date("2026-04-01"),
endsAt: new Date("2026-04-30"),
maxCoupons: 10_000,
});
await coupon.campaigns.issue({ campaignId: campaign.id, count: 10_000, codeLength: 10 });
// 2) Customer applies a code at checkout (non-destructive preview)
const preview = await coupon.validate({
code: "ABC123",
userId: "u_42",
subtotal: 45000,
currency: "KRW",
context: { plan: "PREMIUM" },
});
if (!preview.ok) throw new Error(preview.reason); // NOT_FOUND / EXPIRED / ...
// 3) Customer confirms order — piggyback on consumer's transaction
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: { /* ... */ } });
await coupon.redeem({
code: "ABC123",
userId: "u_42",
orderRef: order.id,
subtotal: 45000,
currency: "KRW",
context: { plan: "PREMIUM" },
tx, // SAME transaction as the order write
});
});
// 4) Admin reads live summary
const s = await coupon.campaigns.summary({ id: campaign.id });Schema Setup
Copy the contents of prisma/fragment.prisma from this package into your
schema.prisma and run your normal Prisma migration workflow:
# After copying the fragment, regenerate and migrate:
npx prisma generate
npx prisma migrate dev --name add_couponField names, indexes and the @@unique constraints must be preserved for the
concurrency guarantees and invariants to hold. Model names, on the other hand,
are yours to change — see below.
Renaming the models
Campaign is a common name, so a consumer schema may already own it. Rename
any of the five models in your copy of the fragment and tell the client about
it through models:
model CouponCampaign { // was: Campaign
// ...same fields, indexes and constraints
}const coupon = createCouponClient({
prisma,
models: { campaign: "couponCampaign" }, // the Prisma delegate name
});models takes delegate names — what you call on the client
(prisma.couponCampaign), which is the model name with a lowercase first
letter. Any key you leave out keeps its default (coupon, campaign,
couponIssuance, couponRedemption, couponAuditLog).
redeem() takes a SELECT ... FOR UPDATE row lock on the coupon table, and
raw SQL needs the physical table name rather than the delegate name. It is
derived by capitalizing the delegate name, which is Prisma's own default. If
your schema uses @@map, declare the real name:
model Coupon {
// ...
@@map("coupons")
}const coupon = createCouponClient({
prisma,
tables: { coupon: "coupons" },
});Both maps accept plain SQL identifiers only ([A-Za-z_][A-Za-z0-9_]*); a
malformed name raises InvalidModelNameError when the client is created, and
a name that matches no delegate raises it on the first call that needs it.
Errors
All errors extend CouponError and carry a stable string code.
| Class | code |
|---|---|
| ValidationError | VALIDATION_ERROR |
| InvalidModelNameError | INVALID_MODEL_NAME |
| InvalidDiscountPolicyError | INVALID_DISCOUNT_POLICY |
| InvalidDateRangeError | INVALID_DATE_RANGE |
| CurrencyMismatchError | CURRENCY_MISMATCH |
| CouponNotFoundError | NOT_FOUND |
| CouponArchivedError | ARCHIVED |
| CouponInactiveError | PAUSED |
| CouponExpiredError | EXPIRED or NOT_STARTED |
| CouponExhaustedError | EXHAUSTED |
| CouponAlreadyRedeemedError | ALREADY_REDEEMED |
| PerUserLimitExceededError | PER_USER_LIMIT |
| IneligibleUserError | INELIGIBLE_USER |
| IneligiblePlanError | INELIGIBLE_PLAN |
| IneligibleProductError | INELIGIBLE_PRODUCT |
| IneligibleCategoryError | INELIGIBLE_CATEGORY |
| MinimumOrderNotMetError | MIN_ORDER_NOT_MET |
| CampaignClosedError | CAMPAIGN_CLOSED |
| CampaignCapacityExceededError | CAMPAIGN_CAPACITY_EXCEEDED |
| TenantMismatchError | TENANT_MISMATCH |
CouponRejectReason
validate() returns { ok: false, reason } where reason is a closed
union of 13 literals:
'NOT_FOUND' | 'NOT_STARTED' | 'EXPIRED' | 'EXHAUSTED'
| 'PER_USER_LIMIT' | 'MIN_ORDER_NOT_MET'
| 'INELIGIBLE_USER' | 'INELIGIBLE_PLAN' | 'INELIGIBLE_PRODUCT' | 'INELIGIBLE_CATEGORY'
| 'PAUSED' | 'ARCHIVED' | 'TENANT_MISMATCH'Each reason maps 1:1 to an error class code emitted by redeem() on the
corresponding failure path.
Events
CouponEvent union:
coupon.created | coupon.issued | coupon.issued.bulk
| coupon.redeemed | coupon.redeem.rejected
| coupon.paused | coupon.resumed | coupon.archived
| campaign.created | campaign.closedEvents fire after the transactional mutation commits. If your handler
throws, the business result is preserved and a logger.warn is emitted.
createCouponClient({
prisma,
events: {
async onEvent(e) {
switch (e.type) {
case "coupon.redeemed":
// enqueue webhook / analytics / email
break;
}
},
},
});Multi-tenant
Every public method requires tenantId, either via:
- explicit
tenantIdon the call args, or config.defaultTenantIdpassed tocreateCouponClient.
If neither is provided, calls throw ValidationError. Cross-tenant lookups
(e.g. tenant B querying tenant A's code) return null / NOT_FOUND to
prevent enumeration attacks.
For single-tenant applications, set defaultTenantId: "default" at init and
never touch tenantId again.
Security Notes
- Coupon codes are case-normalized to upper-case before storage and
lookup.
welcome10andWELCOME10are the same code. codelength cap is 64. Control chars, NUL, whitespace and homoglyph variants are rejected at input.metadataobject is guarded against prototype pollution (__proto__/constructor/prototypekeys rejected).issueBulk.countandcampaigns.issue.countare capped at 10,000.models/tablesentries are checked against[A-Za-z_][A-Za-z0-9_]*before the coupon table name reaches the row-lock query — the only place an identifier is interpolated into SQL. Row values always travel as bound parameters.- Cursor pagination defaults to
limit=50, maxlimit=200. Every list sorts byidas a secondary key, so rows sharing acreatedAt(anything issued in oneissueBulkcall) still page deterministically — no duplicates and no dropped rows. - No
$executeRawUnsafe/$queryRawUnsafeis used anywhere in this package. userIdmust come from your authenticated session, never from the request body. Eligibility (allowedUserIds),maxRedemptionsPerUserand the duplicate-order guard all key on it. A coupon that setsmaxRedemptionsPerUserrefuses anonymousvalidate()/redeem()calls withValidationError, so the cap cannot be bypassed by omitting the user.- The idempotency index is
(couponId, redeemedByUserId, orderRef). Postgres treatsNULLas distinct, so passorderRefon every redeem if you need double-submit protection; without it, a coupon that has nomaxRedemptionsPerUsercan be redeemed repeatedly by the same user. - Caller-supplied strings are bounded: ids and cursors 64 chars, user / actor
ids and
orderRef128 chars,metadata16 KiB of JSON, eligibility lists 1,000 entries of 128 chars. Oversized input throwsValidationErrorbefore it reaches the database. - Only a genuine unique-constraint conflict (Prisma
P2002) is translated intoCouponAlreadyRedeemedError/ "code already exists". Any other database failure propagates unchanged, so the rejection audit log never records a connection error asALREADY_REDEEMED. The original Prisma error is attached aserror.cause, never placed inerror.details. - Generated codes use a cryptographic RNG (
nanoid) over a 31-character alphabet. The default length of 8 gives roughly 2^40 combinations, which is adequate only behind rate limiting on yourvalidate/redeemendpoints; raisecodeLengthfor high-value or long-lived coupons.
Development
Tests run against a real Postgres — there are no mocks. Start the bundled
database and point DATABASE_URL at it:
docker compose up -d # postgres on host port 13011
export DATABASE_URL="postgresql://postgres:postgres@localhost:13011/coupon_test?schema=public"
pnpm install --frozen-lockfile
pnpm run prisma:generate && pnpm run db:push
pnpm run typecheck && pnpm run check:isolation
pnpm run build && pnpm run test:coverage
pnpm run test:concurrency:x3 # reruns the race suite 3xexamples/consumer is a standalone app that installs this package by path and
merges prisma/fragment.prisma into its own schema. It uses the same database
on a separate consumer_sample schema and runs in CI, which is what keeps the
package honest about working outside this repo:
cd examples/consumer
export DATABASE_URL="postgresql://postgres:postgres@localhost:13011/coupon_test?schema=consumer_sample"
pnpm install --frozen-lockfile
pnpm run prisma:generate && pnpm run db:push && pnpm run testCI provisions its own Postgres on the same port 13011, so the workflow sets
DATABASE_URL itself and does not use docker-compose.yml.
Non-goals / Future
- UI components, admin dashboards, React hooks
- Stripe / PayPal / PG integration
- Email / SMS / push
- Edge runtime (Cloudflare Workers / Vercel Edge)
- i18n: error messages are English-only. Translate on the consumer side.
A future minor version may add an optional batchKey to issueBulk() for
caller-supplied idempotency.
License
MIT
