@classytic/promo 0.2.5 → 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 +127 -0
- package/README.md +68 -8
- package/dist/{constants-BB5O8zlN.mjs → constants-BE886vJk.mjs} +52 -34
- package/dist/events/promo-event-catalog.d.mts +233 -0
- package/dist/events/promo-event-catalog.mjs +2 -0
- package/dist/index.d.mts +260 -362
- package/dist/index.mjs +232 -363
- package/dist/promo-event-catalog-Dyh0xQ6w.mjs +250 -0
- package/dist/schemas/index.d.mts +4 -4
- package/dist/schemas/index.mjs +1 -1
- package/package.json +10 -6
- /package/dist/{constants-CrbSSQG5.d.mts → constants-hcMTDJml.d.mts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,133 @@
|
|
|
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
|
+
|
|
6
133
|
## [0.2.5] — 2026-05-08
|
|
7
134
|
|
|
8
135
|
### Fixed
|
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.
|
|
18
|
-
| `@classytic/primitives` | `>=0.
|
|
19
|
-
| `@classytic/repo-core` | `>=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',
|
|
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:
|
|
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/` |
|
|
233
|
-
| `integration` | `tests/integration/`, `tests/services/`, `tests/domain/`, `tests/security/` |
|
|
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
|
-
|
|
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 {
|
|
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 };
|