@classytic/promo 0.2.0 → 0.2.2

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/dist/index.mjs CHANGED
@@ -1,112 +1,10 @@
1
- import { a as PROGRAM_TYPES, c as TRIGGER_MODES, i as PROGRAM_TRANSITIONS, l as VOUCHER_STATUSES, n as DISCOUNT_SCOPES, o as REWARD_TYPES, r as PROGRAM_STATUSES, s as STACKING_MODES, t as DISCOUNT_MODES } from "./constants-D0Rntp2f.mjs";
1
+ import { C as VoucherExhaustedError, E as PromoError, S as ValidationError, T as VoucherNotFoundError, _ as ProgramNotFoundError, a as PROGRAM_TYPES, b as RuleNotFoundError, c as TRIGGER_MODES, d as ConcurrencyConflictError, f as DuplicateRedemptionError, g as InvalidTransitionError, h as InsufficientBalanceError, i as PROGRAM_STATUSES, l as VOUCHER_STATUSES, m as GiftCardExhaustedError, n as DISCOUNT_SCOPES, o as REWARD_TYPES, p as EvaluationNotFoundError, r as PROGRAM_MACHINE, s as STACKING_MODES, t as DISCOUNT_MODES, u as CartHashMismatchError, v as ProgramUsageCapExceededError, w as VoucherExpiredError, x as TenantIsolationError, y as RewardNotFoundError } from "./constants-BB5O8zlN.mjs";
2
+ import { Repository, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
2
3
  import { resolveTenantConfig } from "@classytic/primitives/tenant";
3
4
  import { createEvent, matchEventPattern } from "@classytic/primitives/events";
4
- import mongoose from "mongoose";
5
- import { Repository, multiTenantPlugin } from "@classytic/mongokit";
5
+ import mongoose, { Schema } from "mongoose";
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { z } from "zod";
8
- //#region src/domain/errors/base.ts
9
- var PromoError = class extends Error {
10
- constructor(message) {
11
- super(message);
12
- this.name = this.constructor.name;
13
- }
14
- };
15
- //#endregion
16
- //#region src/domain/errors/domain-errors.ts
17
- var ValidationError = class extends PromoError {
18
- code = "VALIDATION_ERROR";
19
- };
20
- var ProgramNotFoundError = class extends PromoError {
21
- code = "PROGRAM_NOT_FOUND";
22
- constructor(id) {
23
- super(id ? `Program '${id}' not found` : "Program not found");
24
- }
25
- };
26
- var VoucherNotFoundError = class extends PromoError {
27
- code = "VOUCHER_NOT_FOUND";
28
- constructor(codeOrId) {
29
- super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
30
- }
31
- };
32
- var InvalidTransitionError = class extends PromoError {
33
- code = "INVALID_TRANSITION";
34
- constructor(from, to) {
35
- super(`Cannot transition from '${from}' to '${to}'`);
36
- }
37
- };
38
- var VoucherExpiredError = class extends PromoError {
39
- code = "VOUCHER_EXPIRED";
40
- constructor(code) {
41
- super(`Voucher '${code}' has expired`);
42
- }
43
- };
44
- var VoucherExhaustedError = class extends PromoError {
45
- code = "VOUCHER_EXHAUSTED";
46
- constructor(code) {
47
- super(`Voucher '${code}' has reached its usage limit`);
48
- }
49
- };
50
- /**
51
- * Thrown when a gift card's balance has been fully spent and its status
52
- * flipped to `'used'`. Distinct from `VoucherExhaustedError` (usage-limit
53
- * exhaustion on a discount voucher) so hosts can show "top up" vs "retry
54
- * later" messaging.
55
- */
56
- var GiftCardExhaustedError = class extends PromoError {
57
- code = "GIFT_CARD_EXHAUSTED";
58
- constructor(code) {
59
- super(`Gift card '${code}' has been fully spent`);
60
- }
61
- };
62
- /**
63
- * Thrown when a MongoDB WriteConflict surfaces under voucher-spend
64
- * contention. Losers see a stable domain shape rather than the raw
65
- * `"Write conflict during plan execution"` string. Hosts can translate
66
- * this to HTTP 409 + retry.
67
- */
68
- var ConcurrencyConflictError = class extends PromoError {
69
- code = "CONCURRENCY_CONFLICT";
70
- status = 409;
71
- constructor(resource, resourceId, cause) {
72
- super(`Concurrent modification on ${resource} '${resourceId}'`);
73
- this.resource = resource;
74
- this.resourceId = resourceId;
75
- this.cause = cause;
76
- }
77
- };
78
- var InsufficientBalanceError = class extends PromoError {
79
- code = "INSUFFICIENT_BALANCE";
80
- constructor(code, available, requested) {
81
- super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
82
- }
83
- };
84
- var DuplicateRedemptionError = class extends PromoError {
85
- code = "DUPLICATE_REDEMPTION";
86
- constructor(key) {
87
- super(`Duplicate redemption detected for idempotency key '${key}'`);
88
- }
89
- };
90
- var EvaluationNotFoundError = class extends PromoError {
91
- code = "EVALUATION_NOT_FOUND";
92
- constructor(id) {
93
- super(`Evaluation '${id}' not found or already committed`);
94
- }
95
- };
96
- /**
97
- * Thrown by `EvaluationService.commit` when the cart hash provided by the
98
- * caller does not match the cart hash computed at evaluation time. Guards
99
- * against cart-tampering attacks where a user previews a large discount on
100
- * a heavy cart, mutates the cart to something cheaper before committing, and
101
- * tries to apply the stale discount to the altered order.
102
- */
103
- var CartHashMismatchError = class extends PromoError {
104
- code = "CART_HASH_MISMATCH";
105
- constructor(evaluationId) {
106
- super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
107
- }
108
- };
109
- //#endregion
110
8
  //#region src/events/in-process-bus.ts
111
9
  var InProcessPromoBus = class {
112
10
  name = "in-process-promo";
@@ -169,10 +67,81 @@ function applyUserIndexes(schema, indexes, tenant) {
169
67
  }
170
68
  }
171
69
  //#endregion
70
+ //#region src/models/schemas/pending-evaluation.schema.ts
71
+ /**
72
+ * Persisted snapshot of an `evaluate()` outcome, awaiting a follow-up
73
+ * `commit()` or `rollback()`.
74
+ *
75
+ * Why a Mongo collection (not a process-local Map): pending evaluations
76
+ * MUST survive process restart, horizontal scaling, serverless cold
77
+ * starts, and worker handoff. Stale snapshots auto-clean via the TTL
78
+ * index — same pattern cart uses for guest drafts (see
79
+ * cart/src/models/draft.model.ts:131-135).
80
+ *
81
+ * Stored fields are intentionally minimal — only what `commit()` and
82
+ * `rollback()` need:
83
+ * - the materialised result (returned to caller for telemetry)
84
+ * - per-program / per-voucher usages (incremented atomically at commit)
85
+ * - cartHash (anti-tamper guard between evaluate and commit)
86
+ * - expiresAt (TTL drives auto-cleanup; engine never relies on it for
87
+ * correctness — the atomic `findOneAndDelete` in `take()` is the
88
+ * authoritative single-commit guard)
89
+ */
90
+ function createPendingEvaluationSchema() {
91
+ return new Schema({
92
+ evaluationId: {
93
+ type: String,
94
+ required: true,
95
+ unique: true,
96
+ index: true
97
+ },
98
+ result: {
99
+ type: Schema.Types.Mixed,
100
+ required: true
101
+ },
102
+ ctx: {
103
+ type: Schema.Types.Mixed,
104
+ required: true
105
+ },
106
+ customerId: {
107
+ type: String,
108
+ default: null
109
+ },
110
+ programUsages: {
111
+ type: [Schema.Types.Mixed],
112
+ default: []
113
+ },
114
+ voucherUsages: {
115
+ type: [Schema.Types.Mixed],
116
+ default: []
117
+ },
118
+ cartHash: {
119
+ type: String,
120
+ required: true,
121
+ index: true
122
+ },
123
+ expiresAt: {
124
+ type: Date,
125
+ required: true
126
+ }
127
+ }, {
128
+ timestamps: true,
129
+ minimize: false
130
+ });
131
+ }
132
+ /**
133
+ * Wire the TTL index. Mongo evaluates `expireAfterSeconds: 0` against the
134
+ * `expiresAt` field's value — when `expiresAt < now`, the doc is purged
135
+ * by the background TTL monitor (~60s granularity).
136
+ */
137
+ function applyPendingEvaluationIndexes(schema) {
138
+ schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
139
+ }
140
+ //#endregion
172
141
  //#region src/models/schemas/program.schema.ts
173
- const { Schema: Schema$3 } = mongoose;
142
+ const { Schema: Schema$4 } = mongoose;
174
143
  function createProgramSchema() {
175
- const schema = new Schema$3({
144
+ const schema = new Schema$4({
176
145
  name: {
177
146
  type: String,
178
147
  required: true
@@ -217,7 +186,7 @@ function createProgramSchema() {
217
186
  of: Number,
218
187
  default: () => /* @__PURE__ */ new Map()
219
188
  },
220
- metadata: { type: Schema$3.Types.Mixed }
189
+ metadata: { type: Schema$4.Types.Mixed }
221
190
  }, { timestamps: true });
222
191
  schema.index({
223
192
  status: 1,
@@ -238,14 +207,14 @@ function createProgramSchema() {
238
207
  }
239
208
  //#endregion
240
209
  //#region src/models/schemas/reward.schema.ts
241
- const { Schema: Schema$2 } = mongoose;
210
+ const { Schema: Schema$3 } = mongoose;
242
211
  function createRewardSchema() {
243
- const schema = new Schema$2({
212
+ const schema = new Schema$3({
244
213
  programId: {
245
- type: Schema$2.Types.ObjectId,
214
+ type: Schema$3.Types.ObjectId,
246
215
  required: true
247
216
  },
248
- ruleId: { type: Schema$2.Types.ObjectId },
217
+ ruleId: { type: Schema$3.Types.ObjectId },
249
218
  rewardType: {
250
219
  type: String,
251
220
  enum: REWARD_TYPES,
@@ -270,7 +239,7 @@ function createRewardSchema() {
270
239
  default: 1
271
240
  },
272
241
  giftCardAmount: { type: Number },
273
- metadata: { type: Schema$2.Types.Mixed }
242
+ metadata: { type: Schema$3.Types.Mixed }
274
243
  }, { timestamps: true });
275
244
  schema.index({ programId: 1 });
276
245
  schema.index({ ruleId: 1 }, { sparse: true });
@@ -278,11 +247,11 @@ function createRewardSchema() {
278
247
  }
279
248
  //#endregion
280
249
  //#region src/models/schemas/rule.schema.ts
281
- const { Schema: Schema$1 } = mongoose;
250
+ const { Schema: Schema$2 } = mongoose;
282
251
  function createRuleSchema() {
283
- const schema = new Schema$1({
252
+ const schema = new Schema$2({
284
253
  programId: {
285
- type: Schema$1.Types.ObjectId,
254
+ type: Schema$2.Types.ObjectId,
286
255
  required: true
287
256
  },
288
257
  name: { type: String },
@@ -305,7 +274,7 @@ function createRuleSchema() {
305
274
  },
306
275
  startsAt: { type: Date },
307
276
  endsAt: { type: Date },
308
- metadata: { type: Schema$1.Types.Mixed }
277
+ metadata: { type: Schema$2.Types.Mixed }
309
278
  }, { timestamps: true });
310
279
  schema.index({ programId: 1 });
311
280
  schema.index({ code: 1 }, { sparse: true });
@@ -313,11 +282,11 @@ function createRuleSchema() {
313
282
  }
314
283
  //#endregion
315
284
  //#region src/models/schemas/voucher.schema.ts
316
- const { Schema } = mongoose;
285
+ const { Schema: Schema$1 } = mongoose;
317
286
  function createVoucherSchema() {
318
- const schema = new Schema({
287
+ const schema = new Schema$1({
319
288
  programId: {
320
- type: Schema.Types.ObjectId,
289
+ type: Schema$1.Types.ObjectId,
321
290
  required: true,
322
291
  index: true
323
292
  },
@@ -354,7 +323,8 @@ function createVoucherSchema() {
354
323
  type: Date,
355
324
  default: Date.now
356
325
  },
357
- idempotencyKey: { type: String }
326
+ idempotencyKey: { type: String },
327
+ organizationId: { type: String }
358
328
  }],
359
329
  expiresAt: { type: Date },
360
330
  redemptions: [{
@@ -371,9 +341,10 @@ function createVoucherSchema() {
371
341
  type: Date,
372
342
  default: Date.now
373
343
  },
374
- idempotencyKey: { type: String }
344
+ idempotencyKey: { type: String },
345
+ organizationId: { type: String }
375
346
  }],
376
- metadata: { type: Schema.Types.Mixed }
347
+ metadata: { type: Schema$1.Types.Mixed }
377
348
  }, { timestamps: true });
378
349
  schema.index({ code: 1 }, { unique: true });
379
350
  schema.index({
@@ -393,7 +364,8 @@ const MODEL_NAMES = [
393
364
  "PromoProgram",
394
365
  "PromoRule",
395
366
  "PromoReward",
396
- "PromoVoucher"
367
+ "PromoVoucher",
368
+ "PromoPendingEvaluation"
397
369
  ];
398
370
  function applyAutoIndex(models, autoIndex) {
399
371
  if (autoIndex === void 0) return;
@@ -413,10 +385,13 @@ function createModels(connection, tenant, indexes, autoIndex) {
413
385
  const ruleSchema = createRuleSchema();
414
386
  const rewardSchema = createRewardSchema();
415
387
  const voucherSchema = createVoucherSchema();
388
+ const pendingEvaluationSchema = createPendingEvaluationSchema();
416
389
  injectTenantField(programSchema, tenant);
417
390
  injectTenantField(ruleSchema, tenant);
418
391
  injectTenantField(rewardSchema, tenant);
419
392
  injectTenantField(voucherSchema, tenant);
393
+ injectTenantField(pendingEvaluationSchema, tenant);
394
+ applyPendingEvaluationIndexes(pendingEvaluationSchema);
420
395
  if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
421
396
  if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
422
397
  if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
@@ -425,12 +400,94 @@ function createModels(connection, tenant, indexes, autoIndex) {
425
400
  Program: connection.model("PromoProgram", programSchema),
426
401
  Rule: connection.model("PromoRule", ruleSchema),
427
402
  Reward: connection.model("PromoReward", rewardSchema),
428
- Voucher: connection.model("PromoVoucher", voucherSchema)
403
+ Voucher: connection.model("PromoVoucher", voucherSchema),
404
+ PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema)
429
405
  };
430
406
  applyAutoIndex(result, autoIndex);
431
407
  return result;
432
408
  }
433
409
  //#endregion
410
+ //#region src/repositories/pending-evaluation.repository.ts
411
+ /**
412
+ * Pending-evaluation repository. Extends mongokit's `Repository<TDoc>`
413
+ * directly per package rules (no service wrapper, no aliased verbs).
414
+ * Adds one custom domain method: `takeByEvaluationId` — atomic
415
+ * read-and-delete via raw `Model.findOneAndDelete`.
416
+ *
417
+ * **Why raw `findOneAndDelete` (escape from mongokit's `delete()`)**:
418
+ * mongokit's `Repository.delete()` returns `{success, message}` only —
419
+ * it doesn't surface the deleted document. `take` semantics require
420
+ * "atomically remove AND return", which is the canonical defence
421
+ * against double-commit on the same evaluationId at the storage layer
422
+ * (one caller wins the document, the other gets `null`). This is the
423
+ * narrow exception PACKAGE_RULES.md / order/CLAUDE.md sanction:
424
+ * *"Raw findOneAndUpdate/findOneAndDelete is allowed ONLY for atomic
425
+ * state-machine transitions — flag each one with a comment."*
426
+ */
427
+ var PendingEvaluationRepository = class extends Repository {
428
+ /**
429
+ * The repository owns its tenant config so its raw-driver methods
430
+ * (`findOneAndDelete`, `deleteOne`) can apply the SAME scoping rule
431
+ * the mongokit hook pipeline would apply on standard methods —
432
+ * specifically using `tenant.tenantField` (host-configurable as
433
+ * `organizationId`, `branchId`, `tenantId`, etc.) NOT a hardcoded
434
+ * `organizationId`. Without this, deployments that configure custom
435
+ * tenant fields would silently lose isolation on the cache layer.
436
+ */
437
+ tenant;
438
+ constructor(model, plugins = [], tenant) {
439
+ super(model, plugins);
440
+ this.tenant = tenant;
441
+ }
442
+ /** The host-configured tenant field name (or `undefined` if single-tenant). */
443
+ get tenantField() {
444
+ return this.tenant?.enabled ? this.tenant.tenantField : void 0;
445
+ }
446
+ /**
447
+ * Atomic read-and-delete by evaluationId. Two concurrent commit calls
448
+ * on the same id race here at the database layer — the winner gets
449
+ * the document, the loser gets `null` and the calling service throws
450
+ * `EvaluationNotFoundError`. No way both succeed.
451
+ *
452
+ * Honours `ctx.session` so the operation joins the caller's
453
+ * transaction. If the transaction aborts (transient DB error, cap
454
+ * exceeded, etc.) the delete rolls back and the snapshot stays in
455
+ * the store — letting the caller retry without re-evaluation.
456
+ *
457
+ * Tenant scoping uses the configured `tenant.tenantField` (NOT a
458
+ * hardcoded `organizationId`) so hosts on `branchId`/`tenantId`/etc.
459
+ * keep cross-tenant isolation on the cache layer too.
460
+ */
461
+ async takeByEvaluationId(evaluationId, ctx) {
462
+ const filter = this.scopedFilter({ evaluationId }, ctx);
463
+ const session = ctx?.session ?? null;
464
+ return await this.Model.findOneAndDelete(filter).session(session).lean();
465
+ }
466
+ /**
467
+ * Idempotent delete. Returns whether anything was actually removed,
468
+ * so callers can distinguish "we cleaned it" from "already gone".
469
+ */
470
+ async deleteByEvaluationId(evaluationId, ctx) {
471
+ const filter = this.scopedFilter({ evaluationId }, ctx);
472
+ const session = ctx?.session ?? null;
473
+ return (await this.Model.deleteOne(filter).session(session)).deletedCount > 0;
474
+ }
475
+ /**
476
+ * Build a query filter with the configured tenant scope appended,
477
+ * mirroring what mongokit's `multiTenantPlugin` injects on standard
478
+ * Repository methods. We re-implement here because raw driver calls
479
+ * (`findOneAndDelete`, `deleteOne`) bypass the plugin pipeline.
480
+ */
481
+ scopedFilter(base, ctx) {
482
+ const field = this.tenantField;
483
+ if (!field || ctx?.tenantValue === void 0) return base;
484
+ return {
485
+ ...base,
486
+ [field]: ctx.tenantValue
487
+ };
488
+ }
489
+ };
490
+ //#endregion
434
491
  //#region src/events/dispatch.ts
435
492
  /**
436
493
  * Canonical P8 shape — save to outbox first (with `ctx.session` when
@@ -509,7 +566,7 @@ var ProgramRepository = class extends Repository {
509
566
  ...ctx
510
567
  });
511
568
  if (!program) throw new ProgramNotFoundError(id);
512
- if (!PROGRAM_TRANSITIONS[program.status]?.includes(targetStatus)) throw new InvalidTransitionError(program.status, targetStatus);
569
+ PROGRAM_MACHINE.assertTransition(String(program._id), program.status, targetStatus);
513
570
  const updated = await this.update(id, { status: targetStatus }, {
514
571
  throwOnNotFound: true,
515
572
  lean: true,
@@ -538,6 +595,34 @@ var ProgramRepository = class extends Repository {
538
595
  if (!updated) throw new ProgramNotFoundError(id);
539
596
  return updated;
540
597
  }
598
+ /**
599
+ * Atomic compare-and-set increment: succeeds only if the program either
600
+ * has no cap (`maxUsageTotal == null`) OR `usedCount < maxUsageTotal`.
601
+ * If the cap is already saturated, returns `null` so the caller can
602
+ * decide whether to throw (commit-time enforcement) or skip silently
603
+ * (best-effort eligibility check).
604
+ *
605
+ * Industry-standard primitive for "promo with finite supply" — without
606
+ * this, two evaluations both see the program as available and both
607
+ * commit, leading to oversell. The atomic filter on the same write
608
+ * eliminates the race entirely; concurrent losers see a `null` return
609
+ * and can downgrade their commit (apply order without the discount,
610
+ * surface "promo no longer available" to the user, etc.).
611
+ *
612
+ * Routes through mongokit's `update` so tenant scoping + hooks fire.
613
+ */
614
+ async tryIncrementUsage(id, ctx) {
615
+ return await this.update(id, { $inc: { usedCount: 1 } }, {
616
+ query: { $or: [
617
+ { maxUsageTotal: { $exists: false } },
618
+ { maxUsageTotal: null },
619
+ { $expr: { $lt: ["$usedCount", "$maxUsageTotal"] } }
620
+ ] },
621
+ throwOnNotFound: false,
622
+ lean: true,
623
+ ...ctx
624
+ }) ?? null;
625
+ }
541
626
  async decrementUsage(id, ctx) {
542
627
  const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
543
628
  throwOnNotFound: true,
@@ -607,20 +692,27 @@ var RuleRepository = class extends Repository {
607
692
  var VoucherRepository = class extends Repository {
608
693
  dispatchDeps;
609
694
  tenantField;
610
- constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId") {
695
+ tenantEnabled;
696
+ constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId", tenantEnabled = true) {
611
697
  super(model, plugins);
612
698
  this.dispatchDeps = dispatchDeps;
613
699
  this.tenantField = tenantField;
700
+ this.tenantEnabled = tenantEnabled;
614
701
  }
615
702
  /**
616
703
  * Copy the tenant id from `ctx` onto the write payload so the doc persists
617
704
  * with the correct `organizationId` even when the host has opted OUT of the
618
- * auto-wired `multiTenantPlugin` (e.g. arc hosts that scope at the
619
- * framework layer — see `@classytic/order` CLAUDE.md: "Child repos set
620
- * `organizationId` explicitly on the doc"). No-op when `ctx` omits the
621
- * tenant id or the payload already carries it.
705
+ * auto-wired `multiTenantPlugin` but still scopes at its framework layer
706
+ * (e.g. arc's preset + `BaseController` — see `@classytic/order` CLAUDE.md:
707
+ * "Child repos set `organizationId` explicitly on the doc").
708
+ *
709
+ * Skipped entirely when the engine was configured with `tenant: false` —
710
+ * the host's intent is company-wide rows (no `organizationId` on disk),
711
+ * and injecting it from `ctx` would silently re-scope writes per branch
712
+ * while reads still look at the unscoped collection.
622
713
  */
623
714
  _injectTenant(data, ctx) {
715
+ if (!this.tenantEnabled) return data;
624
716
  const tenantValue = ctx?.[this.tenantField];
625
717
  if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
626
718
  return data;
@@ -722,11 +814,17 @@ var VoucherRepository = class extends Repository {
722
814
  * (e.g. arc's preset + `BaseController`). Without this, a host that
723
815
  * runs the plugin off would leak vouchers across branches at
724
816
  * validate/redeem/spend/topUp call sites.
817
+ *
818
+ * When the engine is configured with `tenant: false`, the filter is
819
+ * code-only — vouchers are company-wide and the docs carry no
820
+ * `organizationId`, so injecting one would always miss.
725
821
  */
726
822
  async getByCode(code, ctx) {
727
823
  const filter = { code: code.toUpperCase() };
728
- const tenantValue = ctx?.[this.tenantField];
729
- if (tenantValue != null) filter[this.tenantField] = tenantValue;
824
+ if (this.tenantEnabled) {
825
+ const tenantValue = ctx?.[this.tenantField];
826
+ if (tenantValue != null) filter[this.tenantField] = tenantValue;
827
+ }
730
828
  return this.getByQuery(filter, {
731
829
  throwOnNotFound: false,
732
830
  lean: true,
@@ -758,12 +856,148 @@ function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
758
856
  program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
759
857
  rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
760
858
  reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
761
- voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField)
859
+ voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField, tenant?.enabled ?? false),
860
+ pendingEvaluation: new PendingEvaluationRepository(models.PendingEvaluation, [...tenantPlugins, ...plugins?.pendingEvaluation ?? []], tenant)
762
861
  };
763
862
  }
764
863
  //#endregion
864
+ //#region src/adapters/mongo-evaluation-store.ts
865
+ /**
866
+ * Default Mongo-backed implementation of {@link EvaluationStore}. Translates
867
+ * between the domain snapshot shape and the persistence document shape,
868
+ * forwards atomic `take`/`delete` semantics to the repository, and
869
+ * applies the engine's configured tenant field on writes (the repo
870
+ * applies it on reads).
871
+ *
872
+ * Hosts that prefer a different backend (Redis, DynamoDB, in-memory for
873
+ * tests) can implement `EvaluationStore` directly and pass it via engine
874
+ * config — this class isn't load-bearing.
875
+ */
876
+ var MongoEvaluationStore = class {
877
+ constructor(repo) {
878
+ this.repo = repo;
879
+ }
880
+ async put(id, snapshot, ttlSeconds, ctx) {
881
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
882
+ const session = ctx?.session;
883
+ const tenantField = this.repo.tenantField;
884
+ const tenantValue = ctx?.tenantValue ?? snapshot.ctx.organizationId;
885
+ const filter = { evaluationId: id };
886
+ if (tenantField && tenantValue !== void 0) filter[tenantField] = tenantValue;
887
+ const update = {
888
+ evaluationId: id,
889
+ result: snapshot.result,
890
+ ctx: snapshot.ctx,
891
+ customerId: snapshot.customerId ?? null,
892
+ programUsages: snapshot.programUsages,
893
+ voucherUsages: snapshot.voucherUsages,
894
+ cartHash: snapshot.cartHash,
895
+ expiresAt
896
+ };
897
+ if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
898
+ await this.repo.Model.updateOne(filter, { $set: update }, {
899
+ upsert: true,
900
+ session
901
+ });
902
+ }
903
+ async take(id, ctx) {
904
+ const doc = await this.repo.takeByEvaluationId(id, {
905
+ tenantValue: ctx?.tenantValue,
906
+ session: ctx?.session
907
+ });
908
+ if (!doc) return null;
909
+ return {
910
+ result: doc.result,
911
+ ctx: doc.ctx,
912
+ customerId: doc.customerId ?? void 0,
913
+ programUsages: doc.programUsages,
914
+ voucherUsages: doc.voucherUsages,
915
+ cartHash: doc.cartHash,
916
+ createdAt: doc.createdAt
917
+ };
918
+ }
919
+ async delete(id, ctx) {
920
+ await this.repo.deleteByEvaluationId(id, {
921
+ tenantValue: ctx?.tenantValue,
922
+ session: ctx?.session
923
+ });
924
+ }
925
+ };
926
+ /**
927
+ * In-memory fallback. Useful for unit tests, single-process dev servers,
928
+ * and hosts that consciously opt out of persistence (knowing they lose
929
+ * pending evaluations on restart). NOT recommended for production
930
+ * topologies — see EvaluationStore docblock for why.
931
+ *
932
+ * Tenant scoping uses `ctx.tenantValue` as part of the in-memory key
933
+ * (the field-name layer doesn't matter here — we just namespace by
934
+ * value). No transaction semantics (in-memory has nothing to roll
935
+ * back), no TTL refinement beyond initial expiry check.
936
+ */
937
+ var InMemoryEvaluationStore = class {
938
+ map = /* @__PURE__ */ new Map();
939
+ async put(id, snapshot, ttlSeconds, ctx) {
940
+ this.map.set(this.key(id, ctx), {
941
+ snapshot,
942
+ expiresAt: Date.now() + ttlSeconds * 1e3
943
+ });
944
+ }
945
+ async take(id, ctx) {
946
+ const k = this.key(id, ctx);
947
+ const entry = this.map.get(k);
948
+ if (!entry) return null;
949
+ this.map.delete(k);
950
+ if (entry.expiresAt < Date.now()) return null;
951
+ return entry.snapshot;
952
+ }
953
+ async delete(id, ctx) {
954
+ this.map.delete(this.key(id, ctx));
955
+ }
956
+ key(id, ctx) {
957
+ return ctx?.tenantValue ? `${ctx.tenantValue}:${id}` : id;
958
+ }
959
+ };
960
+ //#endregion
961
+ //#region src/utils/is-write-conflict.ts
962
+ /**
963
+ * Detect MongoDB's transient WriteConflict error across the shapes it
964
+ * arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
965
+ * rethrows). Centralised so every contended write site (gift card spend,
966
+ * gift card top-up, evaluation commit) can opt into uniform mapping
967
+ * to the same typed `ConcurrencyConflictError` / `*UsageCapExceededError`
968
+ * shape — hosts catch one type, decide retry-or-409 once.
969
+ *
970
+ * Why detection is a layered match: under heavy contention MongoDB
971
+ * surfaces the same physical race in different ways depending on which
972
+ * layer caught it first (driver vs mongoose vs mongokit), and each
973
+ * wrapper preserves a slightly different subset of the original error
974
+ * fields. Matching against ALL of them avoids false negatives that
975
+ * would otherwise let the raw error escape to the host with no useful
976
+ * type information.
977
+ */
978
+ function isWriteConflict(err) {
979
+ if (typeof err !== "object" || err === null) return false;
980
+ const e = err;
981
+ if (e.code === 112 || e.codeName === "WriteConflict") return true;
982
+ if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
983
+ if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
984
+ if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
985
+ if (e.cause !== void 0) return isWriteConflict(e.cause);
986
+ return false;
987
+ }
988
+ //#endregion
765
989
  //#region src/services/evaluation.service.ts
766
990
  /**
991
+ * Default TTL for pending evaluation snapshots in the store. 30 minutes
992
+ * comfortably exceeds any realistic evaluate→commit window (typical
993
+ * checkout: seconds to a few minutes; long-tail: ~10 min) without
994
+ * hoarding abandoned evaluations forever. The Mongo TTL index purges
995
+ * expired entries; the engine itself never relies on the TTL for
996
+ * correctness — `store.take()` is atomic and returns `null` when an
997
+ * id was already consumed or never existed.
998
+ */
999
+ const DEFAULT_PENDING_EVALUATION_TTL_SECONDS = 1800;
1000
+ /**
767
1001
  * Deterministic, collision-resistant hash of the evaluated cart. The hash
768
1002
  * covers everything the evaluation algorithm depends on: normalized line
769
1003
  * items (sorted), subtotal, applied codes, and customer identity. Two
@@ -797,8 +1031,7 @@ function computeCartHash(input) {
797
1031
  return createHash("sha256").update(canonical).digest("hex");
798
1032
  }
799
1033
  var EvaluationService = class {
800
- pendingEvaluations = /* @__PURE__ */ new Map();
801
- constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config) {
1034
+ constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config, store) {
802
1035
  this.programRepo = programRepo;
803
1036
  this.ruleRepo = ruleRepo;
804
1037
  this.rewardRepo = rewardRepo;
@@ -806,6 +1039,7 @@ var EvaluationService = class {
806
1039
  this.unitOfWork = unitOfWork;
807
1040
  this.dispatchDeps = dispatchDeps;
808
1041
  this.config = config;
1042
+ this.store = store;
809
1043
  }
810
1044
  async evaluate(input, ctx) {
811
1045
  return this.doEvaluate(input, ctx, false);
@@ -814,50 +1048,80 @@ var EvaluationService = class {
814
1048
  return this.doEvaluate(input, ctx, true);
815
1049
  }
816
1050
  async commit(evaluationId, orderId, ctx, options = {}) {
817
- const stored = this.pendingEvaluations.get(evaluationId);
818
- if (!stored) throw new EvaluationNotFoundError(evaluationId);
819
- if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
820
- return this.unitOfWork.withTransaction(async (session) => {
821
- for (const usage of stored.programUsages) {
822
- await this.programRepo.incrementUsage(usage.programId, {
823
- ...ctx,
824
- session
825
- });
826
- if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
827
- ...ctx,
1051
+ let snapshotForCapMapping = null;
1052
+ try {
1053
+ return await this.unitOfWork.withTransaction(async (session) => {
1054
+ const stored = await this.store.take(evaluationId, {
1055
+ tenantValue: ctx.organizationId,
828
1056
  session
829
1057
  });
1058
+ if (!stored) throw new EvaluationNotFoundError(evaluationId);
1059
+ if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
1060
+ snapshotForCapMapping = stored;
1061
+ return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
1062
+ });
1063
+ } catch (err) {
1064
+ if (isWriteConflict(err) && snapshotForCapMapping) {
1065
+ const firstUsage = snapshotForCapMapping.programUsages[0];
1066
+ if (firstUsage) {
1067
+ const program = await this.programRepo.getById(firstUsage.programId, {
1068
+ ...ctx,
1069
+ lean: true,
1070
+ throwOnNotFound: false
1071
+ });
1072
+ throw new ProgramUsageCapExceededError(firstUsage.programId, program?.maxUsageTotal ?? 0);
1073
+ }
830
1074
  }
831
- for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
832
- orderId,
833
- discountAmount: usage.discountAmount,
834
- redeemedAt: /* @__PURE__ */ new Date()
835
- }, {
1075
+ throw err;
1076
+ }
1077
+ }
1078
+ async commitInTransaction(stored, evaluationId, orderId, session, ctx) {
1079
+ for (const usage of stored.programUsages) {
1080
+ if (!await this.programRepo.tryIncrementUsage(usage.programId, {
836
1081
  ...ctx,
837
1082
  session
838
- });
839
- this.pendingEvaluations.delete(evaluationId);
840
- const commitResult = {
841
- evaluationId,
842
- orderId,
843
- totalDiscount: stored.result.totalDiscount,
844
- programsCommitted: stored.programUsages.length,
845
- vouchersUsed: stored.voucherUsages.length
846
- };
847
- await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
848
- evaluationId,
849
- orderId,
850
- totalDiscount: stored.result.totalDiscount
851
- }, ctx), {
1083
+ })) {
1084
+ const program = await this.programRepo.getById(usage.programId, {
1085
+ ...ctx,
1086
+ session,
1087
+ lean: true,
1088
+ throwOnNotFound: false
1089
+ });
1090
+ throw new ProgramUsageCapExceededError(usage.programId, program?.maxUsageTotal ?? 0);
1091
+ }
1092
+ if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
852
1093
  ...ctx,
853
1094
  session
854
1095
  });
855
- return commitResult;
1096
+ }
1097
+ for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
1098
+ orderId,
1099
+ discountAmount: usage.discountAmount,
1100
+ redeemedAt: /* @__PURE__ */ new Date(),
1101
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1102
+ }, {
1103
+ ...ctx,
1104
+ session
856
1105
  });
1106
+ const commitResult = {
1107
+ evaluationId,
1108
+ orderId,
1109
+ totalDiscount: stored.result.totalDiscount,
1110
+ programsCommitted: stored.programUsages.length,
1111
+ vouchersUsed: stored.voucherUsages.length
1112
+ };
1113
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
1114
+ evaluationId,
1115
+ orderId,
1116
+ totalDiscount: stored.result.totalDiscount
1117
+ }, ctx), {
1118
+ ...ctx,
1119
+ session
1120
+ });
1121
+ return commitResult;
857
1122
  }
858
1123
  async rollback(evaluationId, ctx) {
859
- if (!this.pendingEvaluations.get(evaluationId)) return;
860
- this.pendingEvaluations.delete(evaluationId);
1124
+ await this.store.delete(evaluationId, { tenantValue: ctx.organizationId });
861
1125
  await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
862
1126
  }
863
1127
  async doEvaluate(input, ctx, isPreview) {
@@ -970,15 +1234,18 @@ var EvaluationService = class {
970
1234
  isPreview,
971
1235
  programsApplied
972
1236
  };
973
- if (!isPreview) this.pendingEvaluations.set(evaluationId, {
974
- result,
975
- ctx,
976
- customerId: input.customerId,
977
- programUsages,
978
- voucherUsages,
979
- createdAt: Date.now(),
980
- cartHash
981
- });
1237
+ if (!isPreview) {
1238
+ const snapshot = {
1239
+ result,
1240
+ ctx,
1241
+ customerId: input.customerId,
1242
+ programUsages,
1243
+ voucherUsages,
1244
+ cartHash,
1245
+ createdAt: /* @__PURE__ */ new Date()
1246
+ };
1247
+ await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, { tenantValue: ctx.organizationId });
1248
+ }
982
1249
  await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
983
1250
  evaluationId,
984
1251
  totalDiscount,
@@ -1267,14 +1534,18 @@ var VoucherService = class {
1267
1534
  if (!voucher) throw new VoucherNotFoundError(input.code);
1268
1535
  this.assertVoucherUsable(voucher);
1269
1536
  if (input.idempotencyKey) {
1270
- if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1537
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1538
+ ...ctx,
1539
+ session
1540
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1271
1541
  }
1272
1542
  const redemption = {
1273
1543
  orderId: input.orderId,
1274
1544
  customerId: input.customerId,
1275
1545
  discountAmount: input.discountAmount,
1276
1546
  redeemedAt: /* @__PURE__ */ new Date(),
1277
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1547
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1548
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1278
1549
  };
1279
1550
  const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
1280
1551
  ...ctx,
@@ -1330,14 +1601,18 @@ var VoucherService = class {
1330
1601
  const balance = voucher.currentBalance ?? 0;
1331
1602
  if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
1332
1603
  if (input.idempotencyKey) {
1333
- if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1604
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1605
+ ...ctx,
1606
+ session
1607
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1334
1608
  }
1335
1609
  const entry = {
1336
1610
  amount: -input.amount,
1337
1611
  orderId: input.orderId,
1338
1612
  description: input.description ?? `Spent on order ${input.orderId}`,
1339
1613
  createdAt: /* @__PURE__ */ new Date(),
1340
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1614
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1615
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1341
1616
  };
1342
1617
  const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
1343
1618
  ...ctx,
@@ -1380,37 +1655,61 @@ var VoucherService = class {
1380
1655
  }
1381
1656
  async topUp(input, ctx) {
1382
1657
  if (input.amount <= 0) throw new ValidationError("Top-up amount must be positive");
1383
- const voucher = await this.voucherRepo.getByCode(input.code, ctx);
1384
- if (!voucher) throw new VoucherNotFoundError(input.code);
1385
- const maxBalance = this.config.giftCard.maxBalance;
1386
- if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
1387
- if (input.idempotencyKey) {
1388
- if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1658
+ try {
1659
+ return await this.topUpInTransaction(input, ctx);
1660
+ } catch (err) {
1661
+ if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
1662
+ throw err;
1389
1663
  }
1390
- const entry = {
1391
- amount: input.amount,
1392
- description: input.description ?? "Top-up",
1393
- createdAt: /* @__PURE__ */ new Date(),
1394
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1395
- };
1396
- const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, ctx);
1397
- if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
1398
- lean: true,
1399
- ...ctx
1664
+ }
1665
+ async topUpInTransaction(input, ctx) {
1666
+ return this.unitOfWork.withTransaction(async (session) => {
1667
+ const voucher = await this.voucherRepo.getByCode(input.code, {
1668
+ ...ctx,
1669
+ session
1670
+ });
1671
+ if (!voucher) throw new VoucherNotFoundError(input.code);
1672
+ const maxBalance = this.config.giftCard.maxBalance;
1673
+ if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
1674
+ if (input.idempotencyKey) {
1675
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1676
+ ...ctx,
1677
+ session
1678
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1679
+ }
1680
+ const entry = {
1681
+ amount: input.amount,
1682
+ description: input.description ?? "Top-up",
1683
+ createdAt: /* @__PURE__ */ new Date(),
1684
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1685
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1686
+ };
1687
+ const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, {
1688
+ ...ctx,
1689
+ session
1690
+ });
1691
+ if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
1692
+ lean: true,
1693
+ ...ctx,
1694
+ session
1695
+ });
1696
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
1697
+ voucherId: voucher._id,
1698
+ code: voucher.code,
1699
+ amount: input.amount,
1700
+ newBalance: updated.currentBalance ?? 0
1701
+ }, ctx), {
1702
+ ...ctx,
1703
+ session
1704
+ });
1705
+ return {
1706
+ code: updated.code,
1707
+ initialBalance: updated.initialBalance ?? 0,
1708
+ currentBalance: updated.currentBalance ?? 0,
1709
+ spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
1710
+ voucherId: updated._id
1711
+ };
1400
1712
  });
1401
- await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
1402
- voucherId: voucher._id,
1403
- code: voucher.code,
1404
- amount: input.amount,
1405
- newBalance: updated.currentBalance ?? 0
1406
- }, ctx), ctx);
1407
- return {
1408
- code: updated.code,
1409
- initialBalance: updated.initialBalance ?? 0,
1410
- currentBalance: updated.currentBalance ?? 0,
1411
- spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
1412
- voucherId: updated._id
1413
- };
1414
1713
  }
1415
1714
  assertVoucherUsable(voucher) {
1416
1715
  if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
@@ -1423,61 +1722,19 @@ var VoucherService = class {
1423
1722
  if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
1424
1723
  }
1425
1724
  };
1426
- /**
1427
- * Detect MongoDB's transient WriteConflict error across the shapes it
1428
- * arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
1429
- * rethrows). Centralised so every contended write site can opt-in.
1430
- */
1431
- function isWriteConflict(err) {
1432
- if (typeof err !== "object" || err === null) return false;
1433
- const e = err;
1434
- if (e.code === 112 || e.codeName === "WriteConflict") return true;
1435
- if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1436
- if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
1437
- if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1438
- if (e.cause !== void 0) return isWriteConflict(e.cause);
1439
- return false;
1440
- }
1441
1725
  //#endregion
1442
1726
  //#region src/services/create-services.ts
1443
1727
  function createServices(deps) {
1444
- const { repositories, unitOfWork, dispatchDeps, config } = deps;
1728
+ const { repositories, unitOfWork, dispatchDeps, config, evaluationStore } = deps;
1729
+ const voucher = new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config);
1730
+ const store = evaluationStore ?? new MongoEvaluationStore(repositories.pendingEvaluation);
1445
1731
  return {
1446
- voucher: new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config),
1447
- evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config)
1732
+ voucher,
1733
+ evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config, store)
1448
1734
  };
1449
1735
  }
1450
1736
  //#endregion
1451
1737
  //#region src/events/promo-event-catalog.ts
1452
- /**
1453
- * Promo event catalog — Zod-source-of-truth definitions for every
1454
- * `promo.*` event.
1455
- *
1456
- * Each definition exposes:
1457
- * - `.zodSchema` — source of truth, used by host code's `.safeParse()`
1458
- * - `.schema` — JSON Schema derived via `z.toJSONSchema()`, consumed
1459
- * by Arc's EventRegistry + OpenAPI plugin
1460
- * - `.create(...)` — DomainEvent envelope builder, structurally compatible
1461
- * with `@classytic/arc`'s `EventDefinitionOutput`
1462
- *
1463
- * Structurally compatible with Arc 2.10's `EventRegistry` — hosts register
1464
- * `promoEventDefinitions` directly, no adapter code. Promo does NOT import
1465
- * from `@classytic/arc` (PACKAGE_RULES §11); compatibility is purely
1466
- * structural.
1467
- *
1468
- * Payload schemas mirror the typed interfaces in
1469
- * [event-payloads.ts](./event-payloads.ts). See PACKAGE_RULES §18.5 for
1470
- * the pattern.
1471
- *
1472
- * @example Wiring into an Arc app
1473
- * ```ts
1474
- * import { createEventRegistry } from '@classytic/arc/events';
1475
- * import { promoEventDefinitions } from '@classytic/promo';
1476
- *
1477
- * const registry = createEventRegistry();
1478
- * for (const def of promoEventDefinitions) registry.register(def);
1479
- * ```
1480
- */
1481
1738
  function definePromoEvent(input) {
1482
1739
  const { name, version = 1, description, zodSchema } = input;
1483
1740
  const def = {
@@ -1727,23 +1984,62 @@ function resolveConfig(config) {
1727
1984
  if (resolved.evaluation.maxStackablePromotions < 1) throw new ValidationError("maxStackablePromotions must be >= 1");
1728
1985
  return resolved;
1729
1986
  }
1987
+ /**
1988
+ * Thin adapter over `mongokit.withTransaction` so the engine's local
1989
+ * `UnitOfWork` port stays driver-agnostic while inheriting mongokit's
1990
+ * battle-tested transaction handling: auto-retry on
1991
+ * `TransientTransactionError` + `UnknownTransactionCommitResult`,
1992
+ * standalone-Mongo fallback for dev, consistent session lifecycle.
1993
+ *
1994
+ * Don't replace this with hand-rolled `session.withTransaction()` — the
1995
+ * underlying helper centralises retry semantics across every package
1996
+ * that wires it. See packages/mongokit/src/transaction.ts.
1997
+ */
1730
1998
  var MongoUnitOfWork = class {
1731
1999
  constructor(connection) {
1732
2000
  this.connection = connection;
1733
2001
  }
1734
- async withTransaction(cb) {
1735
- const session = await this.connection.startSession();
1736
- try {
1737
- let result;
1738
- await session.withTransaction(async () => {
1739
- result = await cb(session);
1740
- });
1741
- return result;
1742
- } finally {
1743
- await session.endSession();
1744
- }
2002
+ withTransaction(cb) {
2003
+ return withTransaction(this.connection, cb);
1745
2004
  }
1746
2005
  };
2006
+ /**
2007
+ * Build the promo engine for a host application.
2008
+ *
2009
+ * **Index management — important for boot performance:**
2010
+ *
2011
+ * `createPromoEngine` itself is non-blocking. It registers Mongoose models
2012
+ * and returns immediately. Index creation is delegated to Mongoose's
2013
+ * standard lazy-init: with `autoIndex: true` (default in dev) Mongoose
2014
+ * builds indexes in the background after the first query touches each
2015
+ * model; with `autoIndex: false` (recommended for production) hosts
2016
+ * control index creation explicitly.
2017
+ *
2018
+ * The returned `engine.syncIndexes()` helper is opt-in and **MUST NOT be
2019
+ * `await`ed during Fastify plugin registration / boot** — Atlas index
2020
+ * creation can take 10s+ on fresh collections, longer than typical
2021
+ * plugin timeouts. Three safe patterns:
2022
+ *
2023
+ * 1. **Migration script** (recommended for production):
2024
+ * ```ts
2025
+ * // scripts/sync-indexes.ts
2026
+ * await engine.syncIndexes();
2027
+ * ```
2028
+ * Run before deploying / serving traffic.
2029
+ *
2030
+ * 2. **Background fire-and-log** during boot:
2031
+ * ```ts
2032
+ * engine.syncIndexes().catch((err) => log.warn({ err }, 'index sync'));
2033
+ * ```
2034
+ * App accepts traffic immediately; first queries may wait briefly
2035
+ * on still-building indexes.
2036
+ *
2037
+ * 3. **Lazy init** (`autoIndex: true` in dev): just don't call
2038
+ * `syncIndexes()` at all. Mongoose schedules creation on first
2039
+ * query per model.
2040
+ *
2041
+ * Production hosts should set `autoIndex: false` and use option (1).
2042
+ */
1747
2043
  function createPromoEngine(config) {
1748
2044
  const resolvedConfig = resolveConfig(config);
1749
2045
  const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
@@ -1761,7 +2057,8 @@ function createPromoEngine(config) {
1761
2057
  repositories,
1762
2058
  unitOfWork: new MongoUnitOfWork(config.mongoose),
1763
2059
  dispatchDeps,
1764
- config: resolvedConfig
2060
+ config: resolvedConfig,
2061
+ ...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
1765
2062
  }),
1766
2063
  events,
1767
2064
  async syncIndexes() {
@@ -1770,4 +2067,4 @@ function createPromoEngine(config) {
1770
2067
  };
1771
2068
  }
1772
2069
  //#endregion
1773
- export { EvaluationCommitted, EvaluationCompleted, EvaluationRolledBack, GiftCardExhausted, GiftCardSpent, GiftCardToppedUp, ProgramActivated, ProgramArchived, ProgramCreated, ProgramPaused, ProgramRepository, PromoEvents, RewardAdded, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleRemoved, RuleRepository, RuleUpdated, VoucherCancelled, VoucherExpired, VoucherGenerated, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };
2070
+ export { CartHashMismatchError, ConcurrencyConflictError, DuplicateRedemptionError, EvaluationCommitted, EvaluationCompleted, EvaluationNotFoundError, EvaluationRolledBack, GiftCardExhausted, GiftCardExhaustedError, GiftCardSpent, GiftCardToppedUp, InMemoryEvaluationStore, InsufficientBalanceError, InvalidTransitionError, MongoEvaluationStore, PendingEvaluationRepository, ProgramActivated, ProgramArchived, ProgramCreated, ProgramNotFoundError, ProgramPaused, ProgramRepository, ProgramUsageCapExceededError, PromoError, PromoEvents, RewardAdded, RewardNotFoundError, RewardRemoved, RewardRepository, RewardUpdated, RuleAdded, RuleNotFoundError, RuleRemoved, RuleRepository, RuleUpdated, TenantIsolationError, ValidationError, VoucherCancelled, VoucherExhaustedError, VoucherExpired, VoucherExpiredError, VoucherGenerated, VoucherNotFoundError, VoucherRedeemed, VoucherRepository, createPromoEngine, promoEventDefinitions, resolveConfig };