@classytic/promo 0.1.0 → 0.2.1
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.
- package/CHANGELOG.md +128 -0
- package/README.md +226 -22
- package/dist/{index-J5BC20DN.d.mts → constants-CrbSSQG5.d.mts} +1 -3
- package/dist/{constants-BVajdyL3.mjs → constants-D0Rntp2f.mjs} +1 -3
- package/dist/index.d.mts +1301 -10
- package/dist/index.mjs +2165 -46
- package/dist/schemas/index.d.mts +253 -0
- package/dist/schemas/index.mjs +134 -0
- package/package.json +23 -37
- package/dist/config-iZjn_8pp.d.mts +0 -71
- package/dist/domain/enums/index.d.mts +0 -2
- package/dist/domain/enums/index.mjs +0 -2
- package/dist/domain/index.d.mts +0 -61
- package/dist/domain/index.mjs +0 -4
- package/dist/domain-errors-BEkXvy5O.mjs +0 -80
- package/dist/event-emitter.port-DaodlJSG.d.mts +0 -8
- package/dist/event-types-CsTV1FKX.mjs +0 -25
- package/dist/events/index.d.mts +0 -2
- package/dist/events/index.mjs +0 -3
- package/dist/events-CprEWlN7.mjs +0 -25
- package/dist/index-B7lLH19a.d.mts +0 -13
- package/dist/index-C52zSBkI.d.mts +0 -96
- package/dist/index-Cu9iwy4v.d.mts +0 -99
- package/dist/index-l09KqnlE.d.mts +0 -81
- package/dist/models/index.d.mts +0 -2
- package/dist/models/index.mjs +0 -2
- package/dist/models-DdBNae7h.mjs +0 -277
- package/dist/repositories/index.d.mts +0 -2
- package/dist/repositories/index.mjs +0 -2
- package/dist/repositories-DgZIY9wD.mjs +0 -295
- package/dist/results-Ca5ZCNbN.d.mts +0 -218
- package/dist/services/index.d.mts +0 -2
- package/dist/services/index.mjs +0 -2
- package/dist/services-Cz0gHrmX.mjs +0 -815
- package/dist/types/index.d.mts +0 -3
- package/dist/types/index.mjs +0 -1
- package/dist/unit-of-work.port-DaMW8WZK.d.mts +0 -7
- 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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
8
|
+
npm install @classytic/promo \
|
|
9
|
+
@classytic/mongokit @classytic/primitives @classytic/repo-core \
|
|
10
|
+
mongoose zod
|
|
13
11
|
```
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
Peer deps (exactly what `package.json` declares):
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
|
|
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 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).
|
|
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 + persistent evaluation snapshot store 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`. The full canonical list is exported as `PromoEvents` from the package root.
|
|
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 {
|
|
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 {
|
|
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 };
|