@classytic/promo 0.2.3 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,186 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.4.0] — 2026-06-12
7
+
8
+ PACKAGE_RULES compliance pass on top of 0.3.0 (errors, rule 18/20.1/21,
9
+ P8 post-commit publish, P10, signal/retryPolicy, boot gate).
10
+
11
+ ### BREAKING
12
+
13
+ - **Error taxonomy → `HttpError`.** Every domain error now implements
14
+ `HttpError` from `@classytic/repo-core/errors`: hierarchical lowercase
15
+ `code` (`promo.voucher.exhausted`, `promo.evaluation.cart_hash_mismatch`,
16
+ …) plus an HTTP `status` (404/409/400). The old UPPER_SNAKE codes
17
+ (`VOUCHER_EXHAUSTED`, …) are GONE — hosts switching on `error.code`
18
+ strings must migrate; `instanceof` catches and messages are unchanged.
19
+ Arc serializes these via `toErrorContract()` with zero host mapping.
20
+ - **Model-registry collisions throw (rule 21).** `createPromoEngine` no
21
+ longer silently `deleteModel`s pre-registered promo models; it throws
22
+ `PromoModelCollisionError`. Hot-reload / test fixtures opt in via
23
+ `forceRecreate: true`; two engines in one process need two connections.
24
+ - **Boot capability gate.** `createPromoEngine` asserts the backend's
25
+ `capabilities.transactions` flag at creation (commit/redeem/spend/topUp
26
+ are transactional). `allowNonTransactional: true` skips the gate AND
27
+ enables mongokit's standalone-Mongo fallback on the internal
28
+ unit-of-work (dev/CI escape hatch).
29
+ - **Post-commit transport publish (§P8 phased variant).** When promo owns
30
+ the transaction, in-process `transport.publish` now fires AFTER commit
31
+ (pending-queue flush) instead of inside the transaction — subscribers
32
+ no longer receive ghost events when the transaction rolls back, and
33
+ handlers that re-read the DB observe the committed state. Outbox saves
34
+ are unchanged (in-transaction, session-bound, failures propagate).
35
+ Subscribers that relied on publishes observing in-transaction timing
36
+ must adjust. When the HOST owns the session (`ctx.session`): attach a
37
+ `DomainEvent[]` under the exported `PENDING_PROMO_EVENTS` symbol and
38
+ drain it with `flushPendingPromoEvents` after your commit for the same
39
+ guarantee; without a queue the in-scope publish is the documented best
40
+ effort (promo cannot know when a host commits — rule 33; the durable
41
+ outbox row still rolls back correctly either way).
42
+
43
+ ### Added
44
+
45
+ - **`@classytic/promo/events` subpath** — the Zod event catalog
46
+ (`promoEventDefinitions`, per-event definitions, payload types) without
47
+ pulling the engine factory (rule 18). Root re-exports unchanged.
48
+ - **Explicit collection names + `collectionPrefix` (rule 20.1).**
49
+ Physical names are pinned to the historical pluralizer output
50
+ (`promoprograms`, `promorules`, `promorewards`, `promovouchers`,
51
+ `promopendingevaluations` — zero migration) via the exported
52
+ `DEFAULT_COLLECTIONS`; `collectionPrefix: 'commerce_'` re-namespaces
53
+ without touching model names (populate unaffected).
54
+ - **`DuplicateVoucherCodeError`** (`promo.voucher.duplicate_code`, 409) —
55
+ residual E11000 on the voucher `code` unique index is classified via
56
+ mongokit's `isDuplicateKeyError` in `generateCodes` /
57
+ `generateSingleCode` instead of leaking a raw driver error.
58
+ - **`ctx.signal` / `ctx.retryPolicy`** on `PromoContext` — mongokit 3.16
59
+ reads both off the forwarded context (cancellation at every op
60
+ boundary, `withRetry` on driver round-trips); promo additionally checks
61
+ `signal` between iterations of its batch loops (`expireByDate`, the
62
+ per-program evaluation loop, the commit usage loops).
63
+ - `PENDING_PROMO_EVENTS` + `flushPendingPromoEvents` exported for hosts
64
+ coordinating post-commit publishes on host-owned sessions.
65
+
66
+ ### Changed
67
+
68
+ - **`exactOptionalPropertyTypes: true`** (P10) — optional fields across
69
+ config/input/port interfaces are now `T | undefined`; internal repo
70
+ calls go through an undefined-stripping options forwarder
71
+ (`repoOptionsFromCtx`) that preserves promo's dynamic tenant
72
+ `contextKey` forwarding.
73
+ - tsconfig `declarationMap` / `sourceMap` switched off (build hygiene —
74
+ tsdown output was already map-free; the published artifact is
75
+ unchanged).
76
+ - Suite grown 339 → 380 tests (35 → 38 files): error-contract matrix,
77
+ pending-queue/flush unit tests, post-commit publish integration tests
78
+ (ghost-event regression), model-collision / collection-name /
79
+ duplicate-code / abort-signal / boot-gate coverage.
80
+
81
+ ### Audited (no change required)
82
+
83
+ - §P8 `outbox.save` propagation — already correct since 0.2.5.
84
+ - Rule 18 catalog — all 20 events had Zod definitions + the no-drift
85
+ test; only the `./events` subpath was missing.
86
+ - "Raw `ZodError` thrown at service boundaries" (cross-package audit
87
+ claim) — did not reproduce: promo's services throw typed
88
+ `ValidationError`s; the `/schemas` subpath is host-facing only.
89
+ - §P9 dual-ID — `customIdPlugin` not used, N/A.
90
+ - No `file:` dev links; lockfile refreshed so the installed mongokit /
91
+ repo-core actually match the 0.3.0 peer floors (3.16.0 / 0.6.0 — the
92
+ lockfile still resolved 3.13.3 / 0.4.2).
93
+
94
+ ## [0.3.0] — 2026-06-11
95
+
96
+ ### BREAKING
97
+
98
+ - **Context field renamed: `actorId` → `actorRef`** — aligns promo with the
99
+ commerce-wide convention used by `@classytic/cart`, `@classytic/order`, and
100
+ `@classytic/flow` ("stable user id OR guest session id"). Clean break, no
101
+ alias. Hosts must rename the field on every `PromoContext` they build.
102
+ Affects: `PromoContext` (`Omit`s primitives' `actorId`), event meta
103
+ (`ctx.actorRef` → `meta.userId`), and event payloads — the
104
+ `ProgramLifecycle` / `Rule` / `Reward` / `VoucherGenerated` payload schemas
105
+ now carry `actorRef` instead of `actorId`. Event subscribers reading
106
+ `payload.actorId` must switch to `payload.actorRef`.
107
+
108
+ ### Added
109
+
110
+ - **Session threading — atomic checkout-chain participation.** When the host
111
+ passes a Mongoose `ClientSession` via `ctx.session`,
112
+ `evaluation.commit` / `rollback` and `voucher.redeem` / `spend` / `topUp`
113
+ now JOIN that transaction instead of opening their own — promo writes
114
+ (program usage, voucher redemptions, gift-card ledger, snapshot take)
115
+ commit or abort atomically with the host's order. The host owns
116
+ commit/abort/retry on a joined session (MongoDB has no nested
117
+ transactions). Without `ctx.session` each method owns its transaction via
118
+ mongokit's `withTransaction` as before.
119
+ - `evaluate` now threads `ctx.session` into the snapshot `store.put` and
120
+ `rollback` into `store.delete`, so evaluate→commit inside one host
121
+ transaction is read-your-writes consistent. The live session is stripped
122
+ from the persisted snapshot ctx (a driver handle is not data).
123
+ - Integration suite: `tests/integration/checkout-session-threading.test.ts`
124
+ — aborted host transaction leaves no voucher-usage/program-usage rows and
125
+ the snapshot survives for retry; evaluate+commit inside one committed
126
+ transaction works; redeem/spend join + roll back correctly.
127
+
128
+ ### Changed
129
+
130
+ - Peer ranges raised: `@classytic/mongokit` `>=3.16.0` (was `>=3.13.3`),
131
+ `@classytic/repo-core` `>=0.6.0` (was `>=0.4.2`).
132
+
133
+ ## [0.2.5] — 2026-05-08
134
+
135
+ ### Fixed
136
+
137
+ - **§P8 dispatch — `outbox.save` failures now propagate.** [src/events/dispatch.ts](src/events/dispatch.ts)
138
+ previously caught `outbox.save` errors and only logged them, swallowing
139
+ the failure. Per PACKAGE_RULES §P8 the outbox row is the durability
140
+ anchor for the transactional-outbox pattern: if the save fails, the
141
+ caller's transaction MUST roll back so the business doc and the event
142
+ row land atomically. Swallowing meant a parent doc could commit while
143
+ the event vanished, with no relay row to recover from. Fix re-throws
144
+ after logging, matching the textbook §P8 shape used in
145
+ `@classytic/loyalty` 0.2.3 and `@classytic/order`.
146
+ - `events.publish` failures continue to be swallowed (correctly, per §P8 —
147
+ the host's outbox relay re-publishes from the durable row), with logging
148
+ preserved for observability.
149
+
150
+ ### Changed
151
+
152
+ - **Peer-dep ranges tightened** — `@classytic/mongokit` `>=3.13.3`
153
+ (was `>=3.13.0`) and `@classytic/repo-core` `>=0.4.2` (was `>=0.4.0`).
154
+ 3.13.3 preserves `errorLabels` on `MongoServerError` so concurrent
155
+ transactional writes (the `claim()`-based program / voucher state
156
+ transitions) retry correctly under contention; 0.4.2 adds the typed
157
+ `FindAllOptions` shape with optional `limit?` for callers that need
158
+ bounded non-paginated reads.
159
+
160
+ ### Audited (no change required)
161
+
162
+ - Two `Model.*` raw-driver sites in
163
+ [src/repositories/pending-evaluation.repository.ts](src/repositories/pending-evaluation.repository.ts)
164
+ (`findOneAndDelete`, `deleteOne`) confirmed as the documented atomic
165
+ take-and-return semantic that mongokit's `delete()` doesn't expose.
166
+ Tenant scoping is re-implemented at the repository layer (mirrors
167
+ `multiTenantPlugin`'s injection) so the bypass keeps cross-tenant
168
+ isolation intact.
169
+ - `customIdPlugin` not used — §P9 dual-id resolution N/A.
170
+
171
+ ## [0.2.4]
172
+
173
+ ### Fixed
174
+
175
+ - **`evaluation.preview` / `evaluation.evaluate`**: submitted codes are now
176
+ trimmed (in addition to uppercased) before resolution. `' bigboss10 '`
177
+ resolves to `'BIGBOSS10'` and is reported under that canonical form in
178
+ `appliedCodes`. Duplicates after normalization are de-duped (a single code
179
+ submitted multiple times appears once).
180
+ - **Empty-cart preview**: when `items` is empty (or `subtotal <= 0`), the
181
+ engine now rejects all submitted codes with `reason: 'Cart is empty'`
182
+ instead of listing them under `appliedCodes` with `totalDiscount: 0`. Hosts
183
+ that surface `appliedCodes` directly to the UI no longer need to special-case
184
+ zero-item carts.
185
+
6
186
  ## [0.2.0]
7
187
 
8
188
  Structural rewrite on top of the 0.1.0 shape. The public API is now a
package/README.md CHANGED
@@ -14,9 +14,9 @@ Peer deps (exactly what `package.json` declares):
14
14
 
15
15
  | Peer | Range |
16
16
  |---|---|
17
- | `@classytic/mongokit` | `>=3.11.0` |
18
- | `@classytic/primitives` | `>=0.1.0` |
19
- | `@classytic/repo-core` | `>=0.2.0` |
17
+ | `@classytic/mongokit` | `>=3.16.0` |
18
+ | `@classytic/primitives` | `>=0.5.0` |
19
+ | `@classytic/repo-core` | `>=0.6.0` |
20
20
  | `mongoose` | `>=9.4.1` |
21
21
  | `zod` | `>=4.0.0` |
22
22
 
@@ -28,6 +28,7 @@ Node `>=22`. ESM only.
28
28
  |---|---|
29
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
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
+ | `@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 |
31
32
 
32
33
  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
 
@@ -42,14 +43,23 @@ import { createPromoEngine } from '@classytic/promo';
42
43
  await mongoose.connect(process.env.MONGO_URI!);
43
44
 
44
45
  // 1. Boot the engine against an existing mongoose connection.
46
+ // The factory asserts the backend supports transactions at boot
47
+ // (commit/redeem/spend/topUp are transactional) — pass
48
+ // `allowNonTransactional: true` on a standalone Mongo (dev/CI) to
49
+ // accept best-effort atomicity instead.
45
50
  const engine = createPromoEngine({
46
51
  mongoose: mongoose.connection,
47
52
  // tenant: false // disable scoping entirely
48
53
  // tenant: { tenantField: 'branchId' } // custom tenant field
54
+ // collectionPrefix: 'commerce_', // namespace physical collections
55
+ // forceRecreate: true, // hot-reload/test fixtures ONLY —
56
+ // // re-registering promo's models on a
57
+ // // connection otherwise throws
58
+ // // PromoModelCollisionError
49
59
  });
50
60
  await engine.syncIndexes();
51
61
 
52
- const ctx = { organizationId: '64b0c0c0c0c0c0c0c0c0c0c0', actorId: 'admin' };
62
+ const ctx = { organizationId: '64b0c0c0c0c0c0c0c0c0c0c0', actorRef: 'admin' };
53
63
 
54
64
  // 2. Create a program + rule + reward.
55
65
  const program = await engine.repositories.program.create(
@@ -158,6 +168,28 @@ if (result.appliedDiscounts.length > 0) {
158
168
 
159
169
  `preview(input, ctx)` runs the same algorithm with `isPreview: true` and skips the stash — safe for read-only quoting.
160
170
 
171
+ ### Atomic checkout-chain participation (`ctx.session`)
172
+
173
+ 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.
174
+
175
+ ```ts
176
+ await withTransaction(mongoose.connection, async (session) => {
177
+ const order = await orderEngine.services.order.place(input, { ...ctx, session });
178
+ await promoEngine.services.evaluation.commit(
179
+ evaluationId,
180
+ String(order._id),
181
+ { ...ctx, session }, // ← promo joins the order's transaction
182
+ { cartHash },
183
+ );
184
+ });
185
+ // If order placement (or any later step) throws, voucher usage and
186
+ // program counters roll back — no usage rows for a nonexistent order.
187
+ ```
188
+
189
+ 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.
190
+
191
+ Event publish timing differs between the two modes — see [Publish timing](#publish-timing-ghost-event-prevention): 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.
192
+
161
193
  ---
162
194
 
163
195
  ## Repository pattern
@@ -203,7 +235,35 @@ engine.events.subscribe?.(PromoEvents.VOUCHER_REDEEMED, async (evt) => {
203
235
  });
204
236
  ```
205
237
 
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.
238
+ 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`.
239
+
240
+ ### Publish timing (ghost-event prevention)
241
+
242
+ `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:
243
+
244
+ - **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.
245
+ - **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.
246
+ - **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.
247
+
248
+ ---
249
+
250
+ ## Errors
251
+
252
+ 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`.
253
+
254
+ | Error | `code` | `status` |
255
+ |---|---|---|
256
+ | `ValidationError` | `promo.validation.invalid_input` | 400 |
257
+ | `TenantIsolationError` | `promo.tenant.missing_context` | 400 |
258
+ | `ProgramNotFoundError` / `RuleNotFoundError` / `RewardNotFoundError` / `VoucherNotFoundError` / `EvaluationNotFoundError` | `promo.<entity>.not_found` | 404 |
259
+ | `InvalidTransitionError` | `promo.program.invalid_transition` | 409 |
260
+ | `ProgramUsageCapExceededError` | `promo.program.usage_cap_exceeded` | 409 |
261
+ | `VoucherExpiredError` / `VoucherExhaustedError` | `promo.voucher.expired` / `.exhausted` | 409 |
262
+ | `DuplicateRedemptionError` / `DuplicateVoucherCodeError` | `promo.voucher.duplicate_redemption` / `.duplicate_code` | 409 |
263
+ | `GiftCardExhaustedError` / `InsufficientBalanceError` | `promo.gift_card.exhausted` / `.insufficient_balance` | 409 |
264
+ | `ConcurrencyConflictError` | `promo.concurrency.write_conflict` | 409 |
265
+ | `CartHashMismatchError` | `promo.evaluation.cart_hash_mismatch` | 409 |
266
+ | `PromoModelCollisionError` | `promo.engine.model_collision` | 500 |
207
267
 
208
268
  ---
209
269
 
@@ -225,12 +285,12 @@ npm run test:unit
225
285
  npm run test:integration
226
286
  ```
227
287
 
228
- Current suite: approximately 325 tests across 33 files, split into two vitest projects:
288
+ Current suite: 380 tests across 38 files, split into two vitest projects:
229
289
 
230
290
  | Project | Directories | Files |
231
291
  |---|---|---|
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 |
292
+ | `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 |
293
+ | `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 |
234
294
 
235
295
  Integration tests spin up `mongodb-memory-server` one connection per worker (`fileParallelism: false`, single-worker pool); unit tests require no Mongo.
236
296
 
@@ -1,20 +1,29 @@
1
1
  import { defineStateMachine } from "@classytic/primitives/state-machine";
2
2
  //#region src/domain/errors/base.ts
3
3
  var PromoError = class extends Error {
4
- constructor(message) {
4
+ constructor(message, code, status) {
5
5
  super(message);
6
+ this.code = code;
7
+ this.status = status;
6
8
  this.name = this.constructor.name;
7
9
  }
8
10
  };
9
11
  //#endregion
10
12
  //#region src/domain/errors/domain-errors.ts
13
+ /**
14
+ * Promo domain errors — each implements `HttpError` (via {@link PromoError})
15
+ * with a hierarchical lowercase code (`promo.<entity>.<problem>`) and an
16
+ * HTTP status, so arc's `toErrorContract()` serializes them without any
17
+ * host-side mapping. Hosts catch by `instanceof` OR switch on `error.code`.
18
+ */
11
19
  var ValidationError = class extends PromoError {
12
- code = "VALIDATION_ERROR";
20
+ constructor(message) {
21
+ super(message, "promo.validation.invalid_input", 400);
22
+ }
13
23
  };
14
24
  var ProgramNotFoundError = class extends PromoError {
15
- code = "PROGRAM_NOT_FOUND";
16
25
  constructor(id) {
17
- super(id ? `Program '${id}' not found` : "Program not found");
26
+ super(id ? `Program '${id}' not found` : "Program not found", "promo.program.not_found", 404);
18
27
  }
19
28
  };
20
29
  /**
@@ -25,47 +34,40 @@ var ProgramNotFoundError = class extends PromoError {
25
34
  * order itself can still proceed without the discount if the host wishes.
26
35
  */
27
36
  var ProgramUsageCapExceededError = class extends PromoError {
28
- code = "PROGRAM_USAGE_CAP_EXCEEDED";
29
37
  constructor(programId, maxUsageTotal) {
30
- super(`Program '${programId}' has reached its usage cap of ${maxUsageTotal}`);
38
+ super(`Program '${programId}' has reached its usage cap of ${maxUsageTotal}`, "promo.program.usage_cap_exceeded", 409);
31
39
  this.programId = programId;
32
40
  this.maxUsageTotal = maxUsageTotal;
33
41
  }
34
42
  };
35
43
  var RuleNotFoundError = class extends PromoError {
36
- code = "RULE_NOT_FOUND";
37
44
  constructor(id) {
38
- super(id ? `Rule '${id}' not found` : "Rule not found");
45
+ super(id ? `Rule '${id}' not found` : "Rule not found", "promo.rule.not_found", 404);
39
46
  }
40
47
  };
41
48
  var RewardNotFoundError = class extends PromoError {
42
- code = "REWARD_NOT_FOUND";
43
49
  constructor(id) {
44
- super(id ? `Reward '${id}' not found` : "Reward not found");
50
+ super(id ? `Reward '${id}' not found` : "Reward not found", "promo.reward.not_found", 404);
45
51
  }
46
52
  };
47
53
  var VoucherNotFoundError = class extends PromoError {
48
- code = "VOUCHER_NOT_FOUND";
49
54
  constructor(codeOrId) {
50
- super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
55
+ super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found", "promo.voucher.not_found", 404);
51
56
  }
52
57
  };
53
58
  var InvalidTransitionError = class extends PromoError {
54
- code = "INVALID_TRANSITION";
55
59
  constructor(from, to) {
56
- super(`Cannot transition from '${from}' to '${to}'`);
60
+ super(`Cannot transition from '${from}' to '${to}'`, "promo.program.invalid_transition", 409);
57
61
  }
58
62
  };
59
63
  var VoucherExpiredError = class extends PromoError {
60
- code = "VOUCHER_EXPIRED";
61
64
  constructor(code) {
62
- super(`Voucher '${code}' has expired`);
65
+ super(`Voucher '${code}' has expired`, "promo.voucher.expired", 409);
63
66
  }
64
67
  };
65
68
  var VoucherExhaustedError = class extends PromoError {
66
- code = "VOUCHER_EXHAUSTED";
67
69
  constructor(code) {
68
- super(`Voucher '${code}' has reached its usage limit`);
70
+ super(`Voucher '${code}' has reached its usage limit`, "promo.voucher.exhausted", 409);
69
71
  }
70
72
  };
71
73
  /**
@@ -75,9 +77,8 @@ var VoucherExhaustedError = class extends PromoError {
75
77
  * later" messaging.
76
78
  */
77
79
  var GiftCardExhaustedError = class extends PromoError {
78
- code = "GIFT_CARD_EXHAUSTED";
79
80
  constructor(code) {
80
- super(`Gift card '${code}' has been fully spent`);
81
+ super(`Gift card '${code}' has been fully spent`, "promo.gift_card.exhausted", 409);
81
82
  }
82
83
  };
83
84
  /**
@@ -87,37 +88,43 @@ var GiftCardExhaustedError = class extends PromoError {
87
88
  * this to HTTP 409 + retry.
88
89
  */
89
90
  var ConcurrencyConflictError = class extends PromoError {
90
- code = "CONCURRENCY_CONFLICT";
91
- status = 409;
92
91
  constructor(resource, resourceId, cause) {
93
- super(`Concurrent modification on ${resource} '${resourceId}'`);
92
+ super(`Concurrent modification on ${resource} '${resourceId}'`, "promo.concurrency.write_conflict", 409);
94
93
  this.resource = resource;
95
94
  this.resourceId = resourceId;
96
95
  this.cause = cause;
97
96
  }
98
97
  };
99
98
  var InsufficientBalanceError = class extends PromoError {
100
- code = "INSUFFICIENT_BALANCE";
101
99
  constructor(code, available, requested) {
102
- super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
100
+ super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`, "promo.gift_card.insufficient_balance", 409);
103
101
  }
104
102
  };
105
103
  var TenantIsolationError = class extends PromoError {
106
- code = "TENANT_ISOLATION";
107
104
  constructor() {
108
- super("Tenant context is required but was not provided");
105
+ super("Tenant context is required but was not provided", "promo.tenant.missing_context", 400);
109
106
  }
110
107
  };
111
108
  var DuplicateRedemptionError = class extends PromoError {
112
- code = "DUPLICATE_REDEMPTION";
113
109
  constructor(key) {
114
- super(`Duplicate redemption detected for idempotency key '${key}'`);
110
+ super(`Duplicate redemption detected for idempotency key '${key}'`, "promo.voucher.duplicate_redemption", 409);
111
+ }
112
+ };
113
+ /**
114
+ * Residual E11000 on the voucher `code` unique index, classified via
115
+ * mongokit's `isDuplicateKeyError` and re-thrown as this typed shape
116
+ * (Concurrency House Rules: "typed collision errors are the contract;
117
+ * the unique index is the authoritative guard"). Realistic path:
118
+ * `generateSingleCode` with a caller-supplied code that already exists.
119
+ */
120
+ var DuplicateVoucherCodeError = class extends PromoError {
121
+ constructor(code) {
122
+ super(code ? `Voucher code '${code}' already exists` : "Voucher code already exists", "promo.voucher.duplicate_code", 409);
115
123
  }
116
124
  };
117
125
  var EvaluationNotFoundError = class extends PromoError {
118
- code = "EVALUATION_NOT_FOUND";
119
126
  constructor(id) {
120
- super(`Evaluation '${id}' not found or already committed`);
127
+ super(`Evaluation '${id}' not found or already committed`, "promo.evaluation.not_found", 404);
121
128
  }
122
129
  };
123
130
  /**
@@ -128,9 +135,20 @@ var EvaluationNotFoundError = class extends PromoError {
128
135
  * tries to apply the stale discount to the altered order.
129
136
  */
130
137
  var CartHashMismatchError = class extends PromoError {
131
- code = "CART_HASH_MISMATCH";
132
138
  constructor(evaluationId) {
133
- super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
139
+ super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`, "promo.evaluation.cart_hash_mismatch", 409);
140
+ }
141
+ };
142
+ /**
143
+ * Thrown by `createModels` when a Mongoose model with one of promo's names
144
+ * is already registered on the connection (rule 21). Silent `deleteModel`
145
+ * would clobber another engine's models; the throw makes the collision
146
+ * loud. Hot-reload / test fixtures opt in via `forceRecreate: true`; two
147
+ * promo engines in one process need two Mongoose connections.
148
+ */
149
+ var PromoModelCollisionError = class extends PromoError {
150
+ constructor(modelName) {
151
+ super(`Mongoose model '${modelName}' is already registered on this connection. Pass \`forceRecreate: true\` to createPromoEngine for hot-reload/test fixtures, or use a separate connection (mongoose.createConnection()) to run two promo engines.`, "promo.engine.model_collision", 500);
134
152
  }
135
153
  };
136
154
  //#endregion
@@ -189,4 +207,4 @@ const PROGRAM_MACHINE = defineStateMachine({
189
207
  errorFactory: ({ from, to }) => new InvalidTransitionError(from, to)
190
208
  });
191
209
  //#endregion
192
- export { VoucherExhaustedError as C, PromoError as E, ValidationError as S, VoucherNotFoundError as T, ProgramNotFoundError as _, PROGRAM_TYPES as a, RuleNotFoundError as b, TRIGGER_MODES as c, ConcurrencyConflictError as d, DuplicateRedemptionError as f, InvalidTransitionError as g, InsufficientBalanceError as h, PROGRAM_STATUSES as i, VOUCHER_STATUSES as l, GiftCardExhaustedError as m, DISCOUNT_SCOPES as n, REWARD_TYPES as o, EvaluationNotFoundError as p, PROGRAM_MACHINE as r, STACKING_MODES as s, DISCOUNT_MODES as t, CartHashMismatchError as u, ProgramUsageCapExceededError as v, VoucherExpiredError as w, TenantIsolationError as x, RewardNotFoundError as y };
210
+ export { TenantIsolationError as C, VoucherNotFoundError as D, VoucherExpiredError as E, PromoError as O, RuleNotFoundError as S, VoucherExhaustedError as T, InvalidTransitionError as _, PROGRAM_TYPES as a, PromoModelCollisionError as b, TRIGGER_MODES as c, ConcurrencyConflictError as d, DuplicateRedemptionError as f, InsufficientBalanceError as g, GiftCardExhaustedError as h, PROGRAM_STATUSES as i, VOUCHER_STATUSES as l, EvaluationNotFoundError as m, DISCOUNT_SCOPES as n, REWARD_TYPES as o, DuplicateVoucherCodeError as p, PROGRAM_MACHINE as r, STACKING_MODES as s, DISCOUNT_MODES as t, CartHashMismatchError as u, ProgramNotFoundError as v, ValidationError as w, RewardNotFoundError as x, ProgramUsageCapExceededError as y };