@classytic/promo 0.1.0 → 0.2.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +226 -22
  3. package/dist/{index-J5BC20DN.d.mts → constants-CrbSSQG5.d.mts} +1 -3
  4. package/dist/{constants-BVajdyL3.mjs → constants-D0Rntp2f.mjs} +1 -3
  5. package/dist/index.d.mts +763 -10
  6. package/dist/index.mjs +1721 -34
  7. package/dist/schemas/index.d.mts +253 -0
  8. package/dist/schemas/index.mjs +135 -0
  9. package/package.json +20 -35
  10. package/dist/config-iZjn_8pp.d.mts +0 -71
  11. package/dist/domain/enums/index.d.mts +0 -2
  12. package/dist/domain/enums/index.mjs +0 -2
  13. package/dist/domain/index.d.mts +0 -61
  14. package/dist/domain/index.mjs +0 -4
  15. package/dist/domain-errors-BEkXvy5O.mjs +0 -80
  16. package/dist/event-emitter.port-DaodlJSG.d.mts +0 -8
  17. package/dist/event-types-CsTV1FKX.mjs +0 -25
  18. package/dist/events/index.d.mts +0 -2
  19. package/dist/events/index.mjs +0 -3
  20. package/dist/events-CprEWlN7.mjs +0 -25
  21. package/dist/index-B7lLH19a.d.mts +0 -13
  22. package/dist/index-C52zSBkI.d.mts +0 -96
  23. package/dist/index-Cu9iwy4v.d.mts +0 -99
  24. package/dist/index-l09KqnlE.d.mts +0 -81
  25. package/dist/models/index.d.mts +0 -2
  26. package/dist/models/index.mjs +0 -2
  27. package/dist/models-DdBNae7h.mjs +0 -277
  28. package/dist/repositories/index.d.mts +0 -2
  29. package/dist/repositories/index.mjs +0 -2
  30. package/dist/repositories-DgZIY9wD.mjs +0 -295
  31. package/dist/results-Ca5ZCNbN.d.mts +0 -218
  32. package/dist/services/index.d.mts +0 -2
  33. package/dist/services/index.mjs +0 -2
  34. package/dist/services-Cz0gHrmX.mjs +0 -815
  35. package/dist/types/index.d.mts +0 -3
  36. package/dist/types/index.mjs +0 -1
  37. package/dist/unit-of-work.port-DaMW8WZK.d.mts +0 -7
  38. package/dist/voucher.port-yxfb3MHJ.d.mts +0 -146
package/CHANGELOG.md ADDED
@@ -0,0 +1,128 @@
1
+ # Changelog
2
+
3
+ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.2.0]
7
+
8
+ Structural rewrite on top of the 0.1.0 shape. The public API is now a
9
+ smaller, sharper surface organized around two subpath exports (the root
10
+ engine factory and `./schemas`). Legacy barrel files and per-port
11
+ indirection were removed; hosts wire business flows directly against
12
+ the repositories plus the two remaining domain-orchestration services
13
+ (`voucher`, `evaluation`).
14
+
15
+ ### Changed
16
+
17
+ - **Domain restructure.** Dropped the `src/domain/ports/*` port barrels
18
+ (`program.port.ts`, `reward.port.ts`, `rule.port.ts`, `voucher.port.ts`,
19
+ `event-emitter.port.ts`, and the `index.ts` aggregator) in favor of
20
+ repositories that expose exactly the operations hosts need — program
21
+ FSM transitions (`activate` / `pause` / `archive`), usage tracking
22
+ (`incrementUsage` / `incrementCustomerUsage` / `getCustomerUsage`),
23
+ program lookup (`findActive`), voucher state change (`cancel`),
24
+ atomic voucher writes (`incrementUsage`, `addLedgerEntry`,
25
+ `expireByDate`), and voucher lookup (`getByCode`, `hasIdempotencyKey`).
26
+ The equivalent logic now lives on the repositories where it is
27
+ naturally atomic.
28
+ - **Services collapsed.** `src/services/program.service.ts` was removed
29
+ — program FSM transitions + usage counters moved onto
30
+ `ProgramRepository`. `VoucherService` and `EvaluationService` remain
31
+ because they coordinate multiple repositories, the `UnitOfWork`
32
+ transaction boundary, and the resolved config; they are not a service
33
+ layer wrapping single-repo CRUD.
34
+ - **Events subsystem simplified.** Removed `events/event-bus.ts` +
35
+ `events/event-types.ts` (both internal indirection the engine did not
36
+ need). The new `events/dispatch.ts` funnels every emission through a
37
+ single session-bound outbox-then-transport path; event payloads are
38
+ declared with Zod in `events/promo-event-catalog.ts` and re-exported
39
+ from the root.
40
+ - **Repositories updated to mongokit 3.11 + repo-core 0.2 conventions.**
41
+ Every repo extends `Repository<T>` directly. `VoucherRepository`
42
+ overrides `create` / `createMany` to inject the tenant field from
43
+ `ctx` so writes work whether the host opts into the auto-wired
44
+ `multiTenantPlugin` or scopes at its own framework layer. Atomic
45
+ state changes go through `$inc` + `$push` on a single document.
46
+ - **Tenant config moved to `@classytic/primitives/tenant`.** `resolveConfig`
47
+ delegates to `resolveTenantConfig`; the resolved shape
48
+ (`tenantField` / `fieldType` / `ref` / `contextKey` / `enabled` /
49
+ `required` / `strategy` / `resolve`) forwards straight into
50
+ `multiTenantPlugin(...)` without translation.
51
+ - **Peer deps** tightened to `>=` ranges:
52
+ - `@classytic/mongokit >= 3.11.0`
53
+ - `@classytic/primitives >= 0.1.0`
54
+ - `@classytic/repo-core >= 0.2.0`
55
+ - `mongoose >= 9.4.1`, `zod >= 4.0.0`
56
+ - **DevDeps:** `@classytic/primitives` devDep moved from a `file:` link
57
+ to `>=0.1.0` (now that primitives ships on npm); all `@classytic/*`
58
+ dev ranges standardized on `>=`.
59
+
60
+ ### Removed
61
+
62
+ - `src/services/program.service.ts` — logic folded into
63
+ `ProgramRepository`.
64
+ - `src/domain/ports/*` — structural types now live with the
65
+ repositories they describe.
66
+ - `src/domain/enums/index.ts`, `src/domain/entities/index.ts`,
67
+ `src/domain/errors/index.ts`, `src/domain/index.ts`,
68
+ `src/events/index.ts`, `src/models/index.ts`,
69
+ `src/repositories/index.ts`, `src/services/index.ts`,
70
+ `src/types/index.ts` — internal barrel files deleted; `src/index.ts`
71
+ is now the only re-export surface.
72
+ - `src/events/event-bus.ts` + `src/events/event-types.ts` — replaced
73
+ by the typed event catalog + dispatch helper.
74
+ - `src/utils/mongoose.ts`, `src/utils/tenant-guard.ts` — helpers
75
+ superseded by mongokit + primitives built-ins.
76
+
77
+ ### Added
78
+
79
+ - Subpath export `@classytic/promo/schemas` — Zod v4 validators for
80
+ every input (program / rule / reward / voucher generate + redeem /
81
+ gift card spend + top-up / evaluate + commit) plus the list-filter
82
+ schemas and every enum constant, depending only on zod.
83
+ - `src/events/promo-event-catalog.ts` — typed event definitions
84
+ structurally compatible with `@classytic/arc`'s `EventDefinitionOutput`.
85
+ - `src/events/dispatch.ts` — shared session-aware outbox + transport
86
+ plumbing with per-emission error isolation.
87
+ - `cartHash` field on `EvaluationResult` + optional
88
+ `CommitOptions { cartHash }` — deterministic SHA-256 of the
89
+ evaluated cart that hosts can submit at commit time to catch
90
+ cart-tampering between `evaluate` and `commit`
91
+ (`CartHashMismatchError`).
92
+ - Gift-card concurrency: `spend` wraps the transaction in a
93
+ write-conflict detector that maps MongoDB's transient
94
+ `WriteConflict` (code 112, `TransientTransactionError` label, or
95
+ `ConflictError` rethrows) into a typed `ConcurrencyConflictError`
96
+ for retry-or-409 handling at the host.
97
+ - Test restructure — roughly 325 tests across 33 files, split into
98
+ `unit` and `integration` vitest projects covering full workflows,
99
+ commerce + vertical scenarios, voucher bulk generation, gift-card
100
+ concurrent-spend + lifecycle, outbox dispatch, tiered discounts,
101
+ cart-hash tamper, customer segmentation, program date boundaries,
102
+ multi-code evaluation, advanced stacking, concurrency edge cases,
103
+ and input validation.
104
+
105
+ ### Peer compatibility
106
+
107
+ Anyone on 0.1.0 calling the old `services.program.*` will need to
108
+ migrate to `repositories.program.*`. Signatures are the same — the
109
+ call sites move from `engine.services.program.activate(id, ctx)` to
110
+ `engine.repositories.program.activate(id, ctx)`, and similarly for
111
+ `pause`, `archive`, `incrementUsage`, `getCustomerUsage`,
112
+ `incrementCustomerUsage`, `findActive`. Voucher service and
113
+ evaluation service signatures are unchanged except for the new
114
+ optional `CommitOptions { cartHash }` argument on `commit`.
115
+
116
+ Hosts reading from the dropped barrel files (`@classytic/promo/domain`,
117
+ `.../ports`, `.../events`, etc.) should switch to the root import —
118
+ every public type is re-exported from `@classytic/promo`, and every
119
+ Zod schema is available from `@classytic/promo/schemas`.
120
+
121
+ ## [0.1.0]
122
+
123
+ Initial release. Program / rule / reward / voucher engine for MongoDB
124
+ with an evaluation service that resolves active programs against a
125
+ cart, applies stacked or exclusive rewards, and commits usage inside
126
+ a transaction. Multi-tenant via `multiTenantPlugin`, event-emitting
127
+ through an in-process bus, with code generation, gift-card balance
128
+ ledgers, and a port / service / repository layered architecture.
package/README.md CHANGED
@@ -1,41 +1,245 @@
1
1
  # @classytic/promo
2
2
 
3
- Promotions engine for MongoDB — discount codes, vouchers, cashback, BOGO, and rule-based pricing.
4
-
5
- Framework-agnostic. Works with Fastify (Arc), Express, NestJS, Next.js, or any Node.js app with a Mongoose connection.
3
+ Production-grade promotion, coupon, and discount engine for MongoDB — programs, rules, rewards, vouchers, gift cards, buy-x-get-y.
6
4
 
7
5
  ## Install
8
6
 
9
7
  ```bash
10
- npm install @classytic/promo
11
- # Peer deps:
12
- npm install mongoose@^9 @classytic/mongokit@^3.5
8
+ npm install @classytic/promo \
9
+ @classytic/mongokit @classytic/primitives @classytic/repo-core \
10
+ mongoose zod
13
11
  ```
14
12
 
15
- ## Quick Start
13
+ Peer deps (exactly what `package.json` declares):
16
14
 
17
- ```typescript
18
- import { createPromoEngine } from '@classytic/promo';
15
+ | Peer | Range |
16
+ |---|---|
17
+ | `@classytic/mongokit` | `>=3.11.0` |
18
+ | `@classytic/primitives` | `>=0.1.0` |
19
+ | `@classytic/repo-core` | `>=0.2.0` |
20
+ | `mongoose` | `>=9.4.1` |
21
+ | `zod` | `>=4.0.0` |
22
+
23
+ Node `>=22`. ESM only.
24
+
25
+ ## Exports
26
+
27
+ | Subpath | Contents |
28
+ |---|---|
29
+ | `@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`) |
30
+ | `@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`) |
31
+
32
+ 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.
33
+
34
+ ---
35
+
36
+ ## Quick start
37
+
38
+ ```ts
19
39
  import mongoose from 'mongoose';
40
+ import { createPromoEngine } from '@classytic/promo';
41
+
42
+ await mongoose.connect(process.env.MONGO_URI!);
20
43
 
44
+ // 1. Boot the engine against an existing mongoose connection.
21
45
  const engine = createPromoEngine({
22
46
  mongoose: mongoose.connection,
23
- tenant: { field: 'organizationId' }, // multi-tenant (or false for single)
47
+ // tenant: false // disable scoping entirely
48
+ // tenant: { tenantField: 'branchId' } // custom tenant field
24
49
  });
50
+ await engine.syncIndexes();
51
+
52
+ const ctx = { organizationId: '64b0c0c0c0c0c0c0c0c0c0c0', actorId: 'admin' };
53
+
54
+ // 2. Create a program + rule + reward.
55
+ const program = await engine.repositories.program.create(
56
+ {
57
+ name: 'Summer Sale',
58
+ programType: 'discount_code',
59
+ triggerMode: 'code',
60
+ status: 'active',
61
+ stackingMode: 'stackable',
62
+ priority: 10,
63
+ usedCount: 0,
64
+ applicableCustomerIds: [],
65
+ applicableCustomerTags: [],
66
+ },
67
+ ctx,
68
+ );
69
+
70
+ await engine.repositories.rule.create(
71
+ {
72
+ programId: program._id,
73
+ code: 'SUMMER25',
74
+ minimumAmount: 5_000,
75
+ minimumQuantity: 0,
76
+ applicableProductIds: [],
77
+ applicableCategories: [],
78
+ applicableSkus: [],
79
+ },
80
+ ctx,
81
+ );
82
+
83
+ await engine.repositories.reward.create(
84
+ {
85
+ programId: program._id,
86
+ rewardType: 'discount',
87
+ discountMode: 'percentage',
88
+ discountAmount: 25,
89
+ discountScope: 'order',
90
+ applicableProductIds: [],
91
+ freeQuantity: 0,
92
+ },
93
+ ctx,
94
+ );
95
+
96
+ // 3. Generate a single voucher for that code.
97
+ await engine.services.voucher.generateSingleCode(
98
+ { programId: program._id, code: 'SUMMER25' },
99
+ ctx,
100
+ );
101
+
102
+ // 4. Evaluate a cart, then commit against the order id.
103
+ const result = await engine.services.evaluation.evaluate(
104
+ {
105
+ items: [{ productId: 'prod_1', quantity: 2, unitPrice: 3_000 }],
106
+ subtotal: 6_000,
107
+ codes: ['SUMMER25'],
108
+ },
109
+ ctx,
110
+ );
25
111
 
26
- // Create a discount program
27
- const program = await engine.services.program.create({
28
- name: 'Summer Sale',
29
- type: 'discount_code',
30
- startsAt: new Date(),
31
- }, ctx);
32
-
33
- // Evaluate cart against active promotions
34
- const result = await engine.services.evaluation.evaluate({
35
- cart: { items, subtotal },
36
- }, ctx);
112
+ if (result.appliedDiscounts.length > 0) {
113
+ await engine.services.evaluation.commit(
114
+ result.evaluationId,
115
+ 'ORD-2026-0001',
116
+ ctx,
117
+ { cartHash: result.cartHash }, // tamper guard
118
+ );
119
+ }
37
120
  ```
38
121
 
122
+ ---
123
+
124
+ ## Concepts
125
+
126
+ **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`).
127
+
128
+ **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.
129
+
130
+ **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.
131
+
132
+ **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[]`.
133
+
134
+ **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.
135
+
136
+ **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.
137
+
138
+ ---
139
+
140
+ ## Evaluation pipeline
141
+
142
+ `EvaluationService.evaluate(input, ctx)` (`src/services/evaluation.service.ts`) runs the following steps in order:
143
+
144
+ 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.
145
+ 2. **Load rules + rewards** for those programs in parallel, grouped by `programId`.
146
+ 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.
147
+ 4. **Per-program evaluation**, stopping on:
148
+ - `maxUsageTotal` exhausted,
149
+ - customer-segment mismatch (`applicableCustomerIds` / `applicableCustomerTags`),
150
+ - `maxUsagePerCustomer` exceeded (reads `ProgramRepository.getCustomerUsage`),
151
+ - stacking cap (`stackingMode: 'exclusive'` blocks subsequent programs; `maxStackablePromotions` caps the stackable chain).
152
+ 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`.
153
+ 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.
154
+ 7. **Cart-hash + evaluation stash** — a SHA-256 hash of the normalized items + subtotal + codes + customer identity is computed and returned on the `EvaluationResult`. Non-preview evaluations are stashed in an in-memory pending map keyed by `evaluationId` so `commit()` can replay program + voucher usage writes inside a single transaction.
155
+ 8. **Dispatch `EVALUATION_COMPLETED`** via `dispatchPromoEvent` — routed through the configured `events.transport` and optionally persisted via the host `outbox` inside `ctx.session`.
156
+
157
+ `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`.
158
+
159
+ `preview(input, ctx)` runs the same algorithm with `isPreview: true` and skips the stash — safe for read-only quoting.
160
+
161
+ ---
162
+
163
+ ## Repository pattern
164
+
165
+ 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.
166
+
167
+ | Repository | Domain verbs (beyond mongokit base CRUD) |
168
+ |---|---|
169
+ | `program` | `activate`, `pause`, `archive` (FSM via `PROGRAM_TRANSITIONS`), `incrementUsage`, `decrementUsage`, `incrementCustomerUsage`, `getCustomerUsage`, `findActive` |
170
+ | `rule` | (inherited only — matching happens in the evaluation service) |
171
+ | `reward` | (inherited only) |
172
+ | `voucher` | `cancel`, `incrementUsage` (atomic `$inc` + `$push`), `addLedgerEntry` (atomic balance delta + ledger push), `expireByDate`, `getByCode`, `hasIdempotencyKey` |
173
+
174
+ 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 + in-memory pending-commit stash for evaluations.
175
+
176
+ ---
177
+
178
+ ## Multi-tenant scoping
179
+
180
+ 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.
181
+
182
+ ```ts
183
+ createPromoEngine({ mongoose: conn }); // default: objectId, plugin on
184
+ createPromoEngine({ mongoose: conn, tenant: false }); // field present, plugin off
185
+ createPromoEngine({ mongoose: conn, tenant: { tenantField: 'branchId', fieldType: 'string' } });
186
+ ```
187
+
188
+ `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.
189
+
190
+ ---
191
+
192
+ ## Events
193
+
194
+ 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`.
195
+
196
+ Every event is typed via the catalog in `src/events/promo-event-catalog.ts`:
197
+
198
+ ```ts
199
+ import { PromoEvents, type PromoEventName } from '@classytic/promo';
200
+
201
+ engine.events.subscribe?.(PromoEvents.VOUCHER_REDEEMED, async (evt) => {
202
+ // evt.payload is typed: { voucherId, code, orderId, discountAmount, customerId? }
203
+ });
204
+ ```
205
+
206
+ 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`.
207
+
208
+ ---
209
+
210
+ ## Design principles
211
+
212
+ - **No barrel files inside `src/`.** Only `src/index.ts` re-exports. Internal folders import directly from source files.
213
+ - **Peer-dep siblings only.** `@classytic/mongokit`, `@classytic/primitives`, `@classytic/repo-core` are peers, never dependencies. No imports from other `@classytic/*` packages in production code.
214
+ - **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.
215
+ - **Multi-tenant via `tenantFieldType` engine config.** Default `'objectId'` with `ref: 'organization'` so `$lookup` / `.populate()` work; pass `'string'` for UUID / slug-based auth systems.
216
+ - **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.
217
+
218
+ ---
219
+
220
+ ## Tests
221
+
222
+ ```bash
223
+ npm test # unit + integration (mongodb-memory-server)
224
+ npm run test:unit
225
+ npm run test:integration
226
+ ```
227
+
228
+ Current suite: approximately 325 tests across 33 files, split into two vitest projects:
229
+
230
+ | Project | Directories | Files |
231
+ |---|---|---|
232
+ | `unit` | `tests/unit/` | 6 — code generator, config resolution, constants, domain errors, event bus, event catalog |
233
+ | `integration` | `tests/integration/`, `tests/services/`, `tests/domain/`, `tests/security/` | 27 — full workflows, commerce + vertical scenarios, 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 |
234
+
235
+ Integration tests spin up `mongodb-memory-server` one connection per worker (`fileParallelism: false`, single-worker pool); unit tests require no Mongo.
236
+
237
+ ---
238
+
239
+ ## Changelog
240
+
241
+ See [CHANGELOG.md](./CHANGELOG.md).
242
+
39
243
  ## License
40
244
 
41
- MIT
245
+ MIT. See [LICENSE](./LICENSE).
@@ -7,8 +7,6 @@ declare const REWARD_TYPES: readonly ["discount", "free_product"];
7
7
  declare const DISCOUNT_MODES: readonly ["percentage", "fixed"];
8
8
  declare const DISCOUNT_SCOPES: readonly ["order", "cheapest", "specific_products"];
9
9
  declare const VOUCHER_STATUSES: readonly ["active", "used", "expired", "cancelled"];
10
- //#endregion
11
- //#region src/domain/enums/index.d.ts
12
10
  type ProgramType = (typeof PROGRAM_TYPES)[number];
13
11
  type TriggerMode = (typeof TRIGGER_MODES)[number];
14
12
  type ProgramStatus = (typeof PROGRAM_STATUSES)[number];
@@ -18,4 +16,4 @@ type DiscountMode = (typeof DISCOUNT_MODES)[number];
18
16
  type DiscountScope = (typeof DISCOUNT_SCOPES)[number];
19
17
  type VoucherStatus = (typeof VOUCHER_STATUSES)[number];
20
18
  //#endregion
21
- export { RewardType as a, VoucherStatus as c, PROGRAM_STATUSES as d, PROGRAM_TYPES as f, VOUCHER_STATUSES as g, TRIGGER_MODES as h, ProgramType as i, DISCOUNT_MODES as l, STACKING_MODES as m, DiscountScope as n, StackingMode as o, REWARD_TYPES as p, ProgramStatus as r, TriggerMode as s, DiscountMode as t, DISCOUNT_SCOPES as u };
19
+ export { PROGRAM_STATUSES as a, ProgramType as c, STACKING_MODES as d, StackingMode as f, VoucherStatus as g, VOUCHER_STATUSES as h, DiscountScope as i, REWARD_TYPES as l, TriggerMode as m, DISCOUNT_SCOPES as n, PROGRAM_TYPES as o, TRIGGER_MODES as p, DiscountMode as r, ProgramStatus as s, DISCOUNT_MODES as t, RewardType as u };
@@ -28,8 +28,6 @@ const VOUCHER_STATUSES = [
28
28
  "expired",
29
29
  "cancelled"
30
30
  ];
31
- const DEFAULT_TENANT_FIELD = "organizationId";
32
- const DEFAULT_TENANT_TYPE = "ObjectId";
33
31
  const PROGRAM_TRANSITIONS = {
34
32
  draft: ["active", "archived"],
35
33
  active: [
@@ -42,4 +40,4 @@ const PROGRAM_TRANSITIONS = {
42
40
  archived: []
43
41
  };
44
42
  //#endregion
45
- export { PROGRAM_STATUSES as a, REWARD_TYPES as c, VOUCHER_STATUSES as d, DISCOUNT_SCOPES as i, STACKING_MODES as l, DEFAULT_TENANT_TYPE as n, PROGRAM_TRANSITIONS as o, DISCOUNT_MODES as r, PROGRAM_TYPES as s, DEFAULT_TENANT_FIELD as t, TRIGGER_MODES as u };
43
+ export { PROGRAM_TYPES as a, TRIGGER_MODES as c, PROGRAM_TRANSITIONS as i, VOUCHER_STATUSES as l, DISCOUNT_SCOPES as n, REWARD_TYPES as o, PROGRAM_STATUSES as r, STACKING_MODES as s, DISCOUNT_MODES as t };