@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 zodPeer 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:
- Load active programs via
ProgramRepository.findActive()— filtered bystatus: 'active', current date ≥startsAt, and current date ≤endsAt(post-filtered in memory), sorted bypriorityDESC. - Load rules + rewards for those programs in parallel, grouped by
programId. - Resolve submitted codes to vouchers — each code is upper-cased and looked up via
VoucherRepository.getByCode(); onlystatus: 'active'vouchers with remaining usage count are kept. - Per-program evaluation, stopping on:
maxUsageTotalexhausted,- customer-segment mismatch (
applicableCustomerIds/applicableCustomerTags), maxUsagePerCustomerexceeded (readsProgramRepository.getCustomerUsage),- stacking cap (
stackingMode: 'exclusive'blocks subsequent programs;maxStackablePromotionscaps the stackable chain).
- Best-rule matching — for each program,
matchBestRulepicks the highest-threshold rule whose gates all pass (code, date range, minimumAmount, minimumQuantity, product/category/SKU filters,buyQuantity). Scoring:minimumAmount * 1000 + minimumQuantity. - Reward computation — linked rewards run through
computeDiscount(scope-aware:order,cheapest, orspecific_products;maxDiscountAmountcapped; never exceeds running subtotal) or emit aFreeProductLine. TherunningSubtotaldecreases as each discount stacks. - 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 configuredEvaluationStore(Mongo by default; hosts can plug Redis / DynamoDB / custom by implementing the port) socommit()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). - Dispatch
EVALUATION_COMPLETEDviadispatchPromoEvent— routed through the configuredevents.transportand optionally persisted via the hostoutboxinsidectx.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 }(aDomainEvent[]), then callflushPendingPromoEvents({ events: transport }, queue)after your commit — same guarantee, host-controlled. - Host owns it, no queue (the plain
ctx.sessioncontract): 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/. Onlysrc/index.tsre-exports. Internal folders import directly from source files. - Peer-dep siblings only.
@classytic/mongokit,@classytic/primitives,@classytic/repo-coreare peers, never dependencies. No imports from other@classytic/*packages in production code. - Zod v4 at the seams. Every validator in
@classytic/promo/schemasis Zod v4. TypeScript DTOs insrc/types/inputs.tsare hand-written to stay aligned with the schemas. - Multi-tenant via
tenantFieldTypeengine config. Default'objectId'withref: '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 +UnitOfWorktransactions + 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:integrationCurrent 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.
