npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@withwiz/coupon

v0.2.0

Published

Multi-tenant, concurrency-safe coupon domain core for Prisma + Node.js applications

Downloads

297

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/client

Peer dependencies:

  • @prisma/client >= 5 < 8
  • typescript >= 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_coupon

Field 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.closed

Events 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 tenantId on the call args, or
  • config.defaultTenantId passed to createCouponClient.

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. welcome10 and WELCOME10 are the same code.
  • code length cap is 64. Control chars, NUL, whitespace and homoglyph variants are rejected at input.
  • metadata object is guarded against prototype pollution (__proto__ / constructor / prototype keys rejected).
  • issueBulk.count and campaigns.issue.count are capped at 10,000.
  • models / tables entries 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, max limit=200. Every list sorts by id as a secondary key, so rows sharing a createdAt (anything issued in one issueBulk call) still page deterministically — no duplicates and no dropped rows.
  • No $executeRawUnsafe / $queryRawUnsafe is used anywhere in this package.
  • userId must come from your authenticated session, never from the request body. Eligibility (allowedUserIds), maxRedemptionsPerUser and the duplicate-order guard all key on it. A coupon that sets maxRedemptionsPerUser refuses anonymous validate() / redeem() calls with ValidationError, so the cap cannot be bypassed by omitting the user.
  • The idempotency index is (couponId, redeemedByUserId, orderRef). Postgres treats NULL as distinct, so pass orderRef on every redeem if you need double-submit protection; without it, a coupon that has no maxRedemptionsPerUser can be redeemed repeatedly by the same user.
  • Caller-supplied strings are bounded: ids and cursors 64 chars, user / actor ids and orderRef 128 chars, metadata 16 KiB of JSON, eligibility lists 1,000 entries of 128 chars. Oversized input throws ValidationError before it reaches the database.
  • Only a genuine unique-constraint conflict (Prisma P2002) is translated into CouponAlreadyRedeemedError / "code already exists". Any other database failure propagates unchanged, so the rejection audit log never records a connection error as ALREADY_REDEEMED. The original Prisma error is attached as error.cause, never placed in error.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 your validate / redeem endpoints; raise codeLength for 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 3x

examples/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 test

CI 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