@classytic/promo 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +226 -22
  3. package/dist/{index-J5BC20DN.d.mts → constants-CrbSSQG5.d.mts} +1 -3
  4. package/dist/{constants-BVajdyL3.mjs → constants-D0Rntp2f.mjs} +1 -3
  5. package/dist/index.d.mts +1301 -10
  6. package/dist/index.mjs +2165 -46
  7. package/dist/schemas/index.d.mts +253 -0
  8. package/dist/schemas/index.mjs +134 -0
  9. package/package.json +23 -37
  10. package/dist/config-iZjn_8pp.d.mts +0 -71
  11. package/dist/domain/enums/index.d.mts +0 -2
  12. package/dist/domain/enums/index.mjs +0 -2
  13. package/dist/domain/index.d.mts +0 -61
  14. package/dist/domain/index.mjs +0 -4
  15. package/dist/domain-errors-BEkXvy5O.mjs +0 -80
  16. package/dist/event-emitter.port-DaodlJSG.d.mts +0 -8
  17. package/dist/event-types-CsTV1FKX.mjs +0 -25
  18. package/dist/events/index.d.mts +0 -2
  19. package/dist/events/index.mjs +0 -3
  20. package/dist/events-CprEWlN7.mjs +0 -25
  21. package/dist/index-B7lLH19a.d.mts +0 -13
  22. package/dist/index-C52zSBkI.d.mts +0 -96
  23. package/dist/index-Cu9iwy4v.d.mts +0 -99
  24. package/dist/index-l09KqnlE.d.mts +0 -81
  25. package/dist/models/index.d.mts +0 -2
  26. package/dist/models/index.mjs +0 -2
  27. package/dist/models-DdBNae7h.mjs +0 -277
  28. package/dist/repositories/index.d.mts +0 -2
  29. package/dist/repositories/index.mjs +0 -2
  30. package/dist/repositories-DgZIY9wD.mjs +0 -295
  31. package/dist/results-Ca5ZCNbN.d.mts +0 -218
  32. package/dist/services/index.d.mts +0 -2
  33. package/dist/services/index.mjs +0 -2
  34. package/dist/services-Cz0gHrmX.mjs +0 -815
  35. package/dist/types/index.d.mts +0 -3
  36. package/dist/types/index.mjs +0 -1
  37. package/dist/unit-of-work.port-DaMW8WZK.d.mts +0 -7
  38. package/dist/voucher.port-yxfb3MHJ.d.mts +0 -146
package/dist/index.mjs CHANGED
@@ -1,32 +1,2104 @@
1
- import { n as DEFAULT_TENANT_TYPE, t as DEFAULT_TENANT_FIELD } from "./constants-BVajdyL3.mjs";
2
- import { l as ValidationError } from "./domain-errors-BEkXvy5O.mjs";
3
- import { t as InProcessEventBus } from "./events-CprEWlN7.mjs";
4
- import { t as createModels } from "./models-DdBNae7h.mjs";
5
- import { t as createRepositories } from "./repositories-DgZIY9wD.mjs";
6
- import { t as PromoEvents } from "./event-types-CsTV1FKX.mjs";
7
- import { t as createServices } from "./services-Cz0gHrmX.mjs";
8
- //#region src/index.ts
9
- function resolveConfig(config) {
10
- let tenant;
11
- if (config.tenant === false) tenant = {
12
- enabled: false,
13
- field: "",
14
- type: "ObjectId",
15
- contextKey: ""
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";
2
+ import { Repository, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
3
+ import { resolveTenantConfig } from "@classytic/primitives/tenant";
4
+ import { createEvent, matchEventPattern } from "@classytic/primitives/events";
5
+ import mongoose, { Schema } from "mongoose";
6
+ import { createHash, randomBytes } from "node:crypto";
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
+ /**
27
+ * Raised when a commit-time atomic CAS on `usedCount < maxUsageTotal`
28
+ * fails — i.e. the program's cap was exhausted by another concurrent
29
+ * commit between this caller's evaluate and its commit. The host should
30
+ * surface this as a "promo no longer available" failure to the user; the
31
+ * order itself can still proceed without the discount if the host wishes.
32
+ */
33
+ var ProgramUsageCapExceededError = class extends PromoError {
34
+ code = "PROGRAM_USAGE_CAP_EXCEEDED";
35
+ constructor(programId, maxUsageTotal) {
36
+ super(`Program '${programId}' has reached its usage cap of ${maxUsageTotal}`);
37
+ this.programId = programId;
38
+ this.maxUsageTotal = maxUsageTotal;
39
+ }
40
+ };
41
+ var RuleNotFoundError = class extends PromoError {
42
+ code = "RULE_NOT_FOUND";
43
+ constructor(id) {
44
+ super(id ? `Rule '${id}' not found` : "Rule not found");
45
+ }
46
+ };
47
+ var RewardNotFoundError = class extends PromoError {
48
+ code = "REWARD_NOT_FOUND";
49
+ constructor(id) {
50
+ super(id ? `Reward '${id}' not found` : "Reward not found");
51
+ }
52
+ };
53
+ var VoucherNotFoundError = class extends PromoError {
54
+ code = "VOUCHER_NOT_FOUND";
55
+ constructor(codeOrId) {
56
+ super(codeOrId ? `Voucher '${codeOrId}' not found` : "Voucher not found");
57
+ }
58
+ };
59
+ var InvalidTransitionError = class extends PromoError {
60
+ code = "INVALID_TRANSITION";
61
+ constructor(from, to) {
62
+ super(`Cannot transition from '${from}' to '${to}'`);
63
+ }
64
+ };
65
+ var VoucherExpiredError = class extends PromoError {
66
+ code = "VOUCHER_EXPIRED";
67
+ constructor(code) {
68
+ super(`Voucher '${code}' has expired`);
69
+ }
70
+ };
71
+ var VoucherExhaustedError = class extends PromoError {
72
+ code = "VOUCHER_EXHAUSTED";
73
+ constructor(code) {
74
+ super(`Voucher '${code}' has reached its usage limit`);
75
+ }
76
+ };
77
+ /**
78
+ * Thrown when a gift card's balance has been fully spent and its status
79
+ * flipped to `'used'`. Distinct from `VoucherExhaustedError` (usage-limit
80
+ * exhaustion on a discount voucher) so hosts can show "top up" vs "retry
81
+ * later" messaging.
82
+ */
83
+ var GiftCardExhaustedError = class extends PromoError {
84
+ code = "GIFT_CARD_EXHAUSTED";
85
+ constructor(code) {
86
+ super(`Gift card '${code}' has been fully spent`);
87
+ }
88
+ };
89
+ /**
90
+ * Thrown when a MongoDB WriteConflict surfaces under voucher-spend
91
+ * contention. Losers see a stable domain shape rather than the raw
92
+ * `"Write conflict during plan execution"` string. Hosts can translate
93
+ * this to HTTP 409 + retry.
94
+ */
95
+ var ConcurrencyConflictError = class extends PromoError {
96
+ code = "CONCURRENCY_CONFLICT";
97
+ status = 409;
98
+ constructor(resource, resourceId, cause) {
99
+ super(`Concurrent modification on ${resource} '${resourceId}'`);
100
+ this.resource = resource;
101
+ this.resourceId = resourceId;
102
+ this.cause = cause;
103
+ }
104
+ };
105
+ var InsufficientBalanceError = class extends PromoError {
106
+ code = "INSUFFICIENT_BALANCE";
107
+ constructor(code, available, requested) {
108
+ super(`Voucher '${code}' has insufficient balance: ${available} available, ${requested} requested`);
109
+ }
110
+ };
111
+ var TenantIsolationError = class extends PromoError {
112
+ code = "TENANT_ISOLATION";
113
+ constructor() {
114
+ super("Tenant context is required but was not provided");
115
+ }
116
+ };
117
+ var DuplicateRedemptionError = class extends PromoError {
118
+ code = "DUPLICATE_REDEMPTION";
119
+ constructor(key) {
120
+ super(`Duplicate redemption detected for idempotency key '${key}'`);
121
+ }
122
+ };
123
+ var EvaluationNotFoundError = class extends PromoError {
124
+ code = "EVALUATION_NOT_FOUND";
125
+ constructor(id) {
126
+ super(`Evaluation '${id}' not found or already committed`);
127
+ }
128
+ };
129
+ /**
130
+ * Thrown by `EvaluationService.commit` when the cart hash provided by the
131
+ * caller does not match the cart hash computed at evaluation time. Guards
132
+ * against cart-tampering attacks where a user previews a large discount on
133
+ * a heavy cart, mutates the cart to something cheaper before committing, and
134
+ * tries to apply the stale discount to the altered order.
135
+ */
136
+ var CartHashMismatchError = class extends PromoError {
137
+ code = "CART_HASH_MISMATCH";
138
+ constructor(evaluationId) {
139
+ super(`Evaluation '${evaluationId}' cart hash mismatch — the cart changed between evaluate and commit. Re-evaluate with the current cart.`);
140
+ }
141
+ };
142
+ //#endregion
143
+ //#region src/events/in-process-bus.ts
144
+ var InProcessPromoBus = class {
145
+ name = "in-process-promo";
146
+ handlers = /* @__PURE__ */ new Map();
147
+ logger;
148
+ constructor(options = {}) {
149
+ this.logger = options.logger ?? console;
150
+ }
151
+ async publish(event) {
152
+ const matched = /* @__PURE__ */ new Set();
153
+ for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
154
+ for (const handler of matched) try {
155
+ await handler(event);
156
+ } catch (err) {
157
+ this.logger.error(`[promo] handler error for ${event.type}:`, err);
158
+ }
159
+ }
160
+ async subscribe(pattern, handler) {
161
+ let set = this.handlers.get(pattern);
162
+ if (!set) {
163
+ set = /* @__PURE__ */ new Set();
164
+ this.handlers.set(pattern, set);
165
+ }
166
+ set.add(handler);
167
+ return () => {
168
+ const s = this.handlers.get(pattern);
169
+ if (!s) return;
170
+ s.delete(handler);
171
+ if (s.size === 0) this.handlers.delete(pattern);
172
+ };
173
+ }
174
+ async close() {
175
+ this.handlers.clear();
176
+ }
177
+ };
178
+ //#endregion
179
+ //#region src/models/create-model.ts
180
+ function injectTenantField(schema, tenant) {
181
+ schema.add({ [tenant.tenantField]: {
182
+ type: tenant.fieldType === "objectId" ? mongoose.Schema.Types.ObjectId : String,
183
+ index: true,
184
+ ...tenant.ref ? { ref: tenant.ref } : {}
185
+ } });
186
+ if (!tenant.enabled) return;
187
+ const existingIndexes = schema._indexes;
188
+ if (existingIndexes && existingIndexes.length > 0) for (const indexEntry of existingIndexes) {
189
+ const fields = indexEntry[0];
190
+ const newFields = { [tenant.tenantField]: 1 };
191
+ for (const [key, val] of Object.entries(fields)) newFields[key] = val;
192
+ indexEntry[0] = newFields;
193
+ }
194
+ }
195
+ function applyUserIndexes(schema, indexes, tenant) {
196
+ for (const def of indexes) {
197
+ const fields = tenant.enabled ? {
198
+ [tenant.tenantField]: 1,
199
+ ...def.fields
200
+ } : { ...def.fields };
201
+ schema.index(fields, def.options ?? {});
202
+ }
203
+ }
204
+ //#endregion
205
+ //#region src/models/schemas/pending-evaluation.schema.ts
206
+ /**
207
+ * Persisted snapshot of an `evaluate()` outcome, awaiting a follow-up
208
+ * `commit()` or `rollback()`.
209
+ *
210
+ * Why a Mongo collection (not a process-local Map): pending evaluations
211
+ * MUST survive process restart, horizontal scaling, serverless cold
212
+ * starts, and worker handoff. Stale snapshots auto-clean via the TTL
213
+ * index — same pattern cart uses for guest drafts (see
214
+ * cart/src/models/draft.model.ts:131-135).
215
+ *
216
+ * Stored fields are intentionally minimal — only what `commit()` and
217
+ * `rollback()` need:
218
+ * - the materialised result (returned to caller for telemetry)
219
+ * - per-program / per-voucher usages (incremented atomically at commit)
220
+ * - cartHash (anti-tamper guard between evaluate and commit)
221
+ * - expiresAt (TTL drives auto-cleanup; engine never relies on it for
222
+ * correctness — the atomic `findOneAndDelete` in `take()` is the
223
+ * authoritative single-commit guard)
224
+ */
225
+ function createPendingEvaluationSchema() {
226
+ return new Schema({
227
+ evaluationId: {
228
+ type: String,
229
+ required: true,
230
+ unique: true,
231
+ index: true
232
+ },
233
+ result: {
234
+ type: Schema.Types.Mixed,
235
+ required: true
236
+ },
237
+ ctx: {
238
+ type: Schema.Types.Mixed,
239
+ required: true
240
+ },
241
+ customerId: {
242
+ type: String,
243
+ default: null
244
+ },
245
+ programUsages: {
246
+ type: [Schema.Types.Mixed],
247
+ default: []
248
+ },
249
+ voucherUsages: {
250
+ type: [Schema.Types.Mixed],
251
+ default: []
252
+ },
253
+ cartHash: {
254
+ type: String,
255
+ required: true,
256
+ index: true
257
+ },
258
+ expiresAt: {
259
+ type: Date,
260
+ required: true
261
+ }
262
+ }, {
263
+ timestamps: true,
264
+ minimize: false
265
+ });
266
+ }
267
+ /**
268
+ * Wire the TTL index. Mongo evaluates `expireAfterSeconds: 0` against the
269
+ * `expiresAt` field's value — when `expiresAt < now`, the doc is purged
270
+ * by the background TTL monitor (~60s granularity).
271
+ */
272
+ function applyPendingEvaluationIndexes(schema) {
273
+ schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
274
+ }
275
+ //#endregion
276
+ //#region src/models/schemas/program.schema.ts
277
+ const { Schema: Schema$4 } = mongoose;
278
+ function createProgramSchema() {
279
+ const schema = new Schema$4({
280
+ name: {
281
+ type: String,
282
+ required: true
283
+ },
284
+ description: { type: String },
285
+ programType: {
286
+ type: String,
287
+ enum: PROGRAM_TYPES,
288
+ required: true
289
+ },
290
+ triggerMode: {
291
+ type: String,
292
+ enum: TRIGGER_MODES,
293
+ required: true
294
+ },
295
+ status: {
296
+ type: String,
297
+ enum: PROGRAM_STATUSES,
298
+ default: "draft"
299
+ },
300
+ stackingMode: {
301
+ type: String,
302
+ enum: STACKING_MODES,
303
+ default: "exclusive"
304
+ },
305
+ priority: {
306
+ type: Number,
307
+ default: 0
308
+ },
309
+ startsAt: { type: Date },
310
+ endsAt: { type: Date },
311
+ maxUsageTotal: { type: Number },
312
+ usedCount: {
313
+ type: Number,
314
+ default: 0
315
+ },
316
+ maxUsagePerCustomer: { type: Number },
317
+ applicableCustomerIds: [{ type: String }],
318
+ applicableCustomerTags: [{ type: String }],
319
+ customerUsageCounts: {
320
+ type: Map,
321
+ of: Number,
322
+ default: () => /* @__PURE__ */ new Map()
323
+ },
324
+ metadata: { type: Schema$4.Types.Mixed }
325
+ }, { timestamps: true });
326
+ schema.index({
327
+ status: 1,
328
+ programType: 1,
329
+ priority: -1
330
+ });
331
+ schema.index({
332
+ status: 1,
333
+ startsAt: 1,
334
+ endsAt: 1
335
+ });
336
+ schema.index({
337
+ status: 1,
338
+ priority: -1,
339
+ _id: -1
340
+ });
341
+ return schema;
342
+ }
343
+ //#endregion
344
+ //#region src/models/schemas/reward.schema.ts
345
+ const { Schema: Schema$3 } = mongoose;
346
+ function createRewardSchema() {
347
+ const schema = new Schema$3({
348
+ programId: {
349
+ type: Schema$3.Types.ObjectId,
350
+ required: true
351
+ },
352
+ ruleId: { type: Schema$3.Types.ObjectId },
353
+ rewardType: {
354
+ type: String,
355
+ enum: REWARD_TYPES,
356
+ required: true
357
+ },
358
+ discountMode: {
359
+ type: String,
360
+ enum: DISCOUNT_MODES
361
+ },
362
+ discountAmount: { type: Number },
363
+ maxDiscountAmount: { type: Number },
364
+ discountScope: {
365
+ type: String,
366
+ enum: DISCOUNT_SCOPES,
367
+ default: "order"
368
+ },
369
+ applicableProductIds: [{ type: String }],
370
+ freeProductId: { type: String },
371
+ freeProductSku: { type: String },
372
+ freeQuantity: {
373
+ type: Number,
374
+ default: 1
375
+ },
376
+ giftCardAmount: { type: Number },
377
+ metadata: { type: Schema$3.Types.Mixed }
378
+ }, { timestamps: true });
379
+ schema.index({ programId: 1 });
380
+ schema.index({ ruleId: 1 }, { sparse: true });
381
+ return schema;
382
+ }
383
+ //#endregion
384
+ //#region src/models/schemas/rule.schema.ts
385
+ const { Schema: Schema$2 } = mongoose;
386
+ function createRuleSchema() {
387
+ const schema = new Schema$2({
388
+ programId: {
389
+ type: Schema$2.Types.ObjectId,
390
+ required: true
391
+ },
392
+ name: { type: String },
393
+ minimumAmount: {
394
+ type: Number,
395
+ default: 0
396
+ },
397
+ minimumQuantity: {
398
+ type: Number,
399
+ default: 0
400
+ },
401
+ applicableProductIds: [{ type: String }],
402
+ applicableCategories: [{ type: String }],
403
+ applicableSkus: [{ type: String }],
404
+ buyQuantity: { type: Number },
405
+ code: {
406
+ type: String,
407
+ uppercase: true,
408
+ trim: true
409
+ },
410
+ startsAt: { type: Date },
411
+ endsAt: { type: Date },
412
+ metadata: { type: Schema$2.Types.Mixed }
413
+ }, { timestamps: true });
414
+ schema.index({ programId: 1 });
415
+ schema.index({ code: 1 }, { sparse: true });
416
+ return schema;
417
+ }
418
+ //#endregion
419
+ //#region src/models/schemas/voucher.schema.ts
420
+ const { Schema: Schema$1 } = mongoose;
421
+ function createVoucherSchema() {
422
+ const schema = new Schema$1({
423
+ programId: {
424
+ type: Schema$1.Types.ObjectId,
425
+ required: true,
426
+ index: true
427
+ },
428
+ code: {
429
+ type: String,
430
+ required: true,
431
+ uppercase: true,
432
+ trim: true
433
+ },
434
+ status: {
435
+ type: String,
436
+ enum: VOUCHER_STATUSES,
437
+ default: "active"
438
+ },
439
+ customerId: { type: String },
440
+ usageLimit: {
441
+ type: Number,
442
+ default: 1
443
+ },
444
+ usedCount: {
445
+ type: Number,
446
+ default: 0
447
+ },
448
+ initialBalance: { type: Number },
449
+ currentBalance: { type: Number },
450
+ balanceLedger: [{
451
+ amount: {
452
+ type: Number,
453
+ required: true
454
+ },
455
+ orderId: { type: String },
456
+ description: { type: String },
457
+ createdAt: {
458
+ type: Date,
459
+ default: Date.now
460
+ },
461
+ idempotencyKey: { type: String },
462
+ organizationId: { type: String }
463
+ }],
464
+ expiresAt: { type: Date },
465
+ redemptions: [{
466
+ orderId: {
467
+ type: String,
468
+ required: true
469
+ },
470
+ customerId: { type: String },
471
+ discountAmount: {
472
+ type: Number,
473
+ required: true
474
+ },
475
+ redeemedAt: {
476
+ type: Date,
477
+ default: Date.now
478
+ },
479
+ idempotencyKey: { type: String },
480
+ organizationId: { type: String }
481
+ }],
482
+ metadata: { type: Schema$1.Types.Mixed }
483
+ }, { timestamps: true });
484
+ schema.index({ code: 1 }, { unique: true });
485
+ schema.index({
486
+ programId: 1,
487
+ status: 1
488
+ });
489
+ schema.index({
490
+ customerId: 1,
491
+ status: 1
492
+ }, { sparse: true });
493
+ schema.index({ expiresAt: 1 }, { sparse: true });
494
+ return schema;
495
+ }
496
+ //#endregion
497
+ //#region src/models/create-models.ts
498
+ const MODEL_NAMES = [
499
+ "PromoProgram",
500
+ "PromoRule",
501
+ "PromoReward",
502
+ "PromoVoucher",
503
+ "PromoPendingEvaluation"
504
+ ];
505
+ function applyAutoIndex(models, autoIndex) {
506
+ if (autoIndex === void 0) return;
507
+ for (const [configKey, modelKey] of [
508
+ ["program", "Program"],
509
+ ["rule", "Rule"],
510
+ ["reward", "Reward"],
511
+ ["voucher", "Voucher"]
512
+ ]) {
513
+ const value = typeof autoIndex === "boolean" ? autoIndex : autoIndex[configKey] ?? void 0;
514
+ if (value !== void 0) models[modelKey].schema.set("autoIndex", value);
515
+ }
516
+ }
517
+ function createModels(connection, tenant, indexes, autoIndex) {
518
+ for (const name of MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
519
+ const programSchema = createProgramSchema();
520
+ const ruleSchema = createRuleSchema();
521
+ const rewardSchema = createRewardSchema();
522
+ const voucherSchema = createVoucherSchema();
523
+ const pendingEvaluationSchema = createPendingEvaluationSchema();
524
+ injectTenantField(programSchema, tenant);
525
+ injectTenantField(ruleSchema, tenant);
526
+ injectTenantField(rewardSchema, tenant);
527
+ injectTenantField(voucherSchema, tenant);
528
+ injectTenantField(pendingEvaluationSchema, tenant);
529
+ applyPendingEvaluationIndexes(pendingEvaluationSchema);
530
+ if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
531
+ if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
532
+ if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
533
+ if (indexes?.voucher) applyUserIndexes(voucherSchema, indexes.voucher, tenant);
534
+ const result = {
535
+ Program: connection.model("PromoProgram", programSchema),
536
+ Rule: connection.model("PromoRule", ruleSchema),
537
+ Reward: connection.model("PromoReward", rewardSchema),
538
+ Voucher: connection.model("PromoVoucher", voucherSchema),
539
+ PendingEvaluation: connection.model("PromoPendingEvaluation", pendingEvaluationSchema)
16
540
  };
17
- else if (config.tenant === void 0) tenant = {
18
- enabled: true,
19
- field: DEFAULT_TENANT_FIELD,
20
- type: DEFAULT_TENANT_TYPE,
21
- contextKey: DEFAULT_TENANT_FIELD
541
+ applyAutoIndex(result, autoIndex);
542
+ return result;
543
+ }
544
+ //#endregion
545
+ //#region src/repositories/pending-evaluation.repository.ts
546
+ /**
547
+ * Pending-evaluation repository. Extends mongokit's `Repository<TDoc>`
548
+ * directly per package rules (no service wrapper, no aliased verbs).
549
+ * Adds one custom domain method: `takeByEvaluationId` — atomic
550
+ * read-and-delete via raw `Model.findOneAndDelete`.
551
+ *
552
+ * **Why raw `findOneAndDelete` (escape from mongokit's `delete()`)**:
553
+ * mongokit's `Repository.delete()` returns `{success, message}` only —
554
+ * it doesn't surface the deleted document. `take` semantics require
555
+ * "atomically remove AND return", which is the canonical defence
556
+ * against double-commit on the same evaluationId at the storage layer
557
+ * (one caller wins the document, the other gets `null`). This is the
558
+ * narrow exception PACKAGE_RULES.md / order/CLAUDE.md sanction:
559
+ * *"Raw findOneAndUpdate/findOneAndDelete is allowed ONLY for atomic
560
+ * state-machine transitions — flag each one with a comment."*
561
+ */
562
+ var PendingEvaluationRepository = class extends Repository {
563
+ /**
564
+ * The repository owns its tenant config so its raw-driver methods
565
+ * (`findOneAndDelete`, `deleteOne`) can apply the SAME scoping rule
566
+ * the mongokit hook pipeline would apply on standard methods —
567
+ * specifically using `tenant.tenantField` (host-configurable as
568
+ * `organizationId`, `branchId`, `tenantId`, etc.) NOT a hardcoded
569
+ * `organizationId`. Without this, deployments that configure custom
570
+ * tenant fields would silently lose isolation on the cache layer.
571
+ */
572
+ tenant;
573
+ constructor(model, plugins = [], tenant) {
574
+ super(model, plugins);
575
+ this.tenant = tenant;
576
+ }
577
+ /** The host-configured tenant field name (or `undefined` if single-tenant). */
578
+ get tenantField() {
579
+ return this.tenant?.enabled ? this.tenant.tenantField : void 0;
580
+ }
581
+ /**
582
+ * Atomic read-and-delete by evaluationId. Two concurrent commit calls
583
+ * on the same id race here at the database layer — the winner gets
584
+ * the document, the loser gets `null` and the calling service throws
585
+ * `EvaluationNotFoundError`. No way both succeed.
586
+ *
587
+ * Honours `ctx.session` so the operation joins the caller's
588
+ * transaction. If the transaction aborts (transient DB error, cap
589
+ * exceeded, etc.) the delete rolls back and the snapshot stays in
590
+ * the store — letting the caller retry without re-evaluation.
591
+ *
592
+ * Tenant scoping uses the configured `tenant.tenantField` (NOT a
593
+ * hardcoded `organizationId`) so hosts on `branchId`/`tenantId`/etc.
594
+ * keep cross-tenant isolation on the cache layer too.
595
+ */
596
+ async takeByEvaluationId(evaluationId, ctx) {
597
+ const filter = this.scopedFilter({ evaluationId }, ctx);
598
+ const session = ctx?.session ?? null;
599
+ return await this.Model.findOneAndDelete(filter).session(session).lean();
600
+ }
601
+ /**
602
+ * Idempotent delete. Returns whether anything was actually removed,
603
+ * so callers can distinguish "we cleaned it" from "already gone".
604
+ */
605
+ async deleteByEvaluationId(evaluationId, ctx) {
606
+ const filter = this.scopedFilter({ evaluationId }, ctx);
607
+ const session = ctx?.session ?? null;
608
+ return (await this.Model.deleteOne(filter).session(session)).deletedCount > 0;
609
+ }
610
+ /**
611
+ * Build a query filter with the configured tenant scope appended,
612
+ * mirroring what mongokit's `multiTenantPlugin` injects on standard
613
+ * Repository methods. We re-implement here because raw driver calls
614
+ * (`findOneAndDelete`, `deleteOne`) bypass the plugin pipeline.
615
+ */
616
+ scopedFilter(base, ctx) {
617
+ const field = this.tenantField;
618
+ if (!field || ctx?.tenantValue === void 0) return base;
619
+ return {
620
+ ...base,
621
+ [field]: ctx.tenantValue
622
+ };
623
+ }
624
+ };
625
+ //#endregion
626
+ //#region src/events/dispatch.ts
627
+ /**
628
+ * Canonical P8 shape — save to outbox first (with `ctx.session` when
629
+ * present), then publish to transport. Both paths run independently.
630
+ */
631
+ async function dispatchPromoEvent(deps, event, ctx) {
632
+ const logger = deps.logger ?? console;
633
+ if (deps.outbox) try {
634
+ const saveOptions = {};
635
+ if (ctx?.session !== void 0) saveOptions.session = ctx.session;
636
+ await deps.outbox.save(event, saveOptions);
637
+ } catch (err) {
638
+ logger.error(`[promo] outbox.save failed for ${event.type}:`, err);
639
+ }
640
+ if (deps.events) try {
641
+ await deps.events.publish(event);
642
+ } catch (err) {
643
+ logger.error(`[promo] events.publish failed for ${event.type}:`, err);
644
+ }
645
+ }
646
+ //#endregion
647
+ //#region src/events/event-constants.ts
648
+ const PromoEvents = {
649
+ PROGRAM_CREATED: "promo.program.created",
650
+ PROGRAM_ACTIVATED: "promo.program.activated",
651
+ PROGRAM_PAUSED: "promo.program.paused",
652
+ PROGRAM_ARCHIVED: "promo.program.archived",
653
+ RULE_ADDED: "promo.rule.added",
654
+ RULE_UPDATED: "promo.rule.updated",
655
+ RULE_REMOVED: "promo.rule.removed",
656
+ REWARD_ADDED: "promo.reward.added",
657
+ REWARD_UPDATED: "promo.reward.updated",
658
+ REWARD_REMOVED: "promo.reward.removed",
659
+ VOUCHER_GENERATED: "promo.voucher.generated",
660
+ VOUCHER_REDEEMED: "promo.voucher.redeemed",
661
+ VOUCHER_CANCELLED: "promo.voucher.cancelled",
662
+ VOUCHER_EXPIRED: "promo.voucher.expired",
663
+ GIFT_CARD_SPENT: "promo.gift_card.spent",
664
+ GIFT_CARD_TOPPED_UP: "promo.gift_card.topped_up",
665
+ GIFT_CARD_EXHAUSTED: "promo.gift_card.exhausted",
666
+ EVALUATION_COMPLETED: "promo.evaluation.completed",
667
+ EVALUATION_COMMITTED: "promo.evaluation.committed",
668
+ EVALUATION_ROLLED_BACK: "promo.evaluation.rolled_back"
669
+ };
670
+ //#endregion
671
+ //#region src/events/helpers.ts
672
+ function createEvent$1(type, payload, ctx, meta) {
673
+ return createEvent(type, payload, {
674
+ resource: "promo",
675
+ ...ctx?.actorId !== void 0 ? { userId: ctx.actorId } : {},
676
+ ...ctx?.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {},
677
+ ...meta
678
+ });
679
+ }
680
+ //#endregion
681
+ //#region src/repositories/program.repository.ts
682
+ var ProgramRepository = class extends Repository {
683
+ dispatchDeps;
684
+ constructor(model, plugins = [], dispatchDeps = {}) {
685
+ super(model, plugins);
686
+ this.dispatchDeps = dispatchDeps;
687
+ }
688
+ async activate(id, ctx) {
689
+ return this._transition(id, "active", ctx);
690
+ }
691
+ async pause(id, ctx) {
692
+ return this._transition(id, "paused", ctx);
693
+ }
694
+ async archive(id, ctx) {
695
+ return this._transition(id, "archived", ctx);
696
+ }
697
+ async _transition(id, targetStatus, ctx) {
698
+ const program = await this.getById(id, {
699
+ throwOnNotFound: false,
700
+ lean: true,
701
+ ...ctx
702
+ });
703
+ if (!program) throw new ProgramNotFoundError(id);
704
+ if (!PROGRAM_TRANSITIONS[program.status]?.includes(targetStatus)) throw new InvalidTransitionError(program.status, targetStatus);
705
+ const updated = await this.update(id, { status: targetStatus }, {
706
+ throwOnNotFound: true,
707
+ lean: true,
708
+ ...ctx
709
+ });
710
+ if (!updated) throw new ProgramNotFoundError(id);
711
+ const eventName = {
712
+ active: PromoEvents.PROGRAM_ACTIVATED,
713
+ paused: PromoEvents.PROGRAM_PAUSED,
714
+ archived: PromoEvents.PROGRAM_ARCHIVED
715
+ }[targetStatus];
716
+ if (eventName) await dispatchPromoEvent(this.dispatchDeps, createEvent$1(eventName, {
717
+ programId: updated._id,
718
+ programType: updated.programType,
719
+ status: updated.status,
720
+ actorId: ctx.actorId
721
+ }, ctx), ctx);
722
+ return updated;
723
+ }
724
+ async incrementUsage(id, ctx) {
725
+ const updated = await this.update(id, { $inc: { usedCount: 1 } }, {
726
+ throwOnNotFound: true,
727
+ lean: true,
728
+ ...ctx
729
+ });
730
+ if (!updated) throw new ProgramNotFoundError(id);
731
+ return updated;
732
+ }
733
+ /**
734
+ * Atomic compare-and-set increment: succeeds only if the program either
735
+ * has no cap (`maxUsageTotal == null`) OR `usedCount < maxUsageTotal`.
736
+ * If the cap is already saturated, returns `null` so the caller can
737
+ * decide whether to throw (commit-time enforcement) or skip silently
738
+ * (best-effort eligibility check).
739
+ *
740
+ * Industry-standard primitive for "promo with finite supply" — without
741
+ * this, two evaluations both see the program as available and both
742
+ * commit, leading to oversell. The atomic filter on the same write
743
+ * eliminates the race entirely; concurrent losers see a `null` return
744
+ * and can downgrade their commit (apply order without the discount,
745
+ * surface "promo no longer available" to the user, etc.).
746
+ *
747
+ * Routes through mongokit's `update` so tenant scoping + hooks fire.
748
+ */
749
+ async tryIncrementUsage(id, ctx) {
750
+ return await this.update(id, { $inc: { usedCount: 1 } }, {
751
+ query: { $or: [
752
+ { maxUsageTotal: { $exists: false } },
753
+ { maxUsageTotal: null },
754
+ { $expr: { $lt: ["$usedCount", "$maxUsageTotal"] } }
755
+ ] },
756
+ throwOnNotFound: false,
757
+ lean: true,
758
+ ...ctx
759
+ }) ?? null;
760
+ }
761
+ async decrementUsage(id, ctx) {
762
+ const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
763
+ throwOnNotFound: true,
764
+ lean: true,
765
+ ...ctx
766
+ });
767
+ if (!updated) throw new ProgramNotFoundError(id);
768
+ return updated;
769
+ }
770
+ async getCustomerUsage(id, customerId, ctx) {
771
+ const doc = await this.getById(id, {
772
+ throwOnNotFound: false,
773
+ lean: true,
774
+ ...ctx
775
+ });
776
+ if (!doc) return 0;
777
+ const counts = doc.customerUsageCounts;
778
+ if (!counts) return 0;
779
+ if (counts instanceof Map) return counts.get(customerId) ?? 0;
780
+ return counts[customerId] ?? 0;
781
+ }
782
+ async incrementCustomerUsage(id, customerId, ctx) {
783
+ const updated = await this.update(id, { $inc: { [`customerUsageCounts.${customerId}`]: 1 } }, {
784
+ throwOnNotFound: true,
785
+ lean: true,
786
+ ...ctx
787
+ });
788
+ if (!updated) throw new ProgramNotFoundError(id);
789
+ return updated;
790
+ }
791
+ async findActive(now, ctx) {
792
+ const currentDate = now ?? /* @__PURE__ */ new Date();
793
+ const filters = {
794
+ status: "active",
795
+ $or: [
796
+ { startsAt: { $lte: currentDate } },
797
+ { startsAt: { $exists: false } },
798
+ { startsAt: null }
799
+ ]
800
+ };
801
+ return (await this.findAll(filters, {
802
+ sort: {
803
+ priority: -1,
804
+ _id: -1
805
+ },
806
+ lean: true,
807
+ ...ctx
808
+ })).filter((p) => !p.endsAt || p.endsAt >= currentDate).slice(0, 200);
809
+ }
810
+ };
811
+ //#endregion
812
+ //#region src/repositories/reward.repository.ts
813
+ var RewardRepository = class extends Repository {
814
+ constructor(model, plugins = []) {
815
+ super(model, plugins);
816
+ }
817
+ };
818
+ //#endregion
819
+ //#region src/repositories/rule.repository.ts
820
+ var RuleRepository = class extends Repository {
821
+ constructor(model, plugins = []) {
822
+ super(model, plugins);
823
+ }
824
+ };
825
+ //#endregion
826
+ //#region src/repositories/voucher.repository.ts
827
+ var VoucherRepository = class extends Repository {
828
+ dispatchDeps;
829
+ tenantField;
830
+ tenantEnabled;
831
+ constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId", tenantEnabled = true) {
832
+ super(model, plugins);
833
+ this.dispatchDeps = dispatchDeps;
834
+ this.tenantField = tenantField;
835
+ this.tenantEnabled = tenantEnabled;
836
+ }
837
+ /**
838
+ * Copy the tenant id from `ctx` onto the write payload so the doc persists
839
+ * with the correct `organizationId` even when the host has opted OUT of the
840
+ * auto-wired `multiTenantPlugin` but still scopes at its framework layer
841
+ * (e.g. arc's preset + `BaseController` — see `@classytic/order` CLAUDE.md:
842
+ * "Child repos set `organizationId` explicitly on the doc").
843
+ *
844
+ * Skipped entirely when the engine was configured with `tenant: false` —
845
+ * the host's intent is company-wide rows (no `organizationId` on disk),
846
+ * and injecting it from `ctx` would silently re-scope writes per branch
847
+ * while reads still look at the unscoped collection.
848
+ */
849
+ _injectTenant(data, ctx) {
850
+ if (!this.tenantEnabled) return data;
851
+ const tenantValue = ctx?.[this.tenantField];
852
+ if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
853
+ return data;
854
+ }
855
+ async create(data, options) {
856
+ this._injectTenant(data, options);
857
+ return super.create(data, options);
858
+ }
859
+ async createMany(docs, options) {
860
+ const ctx = options;
861
+ for (const doc of docs) this._injectTenant(doc, ctx);
862
+ return super.createMany(docs, options);
863
+ }
864
+ /** Domain verb: cancel a voucher (status transition + event). */
865
+ async cancel(id, ctx) {
866
+ const voucher = await this.getById(id, {
867
+ throwOnNotFound: false,
868
+ lean: true,
869
+ ...ctx
870
+ });
871
+ if (!voucher) throw new VoucherNotFoundError(id);
872
+ const updated = await this.update(id, { status: "cancelled" }, {
873
+ throwOnNotFound: true,
874
+ lean: true,
875
+ ...ctx
876
+ });
877
+ if (!updated) throw new VoucherNotFoundError(id);
878
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_CANCELLED, {
879
+ voucherId: id,
880
+ code: voucher.code,
881
+ status: "cancelled"
882
+ }, ctx), ctx);
883
+ return updated;
884
+ }
885
+ /** Atomic usage increment + push redemption record. */
886
+ async incrementUsage(id, redemption, ctx) {
887
+ const updated = await this.update(id, {
888
+ $inc: { usedCount: 1 },
889
+ $push: { redemptions: redemption }
890
+ }, {
891
+ throwOnNotFound: true,
892
+ lean: true,
893
+ ...ctx
894
+ });
895
+ if (!updated) throw new VoucherNotFoundError(id);
896
+ return updated;
897
+ }
898
+ /** Atomic balance delta + push ledger entry. */
899
+ async addLedgerEntry(id, entry, balanceDelta, ctx) {
900
+ const updated = await this.update(id, {
901
+ $inc: { currentBalance: balanceDelta },
902
+ $push: { balanceLedger: entry }
903
+ }, {
904
+ throwOnNotFound: true,
905
+ lean: true,
906
+ ...ctx
907
+ });
908
+ if (!updated) throw new VoucherNotFoundError(id);
909
+ return updated;
910
+ }
911
+ /**
912
+ * Batch expire by date.
913
+ *
914
+ * Routes through `findAll` + per-doc `update` instead of a raw
915
+ * `this.Model.updateMany`. Rationale:
916
+ * 1. `findAll` casts the filter through the schema (so an ObjectId
917
+ * tenant field on a write-time String context doesn't silently
918
+ * return 0 rows the way a raw `$match` would).
919
+ * 2. `update` fires the full hook pipeline (multi-tenant, audit,
920
+ * soft-delete) on every expiration — a bulk `updateMany` at the
921
+ * Model layer bypasses all of them.
922
+ * Volume is bounded per tenant (active + past-expiry vouchers), so an
923
+ * N+1 loop is acceptable for correctness.
924
+ */
925
+ async expireByDate(before, ctx) {
926
+ const filter = {
927
+ status: "active",
928
+ expiresAt: { $lte: before }
929
+ };
930
+ const docs = await this.findAll(filter, {
931
+ lean: true,
932
+ ...ctx
933
+ });
934
+ let modified = 0;
935
+ for (const doc of docs) if (await this.update(doc._id, { status: "expired" }, {
936
+ throwOnNotFound: false,
937
+ lean: true,
938
+ ...ctx
939
+ })) modified++;
940
+ return modified;
941
+ }
942
+ /**
943
+ * Lookup voucher by unique code, scoped to the tenant in `ctx` when
944
+ * tenant scoping is configured.
945
+ *
946
+ * Manual tenant injection mirrors `expireByDate` and `ProgramRepository.findActive`
947
+ * — it makes the method work whether the host opts into the auto-wired
948
+ * `multiTenantPlugin` or enforces scoping at its own framework layer
949
+ * (e.g. arc's preset + `BaseController`). Without this, a host that
950
+ * runs the plugin off would leak vouchers across branches at
951
+ * validate/redeem/spend/topUp call sites.
952
+ *
953
+ * When the engine is configured with `tenant: false`, the filter is
954
+ * code-only — vouchers are company-wide and the docs carry no
955
+ * `organizationId`, so injecting one would always miss.
956
+ */
957
+ async getByCode(code, ctx) {
958
+ const filter = { code: code.toUpperCase() };
959
+ if (this.tenantEnabled) {
960
+ const tenantValue = ctx?.[this.tenantField];
961
+ if (tenantValue != null) filter[this.tenantField] = tenantValue;
962
+ }
963
+ return this.getByQuery(filter, {
964
+ throwOnNotFound: false,
965
+ lean: true,
966
+ ...ctx
967
+ });
968
+ }
969
+ /**
970
+ * Idempotency check on nested arrays. Routes through mongokit's `count`
971
+ * so `multiTenantPlugin` injects the configured tenant field from
972
+ * `ctx` — a non-tenant-scoped check would cross organization boundaries.
973
+ */
974
+ async hasIdempotencyKey(id, key, ctx) {
975
+ const filter = {
976
+ _id: id,
977
+ $or: [{ "redemptions.idempotencyKey": key }, { "balanceLedger.idempotencyKey": key }]
978
+ };
979
+ return await this.count(filter, ctx) > 0;
980
+ }
981
+ };
982
+ //#endregion
983
+ //#region src/repositories/create-repositories.ts
984
+ function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
985
+ const tenantPlugins = tenant?.enabled ? [multiTenantPlugin({
986
+ tenantField: tenant.tenantField,
987
+ contextKey: tenant.contextKey,
988
+ fieldType: tenant.fieldType
989
+ })] : [];
990
+ return {
991
+ program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
992
+ rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
993
+ reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
994
+ voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField, tenant?.enabled ?? false),
995
+ pendingEvaluation: new PendingEvaluationRepository(models.PendingEvaluation, [...tenantPlugins, ...plugins?.pendingEvaluation ?? []], tenant)
996
+ };
997
+ }
998
+ //#endregion
999
+ //#region src/adapters/mongo-evaluation-store.ts
1000
+ /**
1001
+ * Default Mongo-backed implementation of {@link EvaluationStore}. Translates
1002
+ * between the domain snapshot shape and the persistence document shape,
1003
+ * forwards atomic `take`/`delete` semantics to the repository, and
1004
+ * applies the engine's configured tenant field on writes (the repo
1005
+ * applies it on reads).
1006
+ *
1007
+ * Hosts that prefer a different backend (Redis, DynamoDB, in-memory for
1008
+ * tests) can implement `EvaluationStore` directly and pass it via engine
1009
+ * config — this class isn't load-bearing.
1010
+ */
1011
+ var MongoEvaluationStore = class {
1012
+ constructor(repo) {
1013
+ this.repo = repo;
1014
+ }
1015
+ async put(id, snapshot, ttlSeconds, ctx) {
1016
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1e3);
1017
+ const session = ctx?.session;
1018
+ const tenantField = this.repo.tenantField;
1019
+ const tenantValue = ctx?.tenantValue ?? snapshot.ctx.organizationId;
1020
+ const filter = { evaluationId: id };
1021
+ if (tenantField && tenantValue !== void 0) filter[tenantField] = tenantValue;
1022
+ const update = {
1023
+ evaluationId: id,
1024
+ result: snapshot.result,
1025
+ ctx: snapshot.ctx,
1026
+ customerId: snapshot.customerId ?? null,
1027
+ programUsages: snapshot.programUsages,
1028
+ voucherUsages: snapshot.voucherUsages,
1029
+ cartHash: snapshot.cartHash,
1030
+ expiresAt
1031
+ };
1032
+ if (tenantField && tenantValue !== void 0) update[tenantField] = tenantValue;
1033
+ await this.repo.Model.updateOne(filter, { $set: update }, {
1034
+ upsert: true,
1035
+ session
1036
+ });
1037
+ }
1038
+ async take(id, ctx) {
1039
+ const doc = await this.repo.takeByEvaluationId(id, {
1040
+ tenantValue: ctx?.tenantValue,
1041
+ session: ctx?.session
1042
+ });
1043
+ if (!doc) return null;
1044
+ return {
1045
+ result: doc.result,
1046
+ ctx: doc.ctx,
1047
+ customerId: doc.customerId ?? void 0,
1048
+ programUsages: doc.programUsages,
1049
+ voucherUsages: doc.voucherUsages,
1050
+ cartHash: doc.cartHash,
1051
+ createdAt: doc.createdAt
1052
+ };
1053
+ }
1054
+ async delete(id, ctx) {
1055
+ await this.repo.deleteByEvaluationId(id, {
1056
+ tenantValue: ctx?.tenantValue,
1057
+ session: ctx?.session
1058
+ });
1059
+ }
1060
+ };
1061
+ /**
1062
+ * In-memory fallback. Useful for unit tests, single-process dev servers,
1063
+ * and hosts that consciously opt out of persistence (knowing they lose
1064
+ * pending evaluations on restart). NOT recommended for production
1065
+ * topologies — see EvaluationStore docblock for why.
1066
+ *
1067
+ * Tenant scoping uses `ctx.tenantValue` as part of the in-memory key
1068
+ * (the field-name layer doesn't matter here — we just namespace by
1069
+ * value). No transaction semantics (in-memory has nothing to roll
1070
+ * back), no TTL refinement beyond initial expiry check.
1071
+ */
1072
+ var InMemoryEvaluationStore = class {
1073
+ map = /* @__PURE__ */ new Map();
1074
+ async put(id, snapshot, ttlSeconds, ctx) {
1075
+ this.map.set(this.key(id, ctx), {
1076
+ snapshot,
1077
+ expiresAt: Date.now() + ttlSeconds * 1e3
1078
+ });
1079
+ }
1080
+ async take(id, ctx) {
1081
+ const k = this.key(id, ctx);
1082
+ const entry = this.map.get(k);
1083
+ if (!entry) return null;
1084
+ this.map.delete(k);
1085
+ if (entry.expiresAt < Date.now()) return null;
1086
+ return entry.snapshot;
1087
+ }
1088
+ async delete(id, ctx) {
1089
+ this.map.delete(this.key(id, ctx));
1090
+ }
1091
+ key(id, ctx) {
1092
+ return ctx?.tenantValue ? `${ctx.tenantValue}:${id}` : id;
1093
+ }
1094
+ };
1095
+ //#endregion
1096
+ //#region src/utils/is-write-conflict.ts
1097
+ /**
1098
+ * Detect MongoDB's transient WriteConflict error across the shapes it
1099
+ * arrives in (raw driver error, mongoose-wrapped VersionError, mongokit
1100
+ * rethrows). Centralised so every contended write site (gift card spend,
1101
+ * gift card top-up, evaluation commit) can opt into uniform mapping
1102
+ * to the same typed `ConcurrencyConflictError` / `*UsageCapExceededError`
1103
+ * shape — hosts catch one type, decide retry-or-409 once.
1104
+ *
1105
+ * Why detection is a layered match: under heavy contention MongoDB
1106
+ * surfaces the same physical race in different ways depending on which
1107
+ * layer caught it first (driver vs mongoose vs mongokit), and each
1108
+ * wrapper preserves a slightly different subset of the original error
1109
+ * fields. Matching against ALL of them avoids false negatives that
1110
+ * would otherwise let the raw error escape to the host with no useful
1111
+ * type information.
1112
+ */
1113
+ function isWriteConflict(err) {
1114
+ if (typeof err !== "object" || err === null) return false;
1115
+ const e = err;
1116
+ if (e.code === 112 || e.codeName === "WriteConflict") return true;
1117
+ if (e.name === "MongoServerError" && typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1118
+ if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
1119
+ if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
1120
+ if (e.cause !== void 0) return isWriteConflict(e.cause);
1121
+ return false;
1122
+ }
1123
+ //#endregion
1124
+ //#region src/services/evaluation.service.ts
1125
+ /**
1126
+ * Default TTL for pending evaluation snapshots in the store. 30 minutes
1127
+ * comfortably exceeds any realistic evaluate→commit window (typical
1128
+ * checkout: seconds to a few minutes; long-tail: ~10 min) without
1129
+ * hoarding abandoned evaluations forever. The Mongo TTL index purges
1130
+ * expired entries; the engine itself never relies on the TTL for
1131
+ * correctness — `store.take()` is atomic and returns `null` when an
1132
+ * id was already consumed or never existed.
1133
+ */
1134
+ const DEFAULT_PENDING_EVALUATION_TTL_SECONDS = 1800;
1135
+ /**
1136
+ * Deterministic, collision-resistant hash of the evaluated cart. The hash
1137
+ * covers everything the evaluation algorithm depends on: normalized line
1138
+ * items (sorted), subtotal, applied codes, and customer identity. Two
1139
+ * evaluations over an identical cart yield the same hash; any mutation —
1140
+ * quantity, unit price, added/removed line, different code, different
1141
+ * customer — yields a different hash.
1142
+ */
1143
+ function computeCartHash(input) {
1144
+ const items = [...input.items].map((item) => ({
1145
+ productId: item.productId ?? "",
1146
+ sku: item.sku ?? "",
1147
+ categoryId: item.categoryId ?? "",
1148
+ quantity: item.quantity,
1149
+ unitPrice: item.unitPrice,
1150
+ lineTotal: item.lineTotal ?? item.quantity * item.unitPrice
1151
+ })).sort((a, b) => {
1152
+ if (a.productId !== b.productId) return a.productId < b.productId ? -1 : 1;
1153
+ if (a.sku !== b.sku) return a.sku < b.sku ? -1 : 1;
1154
+ if (a.unitPrice !== b.unitPrice) return a.unitPrice - b.unitPrice;
1155
+ return a.quantity - b.quantity;
1156
+ });
1157
+ const codes = [...input.codes ?? []].map((c) => c.toUpperCase()).sort();
1158
+ const tags = [...input.customerTags ?? []].sort();
1159
+ const canonical = JSON.stringify({
1160
+ items,
1161
+ subtotal: input.subtotal,
1162
+ codes,
1163
+ customerId: input.customerId ?? null,
1164
+ customerTags: tags
1165
+ });
1166
+ return createHash("sha256").update(canonical).digest("hex");
1167
+ }
1168
+ var EvaluationService = class {
1169
+ constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config, store) {
1170
+ this.programRepo = programRepo;
1171
+ this.ruleRepo = ruleRepo;
1172
+ this.rewardRepo = rewardRepo;
1173
+ this.voucherRepo = voucherRepo;
1174
+ this.unitOfWork = unitOfWork;
1175
+ this.dispatchDeps = dispatchDeps;
1176
+ this.config = config;
1177
+ this.store = store;
1178
+ }
1179
+ async evaluate(input, ctx) {
1180
+ return this.doEvaluate(input, ctx, false);
1181
+ }
1182
+ async preview(input, ctx) {
1183
+ return this.doEvaluate(input, ctx, true);
1184
+ }
1185
+ async commit(evaluationId, orderId, ctx, options = {}) {
1186
+ let snapshotForCapMapping = null;
1187
+ try {
1188
+ return await this.unitOfWork.withTransaction(async (session) => {
1189
+ const stored = await this.store.take(evaluationId, {
1190
+ tenantValue: ctx.organizationId,
1191
+ session
1192
+ });
1193
+ if (!stored) throw new EvaluationNotFoundError(evaluationId);
1194
+ if (options.cartHash !== void 0 && options.cartHash !== stored.cartHash) throw new CartHashMismatchError(evaluationId);
1195
+ snapshotForCapMapping = stored;
1196
+ return await this.commitInTransaction(stored, evaluationId, orderId, session, ctx);
1197
+ });
1198
+ } catch (err) {
1199
+ if (isWriteConflict(err) && snapshotForCapMapping) {
1200
+ const firstUsage = snapshotForCapMapping.programUsages[0];
1201
+ if (firstUsage) {
1202
+ const program = await this.programRepo.getById(firstUsage.programId, {
1203
+ ...ctx,
1204
+ lean: true,
1205
+ throwOnNotFound: false
1206
+ });
1207
+ throw new ProgramUsageCapExceededError(firstUsage.programId, program?.maxUsageTotal ?? 0);
1208
+ }
1209
+ }
1210
+ throw err;
1211
+ }
1212
+ }
1213
+ async commitInTransaction(stored, evaluationId, orderId, session, ctx) {
1214
+ for (const usage of stored.programUsages) {
1215
+ if (!await this.programRepo.tryIncrementUsage(usage.programId, {
1216
+ ...ctx,
1217
+ session
1218
+ })) {
1219
+ const program = await this.programRepo.getById(usage.programId, {
1220
+ ...ctx,
1221
+ session,
1222
+ lean: true,
1223
+ throwOnNotFound: false
1224
+ });
1225
+ throw new ProgramUsageCapExceededError(usage.programId, program?.maxUsageTotal ?? 0);
1226
+ }
1227
+ if (stored.customerId) await this.programRepo.incrementCustomerUsage(usage.programId, stored.customerId, {
1228
+ ...ctx,
1229
+ session
1230
+ });
1231
+ }
1232
+ for (const usage of stored.voucherUsages) await this.voucherRepo.incrementUsage(usage.voucherId, {
1233
+ orderId,
1234
+ discountAmount: usage.discountAmount,
1235
+ redeemedAt: /* @__PURE__ */ new Date(),
1236
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1237
+ }, {
1238
+ ...ctx,
1239
+ session
1240
+ });
1241
+ const commitResult = {
1242
+ evaluationId,
1243
+ orderId,
1244
+ totalDiscount: stored.result.totalDiscount,
1245
+ programsCommitted: stored.programUsages.length,
1246
+ vouchersUsed: stored.voucherUsages.length
1247
+ };
1248
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMMITTED, {
1249
+ evaluationId,
1250
+ orderId,
1251
+ totalDiscount: stored.result.totalDiscount
1252
+ }, ctx), {
1253
+ ...ctx,
1254
+ session
1255
+ });
1256
+ return commitResult;
1257
+ }
1258
+ async rollback(evaluationId, ctx) {
1259
+ await this.store.delete(evaluationId, { tenantValue: ctx.organizationId });
1260
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
1261
+ }
1262
+ async doEvaluate(input, ctx, isPreview) {
1263
+ const submittedCodes = (input.codes ?? []).map((c) => c.toUpperCase());
1264
+ const programs = await this.programRepo.findActive(void 0, ctx);
1265
+ const programIds = programs.map((p) => p._id);
1266
+ const [allRules, allRewards] = await Promise.all([this.ruleRepo.findAll({ programId: { $in: programIds } }, ctx), this.rewardRepo.findAll({ programId: { $in: programIds } }, ctx)]);
1267
+ const rulesByProgram = groupBy(allRules, (r) => r.programId);
1268
+ const rewardsByProgram = groupBy(allRewards, (r) => r.programId);
1269
+ const voucherMap = /* @__PURE__ */ new Map();
1270
+ for (const code of submittedCodes) {
1271
+ const voucher = await this.voucherRepo.getByCode(code, ctx);
1272
+ if (voucher && voucher.status === "active" && voucher.usedCount < voucher.usageLimit) voucherMap.set(code, {
1273
+ voucherId: voucher._id,
1274
+ programId: voucher.programId,
1275
+ balance: voucher.currentBalance
1276
+ });
1277
+ }
1278
+ const appliedDiscounts = [];
1279
+ const freeProducts = [];
1280
+ const appliedCodes = [];
1281
+ const rejectedCodes = [];
1282
+ const warnings = [];
1283
+ const programsApplied = [];
1284
+ const programUsages = [];
1285
+ const voucherUsages = [];
1286
+ let runningSubtotal = input.subtotal;
1287
+ let exclusiveApplied = false;
1288
+ let stackableCount = 0;
1289
+ for (const program of programs) {
1290
+ if (program.maxUsageTotal && program.usedCount >= program.maxUsageTotal) continue;
1291
+ if (!this.isCustomerEligible(program, input)) continue;
1292
+ if (program.maxUsagePerCustomer && input.customerId) {
1293
+ if (await this.programRepo.getCustomerUsage(program._id, input.customerId, ctx) >= program.maxUsagePerCustomer) {
1294
+ warnings.push(`Customer has reached max usage (${program.maxUsagePerCustomer}) for "${program.name}"`);
1295
+ continue;
1296
+ }
1297
+ }
1298
+ if (program.stackingMode === "exclusive" && exclusiveApplied) continue;
1299
+ if (program.stackingMode === "stackable" && stackableCount >= this.config.evaluation.maxStackablePromotions) {
1300
+ warnings.push(`Max stackable promotions (${this.config.evaluation.maxStackablePromotions}) reached`);
1301
+ continue;
1302
+ }
1303
+ const rules = rulesByProgram.get(String(program._id)) ?? [];
1304
+ const rewards = rewardsByProgram.get(String(program._id)) ?? [];
1305
+ if (rules.length === 0 || rewards.length === 0) continue;
1306
+ const matchResult = this.matchBestRule(program, rules, input.items, input.subtotal, submittedCodes, voucherMap);
1307
+ if (!matchResult.matched) {
1308
+ if (matchResult.rejectedCode) rejectedCodes.push(matchResult.rejectedCode);
1309
+ continue;
1310
+ }
1311
+ const applicableRewards = this.filterRewardsByRule(rewards, matchResult.matchedRuleId);
1312
+ for (const reward of applicableRewards) if (reward.rewardType === "discount") {
1313
+ const discount = this.computeDiscount(reward, input.items, runningSubtotal);
1314
+ if (discount <= 0) continue;
1315
+ appliedDiscounts.push({
1316
+ programId: program._id,
1317
+ programName: program.name,
1318
+ rewardId: reward._id,
1319
+ type: reward.discountMode ?? "fixed",
1320
+ scope: reward.discountScope,
1321
+ amount: discount,
1322
+ description: this.describeDiscount(reward, program),
1323
+ voucherCode: matchResult.matchedCode
1324
+ });
1325
+ runningSubtotal = Math.max(0, runningSubtotal - discount);
1326
+ } else if (reward.rewardType === "free_product") freeProducts.push({
1327
+ programId: program._id,
1328
+ programName: program.name,
1329
+ rewardId: reward._id,
1330
+ productId: reward.freeProductId,
1331
+ productSku: reward.freeProductSku,
1332
+ quantity: reward.freeQuantity,
1333
+ description: `Free product from "${program.name}"`
1334
+ });
1335
+ programsApplied.push(program._id);
1336
+ programUsages.push({ programId: program._id });
1337
+ if (matchResult.matchedCode) {
1338
+ appliedCodes.push(matchResult.matchedCode);
1339
+ const voucherInfo = voucherMap.get(matchResult.matchedCode);
1340
+ if (voucherInfo) {
1341
+ const totalProgramDiscount = appliedDiscounts.filter((d) => d.programId === program._id).reduce((sum, d) => sum + d.amount, 0);
1342
+ voucherUsages.push({
1343
+ voucherId: voucherInfo.voucherId,
1344
+ code: matchResult.matchedCode,
1345
+ discountAmount: totalProgramDiscount
1346
+ });
1347
+ }
1348
+ }
1349
+ if (program.stackingMode === "exclusive") exclusiveApplied = true;
1350
+ else stackableCount++;
1351
+ }
1352
+ for (const code of submittedCodes) if (!appliedCodes.includes(code) && !rejectedCodes.some((r) => r.code === code)) rejectedCodes.push({
1353
+ code,
1354
+ reason: "No matching active program found for this code"
1355
+ });
1356
+ const totalDiscount = appliedDiscounts.reduce((sum, d) => sum + d.amount, 0);
1357
+ const evaluationId = randomBytes(16).toString("hex");
1358
+ const cartHash = computeCartHash(input);
1359
+ const result = {
1360
+ evaluationId,
1361
+ cartHash,
1362
+ appliedDiscounts,
1363
+ freeProducts,
1364
+ totalDiscount,
1365
+ subtotalAfterDiscount: Math.max(0, input.subtotal - totalDiscount),
1366
+ appliedCodes,
1367
+ rejectedCodes,
1368
+ warnings,
1369
+ isPreview,
1370
+ programsApplied
1371
+ };
1372
+ if (!isPreview) {
1373
+ const snapshot = {
1374
+ result,
1375
+ ctx,
1376
+ customerId: input.customerId,
1377
+ programUsages,
1378
+ voucherUsages,
1379
+ cartHash,
1380
+ createdAt: /* @__PURE__ */ new Date()
1381
+ };
1382
+ await this.store.put(evaluationId, snapshot, DEFAULT_PENDING_EVALUATION_TTL_SECONDS, { tenantValue: ctx.organizationId });
1383
+ }
1384
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
1385
+ evaluationId,
1386
+ totalDiscount,
1387
+ programsApplied: programsApplied.length,
1388
+ codesUsed: appliedCodes,
1389
+ isPreview
1390
+ }, ctx), ctx);
1391
+ return result;
1392
+ }
1393
+ isCustomerEligible(program, input) {
1394
+ if (program.applicableCustomerIds.length > 0) {
1395
+ if (!input.customerId) return false;
1396
+ if (!program.applicableCustomerIds.includes(input.customerId)) return false;
1397
+ }
1398
+ if (program.applicableCustomerTags.length > 0) {
1399
+ const customerTags = input.customerTags ?? [];
1400
+ if (customerTags.length === 0) return false;
1401
+ if (!program.applicableCustomerTags.some((tag) => customerTags.includes(tag))) return false;
1402
+ }
1403
+ return true;
1404
+ }
1405
+ matchBestRule(program, rules, items, subtotal, submittedCodes, voucherMap) {
1406
+ let bestMatch = { matched: false };
1407
+ let bestThreshold = -1;
1408
+ let lastRejected;
1409
+ for (const rule of rules) {
1410
+ const singleResult = this.matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap);
1411
+ if (singleResult.rejectedCode) lastRejected = singleResult.rejectedCode;
1412
+ if (!singleResult.matched) continue;
1413
+ const threshold = (rule.minimumAmount ?? 0) * 1e3 + (rule.minimumQuantity ?? 0);
1414
+ if (threshold > bestThreshold) {
1415
+ bestThreshold = threshold;
1416
+ bestMatch = singleResult;
1417
+ }
1418
+ }
1419
+ if (!bestMatch.matched && lastRejected) bestMatch.rejectedCode = lastRejected;
1420
+ return bestMatch;
1421
+ }
1422
+ matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap) {
1423
+ if (program.triggerMode === "code") {
1424
+ if (rule.code) {
1425
+ if (!submittedCodes.includes(rule.code)) return { matched: false };
1426
+ if (program.programType === "coupon" || program.programType === "discount_code") {
1427
+ const voucherInfo = voucherMap.get(rule.code);
1428
+ if (!voucherInfo || String(voucherInfo.programId) !== String(program._id)) return {
1429
+ matched: false,
1430
+ rejectedCode: {
1431
+ code: rule.code,
1432
+ reason: "Invalid or exhausted voucher"
1433
+ }
1434
+ };
1435
+ }
1436
+ } else if (![...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))) return { matched: false };
1437
+ }
1438
+ const now = /* @__PURE__ */ new Date();
1439
+ if (rule.startsAt && rule.startsAt > now) return { matched: false };
1440
+ if (rule.endsAt && rule.endsAt < now) return { matched: false };
1441
+ if (rule.minimumAmount > 0 && subtotal < rule.minimumAmount) return { matched: false };
1442
+ if (rule.minimumQuantity > 0) {
1443
+ if (items.reduce((sum, item) => sum + item.quantity, 0) < rule.minimumQuantity) return { matched: false };
1444
+ }
1445
+ if (rule.applicableProductIds.length > 0) {
1446
+ if (!items.some((item) => rule.applicableProductIds.includes(item.productId))) return { matched: false };
1447
+ }
1448
+ if (rule.applicableCategories.length > 0) {
1449
+ if (!items.some((item) => item.categoryId && rule.applicableCategories.includes(item.categoryId))) return { matched: false };
1450
+ }
1451
+ if (rule.applicableSkus.length > 0) {
1452
+ if (!items.some((item) => item.sku && rule.applicableSkus.includes(item.sku))) return { matched: false };
1453
+ }
1454
+ if (rule.buyQuantity && rule.buyQuantity > 0) {
1455
+ if (items.filter((item) => rule.applicableProductIds.length === 0 || rule.applicableProductIds.includes(item.productId)).reduce((sum, item) => sum + item.quantity, 0) < rule.buyQuantity) return { matched: false };
1456
+ }
1457
+ let matchedCode;
1458
+ if (program.triggerMode === "code") if (rule.code && submittedCodes.includes(rule.code)) matchedCode = rule.code;
1459
+ else matchedCode = [...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))?.[0];
1460
+ return {
1461
+ matched: true,
1462
+ matchedCode,
1463
+ matchedRuleId: rule._id
1464
+ };
1465
+ }
1466
+ filterRewardsByRule(rewards, matchedRuleId) {
1467
+ if (!matchedRuleId) return rewards;
1468
+ if (rewards.filter((r) => r.ruleId).length === 0) return rewards;
1469
+ return rewards.filter((r) => !r.ruleId || String(r.ruleId) === String(matchedRuleId));
1470
+ }
1471
+ computeDiscount(reward, items, runningSubtotal) {
1472
+ if (!reward.discountAmount || reward.discountAmount <= 0) return 0;
1473
+ let discount;
1474
+ if (reward.discountScope === "cheapest") {
1475
+ const eligibleItems = this.getEligibleItems(reward, items);
1476
+ if (eligibleItems.length === 0) return 0;
1477
+ const cheapest = Math.min(...eligibleItems.map((item) => item.unitPrice));
1478
+ discount = reward.discountMode === "percentage" ? cheapest * (reward.discountAmount / 100) : Math.min(reward.discountAmount, cheapest);
1479
+ } else if (reward.discountScope === "specific_products") {
1480
+ const eligibleTotal = this.getEligibleItems(reward, items).reduce((sum, item) => sum + (item.lineTotal ?? item.unitPrice * item.quantity), 0);
1481
+ if (eligibleTotal <= 0) return 0;
1482
+ discount = reward.discountMode === "percentage" ? eligibleTotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, eligibleTotal);
1483
+ } else discount = reward.discountMode === "percentage" ? runningSubtotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, runningSubtotal);
1484
+ if (reward.maxDiscountAmount && discount > reward.maxDiscountAmount) discount = reward.maxDiscountAmount;
1485
+ return Math.min(Math.round(discount * 100) / 100, runningSubtotal);
1486
+ }
1487
+ getEligibleItems(reward, items) {
1488
+ if (reward.applicableProductIds.length === 0) return items;
1489
+ return items.filter((item) => reward.applicableProductIds.includes(item.productId));
1490
+ }
1491
+ describeDiscount(reward, program) {
1492
+ if (reward.discountMode === "percentage") return `${reward.discountAmount}% off from "${program.name}"`;
1493
+ return `${reward.discountAmount} off from "${program.name}"`;
1494
+ }
1495
+ };
1496
+ function groupBy(items, keyFn) {
1497
+ const map = /* @__PURE__ */ new Map();
1498
+ for (const item of items) {
1499
+ const key = String(keyFn(item));
1500
+ const group = map.get(key);
1501
+ if (group) group.push(item);
1502
+ else map.set(key, [item]);
1503
+ }
1504
+ return map;
1505
+ }
1506
+ //#endregion
1507
+ //#region src/utils/code-generator.ts
1508
+ const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1509
+ function generateCode(length, prefix = "") {
1510
+ const bytes = randomBytes(length);
1511
+ let code = "";
1512
+ for (let i = 0; i < length; i++) code += CHARSET[bytes[i] % 32];
1513
+ return prefix ? `${prefix}${code}` : code;
1514
+ }
1515
+ function generateCodes(count, length, prefix = "") {
1516
+ const codes = /* @__PURE__ */ new Set();
1517
+ let attempts = 0;
1518
+ const maxAttempts = count * 10;
1519
+ while (codes.size < count && attempts < maxAttempts) {
1520
+ codes.add(generateCode(length, prefix));
1521
+ attempts++;
1522
+ }
1523
+ return [...codes];
1524
+ }
1525
+ //#endregion
1526
+ //#region src/services/voucher.service.ts
1527
+ /**
1528
+ * VoucherService — multi-repo domain orchestration only.
1529
+ *
1530
+ * Code generation, redemption (transactional + idempotency), gift card
1531
+ * spend/topUp, and validation live here because they need programRepo +
1532
+ * voucherRepo + config + transactions.
1533
+ *
1534
+ * Simple lookups (getById, getByCode, getAll) and the cancel domain verb
1535
+ * live on VoucherRepository directly — callers use the repo. §2/§3.
1536
+ */
1537
+ var VoucherService = class {
1538
+ constructor(voucherRepo, programRepo, unitOfWork, dispatchDeps, config) {
1539
+ this.voucherRepo = voucherRepo;
1540
+ this.programRepo = programRepo;
1541
+ this.unitOfWork = unitOfWork;
1542
+ this.dispatchDeps = dispatchDeps;
1543
+ this.config = config;
1544
+ }
1545
+ async generateCodes(input, ctx) {
1546
+ const program = await this.programRepo.getById(input.programId, {
1547
+ throwOnNotFound: false,
1548
+ lean: true,
1549
+ ...ctx
1550
+ });
1551
+ if (!program) throw new ProgramNotFoundError(input.programId);
1552
+ const { codeLength, codePrefix } = this.config.voucher;
1553
+ const codes = generateCodes(input.count, codeLength, codePrefix);
1554
+ const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
1555
+ const isGiftCard = program.programType === "gift_card";
1556
+ const data = codes.map((code) => ({
1557
+ programId: input.programId,
1558
+ code,
1559
+ status: "active",
1560
+ customerId: input.customerId,
1561
+ usageLimit: isGiftCard ? 999999 : 1,
1562
+ usedCount: 0,
1563
+ ...isGiftCard ? {
1564
+ initialBalance: 0,
1565
+ currentBalance: 0,
1566
+ balanceLedger: []
1567
+ } : {},
1568
+ ...expiresAt ? { expiresAt } : {},
1569
+ redemptions: [],
1570
+ ...input.metadata ? { metadata: input.metadata } : {}
1571
+ }));
1572
+ const vouchers = await this.voucherRepo.createMany(data, ctx);
1573
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
1574
+ programId: input.programId,
1575
+ voucherIds: vouchers.map((v) => v._id),
1576
+ codes: vouchers.map((v) => v.code),
1577
+ count: vouchers.length,
1578
+ actorId: ctx.actorId
1579
+ }, ctx), ctx);
1580
+ return vouchers;
1581
+ }
1582
+ async generateSingleCode(input, ctx) {
1583
+ const program = await this.programRepo.getById(input.programId, {
1584
+ throwOnNotFound: false,
1585
+ lean: true,
1586
+ ...ctx
1587
+ });
1588
+ if (!program) throw new ProgramNotFoundError(input.programId);
1589
+ const code = input.code?.toUpperCase() ?? generateCode(this.config.voucher.codeLength, this.config.voucher.codePrefix);
1590
+ const isGiftCard = program.programType === "gift_card";
1591
+ const balance = input.initialBalance ?? 0;
1592
+ const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
1593
+ const data = {
1594
+ programId: input.programId,
1595
+ code,
1596
+ status: "active",
1597
+ customerId: input.customerId,
1598
+ usageLimit: isGiftCard ? 999999 : 1,
1599
+ usedCount: 0,
1600
+ ...isGiftCard ? {
1601
+ initialBalance: balance,
1602
+ currentBalance: balance,
1603
+ balanceLedger: []
1604
+ } : {},
1605
+ ...expiresAt ? { expiresAt } : {},
1606
+ redemptions: [],
1607
+ ...input.metadata ? { metadata: input.metadata } : {}
1608
+ };
1609
+ const voucher = await this.voucherRepo.create(data, ctx);
1610
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
1611
+ programId: input.programId,
1612
+ voucherIds: [voucher._id],
1613
+ codes: [voucher.code],
1614
+ count: 1,
1615
+ actorId: ctx.actorId
1616
+ }, ctx), ctx);
1617
+ return voucher;
1618
+ }
1619
+ async validateCode(code, ctx) {
1620
+ const voucher = await this.voucherRepo.getByCode(code, ctx);
1621
+ if (!voucher) return {
1622
+ valid: false,
1623
+ error: "Voucher not found"
1624
+ };
1625
+ if (voucher.status === "expired") return {
1626
+ valid: false,
1627
+ error: "Voucher has expired"
1628
+ };
1629
+ if (voucher.status === "cancelled") return {
1630
+ valid: false,
1631
+ error: "Voucher has been cancelled"
1632
+ };
1633
+ if (voucher.status === "used") return {
1634
+ valid: false,
1635
+ error: "Voucher has already been used"
1636
+ };
1637
+ if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) return {
1638
+ valid: false,
1639
+ error: "Voucher has expired"
1640
+ };
1641
+ if (voucher.usedCount >= voucher.usageLimit) return {
1642
+ valid: false,
1643
+ error: "Voucher has reached its usage limit"
1644
+ };
1645
+ const program = await this.programRepo.getById(voucher.programId, {
1646
+ throwOnNotFound: false,
1647
+ lean: true,
1648
+ ...ctx
1649
+ });
1650
+ return {
1651
+ valid: true,
1652
+ voucher: {
1653
+ code: voucher.code,
1654
+ programId: voucher.programId,
1655
+ programType: program?.programType ?? "unknown",
1656
+ status: voucher.status,
1657
+ remainingUses: voucher.usageLimit - voucher.usedCount,
1658
+ ...voucher.currentBalance !== void 0 ? { currentBalance: voucher.currentBalance } : {},
1659
+ ...voucher.expiresAt ? { expiresAt: voucher.expiresAt } : {}
1660
+ }
1661
+ };
1662
+ }
1663
+ async redeem(input, ctx) {
1664
+ return this.unitOfWork.withTransaction(async (session) => {
1665
+ const voucher = await this.voucherRepo.getByCode(input.code, {
1666
+ ...ctx,
1667
+ session
1668
+ });
1669
+ if (!voucher) throw new VoucherNotFoundError(input.code);
1670
+ this.assertVoucherUsable(voucher);
1671
+ if (input.idempotencyKey) {
1672
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1673
+ ...ctx,
1674
+ session
1675
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1676
+ }
1677
+ const redemption = {
1678
+ orderId: input.orderId,
1679
+ customerId: input.customerId,
1680
+ discountAmount: input.discountAmount,
1681
+ redeemedAt: /* @__PURE__ */ new Date(),
1682
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1683
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1684
+ };
1685
+ const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
1686
+ ...ctx,
1687
+ session
1688
+ });
1689
+ if (updated.usedCount >= updated.usageLimit) await this.voucherRepo.update(voucher._id, { status: "used" }, {
1690
+ lean: true,
1691
+ ...ctx,
1692
+ session
1693
+ });
1694
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_REDEEMED, {
1695
+ voucherId: voucher._id,
1696
+ code: voucher.code,
1697
+ orderId: input.orderId,
1698
+ discountAmount: input.discountAmount,
1699
+ customerId: input.customerId
1700
+ }, ctx), {
1701
+ ...ctx,
1702
+ session
1703
+ });
1704
+ return updated;
1705
+ });
1706
+ }
1707
+ async getBalance(code, ctx) {
1708
+ const voucher = await this.voucherRepo.getByCode(code, ctx);
1709
+ if (!voucher) throw new VoucherNotFoundError(code);
1710
+ if (voucher.initialBalance === void 0) throw new ValidationError("Voucher is not a gift card");
1711
+ return {
1712
+ code: voucher.code,
1713
+ initialBalance: voucher.initialBalance ?? 0,
1714
+ currentBalance: voucher.currentBalance ?? 0,
1715
+ spent: (voucher.initialBalance ?? 0) - (voucher.currentBalance ?? 0),
1716
+ voucherId: voucher._id
1717
+ };
1718
+ }
1719
+ async spend(input, ctx) {
1720
+ if (input.amount <= 0) throw new ValidationError("Spend amount must be positive");
1721
+ try {
1722
+ return await this.spendInTransaction(input, ctx);
1723
+ } catch (err) {
1724
+ if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
1725
+ throw err;
1726
+ }
1727
+ }
1728
+ async spendInTransaction(input, ctx) {
1729
+ return this.unitOfWork.withTransaction(async (session) => {
1730
+ const voucher = await this.voucherRepo.getByCode(input.code, {
1731
+ ...ctx,
1732
+ session
1733
+ });
1734
+ if (!voucher) throw new VoucherNotFoundError(input.code);
1735
+ this.assertVoucherUsable(voucher);
1736
+ const balance = voucher.currentBalance ?? 0;
1737
+ if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
1738
+ if (input.idempotencyKey) {
1739
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1740
+ ...ctx,
1741
+ session
1742
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1743
+ }
1744
+ const entry = {
1745
+ amount: -input.amount,
1746
+ orderId: input.orderId,
1747
+ description: input.description ?? `Spent on order ${input.orderId}`,
1748
+ createdAt: /* @__PURE__ */ new Date(),
1749
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1750
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1751
+ };
1752
+ const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
1753
+ ...ctx,
1754
+ session
1755
+ });
1756
+ const newBalance = updated.currentBalance ?? 0;
1757
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_SPENT, {
1758
+ voucherId: voucher._id,
1759
+ code: voucher.code,
1760
+ amount: input.amount,
1761
+ remainingBalance: newBalance,
1762
+ orderId: input.orderId
1763
+ }, ctx), {
1764
+ ...ctx,
1765
+ session
1766
+ });
1767
+ if (newBalance <= 0) {
1768
+ await this.voucherRepo.update(voucher._id, { status: "used" }, {
1769
+ lean: true,
1770
+ ...ctx,
1771
+ session
1772
+ });
1773
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_EXHAUSTED, {
1774
+ voucherId: voucher._id,
1775
+ code: voucher.code,
1776
+ status: "used"
1777
+ }, ctx), {
1778
+ ...ctx,
1779
+ session
1780
+ });
1781
+ }
1782
+ return {
1783
+ code: updated.code,
1784
+ initialBalance: updated.initialBalance ?? 0,
1785
+ currentBalance: newBalance,
1786
+ spent: (updated.initialBalance ?? 0) - newBalance,
1787
+ voucherId: updated._id
1788
+ };
1789
+ });
1790
+ }
1791
+ async topUp(input, ctx) {
1792
+ if (input.amount <= 0) throw new ValidationError("Top-up amount must be positive");
1793
+ try {
1794
+ return await this.topUpInTransaction(input, ctx);
1795
+ } catch (err) {
1796
+ if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
1797
+ throw err;
1798
+ }
1799
+ }
1800
+ async topUpInTransaction(input, ctx) {
1801
+ return this.unitOfWork.withTransaction(async (session) => {
1802
+ const voucher = await this.voucherRepo.getByCode(input.code, {
1803
+ ...ctx,
1804
+ session
1805
+ });
1806
+ if (!voucher) throw new VoucherNotFoundError(input.code);
1807
+ const maxBalance = this.config.giftCard.maxBalance;
1808
+ if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
1809
+ if (input.idempotencyKey) {
1810
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey, {
1811
+ ...ctx,
1812
+ session
1813
+ })) throw new DuplicateRedemptionError(input.idempotencyKey);
1814
+ }
1815
+ const entry = {
1816
+ amount: input.amount,
1817
+ description: input.description ?? "Top-up",
1818
+ createdAt: /* @__PURE__ */ new Date(),
1819
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1820
+ ...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
1821
+ };
1822
+ const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, input.amount, {
1823
+ ...ctx,
1824
+ session
1825
+ });
1826
+ if (voucher.status === "used") await this.voucherRepo.update(voucher._id, { status: "active" }, {
1827
+ lean: true,
1828
+ ...ctx,
1829
+ session
1830
+ });
1831
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_TOPPED_UP, {
1832
+ voucherId: voucher._id,
1833
+ code: voucher.code,
1834
+ amount: input.amount,
1835
+ newBalance: updated.currentBalance ?? 0
1836
+ }, ctx), {
1837
+ ...ctx,
1838
+ session
1839
+ });
1840
+ return {
1841
+ code: updated.code,
1842
+ initialBalance: updated.initialBalance ?? 0,
1843
+ currentBalance: updated.currentBalance ?? 0,
1844
+ spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
1845
+ voucherId: updated._id
1846
+ };
1847
+ });
1848
+ }
1849
+ assertVoucherUsable(voucher) {
1850
+ if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
1851
+ if (voucher.status === "cancelled") throw new VoucherExpiredError(voucher.code);
1852
+ if (voucher.status === "used") {
1853
+ if (voucher.initialBalance !== void 0) throw new GiftCardExhaustedError(voucher.code);
1854
+ throw new VoucherExhaustedError(voucher.code);
1855
+ }
1856
+ if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) throw new VoucherExpiredError(voucher.code);
1857
+ if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
1858
+ }
1859
+ };
1860
+ //#endregion
1861
+ //#region src/services/create-services.ts
1862
+ function createServices(deps) {
1863
+ const { repositories, unitOfWork, dispatchDeps, config, evaluationStore } = deps;
1864
+ const voucher = new VoucherService(repositories.voucher, repositories.program, unitOfWork, dispatchDeps, config);
1865
+ const store = evaluationStore ?? new MongoEvaluationStore(repositories.pendingEvaluation);
1866
+ return {
1867
+ voucher,
1868
+ evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, dispatchDeps, config, store)
22
1869
  };
23
- else tenant = {
24
- enabled: true,
25
- field: config.tenant.field ?? "organizationId",
26
- type: config.tenant.type ?? "ObjectId",
27
- contextKey: config.tenant.contextKey ?? config.tenant.field ?? "organizationId",
28
- ...config.tenant.ref ? { ref: config.tenant.ref } : {}
1870
+ }
1871
+ //#endregion
1872
+ //#region src/events/promo-event-catalog.ts
1873
+ function definePromoEvent(input) {
1874
+ const { name, version = 1, description, zodSchema } = input;
1875
+ const def = {
1876
+ name,
1877
+ version,
1878
+ schema: z.toJSONSchema(zodSchema),
1879
+ zodSchema,
1880
+ create(payload, meta) {
1881
+ return createEvent(name, payload, {
1882
+ source: "promo",
1883
+ ...meta
1884
+ });
1885
+ }
29
1886
  };
1887
+ if (description !== void 0) def.description = description;
1888
+ return def;
1889
+ }
1890
+ /** Mirrors `ProgramLifecyclePayload`. */
1891
+ const programLifecycleSchema = z.object({
1892
+ programId: z.string(),
1893
+ programType: z.string(),
1894
+ status: z.string(),
1895
+ actorId: z.string().optional()
1896
+ });
1897
+ /** Mirrors `RulePayload`. */
1898
+ const ruleSchema = z.object({
1899
+ programId: z.string(),
1900
+ ruleId: z.string(),
1901
+ actorId: z.string().optional()
1902
+ });
1903
+ /** Mirrors `RewardPayload`. */
1904
+ const rewardSchema = z.object({
1905
+ programId: z.string(),
1906
+ rewardId: z.string(),
1907
+ actorId: z.string().optional()
1908
+ });
1909
+ /** Mirrors `VoucherGeneratedPayload`. */
1910
+ const voucherGeneratedSchema = z.object({
1911
+ programId: z.string(),
1912
+ voucherIds: z.array(z.string()),
1913
+ codes: z.array(z.string()),
1914
+ count: z.number().int().nonnegative(),
1915
+ actorId: z.string().optional()
1916
+ });
1917
+ /** Mirrors `VoucherRedeemedPayload`. */
1918
+ const voucherRedeemedSchema = z.object({
1919
+ voucherId: z.string(),
1920
+ code: z.string(),
1921
+ orderId: z.string(),
1922
+ discountAmount: z.number(),
1923
+ customerId: z.string().optional()
1924
+ });
1925
+ /**
1926
+ * Mirrors `VoucherLifecyclePayload` — shared by VOUCHER_CANCELLED,
1927
+ * VOUCHER_EXPIRED, and GIFT_CARD_EXHAUSTED (repo emits `status: 'cancelled'`
1928
+ * / `'used'` / host-supplied terminal value).
1929
+ */
1930
+ const voucherLifecycleSchema = z.object({
1931
+ voucherId: z.string(),
1932
+ code: z.string(),
1933
+ status: z.string()
1934
+ });
1935
+ /** Mirrors `GiftCardSpentPayload`. */
1936
+ const giftCardSpentSchema = z.object({
1937
+ voucherId: z.string(),
1938
+ code: z.string(),
1939
+ amount: z.number(),
1940
+ remainingBalance: z.number(),
1941
+ orderId: z.string()
1942
+ });
1943
+ /** Mirrors `GiftCardToppedUpPayload`. */
1944
+ const giftCardToppedUpSchema = z.object({
1945
+ voucherId: z.string(),
1946
+ code: z.string(),
1947
+ amount: z.number(),
1948
+ newBalance: z.number()
1949
+ });
1950
+ /** Mirrors `EvaluationCompletedPayload`. */
1951
+ const evaluationCompletedSchema = z.object({
1952
+ evaluationId: z.string(),
1953
+ totalDiscount: z.number(),
1954
+ programsApplied: z.number().int().nonnegative(),
1955
+ codesUsed: z.array(z.string()),
1956
+ isPreview: z.boolean()
1957
+ });
1958
+ /** Mirrors `EvaluationCommittedPayload`. */
1959
+ const evaluationCommittedSchema = z.object({
1960
+ evaluationId: z.string(),
1961
+ orderId: z.string(),
1962
+ totalDiscount: z.number()
1963
+ });
1964
+ /** Single-field rollback payload — emitted by `evaluation.service.ts`. */
1965
+ const evaluationRolledBackSchema = z.object({ evaluationId: z.string() });
1966
+ const ProgramCreated = definePromoEvent({
1967
+ name: PromoEvents.PROGRAM_CREATED,
1968
+ description: "A new promo program was created (starts in draft).",
1969
+ zodSchema: programLifecycleSchema
1970
+ });
1971
+ const ProgramActivated = definePromoEvent({
1972
+ name: PromoEvents.PROGRAM_ACTIVATED,
1973
+ description: "A draft or paused program was activated.",
1974
+ zodSchema: programLifecycleSchema
1975
+ });
1976
+ const ProgramPaused = definePromoEvent({
1977
+ name: PromoEvents.PROGRAM_PAUSED,
1978
+ description: "An active program was paused.",
1979
+ zodSchema: programLifecycleSchema
1980
+ });
1981
+ const ProgramArchived = definePromoEvent({
1982
+ name: PromoEvents.PROGRAM_ARCHIVED,
1983
+ description: "A program was archived (terminal — no further transitions).",
1984
+ zodSchema: programLifecycleSchema
1985
+ });
1986
+ const RuleAdded = definePromoEvent({
1987
+ name: PromoEvents.RULE_ADDED,
1988
+ description: "A new rule was added to a program.",
1989
+ zodSchema: ruleSchema
1990
+ });
1991
+ const RuleUpdated = definePromoEvent({
1992
+ name: PromoEvents.RULE_UPDATED,
1993
+ description: "An existing rule on a program was updated.",
1994
+ zodSchema: ruleSchema
1995
+ });
1996
+ const RuleRemoved = definePromoEvent({
1997
+ name: PromoEvents.RULE_REMOVED,
1998
+ description: "A rule was removed from a program.",
1999
+ zodSchema: ruleSchema
2000
+ });
2001
+ const RewardAdded = definePromoEvent({
2002
+ name: PromoEvents.REWARD_ADDED,
2003
+ description: "A reward was added to a program.",
2004
+ zodSchema: rewardSchema
2005
+ });
2006
+ const RewardUpdated = definePromoEvent({
2007
+ name: PromoEvents.REWARD_UPDATED,
2008
+ description: "A reward on a program was updated.",
2009
+ zodSchema: rewardSchema
2010
+ });
2011
+ const RewardRemoved = definePromoEvent({
2012
+ name: PromoEvents.REWARD_REMOVED,
2013
+ description: "A reward was removed from a program.",
2014
+ zodSchema: rewardSchema
2015
+ });
2016
+ const VoucherGenerated = definePromoEvent({
2017
+ name: PromoEvents.VOUCHER_GENERATED,
2018
+ description: "One or more vouchers were generated for a program.",
2019
+ zodSchema: voucherGeneratedSchema
2020
+ });
2021
+ const VoucherRedeemed = definePromoEvent({
2022
+ name: PromoEvents.VOUCHER_REDEEMED,
2023
+ description: "A voucher was redeemed against an order.",
2024
+ zodSchema: voucherRedeemedSchema
2025
+ });
2026
+ const VoucherCancelled = definePromoEvent({
2027
+ name: PromoEvents.VOUCHER_CANCELLED,
2028
+ description: "A voucher was cancelled by an operator.",
2029
+ zodSchema: voucherLifecycleSchema
2030
+ });
2031
+ const VoucherExpired = definePromoEvent({
2032
+ name: PromoEvents.VOUCHER_EXPIRED,
2033
+ description: "A voucher reached its expiry date and was transitioned to expired.",
2034
+ zodSchema: voucherLifecycleSchema
2035
+ });
2036
+ const GiftCardSpent = definePromoEvent({
2037
+ name: PromoEvents.GIFT_CARD_SPENT,
2038
+ description: "A gift-card voucher was debited against an order — remaining balance reported.",
2039
+ zodSchema: giftCardSpentSchema
2040
+ });
2041
+ const GiftCardToppedUp = definePromoEvent({
2042
+ name: PromoEvents.GIFT_CARD_TOPPED_UP,
2043
+ description: "A gift-card voucher received a top-up — new balance reported.",
2044
+ zodSchema: giftCardToppedUpSchema
2045
+ });
2046
+ const GiftCardExhausted = definePromoEvent({
2047
+ name: PromoEvents.GIFT_CARD_EXHAUSTED,
2048
+ description: "A gift-card voucher reached a zero balance and was marked used.",
2049
+ zodSchema: voucherLifecycleSchema
2050
+ });
2051
+ const EvaluationCompleted = definePromoEvent({
2052
+ name: PromoEvents.EVALUATION_COMPLETED,
2053
+ description: "A cart evaluation finished (preview or pre-commit) — totals and applied codes reported.",
2054
+ zodSchema: evaluationCompletedSchema
2055
+ });
2056
+ const EvaluationCommitted = definePromoEvent({
2057
+ name: PromoEvents.EVALUATION_COMMITTED,
2058
+ description: "A stored evaluation was committed against an order — usage counters moved.",
2059
+ zodSchema: evaluationCommittedSchema
2060
+ });
2061
+ const EvaluationRolledBack = definePromoEvent({
2062
+ name: PromoEvents.EVALUATION_ROLLED_BACK,
2063
+ description: "A stored evaluation was discarded without commit.",
2064
+ zodSchema: evaluationRolledBackSchema
2065
+ });
2066
+ /**
2067
+ * Every promo event defined in the package — pass to Arc's
2068
+ * `EventRegistry`. Hosts wire ONE array; the whole `promo.*` namespace
2069
+ * becomes introspectable via OpenAPI and auto-validated at publish time
2070
+ * when `eventPlugin({ validateMode: 'reject' })` is set.
2071
+ */
2072
+ const promoEventDefinitions = [
2073
+ ProgramCreated,
2074
+ ProgramActivated,
2075
+ ProgramPaused,
2076
+ ProgramArchived,
2077
+ RuleAdded,
2078
+ RuleUpdated,
2079
+ RuleRemoved,
2080
+ RewardAdded,
2081
+ RewardUpdated,
2082
+ RewardRemoved,
2083
+ VoucherGenerated,
2084
+ VoucherRedeemed,
2085
+ VoucherCancelled,
2086
+ VoucherExpired,
2087
+ GiftCardSpent,
2088
+ GiftCardToppedUp,
2089
+ GiftCardExhausted,
2090
+ EvaluationCompleted,
2091
+ EvaluationCommitted,
2092
+ EvaluationRolledBack
2093
+ ];
2094
+ //#endregion
2095
+ //#region src/index.ts
2096
+ function resolveConfig(config) {
2097
+ const tenant = resolveTenantConfig(config.tenant);
2098
+ if (typeof config.tenant === "object" && config.tenant !== null) {
2099
+ const explicit = config.tenant.contextKey ?? config.tenant.tenantField;
2100
+ if (explicit) tenant.contextKey = explicit;
2101
+ }
30
2102
  const resolved = {
31
2103
  evaluation: {
32
2104
  maxStackablePromotions: config.evaluation?.maxStackablePromotions ?? 5,
@@ -47,40 +2119,87 @@ function resolveConfig(config) {
47
2119
  if (resolved.evaluation.maxStackablePromotions < 1) throw new ValidationError("maxStackablePromotions must be >= 1");
48
2120
  return resolved;
49
2121
  }
2122
+ /**
2123
+ * Thin adapter over `mongokit.withTransaction` so the engine's local
2124
+ * `UnitOfWork` port stays driver-agnostic while inheriting mongokit's
2125
+ * battle-tested transaction handling: auto-retry on
2126
+ * `TransientTransactionError` + `UnknownTransactionCommitResult`,
2127
+ * standalone-Mongo fallback for dev, consistent session lifecycle.
2128
+ *
2129
+ * Don't replace this with hand-rolled `session.withTransaction()` — the
2130
+ * underlying helper centralises retry semantics across every package
2131
+ * that wires it. See packages/mongokit/src/transaction.ts.
2132
+ */
50
2133
  var MongoUnitOfWork = class {
51
2134
  constructor(connection) {
52
2135
  this.connection = connection;
53
2136
  }
54
- async withTransaction(cb) {
55
- const session = await this.connection.startSession();
56
- try {
57
- let result;
58
- await session.withTransaction(async () => {
59
- result = await cb(session);
60
- });
61
- return result;
62
- } finally {
63
- await session.endSession();
64
- }
2137
+ withTransaction(cb) {
2138
+ return withTransaction(this.connection, cb);
65
2139
  }
66
2140
  };
2141
+ /**
2142
+ * Build the promo engine for a host application.
2143
+ *
2144
+ * **Index management — important for boot performance:**
2145
+ *
2146
+ * `createPromoEngine` itself is non-blocking. It registers Mongoose models
2147
+ * and returns immediately. Index creation is delegated to Mongoose's
2148
+ * standard lazy-init: with `autoIndex: true` (default in dev) Mongoose
2149
+ * builds indexes in the background after the first query touches each
2150
+ * model; with `autoIndex: false` (recommended for production) hosts
2151
+ * control index creation explicitly.
2152
+ *
2153
+ * The returned `engine.syncIndexes()` helper is opt-in and **MUST NOT be
2154
+ * `await`ed during Fastify plugin registration / boot** — Atlas index
2155
+ * creation can take 10s+ on fresh collections, longer than typical
2156
+ * plugin timeouts. Three safe patterns:
2157
+ *
2158
+ * 1. **Migration script** (recommended for production):
2159
+ * ```ts
2160
+ * // scripts/sync-indexes.ts
2161
+ * await engine.syncIndexes();
2162
+ * ```
2163
+ * Run before deploying / serving traffic.
2164
+ *
2165
+ * 2. **Background fire-and-log** during boot:
2166
+ * ```ts
2167
+ * engine.syncIndexes().catch((err) => log.warn({ err }, 'index sync'));
2168
+ * ```
2169
+ * App accepts traffic immediately; first queries may wait briefly
2170
+ * on still-building indexes.
2171
+ *
2172
+ * 3. **Lazy init** (`autoIndex: true` in dev): just don't call
2173
+ * `syncIndexes()` at all. Mongoose schedules creation on first
2174
+ * query per model.
2175
+ *
2176
+ * Production hosts should set `autoIndex: false` and use option (1).
2177
+ */
67
2178
  function createPromoEngine(config) {
68
2179
  const resolvedConfig = resolveConfig(config);
69
- const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes);
70
- const repositories = createRepositories(models, config.plugins, resolvedConfig.tenant.field);
71
- const unitOfWork = new MongoUnitOfWork(config.mongoose);
72
- const events = config.events?.adapter ?? new InProcessEventBus();
2180
+ const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
2181
+ const events = config.events?.transport ?? new InProcessPromoBus();
2182
+ const dispatchDeps = {
2183
+ events,
2184
+ ...config.outbox !== void 0 ? { outbox: config.outbox } : {},
2185
+ ...config.logger !== void 0 ? { logger: config.logger } : {}
2186
+ };
2187
+ const repositories = createRepositories(models, config.plugins, resolvedConfig.tenant, dispatchDeps);
73
2188
  return {
74
2189
  models,
75
2190
  repositories,
76
2191
  services: createServices({
77
2192
  repositories,
78
- unitOfWork,
79
- events,
80
- config: resolvedConfig
2193
+ unitOfWork: new MongoUnitOfWork(config.mongoose),
2194
+ dispatchDeps,
2195
+ config: resolvedConfig,
2196
+ ...config.evaluationStore !== void 0 ? { evaluationStore: config.evaluationStore } : {}
81
2197
  }),
82
- events
2198
+ events,
2199
+ async syncIndexes() {
2200
+ await Promise.all(Object.values(models).map((m) => m.createIndexes()));
2201
+ }
83
2202
  };
84
2203
  }
85
2204
  //#endregion
86
- export { PromoEvents, createPromoEngine, resolveConfig };
2205
+ 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 };