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

@classytic/promo

v0.4.0

Published

Production-grade promotion, coupon, and discount engine for MongoDB — programs, rules, rewards, vouchers, gift cards, buy-x-get-y

Readme

@classytic/promo

Production-grade promotion, coupon, and discount engine for MongoDB — programs, rules, rewards, vouchers, gift cards, buy-x-get-y.

Install

npm install @classytic/promo \
  @classytic/mongokit @classytic/primitives @classytic/repo-core \
  mongoose zod

Peer deps (exactly what package.json declares):

| Peer | Range | |---|---| | @classytic/mongokit | >=3.16.0 | | @classytic/primitives | >=0.5.0 | | @classytic/repo-core | >=0.6.0 | | mongoose | >=9.4.1 | | zod | >=4.0.0 |

Node >=22. ESM only.

Exports

| Subpath | Contents | |---|---| | @classytic/promo | Engine factory (createPromoEngine), resolveConfig, every repository class (ProgramRepository, RuleRepository, RewardRepository, VoucherRepository), domain entity types (Program, Rule, Reward, Voucher, VoucherRedemption, BalanceLedgerEntry), the PromoEvents constant + typed event catalog (ProgramCreated, VoucherRedeemed, GiftCardSpent, EvaluationCompleted, …), config types (PromoConfig, ResolvedConfig, ResolvedTenant), input DTOs, and result shapes (EvaluationResult, CommitResult, GiftCardBalance, PaginatedResult) | | @classytic/promo/schemas | Zod v4 validators: programCreateSchema, programUpdateSchema, programActionSchema, ruleCreateSchema, rewardCreateSchema, voucherGenerateBatchSchema, voucherGenerateSingleSchema, voucherRedeemSchema, giftCardSpendSchema, giftCardTopUpSchema, evaluateSchema, commitEvaluationSchema, the list-filter schemas, plus enum constants (PROGRAM_TYPES, TRIGGER_MODES, STACKING_MODES, REWARD_TYPES, DISCOUNT_MODES, DISCOUNT_SCOPES, VOUCHER_STATUSES) | | @classytic/promo/events | The Zod-source event catalog on its own: promoEventDefinitions (all 20 promo.* events), per-event definitions (VoucherRedeemed, GiftCardSpent, …), payload types, and the PromoEventDefinition shape — register straight into Arc's EventRegistry without pulling the engine factory |

The /schemas entry depends only on zod — importing it does not pull mongoose, mongokit, or the engine factory into the bundle. Ideal for frontend form validation and SDK codegen.


Quick start

import mongoose from 'mongoose';
import { createPromoEngine } from '@classytic/promo';

await mongoose.connect(process.env.MONGO_URI!);

// 1. Boot the engine against an existing mongoose connection.
//    The factory asserts the backend supports transactions at boot
//    (commit/redeem/spend/topUp are transactional) — pass
//    `allowNonTransactional: true` on a standalone Mongo (dev/CI) to
//    accept best-effort atomicity instead.
const engine = createPromoEngine({
  mongoose: mongoose.connection,
  // tenant: false                       // disable scoping entirely
  // tenant: { tenantField: 'branchId' } // custom tenant field
  // collectionPrefix: 'commerce_',      // namespace physical collections
  // forceRecreate: true,                // hot-reload/test fixtures ONLY —
  //                                     // re-registering promo's models on a
  //                                     // connection otherwise throws
  //                                     // PromoModelCollisionError
});
await engine.syncIndexes();

const ctx = { organizationId: '64b0c0c0c0c0c0c0c0c0c0c0', actorRef: 'admin' };

// 2. Create a program + rule + reward.
const program = await engine.repositories.program.create(
  {
    name: 'Summer Sale',
    programType: 'discount_code',
    triggerMode: 'code',
    status: 'active',
    stackingMode: 'stackable',
    priority: 10,
    usedCount: 0,
    applicableCustomerIds: [],
    applicableCustomerTags: [],
  },
  ctx,
);

await engine.repositories.rule.create(
  {
    programId: program._id,
    code: 'SUMMER25',
    minimumAmount: 5_000,
    minimumQuantity: 0,
    applicableProductIds: [],
    applicableCategories: [],
    applicableSkus: [],
  },
  ctx,
);

await engine.repositories.reward.create(
  {
    programId: program._id,
    rewardType: 'discount',
    discountMode: 'percentage',
    discountAmount: 25,
    discountScope: 'order',
    applicableProductIds: [],
    freeQuantity: 0,
  },
  ctx,
);

// 3. Generate a single voucher for that code.
await engine.services.voucher.generateSingleCode(
  { programId: program._id, code: 'SUMMER25' },
  ctx,
);

// 4. Evaluate a cart, then commit against the order id.
const result = await engine.services.evaluation.evaluate(
  {
    items: [{ productId: 'prod_1', quantity: 2, unitPrice: 3_000 }],
    subtotal: 6_000,
    codes: ['SUMMER25'],
  },
  ctx,
);

if (result.appliedDiscounts.length > 0) {
  await engine.services.evaluation.commit(
    result.evaluationId,
    'ORD-2026-0001',
    ctx,
    { cartHash: result.cartHash }, // tamper guard
  );
}

Concepts

Program (src/domain/entities/program.ts) — The top-level promotion container. Carries programType (promotion | coupon | discount_code | buy_x_get_y | gift_card), a triggerMode (auto | code), a status FSM (draft → active → paused → expired → archived), a stackingMode (exclusive | stackable), priority, validity window, and customer-segment filters (applicableCustomerIds, applicableCustomerTags, maxUsagePerCustomer).

Rule (src/domain/entities/rule.ts) — A qualifier attached to a program. Declares the gates a cart must pass to unlock the program: minimumAmount, minimumQuantity, applicableProductIds / applicableCategories / applicableSkus, buyQuantity (for BXGY), optional code (for code-triggered programs), and an own startsAt / endsAt window. A program may carry multiple rules to express tiers — the engine picks the highest-threshold rule that matches.

Reward (src/domain/entities/reward.ts) — The payout a qualifying program emits. Either discount (with discountMode: percentage | fixed, discountScope: order | cheapest | specific_products, optional maxDiscountAmount cap) or free_product (with freeProductId / freeProductSku / freeQuantity). Optionally scoped to a specific ruleId for tiered programs.

Voucher (src/domain/entities/voucher.ts) — A concrete redeemable code instance of a program. Carries code, status (active | used | expired | cancelled), usageLimit / usedCount, optional customerId binding, optional expiresAt, and a redemptions[] audit log. For gift_card programs it also carries initialBalance / currentBalance and an append-only balanceLedger[].

Gift card — A voucher whose program has programType: 'gift_card'. Spend / top-up / balance-check flow through engine.services.voucher.spend(...), .topUp(...), and .getBalance(...), each of which uses a Mongo transaction and an idempotency-key guard. config.giftCard.allowNegativeBalance + config.giftCard.maxBalance gate the writes.

Buy-X-Get-Y — A programType: 'buy_x_get_y' program where a rule sets buyQuantity (the "X") and a linked reward carries rewardType: 'free_product' with a freeQuantity (the "Y"). The evaluation engine honors both on a qualifying cart.


Evaluation pipeline

EvaluationService.evaluate(input, ctx) (src/services/evaluation.service.ts) runs the following steps in order:

  1. Load active programs via ProgramRepository.findActive() — filtered by status: 'active', current date ≥ startsAt, and current date ≤ endsAt (post-filtered in memory), sorted by priority DESC.
  2. Load rules + rewards for those programs in parallel, grouped by programId.
  3. Resolve submitted codes to vouchers — each code is upper-cased and looked up via VoucherRepository.getByCode(); only status: 'active' vouchers with remaining usage count are kept.
  4. Per-program evaluation, stopping on:
    • maxUsageTotal exhausted,
    • customer-segment mismatch (applicableCustomerIds / applicableCustomerTags),
    • maxUsagePerCustomer exceeded (reads ProgramRepository.getCustomerUsage),
    • stacking cap (stackingMode: 'exclusive' blocks subsequent programs; maxStackablePromotions caps the stackable chain).
  5. Best-rule matching — for each program, matchBestRule picks the highest-threshold rule whose gates all pass (code, date range, minimumAmount, minimumQuantity, product/category/SKU filters, buyQuantity). Scoring: minimumAmount * 1000 + minimumQuantity.
  6. Reward computation — linked rewards run through computeDiscount (scope-aware: order, cheapest, or specific_products; maxDiscountAmount capped; never exceeds running subtotal) or emit a FreeProductLine. The runningSubtotal decreases as each discount stacks.
  7. Cart-hash + evaluation snapshot — a SHA-256 hash of the normalized items + subtotal + codes + customer identity is computed and returned on the EvaluationResult. Non-preview evaluations are persisted via the configured EvaluationStore (Mongo by default; hosts can plug Redis / DynamoDB / custom by implementing the port) so commit() can replay program + voucher usage writes inside a single transaction. Snapshots survive process restart, horizontal scaling, serverless cold starts, and worker handoff. The default Mongo store applies a TTL index so abandoned snapshots auto-expire (default 30 min).
  8. Dispatch EVALUATION_COMPLETED via dispatchPromoEvent — routed through the configured events.transport and optionally persisted via the host outbox inside ctx.session.

commit(evaluationId, orderId, ctx, { cartHash }) (same service) consumes the stash: it optionally re-verifies the submitted cartHash (throws CartHashMismatchError on mismatch), opens a transaction, increments program.usedCount + per-customer usage on every applied program, increments voucher usage + appends a redemption record per applied voucher, emits EVALUATION_COMMITTED, and returns a CommitResult. rollback(evaluationId, ctx) drops the stash and emits EVALUATION_ROLLED_BACK.

preview(input, ctx) runs the same algorithm with isPreview: true and skips the stash — safe for read-only quoting.

Atomic checkout-chain participation (ctx.session)

Every service method honors a host-supplied Mongoose ClientSession on ctx.session. When present, evaluation.evaluate / commit / rollback and voucher.redeem / spend / topUp join the host's transaction instead of opening their own — all promo writes (program usage, voucher redemptions, gift-card ledger entries, evaluation-snapshot take) commit or abort atomically with the rest of the checkout chain (cart → order → flow reservation → promo commit → invoice). MongoDB has no nested transactions, so the host owns commit/abort/retry; promo never starts a second transaction on a joined session.

await withTransaction(mongoose.connection, async (session) => {
  const order = await orderEngine.services.order.place(input, { ...ctx, session });
  await promoEngine.services.evaluation.commit(
    evaluationId,
    String(order._id),
    { ...ctx, session },              // ← promo joins the order's transaction
    { cartHash },
  );
});
// If order placement (or any later step) throws, voucher usage and
// program counters roll back — no usage rows for a nonexistent order.

Without ctx.session, each method owns its own transaction via mongokit's withTransaction (auto-retry on transient errors) — the standalone behaviour is unchanged. Reads that feed writes (getByCode, findActive, idempotency checks, the snapshot take) also run on the session, so evaluate→commit inside one transaction is read-your-writes consistent.

Event publish timing differs between the two modes — see Publish timing: promo-owned transactions flush in-process publishes after commit; on a joined host session, attach a PENDING_PROMO_EVENTS queue if your subscribers must never see uncommitted state.


Repository pattern

All four repositories extend mongokit's Repository<T> directly — no wrapper layer. Hosts get the full mongokit surface (pagination, transactions, hooks, soft-delete, tenant plugin) on every repo.

| Repository | Domain verbs (beyond mongokit base CRUD) | |---|---| | program | activate, pause, archive (FSM via PROGRAM_TRANSITIONS), incrementUsage, decrementUsage, incrementCustomerUsage, getCustomerUsage, findActive | | rule | (inherited only — matching happens in the evaluation service) | | reward | (inherited only) | | voucher | cancel, incrementUsage (atomic $inc + $push), addLedgerEntry (atomic balance delta + ledger push), expireByDate, getByCode, hasIdempotencyKey |

The two services (engine.services.voucher, engine.services.evaluation) exist because they coordinate multiple repositories + transactions + config: code generation + redemption + gift-card spend/top-up for vouchers, and the multi-program evaluation algorithm + persistent evaluation snapshot store for evaluations.


Multi-tenant scoping

The engine resolves config.tenant via @classytic/primitives/tenant. By default every write carries an organizationId: ObjectId field and multiTenantPlugin({ tenantField: 'organizationId' }) is wired onto every repository.

createPromoEngine({ mongoose: conn });                         // default: objectId, plugin on
createPromoEngine({ mongoose: conn, tenant: false });          // field present, plugin off
createPromoEngine({ mongoose: conn, tenant: { tenantField: 'branchId', fieldType: 'string' } });

tenant: false still injects the organizationId field onto the schema so hosts that scope at their own framework layer (e.g. arc's preset + BaseController) get the field written and filtered correctly. This mirrors the @classytic/order convention. VoucherRepository.getByCode and ProgramRepository.findActive inject the tenant filter manually — they work whether the plugin is on or off.


Events

The engine exposes engine.events — an EventTransport from @classytic/primitives/events. Default: in-process fanout. Pass events: { transport } into createPromoEngine() to drop in any Arc-compatible transport (Memory / Redis / Kafka / outbox-relay). Pass outbox alongside for durable at-least-once delivery inside the caller's ctx.session.

Every event is typed via the catalog in src/events/promo-event-catalog.ts:

import { PromoEvents, type PromoEventName } from '@classytic/promo';

engine.events.subscribe?.(PromoEvents.VOUCHER_REDEEMED, async (evt) => {
  // evt.payload is typed: { voucherId, code, orderId, discountAmount, customerId? }
});

Representative event names: promo.program.created / .activated / .paused / .archived, promo.rule.added / .updated / .removed, promo.reward.added / .updated / .removed, promo.voucher.generated / .redeemed / .cancelled / .expired, promo.gift_card.spent / .topped_up / .exhausted, promo.evaluation.completed / .committed / .rolled_back. The full canonical list is exported as PromoEvents from the package root; the Zod catalog (promoEventDefinitions) is also available standalone from @classytic/promo/events.

Publish timing (ghost-event prevention)

outbox.save always runs inside the transaction (session-bound — a rollback discards the row; a save failure rolls the transaction back, per P8). The in-process transport.publish timing depends on who owns the transaction:

  • Promo owns it (no ctx.session): publishes are queued and flushed after commit. Subscribers never see events for state that rolled back, and a handler that re-reads the DB observes the committed write.
  • Host owns it AND attached a queue: pass { ...ctx, session, [PENDING_PROMO_EVENTS]: queue } (a DomainEvent[]), then call flushPendingPromoEvents({ events: transport }, queue) after your commit — same guarantee, host-controlled.
  • Host owns it, no queue (the plain ctx.session contract): promo cannot know when your transaction commits, so the publish happens in-scope immediately after the write. This is a documented best effort — if you abort, in-process subscribers may have seen an event for state that never committed (the durable outbox row still rolls back correctly). Attach a queue if your subscribers need strict semantics.

Errors

Every domain error implements HttpError from @classytic/repo-core/errors — a stable hierarchical lowercase code (promo.<entity>.<problem>) plus an HTTP status. Arc serializes them via toErrorContract() with zero host-side mapping; hosts catch by instanceof or switch on error.code.

| Error | code | status | |---|---|---| | ValidationError | promo.validation.invalid_input | 400 | | TenantIsolationError | promo.tenant.missing_context | 400 | | ProgramNotFoundError / RuleNotFoundError / RewardNotFoundError / VoucherNotFoundError / EvaluationNotFoundError | promo.<entity>.not_found | 404 | | InvalidTransitionError | promo.program.invalid_transition | 409 | | ProgramUsageCapExceededError | promo.program.usage_cap_exceeded | 409 | | VoucherExpiredError / VoucherExhaustedError | promo.voucher.expired / .exhausted | 409 | | DuplicateRedemptionError / DuplicateVoucherCodeError | promo.voucher.duplicate_redemption / .duplicate_code | 409 | | GiftCardExhaustedError / InsufficientBalanceError | promo.gift_card.exhausted / .insufficient_balance | 409 | | ConcurrencyConflictError | promo.concurrency.write_conflict | 409 | | CartHashMismatchError | promo.evaluation.cart_hash_mismatch | 409 | | PromoModelCollisionError | promo.engine.model_collision | 500 |


Design principles

  • No barrel files inside src/. Only src/index.ts re-exports. Internal folders import directly from source files.
  • Peer-dep siblings only. @classytic/mongokit, @classytic/primitives, @classytic/repo-core are peers, never dependencies. No imports from other @classytic/* packages in production code.
  • Zod v4 at the seams. Every validator in @classytic/promo/schemas is Zod v4. TypeScript DTOs in src/types/inputs.ts are hand-written to stay aligned with the schemas.
  • Multi-tenant via tenantFieldType engine config. Default 'objectId' with ref: 'organization' so $lookup / .populate() work; pass 'string' for UUID / slug-based auth systems.
  • Extend mongokit Repository<T> directly. The two remaining services exist only because they coordinate multiple repositories + UnitOfWork transactions + config; they are not a service layer wrapping the repos.

Tests

npm test              # unit + integration (mongodb-memory-server)
npm run test:unit
npm run test:integration

Current suite: 380 tests across 38 files, split into two vitest projects:

| Project | Directories | Files | |---|---|---| | unit | tests/unit/ | 7 — code generator, config resolution, constants, domain errors (HttpError contract matrix), event bus, event catalog (no-drift invariant), pending-events dispatch + boot gate | | integration | tests/integration/, tests/services/, tests/domain/, tests/security/ | 31 — full workflows, commerce + vertical scenarios, checkout-chain session threading, post-commit publish (ghost-event regression), engine compliance (model collision, collection names, duplicate code, abort signal), gift-card concurrent-spend + lifecycle, voucher bulk generation, outbox dispatch, evaluation + voucher service edge cases, concurrency, tiered discounts, customer segmentation, cart-hash tamper guards, input validation |

Integration tests spin up mongodb-memory-server one connection per worker (fileParallelism: false, single-worker pool); unit tests require no Mongo.


Changelog

See CHANGELOG.md.

License

MIT. See LICENSE.