@classytic/promo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +763 -10
  6. package/dist/index.mjs +1721 -34
  7. package/dist/schemas/index.d.mts +253 -0
  8. package/dist/schemas/index.mjs +135 -0
  9. package/package.json +20 -35
  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,1712 @@
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 { resolveTenantConfig } from "@classytic/primitives/tenant";
3
+ import { createEvent, matchEventPattern } from "@classytic/primitives/events";
4
+ import mongoose from "mongoose";
5
+ import { Repository, multiTenantPlugin } from "@classytic/mongokit";
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
+ 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
+ //#region src/events/in-process-bus.ts
111
+ var InProcessPromoBus = class {
112
+ name = "in-process-promo";
113
+ handlers = /* @__PURE__ */ new Map();
114
+ logger;
115
+ constructor(options = {}) {
116
+ this.logger = options.logger ?? console;
117
+ }
118
+ async publish(event) {
119
+ const matched = /* @__PURE__ */ new Set();
120
+ for (const [pattern, set] of this.handlers.entries()) if (matchEventPattern(pattern, event.type)) for (const h of set) matched.add(h);
121
+ for (const handler of matched) try {
122
+ await handler(event);
123
+ } catch (err) {
124
+ this.logger.error(`[promo] handler error for ${event.type}:`, err);
125
+ }
126
+ }
127
+ async subscribe(pattern, handler) {
128
+ let set = this.handlers.get(pattern);
129
+ if (!set) {
130
+ set = /* @__PURE__ */ new Set();
131
+ this.handlers.set(pattern, set);
132
+ }
133
+ set.add(handler);
134
+ return () => {
135
+ const s = this.handlers.get(pattern);
136
+ if (!s) return;
137
+ s.delete(handler);
138
+ if (s.size === 0) this.handlers.delete(pattern);
139
+ };
140
+ }
141
+ async close() {
142
+ this.handlers.clear();
143
+ }
144
+ };
145
+ //#endregion
146
+ //#region src/models/create-model.ts
147
+ function injectTenantField(schema, tenant) {
148
+ schema.add({ [tenant.tenantField]: {
149
+ type: tenant.fieldType === "objectId" ? mongoose.Schema.Types.ObjectId : String,
150
+ index: true,
151
+ ...tenant.ref ? { ref: tenant.ref } : {}
152
+ } });
153
+ if (!tenant.enabled) return;
154
+ const existingIndexes = schema._indexes;
155
+ if (existingIndexes && existingIndexes.length > 0) for (const indexEntry of existingIndexes) {
156
+ const fields = indexEntry[0];
157
+ const newFields = { [tenant.tenantField]: 1 };
158
+ for (const [key, val] of Object.entries(fields)) newFields[key] = val;
159
+ indexEntry[0] = newFields;
160
+ }
161
+ }
162
+ function applyUserIndexes(schema, indexes, tenant) {
163
+ for (const def of indexes) {
164
+ const fields = tenant.enabled ? {
165
+ [tenant.tenantField]: 1,
166
+ ...def.fields
167
+ } : { ...def.fields };
168
+ schema.index(fields, def.options ?? {});
169
+ }
170
+ }
171
+ //#endregion
172
+ //#region src/models/schemas/program.schema.ts
173
+ const { Schema: Schema$3 } = mongoose;
174
+ function createProgramSchema() {
175
+ const schema = new Schema$3({
176
+ name: {
177
+ type: String,
178
+ required: true
179
+ },
180
+ description: { type: String },
181
+ programType: {
182
+ type: String,
183
+ enum: PROGRAM_TYPES,
184
+ required: true
185
+ },
186
+ triggerMode: {
187
+ type: String,
188
+ enum: TRIGGER_MODES,
189
+ required: true
190
+ },
191
+ status: {
192
+ type: String,
193
+ enum: PROGRAM_STATUSES,
194
+ default: "draft"
195
+ },
196
+ stackingMode: {
197
+ type: String,
198
+ enum: STACKING_MODES,
199
+ default: "exclusive"
200
+ },
201
+ priority: {
202
+ type: Number,
203
+ default: 0
204
+ },
205
+ startsAt: { type: Date },
206
+ endsAt: { type: Date },
207
+ maxUsageTotal: { type: Number },
208
+ usedCount: {
209
+ type: Number,
210
+ default: 0
211
+ },
212
+ maxUsagePerCustomer: { type: Number },
213
+ applicableCustomerIds: [{ type: String }],
214
+ applicableCustomerTags: [{ type: String }],
215
+ customerUsageCounts: {
216
+ type: Map,
217
+ of: Number,
218
+ default: () => /* @__PURE__ */ new Map()
219
+ },
220
+ metadata: { type: Schema$3.Types.Mixed }
221
+ }, { timestamps: true });
222
+ schema.index({
223
+ status: 1,
224
+ programType: 1,
225
+ priority: -1
226
+ });
227
+ schema.index({
228
+ status: 1,
229
+ startsAt: 1,
230
+ endsAt: 1
231
+ });
232
+ schema.index({
233
+ status: 1,
234
+ priority: -1,
235
+ _id: -1
236
+ });
237
+ return schema;
238
+ }
239
+ //#endregion
240
+ //#region src/models/schemas/reward.schema.ts
241
+ const { Schema: Schema$2 } = mongoose;
242
+ function createRewardSchema() {
243
+ const schema = new Schema$2({
244
+ programId: {
245
+ type: Schema$2.Types.ObjectId,
246
+ required: true
247
+ },
248
+ ruleId: { type: Schema$2.Types.ObjectId },
249
+ rewardType: {
250
+ type: String,
251
+ enum: REWARD_TYPES,
252
+ required: true
253
+ },
254
+ discountMode: {
255
+ type: String,
256
+ enum: DISCOUNT_MODES
257
+ },
258
+ discountAmount: { type: Number },
259
+ maxDiscountAmount: { type: Number },
260
+ discountScope: {
261
+ type: String,
262
+ enum: DISCOUNT_SCOPES,
263
+ default: "order"
264
+ },
265
+ applicableProductIds: [{ type: String }],
266
+ freeProductId: { type: String },
267
+ freeProductSku: { type: String },
268
+ freeQuantity: {
269
+ type: Number,
270
+ default: 1
271
+ },
272
+ giftCardAmount: { type: Number },
273
+ metadata: { type: Schema$2.Types.Mixed }
274
+ }, { timestamps: true });
275
+ schema.index({ programId: 1 });
276
+ schema.index({ ruleId: 1 }, { sparse: true });
277
+ return schema;
278
+ }
279
+ //#endregion
280
+ //#region src/models/schemas/rule.schema.ts
281
+ const { Schema: Schema$1 } = mongoose;
282
+ function createRuleSchema() {
283
+ const schema = new Schema$1({
284
+ programId: {
285
+ type: Schema$1.Types.ObjectId,
286
+ required: true
287
+ },
288
+ name: { type: String },
289
+ minimumAmount: {
290
+ type: Number,
291
+ default: 0
292
+ },
293
+ minimumQuantity: {
294
+ type: Number,
295
+ default: 0
296
+ },
297
+ applicableProductIds: [{ type: String }],
298
+ applicableCategories: [{ type: String }],
299
+ applicableSkus: [{ type: String }],
300
+ buyQuantity: { type: Number },
301
+ code: {
302
+ type: String,
303
+ uppercase: true,
304
+ trim: true
305
+ },
306
+ startsAt: { type: Date },
307
+ endsAt: { type: Date },
308
+ metadata: { type: Schema$1.Types.Mixed }
309
+ }, { timestamps: true });
310
+ schema.index({ programId: 1 });
311
+ schema.index({ code: 1 }, { sparse: true });
312
+ return schema;
313
+ }
314
+ //#endregion
315
+ //#region src/models/schemas/voucher.schema.ts
316
+ const { Schema } = mongoose;
317
+ function createVoucherSchema() {
318
+ const schema = new Schema({
319
+ programId: {
320
+ type: Schema.Types.ObjectId,
321
+ required: true,
322
+ index: true
323
+ },
324
+ code: {
325
+ type: String,
326
+ required: true,
327
+ uppercase: true,
328
+ trim: true
329
+ },
330
+ status: {
331
+ type: String,
332
+ enum: VOUCHER_STATUSES,
333
+ default: "active"
334
+ },
335
+ customerId: { type: String },
336
+ usageLimit: {
337
+ type: Number,
338
+ default: 1
339
+ },
340
+ usedCount: {
341
+ type: Number,
342
+ default: 0
343
+ },
344
+ initialBalance: { type: Number },
345
+ currentBalance: { type: Number },
346
+ balanceLedger: [{
347
+ amount: {
348
+ type: Number,
349
+ required: true
350
+ },
351
+ orderId: { type: String },
352
+ description: { type: String },
353
+ createdAt: {
354
+ type: Date,
355
+ default: Date.now
356
+ },
357
+ idempotencyKey: { type: String }
358
+ }],
359
+ expiresAt: { type: Date },
360
+ redemptions: [{
361
+ orderId: {
362
+ type: String,
363
+ required: true
364
+ },
365
+ customerId: { type: String },
366
+ discountAmount: {
367
+ type: Number,
368
+ required: true
369
+ },
370
+ redeemedAt: {
371
+ type: Date,
372
+ default: Date.now
373
+ },
374
+ idempotencyKey: { type: String }
375
+ }],
376
+ metadata: { type: Schema.Types.Mixed }
377
+ }, { timestamps: true });
378
+ schema.index({ code: 1 }, { unique: true });
379
+ schema.index({
380
+ programId: 1,
381
+ status: 1
382
+ });
383
+ schema.index({
384
+ customerId: 1,
385
+ status: 1
386
+ }, { sparse: true });
387
+ schema.index({ expiresAt: 1 }, { sparse: true });
388
+ return schema;
389
+ }
390
+ //#endregion
391
+ //#region src/models/create-models.ts
392
+ const MODEL_NAMES = [
393
+ "PromoProgram",
394
+ "PromoRule",
395
+ "PromoReward",
396
+ "PromoVoucher"
397
+ ];
398
+ function applyAutoIndex(models, autoIndex) {
399
+ if (autoIndex === void 0) return;
400
+ for (const [configKey, modelKey] of [
401
+ ["program", "Program"],
402
+ ["rule", "Rule"],
403
+ ["reward", "Reward"],
404
+ ["voucher", "Voucher"]
405
+ ]) {
406
+ const value = typeof autoIndex === "boolean" ? autoIndex : autoIndex[configKey] ?? void 0;
407
+ if (value !== void 0) models[modelKey].schema.set("autoIndex", value);
408
+ }
409
+ }
410
+ function createModels(connection, tenant, indexes, autoIndex) {
411
+ for (const name of MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
412
+ const programSchema = createProgramSchema();
413
+ const ruleSchema = createRuleSchema();
414
+ const rewardSchema = createRewardSchema();
415
+ const voucherSchema = createVoucherSchema();
416
+ injectTenantField(programSchema, tenant);
417
+ injectTenantField(ruleSchema, tenant);
418
+ injectTenantField(rewardSchema, tenant);
419
+ injectTenantField(voucherSchema, tenant);
420
+ if (indexes?.program) applyUserIndexes(programSchema, indexes.program, tenant);
421
+ if (indexes?.rule) applyUserIndexes(ruleSchema, indexes.rule, tenant);
422
+ if (indexes?.reward) applyUserIndexes(rewardSchema, indexes.reward, tenant);
423
+ if (indexes?.voucher) applyUserIndexes(voucherSchema, indexes.voucher, tenant);
424
+ const result = {
425
+ Program: connection.model("PromoProgram", programSchema),
426
+ Rule: connection.model("PromoRule", ruleSchema),
427
+ Reward: connection.model("PromoReward", rewardSchema),
428
+ Voucher: connection.model("PromoVoucher", voucherSchema)
16
429
  };
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
430
+ applyAutoIndex(result, autoIndex);
431
+ return result;
432
+ }
433
+ //#endregion
434
+ //#region src/events/dispatch.ts
435
+ /**
436
+ * Canonical P8 shape — save to outbox first (with `ctx.session` when
437
+ * present), then publish to transport. Both paths run independently.
438
+ */
439
+ async function dispatchPromoEvent(deps, event, ctx) {
440
+ const logger = deps.logger ?? console;
441
+ if (deps.outbox) try {
442
+ const saveOptions = {};
443
+ if (ctx?.session !== void 0) saveOptions.session = ctx.session;
444
+ await deps.outbox.save(event, saveOptions);
445
+ } catch (err) {
446
+ logger.error(`[promo] outbox.save failed for ${event.type}:`, err);
447
+ }
448
+ if (deps.events) try {
449
+ await deps.events.publish(event);
450
+ } catch (err) {
451
+ logger.error(`[promo] events.publish failed for ${event.type}:`, err);
452
+ }
453
+ }
454
+ //#endregion
455
+ //#region src/events/event-constants.ts
456
+ const PromoEvents = {
457
+ PROGRAM_CREATED: "promo.program.created",
458
+ PROGRAM_ACTIVATED: "promo.program.activated",
459
+ PROGRAM_PAUSED: "promo.program.paused",
460
+ PROGRAM_ARCHIVED: "promo.program.archived",
461
+ RULE_ADDED: "promo.rule.added",
462
+ RULE_UPDATED: "promo.rule.updated",
463
+ RULE_REMOVED: "promo.rule.removed",
464
+ REWARD_ADDED: "promo.reward.added",
465
+ REWARD_UPDATED: "promo.reward.updated",
466
+ REWARD_REMOVED: "promo.reward.removed",
467
+ VOUCHER_GENERATED: "promo.voucher.generated",
468
+ VOUCHER_REDEEMED: "promo.voucher.redeemed",
469
+ VOUCHER_CANCELLED: "promo.voucher.cancelled",
470
+ VOUCHER_EXPIRED: "promo.voucher.expired",
471
+ GIFT_CARD_SPENT: "promo.gift_card.spent",
472
+ GIFT_CARD_TOPPED_UP: "promo.gift_card.topped_up",
473
+ GIFT_CARD_EXHAUSTED: "promo.gift_card.exhausted",
474
+ EVALUATION_COMPLETED: "promo.evaluation.completed",
475
+ EVALUATION_COMMITTED: "promo.evaluation.committed",
476
+ EVALUATION_ROLLED_BACK: "promo.evaluation.rolled_back"
477
+ };
478
+ //#endregion
479
+ //#region src/events/helpers.ts
480
+ function createEvent$1(type, payload, ctx, meta) {
481
+ return createEvent(type, payload, {
482
+ resource: "promo",
483
+ ...ctx?.actorId !== void 0 ? { userId: ctx.actorId } : {},
484
+ ...ctx?.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {},
485
+ ...meta
486
+ });
487
+ }
488
+ //#endregion
489
+ //#region src/repositories/program.repository.ts
490
+ var ProgramRepository = class extends Repository {
491
+ dispatchDeps;
492
+ constructor(model, plugins = [], dispatchDeps = {}) {
493
+ super(model, plugins);
494
+ this.dispatchDeps = dispatchDeps;
495
+ }
496
+ async activate(id, ctx) {
497
+ return this._transition(id, "active", ctx);
498
+ }
499
+ async pause(id, ctx) {
500
+ return this._transition(id, "paused", ctx);
501
+ }
502
+ async archive(id, ctx) {
503
+ return this._transition(id, "archived", ctx);
504
+ }
505
+ async _transition(id, targetStatus, ctx) {
506
+ const program = await this.getById(id, {
507
+ throwOnNotFound: false,
508
+ lean: true,
509
+ ...ctx
510
+ });
511
+ if (!program) throw new ProgramNotFoundError(id);
512
+ if (!PROGRAM_TRANSITIONS[program.status]?.includes(targetStatus)) throw new InvalidTransitionError(program.status, targetStatus);
513
+ const updated = await this.update(id, { status: targetStatus }, {
514
+ throwOnNotFound: true,
515
+ lean: true,
516
+ ...ctx
517
+ });
518
+ if (!updated) throw new ProgramNotFoundError(id);
519
+ const eventName = {
520
+ active: PromoEvents.PROGRAM_ACTIVATED,
521
+ paused: PromoEvents.PROGRAM_PAUSED,
522
+ archived: PromoEvents.PROGRAM_ARCHIVED
523
+ }[targetStatus];
524
+ if (eventName) await dispatchPromoEvent(this.dispatchDeps, createEvent$1(eventName, {
525
+ programId: updated._id,
526
+ programType: updated.programType,
527
+ status: updated.status,
528
+ actorId: ctx.actorId
529
+ }, ctx), ctx);
530
+ return updated;
531
+ }
532
+ async incrementUsage(id, ctx) {
533
+ const updated = await this.update(id, { $inc: { usedCount: 1 } }, {
534
+ throwOnNotFound: true,
535
+ lean: true,
536
+ ...ctx
537
+ });
538
+ if (!updated) throw new ProgramNotFoundError(id);
539
+ return updated;
540
+ }
541
+ async decrementUsage(id, ctx) {
542
+ const updated = await this.update(id, { $inc: { usedCount: -1 } }, {
543
+ throwOnNotFound: true,
544
+ lean: true,
545
+ ...ctx
546
+ });
547
+ if (!updated) throw new ProgramNotFoundError(id);
548
+ return updated;
549
+ }
550
+ async getCustomerUsage(id, customerId, ctx) {
551
+ const doc = await this.getById(id, {
552
+ throwOnNotFound: false,
553
+ lean: true,
554
+ ...ctx
555
+ });
556
+ if (!doc) return 0;
557
+ const counts = doc.customerUsageCounts;
558
+ if (!counts) return 0;
559
+ if (counts instanceof Map) return counts.get(customerId) ?? 0;
560
+ return counts[customerId] ?? 0;
561
+ }
562
+ async incrementCustomerUsage(id, customerId, ctx) {
563
+ const updated = await this.update(id, { $inc: { [`customerUsageCounts.${customerId}`]: 1 } }, {
564
+ throwOnNotFound: true,
565
+ lean: true,
566
+ ...ctx
567
+ });
568
+ if (!updated) throw new ProgramNotFoundError(id);
569
+ return updated;
570
+ }
571
+ async findActive(now, ctx) {
572
+ const currentDate = now ?? /* @__PURE__ */ new Date();
573
+ const filters = {
574
+ status: "active",
575
+ $or: [
576
+ { startsAt: { $lte: currentDate } },
577
+ { startsAt: { $exists: false } },
578
+ { startsAt: null }
579
+ ]
580
+ };
581
+ return (await this.findAll(filters, {
582
+ sort: {
583
+ priority: -1,
584
+ _id: -1
585
+ },
586
+ lean: true,
587
+ ...ctx
588
+ })).filter((p) => !p.endsAt || p.endsAt >= currentDate).slice(0, 200);
589
+ }
590
+ };
591
+ //#endregion
592
+ //#region src/repositories/reward.repository.ts
593
+ var RewardRepository = class extends Repository {
594
+ constructor(model, plugins = []) {
595
+ super(model, plugins);
596
+ }
597
+ };
598
+ //#endregion
599
+ //#region src/repositories/rule.repository.ts
600
+ var RuleRepository = class extends Repository {
601
+ constructor(model, plugins = []) {
602
+ super(model, plugins);
603
+ }
604
+ };
605
+ //#endregion
606
+ //#region src/repositories/voucher.repository.ts
607
+ var VoucherRepository = class extends Repository {
608
+ dispatchDeps;
609
+ tenantField;
610
+ constructor(model, plugins = [], dispatchDeps = {}, tenantField = "organizationId") {
611
+ super(model, plugins);
612
+ this.dispatchDeps = dispatchDeps;
613
+ this.tenantField = tenantField;
614
+ }
615
+ /**
616
+ * Copy the tenant id from `ctx` onto the write payload so the doc persists
617
+ * 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.
622
+ */
623
+ _injectTenant(data, ctx) {
624
+ const tenantValue = ctx?.[this.tenantField];
625
+ if (tenantValue != null && data[this.tenantField] == null) data[this.tenantField] = tenantValue;
626
+ return data;
627
+ }
628
+ async create(data, options) {
629
+ this._injectTenant(data, options);
630
+ return super.create(data, options);
631
+ }
632
+ async createMany(docs, options) {
633
+ const ctx = options;
634
+ for (const doc of docs) this._injectTenant(doc, ctx);
635
+ return super.createMany(docs, options);
636
+ }
637
+ /** Domain verb: cancel a voucher (status transition + event). */
638
+ async cancel(id, ctx) {
639
+ const voucher = await this.getById(id, {
640
+ throwOnNotFound: false,
641
+ lean: true,
642
+ ...ctx
643
+ });
644
+ if (!voucher) throw new VoucherNotFoundError(id);
645
+ const updated = await this.update(id, { status: "cancelled" }, {
646
+ throwOnNotFound: true,
647
+ lean: true,
648
+ ...ctx
649
+ });
650
+ if (!updated) throw new VoucherNotFoundError(id);
651
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_CANCELLED, {
652
+ voucherId: id,
653
+ code: voucher.code,
654
+ status: "cancelled"
655
+ }, ctx), ctx);
656
+ return updated;
657
+ }
658
+ /** Atomic usage increment + push redemption record. */
659
+ async incrementUsage(id, redemption, ctx) {
660
+ const updated = await this.update(id, {
661
+ $inc: { usedCount: 1 },
662
+ $push: { redemptions: redemption }
663
+ }, {
664
+ throwOnNotFound: true,
665
+ lean: true,
666
+ ...ctx
667
+ });
668
+ if (!updated) throw new VoucherNotFoundError(id);
669
+ return updated;
670
+ }
671
+ /** Atomic balance delta + push ledger entry. */
672
+ async addLedgerEntry(id, entry, balanceDelta, ctx) {
673
+ const updated = await this.update(id, {
674
+ $inc: { currentBalance: balanceDelta },
675
+ $push: { balanceLedger: entry }
676
+ }, {
677
+ throwOnNotFound: true,
678
+ lean: true,
679
+ ...ctx
680
+ });
681
+ if (!updated) throw new VoucherNotFoundError(id);
682
+ return updated;
683
+ }
684
+ /**
685
+ * Batch expire by date.
686
+ *
687
+ * Routes through `findAll` + per-doc `update` instead of a raw
688
+ * `this.Model.updateMany`. Rationale:
689
+ * 1. `findAll` casts the filter through the schema (so an ObjectId
690
+ * tenant field on a write-time String context doesn't silently
691
+ * return 0 rows the way a raw `$match` would).
692
+ * 2. `update` fires the full hook pipeline (multi-tenant, audit,
693
+ * soft-delete) on every expiration — a bulk `updateMany` at the
694
+ * Model layer bypasses all of them.
695
+ * Volume is bounded per tenant (active + past-expiry vouchers), so an
696
+ * N+1 loop is acceptable for correctness.
697
+ */
698
+ async expireByDate(before, ctx) {
699
+ const filter = {
700
+ status: "active",
701
+ expiresAt: { $lte: before }
702
+ };
703
+ const docs = await this.findAll(filter, {
704
+ lean: true,
705
+ ...ctx
706
+ });
707
+ let modified = 0;
708
+ for (const doc of docs) if (await this.update(doc._id, { status: "expired" }, {
709
+ throwOnNotFound: false,
710
+ lean: true,
711
+ ...ctx
712
+ })) modified++;
713
+ return modified;
714
+ }
715
+ /**
716
+ * Lookup voucher by unique code, scoped to the tenant in `ctx` when
717
+ * tenant scoping is configured.
718
+ *
719
+ * Manual tenant injection mirrors `expireByDate` and `ProgramRepository.findActive`
720
+ * — it makes the method work whether the host opts into the auto-wired
721
+ * `multiTenantPlugin` or enforces scoping at its own framework layer
722
+ * (e.g. arc's preset + `BaseController`). Without this, a host that
723
+ * runs the plugin off would leak vouchers across branches at
724
+ * validate/redeem/spend/topUp call sites.
725
+ */
726
+ async getByCode(code, ctx) {
727
+ const filter = { code: code.toUpperCase() };
728
+ const tenantValue = ctx?.[this.tenantField];
729
+ if (tenantValue != null) filter[this.tenantField] = tenantValue;
730
+ return this.getByQuery(filter, {
731
+ throwOnNotFound: false,
732
+ lean: true,
733
+ ...ctx
734
+ });
735
+ }
736
+ /**
737
+ * Idempotency check on nested arrays. Routes through mongokit's `count`
738
+ * so `multiTenantPlugin` injects the configured tenant field from
739
+ * `ctx` — a non-tenant-scoped check would cross organization boundaries.
740
+ */
741
+ async hasIdempotencyKey(id, key, ctx) {
742
+ const filter = {
743
+ _id: id,
744
+ $or: [{ "redemptions.idempotencyKey": key }, { "balanceLedger.idempotencyKey": key }]
745
+ };
746
+ return await this.count(filter, ctx) > 0;
747
+ }
748
+ };
749
+ //#endregion
750
+ //#region src/repositories/create-repositories.ts
751
+ function createRepositories(models, plugins, tenant, dispatchDeps = {}) {
752
+ const tenantPlugins = tenant?.enabled ? [multiTenantPlugin({
753
+ tenantField: tenant.tenantField,
754
+ contextKey: tenant.contextKey,
755
+ fieldType: tenant.fieldType
756
+ })] : [];
757
+ return {
758
+ program: new ProgramRepository(models.Program, [...tenantPlugins, ...plugins?.program ?? []], dispatchDeps),
759
+ rule: new RuleRepository(models.Rule, [...tenantPlugins, ...plugins?.rule ?? []]),
760
+ reward: new RewardRepository(models.Reward, [...tenantPlugins, ...plugins?.reward ?? []]),
761
+ voucher: new VoucherRepository(models.Voucher, [...tenantPlugins, ...plugins?.voucher ?? []], dispatchDeps, tenant?.tenantField)
22
762
  };
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 } : {}
763
+ }
764
+ //#endregion
765
+ //#region src/services/evaluation.service.ts
766
+ /**
767
+ * Deterministic, collision-resistant hash of the evaluated cart. The hash
768
+ * covers everything the evaluation algorithm depends on: normalized line
769
+ * items (sorted), subtotal, applied codes, and customer identity. Two
770
+ * evaluations over an identical cart yield the same hash; any mutation —
771
+ * quantity, unit price, added/removed line, different code, different
772
+ * customer — yields a different hash.
773
+ */
774
+ function computeCartHash(input) {
775
+ const items = [...input.items].map((item) => ({
776
+ productId: item.productId ?? "",
777
+ sku: item.sku ?? "",
778
+ categoryId: item.categoryId ?? "",
779
+ quantity: item.quantity,
780
+ unitPrice: item.unitPrice,
781
+ lineTotal: item.lineTotal ?? item.quantity * item.unitPrice
782
+ })).sort((a, b) => {
783
+ if (a.productId !== b.productId) return a.productId < b.productId ? -1 : 1;
784
+ if (a.sku !== b.sku) return a.sku < b.sku ? -1 : 1;
785
+ if (a.unitPrice !== b.unitPrice) return a.unitPrice - b.unitPrice;
786
+ return a.quantity - b.quantity;
787
+ });
788
+ const codes = [...input.codes ?? []].map((c) => c.toUpperCase()).sort();
789
+ const tags = [...input.customerTags ?? []].sort();
790
+ const canonical = JSON.stringify({
791
+ items,
792
+ subtotal: input.subtotal,
793
+ codes,
794
+ customerId: input.customerId ?? null,
795
+ customerTags: tags
796
+ });
797
+ return createHash("sha256").update(canonical).digest("hex");
798
+ }
799
+ var EvaluationService = class {
800
+ pendingEvaluations = /* @__PURE__ */ new Map();
801
+ constructor(programRepo, ruleRepo, rewardRepo, voucherRepo, unitOfWork, dispatchDeps, config) {
802
+ this.programRepo = programRepo;
803
+ this.ruleRepo = ruleRepo;
804
+ this.rewardRepo = rewardRepo;
805
+ this.voucherRepo = voucherRepo;
806
+ this.unitOfWork = unitOfWork;
807
+ this.dispatchDeps = dispatchDeps;
808
+ this.config = config;
809
+ }
810
+ async evaluate(input, ctx) {
811
+ return this.doEvaluate(input, ctx, false);
812
+ }
813
+ async preview(input, ctx) {
814
+ return this.doEvaluate(input, ctx, true);
815
+ }
816
+ 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,
828
+ session
829
+ });
830
+ }
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
+ }, {
836
+ ...ctx,
837
+ 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), {
852
+ ...ctx,
853
+ session
854
+ });
855
+ return commitResult;
856
+ });
857
+ }
858
+ async rollback(evaluationId, ctx) {
859
+ if (!this.pendingEvaluations.get(evaluationId)) return;
860
+ this.pendingEvaluations.delete(evaluationId);
861
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId }, ctx), ctx);
862
+ }
863
+ async doEvaluate(input, ctx, isPreview) {
864
+ const submittedCodes = (input.codes ?? []).map((c) => c.toUpperCase());
865
+ const programs = await this.programRepo.findActive(void 0, ctx);
866
+ const programIds = programs.map((p) => p._id);
867
+ const [allRules, allRewards] = await Promise.all([this.ruleRepo.findAll({ programId: { $in: programIds } }, ctx), this.rewardRepo.findAll({ programId: { $in: programIds } }, ctx)]);
868
+ const rulesByProgram = groupBy(allRules, (r) => r.programId);
869
+ const rewardsByProgram = groupBy(allRewards, (r) => r.programId);
870
+ const voucherMap = /* @__PURE__ */ new Map();
871
+ for (const code of submittedCodes) {
872
+ const voucher = await this.voucherRepo.getByCode(code, ctx);
873
+ if (voucher && voucher.status === "active" && voucher.usedCount < voucher.usageLimit) voucherMap.set(code, {
874
+ voucherId: voucher._id,
875
+ programId: voucher.programId,
876
+ balance: voucher.currentBalance
877
+ });
878
+ }
879
+ const appliedDiscounts = [];
880
+ const freeProducts = [];
881
+ const appliedCodes = [];
882
+ const rejectedCodes = [];
883
+ const warnings = [];
884
+ const programsApplied = [];
885
+ const programUsages = [];
886
+ const voucherUsages = [];
887
+ let runningSubtotal = input.subtotal;
888
+ let exclusiveApplied = false;
889
+ let stackableCount = 0;
890
+ for (const program of programs) {
891
+ if (program.maxUsageTotal && program.usedCount >= program.maxUsageTotal) continue;
892
+ if (!this.isCustomerEligible(program, input)) continue;
893
+ if (program.maxUsagePerCustomer && input.customerId) {
894
+ if (await this.programRepo.getCustomerUsage(program._id, input.customerId, ctx) >= program.maxUsagePerCustomer) {
895
+ warnings.push(`Customer has reached max usage (${program.maxUsagePerCustomer}) for "${program.name}"`);
896
+ continue;
897
+ }
898
+ }
899
+ if (program.stackingMode === "exclusive" && exclusiveApplied) continue;
900
+ if (program.stackingMode === "stackable" && stackableCount >= this.config.evaluation.maxStackablePromotions) {
901
+ warnings.push(`Max stackable promotions (${this.config.evaluation.maxStackablePromotions}) reached`);
902
+ continue;
903
+ }
904
+ const rules = rulesByProgram.get(String(program._id)) ?? [];
905
+ const rewards = rewardsByProgram.get(String(program._id)) ?? [];
906
+ if (rules.length === 0 || rewards.length === 0) continue;
907
+ const matchResult = this.matchBestRule(program, rules, input.items, input.subtotal, submittedCodes, voucherMap);
908
+ if (!matchResult.matched) {
909
+ if (matchResult.rejectedCode) rejectedCodes.push(matchResult.rejectedCode);
910
+ continue;
911
+ }
912
+ const applicableRewards = this.filterRewardsByRule(rewards, matchResult.matchedRuleId);
913
+ for (const reward of applicableRewards) if (reward.rewardType === "discount") {
914
+ const discount = this.computeDiscount(reward, input.items, runningSubtotal);
915
+ if (discount <= 0) continue;
916
+ appliedDiscounts.push({
917
+ programId: program._id,
918
+ programName: program.name,
919
+ rewardId: reward._id,
920
+ type: reward.discountMode ?? "fixed",
921
+ scope: reward.discountScope,
922
+ amount: discount,
923
+ description: this.describeDiscount(reward, program),
924
+ voucherCode: matchResult.matchedCode
925
+ });
926
+ runningSubtotal = Math.max(0, runningSubtotal - discount);
927
+ } else if (reward.rewardType === "free_product") freeProducts.push({
928
+ programId: program._id,
929
+ programName: program.name,
930
+ rewardId: reward._id,
931
+ productId: reward.freeProductId,
932
+ productSku: reward.freeProductSku,
933
+ quantity: reward.freeQuantity,
934
+ description: `Free product from "${program.name}"`
935
+ });
936
+ programsApplied.push(program._id);
937
+ programUsages.push({ programId: program._id });
938
+ if (matchResult.matchedCode) {
939
+ appliedCodes.push(matchResult.matchedCode);
940
+ const voucherInfo = voucherMap.get(matchResult.matchedCode);
941
+ if (voucherInfo) {
942
+ const totalProgramDiscount = appliedDiscounts.filter((d) => d.programId === program._id).reduce((sum, d) => sum + d.amount, 0);
943
+ voucherUsages.push({
944
+ voucherId: voucherInfo.voucherId,
945
+ code: matchResult.matchedCode,
946
+ discountAmount: totalProgramDiscount
947
+ });
948
+ }
949
+ }
950
+ if (program.stackingMode === "exclusive") exclusiveApplied = true;
951
+ else stackableCount++;
952
+ }
953
+ for (const code of submittedCodes) if (!appliedCodes.includes(code) && !rejectedCodes.some((r) => r.code === code)) rejectedCodes.push({
954
+ code,
955
+ reason: "No matching active program found for this code"
956
+ });
957
+ const totalDiscount = appliedDiscounts.reduce((sum, d) => sum + d.amount, 0);
958
+ const evaluationId = randomBytes(16).toString("hex");
959
+ const cartHash = computeCartHash(input);
960
+ const result = {
961
+ evaluationId,
962
+ cartHash,
963
+ appliedDiscounts,
964
+ freeProducts,
965
+ totalDiscount,
966
+ subtotalAfterDiscount: Math.max(0, input.subtotal - totalDiscount),
967
+ appliedCodes,
968
+ rejectedCodes,
969
+ warnings,
970
+ isPreview,
971
+ programsApplied
972
+ };
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
+ });
982
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.EVALUATION_COMPLETED, {
983
+ evaluationId,
984
+ totalDiscount,
985
+ programsApplied: programsApplied.length,
986
+ codesUsed: appliedCodes,
987
+ isPreview
988
+ }, ctx), ctx);
989
+ return result;
990
+ }
991
+ isCustomerEligible(program, input) {
992
+ if (program.applicableCustomerIds.length > 0) {
993
+ if (!input.customerId) return false;
994
+ if (!program.applicableCustomerIds.includes(input.customerId)) return false;
995
+ }
996
+ if (program.applicableCustomerTags.length > 0) {
997
+ const customerTags = input.customerTags ?? [];
998
+ if (customerTags.length === 0) return false;
999
+ if (!program.applicableCustomerTags.some((tag) => customerTags.includes(tag))) return false;
1000
+ }
1001
+ return true;
1002
+ }
1003
+ matchBestRule(program, rules, items, subtotal, submittedCodes, voucherMap) {
1004
+ let bestMatch = { matched: false };
1005
+ let bestThreshold = -1;
1006
+ let lastRejected;
1007
+ for (const rule of rules) {
1008
+ const singleResult = this.matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap);
1009
+ if (singleResult.rejectedCode) lastRejected = singleResult.rejectedCode;
1010
+ if (!singleResult.matched) continue;
1011
+ const threshold = (rule.minimumAmount ?? 0) * 1e3 + (rule.minimumQuantity ?? 0);
1012
+ if (threshold > bestThreshold) {
1013
+ bestThreshold = threshold;
1014
+ bestMatch = singleResult;
1015
+ }
1016
+ }
1017
+ if (!bestMatch.matched && lastRejected) bestMatch.rejectedCode = lastRejected;
1018
+ return bestMatch;
1019
+ }
1020
+ matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap) {
1021
+ if (program.triggerMode === "code") {
1022
+ if (rule.code) {
1023
+ if (!submittedCodes.includes(rule.code)) return { matched: false };
1024
+ if (program.programType === "coupon" || program.programType === "discount_code") {
1025
+ const voucherInfo = voucherMap.get(rule.code);
1026
+ if (!voucherInfo || String(voucherInfo.programId) !== String(program._id)) return {
1027
+ matched: false,
1028
+ rejectedCode: {
1029
+ code: rule.code,
1030
+ reason: "Invalid or exhausted voucher"
1031
+ }
1032
+ };
1033
+ }
1034
+ } else if (![...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))) return { matched: false };
1035
+ }
1036
+ const now = /* @__PURE__ */ new Date();
1037
+ if (rule.startsAt && rule.startsAt > now) return { matched: false };
1038
+ if (rule.endsAt && rule.endsAt < now) return { matched: false };
1039
+ if (rule.minimumAmount > 0 && subtotal < rule.minimumAmount) return { matched: false };
1040
+ if (rule.minimumQuantity > 0) {
1041
+ if (items.reduce((sum, item) => sum + item.quantity, 0) < rule.minimumQuantity) return { matched: false };
1042
+ }
1043
+ if (rule.applicableProductIds.length > 0) {
1044
+ if (!items.some((item) => rule.applicableProductIds.includes(item.productId))) return { matched: false };
1045
+ }
1046
+ if (rule.applicableCategories.length > 0) {
1047
+ if (!items.some((item) => item.categoryId && rule.applicableCategories.includes(item.categoryId))) return { matched: false };
1048
+ }
1049
+ if (rule.applicableSkus.length > 0) {
1050
+ if (!items.some((item) => item.sku && rule.applicableSkus.includes(item.sku))) return { matched: false };
1051
+ }
1052
+ if (rule.buyQuantity && rule.buyQuantity > 0) {
1053
+ 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 };
1054
+ }
1055
+ let matchedCode;
1056
+ if (program.triggerMode === "code") if (rule.code && submittedCodes.includes(rule.code)) matchedCode = rule.code;
1057
+ else matchedCode = [...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))?.[0];
1058
+ return {
1059
+ matched: true,
1060
+ matchedCode,
1061
+ matchedRuleId: rule._id
1062
+ };
1063
+ }
1064
+ filterRewardsByRule(rewards, matchedRuleId) {
1065
+ if (!matchedRuleId) return rewards;
1066
+ if (rewards.filter((r) => r.ruleId).length === 0) return rewards;
1067
+ return rewards.filter((r) => !r.ruleId || String(r.ruleId) === String(matchedRuleId));
1068
+ }
1069
+ computeDiscount(reward, items, runningSubtotal) {
1070
+ if (!reward.discountAmount || reward.discountAmount <= 0) return 0;
1071
+ let discount;
1072
+ if (reward.discountScope === "cheapest") {
1073
+ const eligibleItems = this.getEligibleItems(reward, items);
1074
+ if (eligibleItems.length === 0) return 0;
1075
+ const cheapest = Math.min(...eligibleItems.map((item) => item.unitPrice));
1076
+ discount = reward.discountMode === "percentage" ? cheapest * (reward.discountAmount / 100) : Math.min(reward.discountAmount, cheapest);
1077
+ } else if (reward.discountScope === "specific_products") {
1078
+ const eligibleTotal = this.getEligibleItems(reward, items).reduce((sum, item) => sum + (item.lineTotal ?? item.unitPrice * item.quantity), 0);
1079
+ if (eligibleTotal <= 0) return 0;
1080
+ discount = reward.discountMode === "percentage" ? eligibleTotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, eligibleTotal);
1081
+ } else discount = reward.discountMode === "percentage" ? runningSubtotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, runningSubtotal);
1082
+ if (reward.maxDiscountAmount && discount > reward.maxDiscountAmount) discount = reward.maxDiscountAmount;
1083
+ return Math.min(Math.round(discount * 100) / 100, runningSubtotal);
1084
+ }
1085
+ getEligibleItems(reward, items) {
1086
+ if (reward.applicableProductIds.length === 0) return items;
1087
+ return items.filter((item) => reward.applicableProductIds.includes(item.productId));
1088
+ }
1089
+ describeDiscount(reward, program) {
1090
+ if (reward.discountMode === "percentage") return `${reward.discountAmount}% off from "${program.name}"`;
1091
+ return `${reward.discountAmount} off from "${program.name}"`;
1092
+ }
1093
+ };
1094
+ function groupBy(items, keyFn) {
1095
+ const map = /* @__PURE__ */ new Map();
1096
+ for (const item of items) {
1097
+ const key = String(keyFn(item));
1098
+ const group = map.get(key);
1099
+ if (group) group.push(item);
1100
+ else map.set(key, [item]);
1101
+ }
1102
+ return map;
1103
+ }
1104
+ //#endregion
1105
+ //#region src/utils/code-generator.ts
1106
+ const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1107
+ function generateCode(length, prefix = "") {
1108
+ const bytes = randomBytes(length);
1109
+ let code = "";
1110
+ for (let i = 0; i < length; i++) code += CHARSET[bytes[i] % 32];
1111
+ return prefix ? `${prefix}${code}` : code;
1112
+ }
1113
+ function generateCodes(count, length, prefix = "") {
1114
+ const codes = /* @__PURE__ */ new Set();
1115
+ let attempts = 0;
1116
+ const maxAttempts = count * 10;
1117
+ while (codes.size < count && attempts < maxAttempts) {
1118
+ codes.add(generateCode(length, prefix));
1119
+ attempts++;
1120
+ }
1121
+ return [...codes];
1122
+ }
1123
+ //#endregion
1124
+ //#region src/services/voucher.service.ts
1125
+ /**
1126
+ * VoucherService — multi-repo domain orchestration only.
1127
+ *
1128
+ * Code generation, redemption (transactional + idempotency), gift card
1129
+ * spend/topUp, and validation live here because they need programRepo +
1130
+ * voucherRepo + config + transactions.
1131
+ *
1132
+ * Simple lookups (getById, getByCode, getAll) and the cancel domain verb
1133
+ * live on VoucherRepository directly — callers use the repo. §2/§3.
1134
+ */
1135
+ var VoucherService = class {
1136
+ constructor(voucherRepo, programRepo, unitOfWork, dispatchDeps, config) {
1137
+ this.voucherRepo = voucherRepo;
1138
+ this.programRepo = programRepo;
1139
+ this.unitOfWork = unitOfWork;
1140
+ this.dispatchDeps = dispatchDeps;
1141
+ this.config = config;
1142
+ }
1143
+ async generateCodes(input, ctx) {
1144
+ const program = await this.programRepo.getById(input.programId, {
1145
+ throwOnNotFound: false,
1146
+ lean: true,
1147
+ ...ctx
1148
+ });
1149
+ if (!program) throw new ProgramNotFoundError(input.programId);
1150
+ const { codeLength, codePrefix } = this.config.voucher;
1151
+ const codes = generateCodes(input.count, codeLength, codePrefix);
1152
+ const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
1153
+ const isGiftCard = program.programType === "gift_card";
1154
+ const data = codes.map((code) => ({
1155
+ programId: input.programId,
1156
+ code,
1157
+ status: "active",
1158
+ customerId: input.customerId,
1159
+ usageLimit: isGiftCard ? 999999 : 1,
1160
+ usedCount: 0,
1161
+ ...isGiftCard ? {
1162
+ initialBalance: 0,
1163
+ currentBalance: 0,
1164
+ balanceLedger: []
1165
+ } : {},
1166
+ ...expiresAt ? { expiresAt } : {},
1167
+ redemptions: [],
1168
+ ...input.metadata ? { metadata: input.metadata } : {}
1169
+ }));
1170
+ const vouchers = await this.voucherRepo.createMany(data, ctx);
1171
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
1172
+ programId: input.programId,
1173
+ voucherIds: vouchers.map((v) => v._id),
1174
+ codes: vouchers.map((v) => v.code),
1175
+ count: vouchers.length,
1176
+ actorId: ctx.actorId
1177
+ }, ctx), ctx);
1178
+ return vouchers;
1179
+ }
1180
+ async generateSingleCode(input, ctx) {
1181
+ const program = await this.programRepo.getById(input.programId, {
1182
+ throwOnNotFound: false,
1183
+ lean: true,
1184
+ ...ctx
1185
+ });
1186
+ if (!program) throw new ProgramNotFoundError(input.programId);
1187
+ const code = input.code?.toUpperCase() ?? generateCode(this.config.voucher.codeLength, this.config.voucher.codePrefix);
1188
+ const isGiftCard = program.programType === "gift_card";
1189
+ const balance = input.initialBalance ?? 0;
1190
+ const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
1191
+ const data = {
1192
+ programId: input.programId,
1193
+ code,
1194
+ status: "active",
1195
+ customerId: input.customerId,
1196
+ usageLimit: isGiftCard ? 999999 : 1,
1197
+ usedCount: 0,
1198
+ ...isGiftCard ? {
1199
+ initialBalance: balance,
1200
+ currentBalance: balance,
1201
+ balanceLedger: []
1202
+ } : {},
1203
+ ...expiresAt ? { expiresAt } : {},
1204
+ redemptions: [],
1205
+ ...input.metadata ? { metadata: input.metadata } : {}
1206
+ };
1207
+ const voucher = await this.voucherRepo.create(data, ctx);
1208
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_GENERATED, {
1209
+ programId: input.programId,
1210
+ voucherIds: [voucher._id],
1211
+ codes: [voucher.code],
1212
+ count: 1,
1213
+ actorId: ctx.actorId
1214
+ }, ctx), ctx);
1215
+ return voucher;
1216
+ }
1217
+ async validateCode(code, ctx) {
1218
+ const voucher = await this.voucherRepo.getByCode(code, ctx);
1219
+ if (!voucher) return {
1220
+ valid: false,
1221
+ error: "Voucher not found"
1222
+ };
1223
+ if (voucher.status === "expired") return {
1224
+ valid: false,
1225
+ error: "Voucher has expired"
1226
+ };
1227
+ if (voucher.status === "cancelled") return {
1228
+ valid: false,
1229
+ error: "Voucher has been cancelled"
1230
+ };
1231
+ if (voucher.status === "used") return {
1232
+ valid: false,
1233
+ error: "Voucher has already been used"
1234
+ };
1235
+ if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) return {
1236
+ valid: false,
1237
+ error: "Voucher has expired"
1238
+ };
1239
+ if (voucher.usedCount >= voucher.usageLimit) return {
1240
+ valid: false,
1241
+ error: "Voucher has reached its usage limit"
1242
+ };
1243
+ const program = await this.programRepo.getById(voucher.programId, {
1244
+ throwOnNotFound: false,
1245
+ lean: true,
1246
+ ...ctx
1247
+ });
1248
+ return {
1249
+ valid: true,
1250
+ voucher: {
1251
+ code: voucher.code,
1252
+ programId: voucher.programId,
1253
+ programType: program?.programType ?? "unknown",
1254
+ status: voucher.status,
1255
+ remainingUses: voucher.usageLimit - voucher.usedCount,
1256
+ ...voucher.currentBalance !== void 0 ? { currentBalance: voucher.currentBalance } : {},
1257
+ ...voucher.expiresAt ? { expiresAt: voucher.expiresAt } : {}
1258
+ }
1259
+ };
1260
+ }
1261
+ async redeem(input, ctx) {
1262
+ return this.unitOfWork.withTransaction(async (session) => {
1263
+ const voucher = await this.voucherRepo.getByCode(input.code, {
1264
+ ...ctx,
1265
+ session
1266
+ });
1267
+ if (!voucher) throw new VoucherNotFoundError(input.code);
1268
+ this.assertVoucherUsable(voucher);
1269
+ if (input.idempotencyKey) {
1270
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1271
+ }
1272
+ const redemption = {
1273
+ orderId: input.orderId,
1274
+ customerId: input.customerId,
1275
+ discountAmount: input.discountAmount,
1276
+ redeemedAt: /* @__PURE__ */ new Date(),
1277
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1278
+ };
1279
+ const updated = await this.voucherRepo.incrementUsage(voucher._id, redemption, {
1280
+ ...ctx,
1281
+ session
1282
+ });
1283
+ if (updated.usedCount >= updated.usageLimit) await this.voucherRepo.update(voucher._id, { status: "used" }, {
1284
+ lean: true,
1285
+ ...ctx,
1286
+ session
1287
+ });
1288
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.VOUCHER_REDEEMED, {
1289
+ voucherId: voucher._id,
1290
+ code: voucher.code,
1291
+ orderId: input.orderId,
1292
+ discountAmount: input.discountAmount,
1293
+ customerId: input.customerId
1294
+ }, ctx), {
1295
+ ...ctx,
1296
+ session
1297
+ });
1298
+ return updated;
1299
+ });
1300
+ }
1301
+ async getBalance(code, ctx) {
1302
+ const voucher = await this.voucherRepo.getByCode(code, ctx);
1303
+ if (!voucher) throw new VoucherNotFoundError(code);
1304
+ if (voucher.initialBalance === void 0) throw new ValidationError("Voucher is not a gift card");
1305
+ return {
1306
+ code: voucher.code,
1307
+ initialBalance: voucher.initialBalance ?? 0,
1308
+ currentBalance: voucher.currentBalance ?? 0,
1309
+ spent: (voucher.initialBalance ?? 0) - (voucher.currentBalance ?? 0),
1310
+ voucherId: voucher._id
1311
+ };
1312
+ }
1313
+ async spend(input, ctx) {
1314
+ if (input.amount <= 0) throw new ValidationError("Spend amount must be positive");
1315
+ try {
1316
+ return await this.spendInTransaction(input, ctx);
1317
+ } catch (err) {
1318
+ if (isWriteConflict(err)) throw new ConcurrencyConflictError("voucher", input.code, err);
1319
+ throw err;
1320
+ }
1321
+ }
1322
+ async spendInTransaction(input, ctx) {
1323
+ return this.unitOfWork.withTransaction(async (session) => {
1324
+ const voucher = await this.voucherRepo.getByCode(input.code, {
1325
+ ...ctx,
1326
+ session
1327
+ });
1328
+ if (!voucher) throw new VoucherNotFoundError(input.code);
1329
+ this.assertVoucherUsable(voucher);
1330
+ const balance = voucher.currentBalance ?? 0;
1331
+ if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
1332
+ if (input.idempotencyKey) {
1333
+ if (await this.voucherRepo.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
1334
+ }
1335
+ const entry = {
1336
+ amount: -input.amount,
1337
+ orderId: input.orderId,
1338
+ description: input.description ?? `Spent on order ${input.orderId}`,
1339
+ createdAt: /* @__PURE__ */ new Date(),
1340
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
1341
+ };
1342
+ const updated = await this.voucherRepo.addLedgerEntry(voucher._id, entry, -input.amount, {
1343
+ ...ctx,
1344
+ session
1345
+ });
1346
+ const newBalance = updated.currentBalance ?? 0;
1347
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_SPENT, {
1348
+ voucherId: voucher._id,
1349
+ code: voucher.code,
1350
+ amount: input.amount,
1351
+ remainingBalance: newBalance,
1352
+ orderId: input.orderId
1353
+ }, ctx), {
1354
+ ...ctx,
1355
+ session
1356
+ });
1357
+ if (newBalance <= 0) {
1358
+ await this.voucherRepo.update(voucher._id, { status: "used" }, {
1359
+ lean: true,
1360
+ ...ctx,
1361
+ session
1362
+ });
1363
+ await dispatchPromoEvent(this.dispatchDeps, createEvent$1(PromoEvents.GIFT_CARD_EXHAUSTED, {
1364
+ voucherId: voucher._id,
1365
+ code: voucher.code,
1366
+ status: "used"
1367
+ }, ctx), {
1368
+ ...ctx,
1369
+ session
1370
+ });
1371
+ }
1372
+ return {
1373
+ code: updated.code,
1374
+ initialBalance: updated.initialBalance ?? 0,
1375
+ currentBalance: newBalance,
1376
+ spent: (updated.initialBalance ?? 0) - newBalance,
1377
+ voucherId: updated._id
1378
+ };
1379
+ });
1380
+ }
1381
+ async topUp(input, ctx) {
1382
+ 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);
1389
+ }
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
1400
+ });
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
+ }
1415
+ assertVoucherUsable(voucher) {
1416
+ if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
1417
+ if (voucher.status === "cancelled") throw new VoucherExpiredError(voucher.code);
1418
+ if (voucher.status === "used") {
1419
+ if (voucher.initialBalance !== void 0) throw new GiftCardExhaustedError(voucher.code);
1420
+ throw new VoucherExhaustedError(voucher.code);
1421
+ }
1422
+ if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) throw new VoucherExpiredError(voucher.code);
1423
+ if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
1424
+ }
1425
+ };
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
+ //#endregion
1442
+ //#region src/services/create-services.ts
1443
+ function createServices(deps) {
1444
+ const { repositories, unitOfWork, dispatchDeps, config } = deps;
1445
+ 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)
29
1448
  };
1449
+ }
1450
+ //#endregion
1451
+ //#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
+ function definePromoEvent(input) {
1482
+ const { name, version = 1, description, zodSchema } = input;
1483
+ const def = {
1484
+ name,
1485
+ version,
1486
+ schema: z.toJSONSchema(zodSchema),
1487
+ zodSchema,
1488
+ create(payload, meta) {
1489
+ return createEvent(name, payload, {
1490
+ source: "promo",
1491
+ ...meta
1492
+ });
1493
+ }
1494
+ };
1495
+ if (description !== void 0) def.description = description;
1496
+ return def;
1497
+ }
1498
+ /** Mirrors `ProgramLifecyclePayload`. */
1499
+ const programLifecycleSchema = z.object({
1500
+ programId: z.string(),
1501
+ programType: z.string(),
1502
+ status: z.string(),
1503
+ actorId: z.string().optional()
1504
+ });
1505
+ /** Mirrors `RulePayload`. */
1506
+ const ruleSchema = z.object({
1507
+ programId: z.string(),
1508
+ ruleId: z.string(),
1509
+ actorId: z.string().optional()
1510
+ });
1511
+ /** Mirrors `RewardPayload`. */
1512
+ const rewardSchema = z.object({
1513
+ programId: z.string(),
1514
+ rewardId: z.string(),
1515
+ actorId: z.string().optional()
1516
+ });
1517
+ /** Mirrors `VoucherGeneratedPayload`. */
1518
+ const voucherGeneratedSchema = z.object({
1519
+ programId: z.string(),
1520
+ voucherIds: z.array(z.string()),
1521
+ codes: z.array(z.string()),
1522
+ count: z.number().int().nonnegative(),
1523
+ actorId: z.string().optional()
1524
+ });
1525
+ /** Mirrors `VoucherRedeemedPayload`. */
1526
+ const voucherRedeemedSchema = z.object({
1527
+ voucherId: z.string(),
1528
+ code: z.string(),
1529
+ orderId: z.string(),
1530
+ discountAmount: z.number(),
1531
+ customerId: z.string().optional()
1532
+ });
1533
+ /**
1534
+ * Mirrors `VoucherLifecyclePayload` — shared by VOUCHER_CANCELLED,
1535
+ * VOUCHER_EXPIRED, and GIFT_CARD_EXHAUSTED (repo emits `status: 'cancelled'`
1536
+ * / `'used'` / host-supplied terminal value).
1537
+ */
1538
+ const voucherLifecycleSchema = z.object({
1539
+ voucherId: z.string(),
1540
+ code: z.string(),
1541
+ status: z.string()
1542
+ });
1543
+ /** Mirrors `GiftCardSpentPayload`. */
1544
+ const giftCardSpentSchema = z.object({
1545
+ voucherId: z.string(),
1546
+ code: z.string(),
1547
+ amount: z.number(),
1548
+ remainingBalance: z.number(),
1549
+ orderId: z.string()
1550
+ });
1551
+ /** Mirrors `GiftCardToppedUpPayload`. */
1552
+ const giftCardToppedUpSchema = z.object({
1553
+ voucherId: z.string(),
1554
+ code: z.string(),
1555
+ amount: z.number(),
1556
+ newBalance: z.number()
1557
+ });
1558
+ /** Mirrors `EvaluationCompletedPayload`. */
1559
+ const evaluationCompletedSchema = z.object({
1560
+ evaluationId: z.string(),
1561
+ totalDiscount: z.number(),
1562
+ programsApplied: z.number().int().nonnegative(),
1563
+ codesUsed: z.array(z.string()),
1564
+ isPreview: z.boolean()
1565
+ });
1566
+ /** Mirrors `EvaluationCommittedPayload`. */
1567
+ const evaluationCommittedSchema = z.object({
1568
+ evaluationId: z.string(),
1569
+ orderId: z.string(),
1570
+ totalDiscount: z.number()
1571
+ });
1572
+ /** Single-field rollback payload — emitted by `evaluation.service.ts`. */
1573
+ const evaluationRolledBackSchema = z.object({ evaluationId: z.string() });
1574
+ const ProgramCreated = definePromoEvent({
1575
+ name: PromoEvents.PROGRAM_CREATED,
1576
+ description: "A new promo program was created (starts in draft).",
1577
+ zodSchema: programLifecycleSchema
1578
+ });
1579
+ const ProgramActivated = definePromoEvent({
1580
+ name: PromoEvents.PROGRAM_ACTIVATED,
1581
+ description: "A draft or paused program was activated.",
1582
+ zodSchema: programLifecycleSchema
1583
+ });
1584
+ const ProgramPaused = definePromoEvent({
1585
+ name: PromoEvents.PROGRAM_PAUSED,
1586
+ description: "An active program was paused.",
1587
+ zodSchema: programLifecycleSchema
1588
+ });
1589
+ const ProgramArchived = definePromoEvent({
1590
+ name: PromoEvents.PROGRAM_ARCHIVED,
1591
+ description: "A program was archived (terminal — no further transitions).",
1592
+ zodSchema: programLifecycleSchema
1593
+ });
1594
+ const RuleAdded = definePromoEvent({
1595
+ name: PromoEvents.RULE_ADDED,
1596
+ description: "A new rule was added to a program.",
1597
+ zodSchema: ruleSchema
1598
+ });
1599
+ const RuleUpdated = definePromoEvent({
1600
+ name: PromoEvents.RULE_UPDATED,
1601
+ description: "An existing rule on a program was updated.",
1602
+ zodSchema: ruleSchema
1603
+ });
1604
+ const RuleRemoved = definePromoEvent({
1605
+ name: PromoEvents.RULE_REMOVED,
1606
+ description: "A rule was removed from a program.",
1607
+ zodSchema: ruleSchema
1608
+ });
1609
+ const RewardAdded = definePromoEvent({
1610
+ name: PromoEvents.REWARD_ADDED,
1611
+ description: "A reward was added to a program.",
1612
+ zodSchema: rewardSchema
1613
+ });
1614
+ const RewardUpdated = definePromoEvent({
1615
+ name: PromoEvents.REWARD_UPDATED,
1616
+ description: "A reward on a program was updated.",
1617
+ zodSchema: rewardSchema
1618
+ });
1619
+ const RewardRemoved = definePromoEvent({
1620
+ name: PromoEvents.REWARD_REMOVED,
1621
+ description: "A reward was removed from a program.",
1622
+ zodSchema: rewardSchema
1623
+ });
1624
+ const VoucherGenerated = definePromoEvent({
1625
+ name: PromoEvents.VOUCHER_GENERATED,
1626
+ description: "One or more vouchers were generated for a program.",
1627
+ zodSchema: voucherGeneratedSchema
1628
+ });
1629
+ const VoucherRedeemed = definePromoEvent({
1630
+ name: PromoEvents.VOUCHER_REDEEMED,
1631
+ description: "A voucher was redeemed against an order.",
1632
+ zodSchema: voucherRedeemedSchema
1633
+ });
1634
+ const VoucherCancelled = definePromoEvent({
1635
+ name: PromoEvents.VOUCHER_CANCELLED,
1636
+ description: "A voucher was cancelled by an operator.",
1637
+ zodSchema: voucherLifecycleSchema
1638
+ });
1639
+ const VoucherExpired = definePromoEvent({
1640
+ name: PromoEvents.VOUCHER_EXPIRED,
1641
+ description: "A voucher reached its expiry date and was transitioned to expired.",
1642
+ zodSchema: voucherLifecycleSchema
1643
+ });
1644
+ const GiftCardSpent = definePromoEvent({
1645
+ name: PromoEvents.GIFT_CARD_SPENT,
1646
+ description: "A gift-card voucher was debited against an order — remaining balance reported.",
1647
+ zodSchema: giftCardSpentSchema
1648
+ });
1649
+ const GiftCardToppedUp = definePromoEvent({
1650
+ name: PromoEvents.GIFT_CARD_TOPPED_UP,
1651
+ description: "A gift-card voucher received a top-up — new balance reported.",
1652
+ zodSchema: giftCardToppedUpSchema
1653
+ });
1654
+ const GiftCardExhausted = definePromoEvent({
1655
+ name: PromoEvents.GIFT_CARD_EXHAUSTED,
1656
+ description: "A gift-card voucher reached a zero balance and was marked used.",
1657
+ zodSchema: voucherLifecycleSchema
1658
+ });
1659
+ const EvaluationCompleted = definePromoEvent({
1660
+ name: PromoEvents.EVALUATION_COMPLETED,
1661
+ description: "A cart evaluation finished (preview or pre-commit) — totals and applied codes reported.",
1662
+ zodSchema: evaluationCompletedSchema
1663
+ });
1664
+ const EvaluationCommitted = definePromoEvent({
1665
+ name: PromoEvents.EVALUATION_COMMITTED,
1666
+ description: "A stored evaluation was committed against an order — usage counters moved.",
1667
+ zodSchema: evaluationCommittedSchema
1668
+ });
1669
+ const EvaluationRolledBack = definePromoEvent({
1670
+ name: PromoEvents.EVALUATION_ROLLED_BACK,
1671
+ description: "A stored evaluation was discarded without commit.",
1672
+ zodSchema: evaluationRolledBackSchema
1673
+ });
1674
+ /**
1675
+ * Every promo event defined in the package — pass to Arc's
1676
+ * `EventRegistry`. Hosts wire ONE array; the whole `promo.*` namespace
1677
+ * becomes introspectable via OpenAPI and auto-validated at publish time
1678
+ * when `eventPlugin({ validateMode: 'reject' })` is set.
1679
+ */
1680
+ const promoEventDefinitions = [
1681
+ ProgramCreated,
1682
+ ProgramActivated,
1683
+ ProgramPaused,
1684
+ ProgramArchived,
1685
+ RuleAdded,
1686
+ RuleUpdated,
1687
+ RuleRemoved,
1688
+ RewardAdded,
1689
+ RewardUpdated,
1690
+ RewardRemoved,
1691
+ VoucherGenerated,
1692
+ VoucherRedeemed,
1693
+ VoucherCancelled,
1694
+ VoucherExpired,
1695
+ GiftCardSpent,
1696
+ GiftCardToppedUp,
1697
+ GiftCardExhausted,
1698
+ EvaluationCompleted,
1699
+ EvaluationCommitted,
1700
+ EvaluationRolledBack
1701
+ ];
1702
+ //#endregion
1703
+ //#region src/index.ts
1704
+ function resolveConfig(config) {
1705
+ const tenant = resolveTenantConfig(config.tenant);
1706
+ if (typeof config.tenant === "object" && config.tenant !== null) {
1707
+ const explicit = config.tenant.contextKey ?? config.tenant.tenantField;
1708
+ if (explicit) tenant.contextKey = explicit;
1709
+ }
30
1710
  const resolved = {
31
1711
  evaluation: {
32
1712
  maxStackablePromotions: config.evaluation?.maxStackablePromotions ?? 5,
@@ -66,21 +1746,28 @@ var MongoUnitOfWork = class {
66
1746
  };
67
1747
  function createPromoEngine(config) {
68
1748
  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();
1749
+ const models = createModels(config.mongoose, resolvedConfig.tenant, config.indexes, config.autoIndex);
1750
+ const events = config.events?.transport ?? new InProcessPromoBus();
1751
+ const dispatchDeps = {
1752
+ events,
1753
+ ...config.outbox !== void 0 ? { outbox: config.outbox } : {},
1754
+ ...config.logger !== void 0 ? { logger: config.logger } : {}
1755
+ };
1756
+ const repositories = createRepositories(models, config.plugins, resolvedConfig.tenant, dispatchDeps);
73
1757
  return {
74
1758
  models,
75
1759
  repositories,
76
1760
  services: createServices({
77
1761
  repositories,
78
- unitOfWork,
79
- events,
1762
+ unitOfWork: new MongoUnitOfWork(config.mongoose),
1763
+ dispatchDeps,
80
1764
  config: resolvedConfig
81
1765
  }),
82
- events
1766
+ events,
1767
+ async syncIndexes() {
1768
+ await Promise.all(Object.values(models).map((m) => m.createIndexes()));
1769
+ }
83
1770
  };
84
1771
  }
85
1772
  //#endregion
86
- export { PromoEvents, createPromoEngine, resolveConfig };
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 };