@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
@@ -1,815 +0,0 @@
1
- import { o as PROGRAM_TRANSITIONS } from "./constants-BVajdyL3.mjs";
2
- import { a as ProgramNotFoundError, c as TenantIsolationError, d as VoucherExpiredError, f as VoucherNotFoundError, i as InvalidTransitionError, l as ValidationError, n as EvaluationNotFoundError, r as InsufficientBalanceError, t as DuplicateRedemptionError, u as VoucherExhaustedError } from "./domain-errors-BEkXvy5O.mjs";
3
- import { t as PromoEvents } from "./event-types-CsTV1FKX.mjs";
4
- import { randomBytes } from "node:crypto";
5
- //#region src/utils/tenant-guard.ts
6
- function assertTenantContext(ctx, tenant) {
7
- if (!tenant.enabled) return;
8
- if (!ctx[tenant.contextKey]) throw new TenantIsolationError();
9
- }
10
- function getTenantValue(ctx, tenant) {
11
- if (!tenant.enabled) return void 0;
12
- return ctx[tenant.contextKey];
13
- }
14
- //#endregion
15
- //#region src/services/evaluation.service.ts
16
- var EvaluationService = class {
17
- pendingEvaluations = /* @__PURE__ */ new Map();
18
- constructor(programPort, rulePort, rewardPort, voucherPort, unitOfWork, events, config) {
19
- this.programPort = programPort;
20
- this.rulePort = rulePort;
21
- this.rewardPort = rewardPort;
22
- this.voucherPort = voucherPort;
23
- this.unitOfWork = unitOfWork;
24
- this.events = events;
25
- this.config = config;
26
- }
27
- async evaluate(input, ctx) {
28
- return this.doEvaluate(input, ctx, false);
29
- }
30
- async preview(input, ctx) {
31
- return this.doEvaluate(input, ctx, true);
32
- }
33
- async commit(evaluationId, orderId, ctx) {
34
- assertTenantContext(ctx, this.config.tenant);
35
- const tenantId = getTenantValue(ctx, this.config.tenant);
36
- const stored = this.pendingEvaluations.get(evaluationId);
37
- if (!stored) throw new EvaluationNotFoundError(evaluationId);
38
- return this.unitOfWork.withTransaction(async (session) => {
39
- for (const usage of stored.programUsages) {
40
- await this.programPort.incrementUsage(usage.programId, tenantId, session);
41
- if (stored.customerId) await this.programPort.incrementCustomerUsage(usage.programId, stored.customerId, tenantId, session);
42
- }
43
- for (const usage of stored.voucherUsages) await this.voucherPort.incrementUsage(usage.voucherId, {
44
- orderId,
45
- discountAmount: usage.discountAmount,
46
- redeemedAt: /* @__PURE__ */ new Date()
47
- }, tenantId, session);
48
- this.pendingEvaluations.delete(evaluationId);
49
- const commitResult = {
50
- evaluationId,
51
- orderId,
52
- totalDiscount: stored.result.totalDiscount,
53
- programsCommitted: stored.programUsages.length,
54
- vouchersUsed: stored.voucherUsages.length
55
- };
56
- this.events.emit(PromoEvents.EVALUATION_COMMITTED, {
57
- evaluationId,
58
- orderId,
59
- totalDiscount: stored.result.totalDiscount
60
- });
61
- return commitResult;
62
- });
63
- }
64
- async rollback(evaluationId, ctx) {
65
- assertTenantContext(ctx, this.config.tenant);
66
- if (!this.pendingEvaluations.get(evaluationId)) return;
67
- this.pendingEvaluations.delete(evaluationId);
68
- this.events.emit(PromoEvents.EVALUATION_ROLLED_BACK, { evaluationId });
69
- }
70
- async doEvaluate(input, ctx, isPreview) {
71
- assertTenantContext(ctx, this.config.tenant);
72
- const tenantId = getTenantValue(ctx, this.config.tenant);
73
- const submittedCodes = (input.codes ?? []).map((c) => c.toUpperCase());
74
- const programs = await this.programPort.findActive(tenantId);
75
- const programIds = programs.map((p) => p._id);
76
- const [allRules, allRewards] = await Promise.all([this.rulePort.findByProgramIds(programIds, tenantId), this.rewardPort.findByProgramIds(programIds, tenantId)]);
77
- const rulesByProgram = groupBy(allRules, (r) => r.programId);
78
- const rewardsByProgram = groupBy(allRewards, (r) => r.programId);
79
- const voucherMap = /* @__PURE__ */ new Map();
80
- for (const code of submittedCodes) {
81
- const voucher = await this.voucherPort.getByCode(code, tenantId);
82
- if (voucher && voucher.status === "active" && voucher.usedCount < voucher.usageLimit) voucherMap.set(code, {
83
- voucherId: voucher._id,
84
- programId: voucher.programId,
85
- balance: voucher.currentBalance
86
- });
87
- }
88
- const appliedDiscounts = [];
89
- const freeProducts = [];
90
- const appliedCodes = [];
91
- const rejectedCodes = [];
92
- const warnings = [];
93
- const programsApplied = [];
94
- const programUsages = [];
95
- const voucherUsages = [];
96
- let runningSubtotal = input.subtotal;
97
- let exclusiveApplied = false;
98
- let stackableCount = 0;
99
- for (const program of programs) {
100
- if (program.maxUsageTotal && program.usedCount >= program.maxUsageTotal) continue;
101
- if (!this.isCustomerEligible(program, input)) continue;
102
- if (program.maxUsagePerCustomer && input.customerId) {
103
- if (await this.programPort.getCustomerUsage(program._id, input.customerId, tenantId) >= program.maxUsagePerCustomer) {
104
- warnings.push(`Customer has reached max usage (${program.maxUsagePerCustomer}) for "${program.name}"`);
105
- continue;
106
- }
107
- }
108
- if (program.stackingMode === "exclusive" && exclusiveApplied) continue;
109
- if (program.stackingMode === "stackable" && stackableCount >= this.config.evaluation.maxStackablePromotions) {
110
- warnings.push(`Max stackable promotions (${this.config.evaluation.maxStackablePromotions}) reached`);
111
- continue;
112
- }
113
- const rules = rulesByProgram.get(String(program._id)) ?? [];
114
- const rewards = rewardsByProgram.get(String(program._id)) ?? [];
115
- if (rules.length === 0 || rewards.length === 0) continue;
116
- const matchResult = this.matchBestRule(program, rules, input.items, input.subtotal, submittedCodes, voucherMap);
117
- if (!matchResult.matched) {
118
- if (matchResult.rejectedCode) rejectedCodes.push(matchResult.rejectedCode);
119
- continue;
120
- }
121
- const applicableRewards = this.filterRewardsByRule(rewards, matchResult.matchedRuleId);
122
- for (const reward of applicableRewards) if (reward.rewardType === "discount") {
123
- const discount = this.computeDiscount(reward, input.items, runningSubtotal);
124
- if (discount <= 0) continue;
125
- appliedDiscounts.push({
126
- programId: program._id,
127
- programName: program.name,
128
- rewardId: reward._id,
129
- type: reward.discountMode ?? "fixed",
130
- scope: reward.discountScope,
131
- amount: discount,
132
- description: this.describeDiscount(reward, program),
133
- voucherCode: matchResult.matchedCode
134
- });
135
- runningSubtotal = Math.max(0, runningSubtotal - discount);
136
- } else if (reward.rewardType === "free_product") freeProducts.push({
137
- programId: program._id,
138
- programName: program.name,
139
- rewardId: reward._id,
140
- productId: reward.freeProductId,
141
- productSku: reward.freeProductSku,
142
- quantity: reward.freeQuantity,
143
- description: `Free product from "${program.name}"`
144
- });
145
- programsApplied.push(program._id);
146
- programUsages.push({ programId: program._id });
147
- if (matchResult.matchedCode) {
148
- appliedCodes.push(matchResult.matchedCode);
149
- const voucherInfo = voucherMap.get(matchResult.matchedCode);
150
- if (voucherInfo) {
151
- const totalProgramDiscount = appliedDiscounts.filter((d) => d.programId === program._id).reduce((sum, d) => sum + d.amount, 0);
152
- voucherUsages.push({
153
- voucherId: voucherInfo.voucherId,
154
- code: matchResult.matchedCode,
155
- discountAmount: totalProgramDiscount
156
- });
157
- }
158
- }
159
- if (program.stackingMode === "exclusive") exclusiveApplied = true;
160
- else stackableCount++;
161
- }
162
- for (const code of submittedCodes) if (!appliedCodes.includes(code) && !rejectedCodes.some((r) => r.code === code)) rejectedCodes.push({
163
- code,
164
- reason: "No matching active program found for this code"
165
- });
166
- const totalDiscount = appliedDiscounts.reduce((sum, d) => sum + d.amount, 0);
167
- const evaluationId = randomBytes(16).toString("hex");
168
- const result = {
169
- evaluationId,
170
- appliedDiscounts,
171
- freeProducts,
172
- totalDiscount,
173
- subtotalAfterDiscount: Math.max(0, input.subtotal - totalDiscount),
174
- appliedCodes,
175
- rejectedCodes,
176
- warnings,
177
- isPreview,
178
- programsApplied
179
- };
180
- if (!isPreview) this.pendingEvaluations.set(evaluationId, {
181
- result,
182
- tenantId,
183
- customerId: input.customerId,
184
- programUsages,
185
- voucherUsages,
186
- createdAt: Date.now()
187
- });
188
- this.events.emit(PromoEvents.EVALUATION_COMPLETED, {
189
- evaluationId,
190
- totalDiscount,
191
- programsApplied: programsApplied.length,
192
- codesUsed: appliedCodes,
193
- isPreview
194
- });
195
- return result;
196
- }
197
- isCustomerEligible(program, input) {
198
- if (program.applicableCustomerIds.length > 0) {
199
- if (!input.customerId) return false;
200
- if (!program.applicableCustomerIds.includes(input.customerId)) return false;
201
- }
202
- if (program.applicableCustomerTags.length > 0) {
203
- const customerTags = input.customerTags ?? [];
204
- if (customerTags.length === 0) return false;
205
- if (!program.applicableCustomerTags.some((tag) => customerTags.includes(tag))) return false;
206
- }
207
- return true;
208
- }
209
- matchBestRule(program, rules, items, subtotal, submittedCodes, voucherMap) {
210
- let bestMatch = { matched: false };
211
- let bestThreshold = -1;
212
- let lastRejected;
213
- for (const rule of rules) {
214
- const singleResult = this.matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap);
215
- if (singleResult.rejectedCode) lastRejected = singleResult.rejectedCode;
216
- if (!singleResult.matched) continue;
217
- const threshold = (rule.minimumAmount ?? 0) * 1e3 + (rule.minimumQuantity ?? 0);
218
- if (threshold > bestThreshold) {
219
- bestThreshold = threshold;
220
- bestMatch = singleResult;
221
- }
222
- }
223
- if (!bestMatch.matched && lastRejected) bestMatch.rejectedCode = lastRejected;
224
- return bestMatch;
225
- }
226
- matchSingleRule(program, rule, items, subtotal, submittedCodes, voucherMap) {
227
- if (program.triggerMode === "code") {
228
- if (rule.code) {
229
- if (!submittedCodes.includes(rule.code)) return { matched: false };
230
- if (program.programType === "coupon" || program.programType === "discount_code") {
231
- const voucherInfo = voucherMap.get(rule.code);
232
- if (!voucherInfo || String(voucherInfo.programId) !== String(program._id)) return {
233
- matched: false,
234
- rejectedCode: {
235
- code: rule.code,
236
- reason: "Invalid or exhausted voucher"
237
- }
238
- };
239
- }
240
- } else if (![...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))) return { matched: false };
241
- }
242
- const now = /* @__PURE__ */ new Date();
243
- if (rule.startsAt && rule.startsAt > now) return { matched: false };
244
- if (rule.endsAt && rule.endsAt < now) return { matched: false };
245
- if (rule.minimumAmount > 0 && subtotal < rule.minimumAmount) return { matched: false };
246
- if (rule.minimumQuantity > 0) {
247
- if (items.reduce((sum, item) => sum + item.quantity, 0) < rule.minimumQuantity) return { matched: false };
248
- }
249
- if (rule.applicableProductIds.length > 0) {
250
- if (!items.some((item) => rule.applicableProductIds.includes(item.productId))) return { matched: false };
251
- }
252
- if (rule.applicableCategories.length > 0) {
253
- if (!items.some((item) => item.categoryId && rule.applicableCategories.includes(item.categoryId))) return { matched: false };
254
- }
255
- if (rule.applicableSkus.length > 0) {
256
- if (!items.some((item) => item.sku && rule.applicableSkus.includes(item.sku))) return { matched: false };
257
- }
258
- if (rule.buyQuantity && rule.buyQuantity > 0) {
259
- 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 };
260
- }
261
- let matchedCode;
262
- if (program.triggerMode === "code") if (rule.code && submittedCodes.includes(rule.code)) matchedCode = rule.code;
263
- else matchedCode = [...voucherMap.entries()].find(([code, info]) => String(info.programId) === String(program._id) && submittedCodes.includes(code))?.[0];
264
- return {
265
- matched: true,
266
- matchedCode,
267
- matchedRuleId: rule._id
268
- };
269
- }
270
- filterRewardsByRule(rewards, matchedRuleId) {
271
- if (!matchedRuleId) return rewards;
272
- if (rewards.filter((r) => r.ruleId).length === 0) return rewards;
273
- return rewards.filter((r) => !r.ruleId || String(r.ruleId) === String(matchedRuleId));
274
- }
275
- computeDiscount(reward, items, runningSubtotal) {
276
- if (!reward.discountAmount || reward.discountAmount <= 0) return 0;
277
- let discount;
278
- if (reward.discountScope === "cheapest") {
279
- const eligibleItems = this.getEligibleItems(reward, items);
280
- if (eligibleItems.length === 0) return 0;
281
- const cheapest = Math.min(...eligibleItems.map((item) => item.unitPrice));
282
- discount = reward.discountMode === "percentage" ? cheapest * (reward.discountAmount / 100) : Math.min(reward.discountAmount, cheapest);
283
- } else if (reward.discountScope === "specific_products") {
284
- const eligibleTotal = this.getEligibleItems(reward, items).reduce((sum, item) => sum + (item.lineTotal ?? item.unitPrice * item.quantity), 0);
285
- if (eligibleTotal <= 0) return 0;
286
- discount = reward.discountMode === "percentage" ? eligibleTotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, eligibleTotal);
287
- } else discount = reward.discountMode === "percentage" ? runningSubtotal * (reward.discountAmount / 100) : Math.min(reward.discountAmount, runningSubtotal);
288
- if (reward.maxDiscountAmount && discount > reward.maxDiscountAmount) discount = reward.maxDiscountAmount;
289
- return Math.min(Math.round(discount * 100) / 100, runningSubtotal);
290
- }
291
- getEligibleItems(reward, items) {
292
- if (reward.applicableProductIds.length === 0) return items;
293
- return items.filter((item) => reward.applicableProductIds.includes(item.productId));
294
- }
295
- describeDiscount(reward, program) {
296
- if (reward.discountMode === "percentage") return `${reward.discountAmount}% off from "${program.name}"`;
297
- return `${reward.discountAmount} off from "${program.name}"`;
298
- }
299
- };
300
- function groupBy(items, keyFn) {
301
- const map = /* @__PURE__ */ new Map();
302
- for (const item of items) {
303
- const key = String(keyFn(item));
304
- const group = map.get(key);
305
- if (group) group.push(item);
306
- else map.set(key, [item]);
307
- }
308
- return map;
309
- }
310
- //#endregion
311
- //#region src/services/program.service.ts
312
- var ProgramService = class {
313
- constructor(programPort, rulePort, rewardPort, events, config) {
314
- this.programPort = programPort;
315
- this.rulePort = rulePort;
316
- this.rewardPort = rewardPort;
317
- this.events = events;
318
- this.config = config;
319
- }
320
- async create(input, ctx) {
321
- assertTenantContext(ctx, this.config.tenant);
322
- const tenantId = getTenantValue(ctx, this.config.tenant);
323
- const data = {
324
- ...input,
325
- status: "draft",
326
- usedCount: 0,
327
- ...tenantId ? { [this.config.tenant.field]: tenantId } : {}
328
- };
329
- const program = await this.programPort.create(data);
330
- this.events.emit(PromoEvents.PROGRAM_CREATED, {
331
- programId: program._id,
332
- programType: program.programType,
333
- status: program.status,
334
- actorId: ctx.actorId
335
- });
336
- return program;
337
- }
338
- async getById(id, ctx) {
339
- assertTenantContext(ctx, this.config.tenant);
340
- const program = await this.programPort.getById(id, getTenantValue(ctx, this.config.tenant));
341
- if (!program) throw new ProgramNotFoundError(id);
342
- return program;
343
- }
344
- async list(query, ctx) {
345
- assertTenantContext(ctx, this.config.tenant);
346
- const tenantId = getTenantValue(ctx, this.config.tenant);
347
- const filters = {
348
- ...query.filters,
349
- ...tenantId ? { [this.config.tenant.field]: tenantId } : {}
350
- };
351
- const programs = await this.programPort.findMany(filters, {
352
- limit: query.limit ?? 20,
353
- page: query.page ?? 1,
354
- sort: query.sort ?? "-priority"
355
- });
356
- return {
357
- docs: programs,
358
- page: query.page ?? 1,
359
- limit: query.limit ?? 20,
360
- total: programs.length,
361
- pages: 1,
362
- hasNext: false,
363
- hasPrev: false
364
- };
365
- }
366
- async update(id, input, ctx) {
367
- assertTenantContext(ctx, this.config.tenant);
368
- const tenantId = getTenantValue(ctx, this.config.tenant);
369
- if (!await this.programPort.getById(id, tenantId)) throw new ProgramNotFoundError(id);
370
- return this.programPort.update(id, input, tenantId);
371
- }
372
- async activate(id, ctx) {
373
- return this.transition(id, "active", ctx);
374
- }
375
- async pause(id, ctx) {
376
- return this.transition(id, "paused", ctx);
377
- }
378
- async archive(id, ctx) {
379
- return this.transition(id, "archived", ctx);
380
- }
381
- async transition(id, targetStatus, ctx) {
382
- assertTenantContext(ctx, this.config.tenant);
383
- const tenantId = getTenantValue(ctx, this.config.tenant);
384
- const program = await this.programPort.getById(id, tenantId);
385
- if (!program) throw new ProgramNotFoundError(id);
386
- if (!PROGRAM_TRANSITIONS[program.status]?.includes(targetStatus)) throw new InvalidTransitionError(program.status, targetStatus);
387
- const updated = await this.programPort.update(id, { status: targetStatus }, tenantId);
388
- const eventMap = {
389
- active: PromoEvents.PROGRAM_ACTIVATED,
390
- paused: PromoEvents.PROGRAM_PAUSED,
391
- archived: PromoEvents.PROGRAM_ARCHIVED
392
- };
393
- if (eventMap[targetStatus]) this.events.emit(eventMap[targetStatus], {
394
- programId: updated._id,
395
- programType: updated.programType,
396
- status: updated.status,
397
- actorId: ctx.actorId
398
- });
399
- return updated;
400
- }
401
- async addRule(programId, input, ctx) {
402
- assertTenantContext(ctx, this.config.tenant);
403
- const tenantId = getTenantValue(ctx, this.config.tenant);
404
- const program = await this.programPort.getById(programId, tenantId);
405
- if (!program) throw new ProgramNotFoundError(programId);
406
- if (input.code && program.triggerMode !== "code") throw new ValidationError("Cannot add a code-based rule to an auto-triggered program");
407
- const data = {
408
- ...input,
409
- programId,
410
- ...tenantId ? { [this.config.tenant.field]: tenantId } : {}
411
- };
412
- const rule = await this.rulePort.create(data);
413
- this.events.emit(PromoEvents.RULE_ADDED, {
414
- programId,
415
- ruleId: rule._id,
416
- actorId: ctx.actorId
417
- });
418
- return rule;
419
- }
420
- async updateRule(programId, ruleId, input, ctx) {
421
- assertTenantContext(ctx, this.config.tenant);
422
- const tenantId = getTenantValue(ctx, this.config.tenant);
423
- const rule = await this.rulePort.getById(ruleId, tenantId);
424
- if (!rule || String(rule.programId) !== String(programId)) throw new ValidationError("Rule not found in this program");
425
- const updated = await this.rulePort.update(ruleId, input, tenantId);
426
- this.events.emit(PromoEvents.RULE_UPDATED, {
427
- programId,
428
- ruleId,
429
- actorId: ctx.actorId
430
- });
431
- return updated;
432
- }
433
- async removeRule(programId, ruleId, ctx) {
434
- assertTenantContext(ctx, this.config.tenant);
435
- const tenantId = getTenantValue(ctx, this.config.tenant);
436
- const rule = await this.rulePort.getById(ruleId, tenantId);
437
- if (!rule || String(rule.programId) !== String(programId)) throw new ValidationError("Rule not found in this program");
438
- await this.rulePort.delete(ruleId, tenantId);
439
- this.events.emit(PromoEvents.RULE_REMOVED, {
440
- programId,
441
- ruleId,
442
- actorId: ctx.actorId
443
- });
444
- }
445
- async listRules(programId, ctx) {
446
- assertTenantContext(ctx, this.config.tenant);
447
- return this.rulePort.findByProgramId(programId, getTenantValue(ctx, this.config.tenant));
448
- }
449
- async addReward(programId, input, ctx) {
450
- assertTenantContext(ctx, this.config.tenant);
451
- const tenantId = getTenantValue(ctx, this.config.tenant);
452
- if (!await this.programPort.getById(programId, tenantId)) throw new ProgramNotFoundError(programId);
453
- const data = {
454
- ...input,
455
- programId,
456
- ...tenantId ? { [this.config.tenant.field]: tenantId } : {}
457
- };
458
- const reward = await this.rewardPort.create(data);
459
- this.events.emit(PromoEvents.REWARD_ADDED, {
460
- programId,
461
- rewardId: reward._id,
462
- actorId: ctx.actorId
463
- });
464
- return reward;
465
- }
466
- async updateReward(programId, rewardId, input, ctx) {
467
- assertTenantContext(ctx, this.config.tenant);
468
- const tenantId = getTenantValue(ctx, this.config.tenant);
469
- const reward = await this.rewardPort.getById(rewardId, tenantId);
470
- if (!reward || String(reward.programId) !== String(programId)) throw new ValidationError("Reward not found in this program");
471
- const updated = await this.rewardPort.update(rewardId, input, tenantId);
472
- this.events.emit(PromoEvents.REWARD_UPDATED, {
473
- programId,
474
- rewardId,
475
- actorId: ctx.actorId
476
- });
477
- return updated;
478
- }
479
- async removeReward(programId, rewardId, ctx) {
480
- assertTenantContext(ctx, this.config.tenant);
481
- const tenantId = getTenantValue(ctx, this.config.tenant);
482
- const reward = await this.rewardPort.getById(rewardId, tenantId);
483
- if (!reward || String(reward.programId) !== String(programId)) throw new ValidationError("Reward not found in this program");
484
- await this.rewardPort.delete(rewardId, tenantId);
485
- this.events.emit(PromoEvents.REWARD_REMOVED, {
486
- programId,
487
- rewardId,
488
- actorId: ctx.actorId
489
- });
490
- }
491
- async listRewards(programId, ctx) {
492
- assertTenantContext(ctx, this.config.tenant);
493
- return this.rewardPort.findByProgramId(programId, getTenantValue(ctx, this.config.tenant));
494
- }
495
- async getFullProgram(id, ctx) {
496
- const program = await this.getById(id, ctx);
497
- const tenantId = getTenantValue(ctx, this.config.tenant);
498
- const [rules, rewards] = await Promise.all([this.rulePort.findByProgramId(id, tenantId), this.rewardPort.findByProgramId(id, tenantId)]);
499
- return {
500
- ...program,
501
- rules,
502
- rewards
503
- };
504
- }
505
- };
506
- //#endregion
507
- //#region src/utils/code-generator.ts
508
- const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
509
- function generateCode(length, prefix = "") {
510
- const bytes = randomBytes(length);
511
- let code = "";
512
- for (let i = 0; i < length; i++) code += CHARSET[bytes[i] % 32];
513
- return prefix ? `${prefix}${code}` : code;
514
- }
515
- function generateCodes(count, length, prefix = "") {
516
- const codes = /* @__PURE__ */ new Set();
517
- let attempts = 0;
518
- const maxAttempts = count * 10;
519
- while (codes.size < count && attempts < maxAttempts) {
520
- codes.add(generateCode(length, prefix));
521
- attempts++;
522
- }
523
- return [...codes];
524
- }
525
- //#endregion
526
- //#region src/services/voucher.service.ts
527
- var VoucherService = class {
528
- constructor(voucherPort, programPort, unitOfWork, events, config) {
529
- this.voucherPort = voucherPort;
530
- this.programPort = programPort;
531
- this.unitOfWork = unitOfWork;
532
- this.events = events;
533
- this.config = config;
534
- }
535
- async generateCodes(input, ctx) {
536
- assertTenantContext(ctx, this.config.tenant);
537
- const tenantId = getTenantValue(ctx, this.config.tenant);
538
- const program = await this.programPort.getById(input.programId, tenantId);
539
- if (!program) throw new ProgramNotFoundError(input.programId);
540
- const { codeLength, codePrefix } = this.config.voucher;
541
- const codes = generateCodes(input.count, codeLength, codePrefix);
542
- const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
543
- const isGiftCard = program.programType === "gift_card";
544
- const balance = isGiftCard ? 0 : 0;
545
- const data = codes.map((code) => ({
546
- programId: input.programId,
547
- code,
548
- status: "active",
549
- customerId: input.customerId,
550
- usageLimit: isGiftCard ? 999999 : 1,
551
- usedCount: 0,
552
- ...isGiftCard ? {
553
- initialBalance: balance,
554
- currentBalance: balance,
555
- balanceLedger: []
556
- } : {},
557
- ...expiresAt ? { expiresAt } : {},
558
- redemptions: [],
559
- ...input.metadata ? { metadata: input.metadata } : {},
560
- ...tenantId ? { [this.config.tenant.field]: tenantId } : {}
561
- }));
562
- const vouchers = await this.voucherPort.createMany(data);
563
- this.events.emit(PromoEvents.VOUCHER_GENERATED, {
564
- programId: input.programId,
565
- voucherIds: vouchers.map((v) => v._id),
566
- codes: vouchers.map((v) => v.code),
567
- count: vouchers.length,
568
- actorId: ctx.actorId
569
- });
570
- return vouchers;
571
- }
572
- async generateSingleCode(input, ctx) {
573
- assertTenantContext(ctx, this.config.tenant);
574
- const tenantId = getTenantValue(ctx, this.config.tenant);
575
- const program = await this.programPort.getById(input.programId, tenantId);
576
- if (!program) throw new ProgramNotFoundError(input.programId);
577
- const code = input.code?.toUpperCase() ?? generateCode(this.config.voucher.codeLength, this.config.voucher.codePrefix);
578
- const isGiftCard = program.programType === "gift_card";
579
- const balance = input.initialBalance ?? 0;
580
- const expiresAt = input.expiresAt ?? (this.config.voucher.defaultExpiryDays ? new Date(Date.now() + this.config.voucher.defaultExpiryDays * 864e5) : void 0);
581
- const data = {
582
- programId: input.programId,
583
- code,
584
- status: "active",
585
- customerId: input.customerId,
586
- usageLimit: isGiftCard ? 999999 : 1,
587
- usedCount: 0,
588
- ...isGiftCard ? {
589
- initialBalance: balance,
590
- currentBalance: balance,
591
- balanceLedger: []
592
- } : {},
593
- ...expiresAt ? { expiresAt } : {},
594
- redemptions: [],
595
- ...input.metadata ? { metadata: input.metadata } : {},
596
- ...tenantId ? { [this.config.tenant.field]: tenantId } : {}
597
- };
598
- const voucher = await this.voucherPort.create(data);
599
- this.events.emit(PromoEvents.VOUCHER_GENERATED, {
600
- programId: input.programId,
601
- voucherIds: [voucher._id],
602
- codes: [voucher.code],
603
- count: 1,
604
- actorId: ctx.actorId
605
- });
606
- return voucher;
607
- }
608
- async validateCode(code, ctx) {
609
- assertTenantContext(ctx, this.config.tenant);
610
- const tenantId = getTenantValue(ctx, this.config.tenant);
611
- const voucher = await this.voucherPort.getByCode(code, tenantId);
612
- if (!voucher) return {
613
- valid: false,
614
- error: "Voucher not found"
615
- };
616
- if (voucher.status === "expired") return {
617
- valid: false,
618
- error: "Voucher has expired"
619
- };
620
- if (voucher.status === "cancelled") return {
621
- valid: false,
622
- error: "Voucher has been cancelled"
623
- };
624
- if (voucher.status === "used") return {
625
- valid: false,
626
- error: "Voucher has already been used"
627
- };
628
- if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) return {
629
- valid: false,
630
- error: "Voucher has expired"
631
- };
632
- if (voucher.usedCount >= voucher.usageLimit) return {
633
- valid: false,
634
- error: "Voucher has reached its usage limit"
635
- };
636
- const program = await this.programPort.getById(voucher.programId, tenantId);
637
- return {
638
- valid: true,
639
- voucher: {
640
- code: voucher.code,
641
- programId: voucher.programId,
642
- programType: program?.programType ?? "unknown",
643
- status: voucher.status,
644
- remainingUses: voucher.usageLimit - voucher.usedCount,
645
- ...voucher.currentBalance !== void 0 ? { currentBalance: voucher.currentBalance } : {},
646
- ...voucher.expiresAt ? { expiresAt: voucher.expiresAt } : {}
647
- }
648
- };
649
- }
650
- async getByCode(code, ctx) {
651
- assertTenantContext(ctx, this.config.tenant);
652
- return this.voucherPort.getByCode(code, getTenantValue(ctx, this.config.tenant));
653
- }
654
- async getById(id, ctx) {
655
- assertTenantContext(ctx, this.config.tenant);
656
- return this.voucherPort.getById(id, getTenantValue(ctx, this.config.tenant));
657
- }
658
- async cancel(id, ctx) {
659
- assertTenantContext(ctx, this.config.tenant);
660
- const tenantId = getTenantValue(ctx, this.config.tenant);
661
- const voucher = await this.voucherPort.getById(id, tenantId);
662
- if (!voucher) throw new VoucherNotFoundError(id);
663
- const updated = await this.voucherPort.update(id, { status: "cancelled" }, tenantId);
664
- this.events.emit(PromoEvents.VOUCHER_CANCELLED, {
665
- voucherId: id,
666
- code: voucher.code,
667
- status: "cancelled"
668
- });
669
- return updated;
670
- }
671
- async redeem(input, ctx) {
672
- assertTenantContext(ctx, this.config.tenant);
673
- const tenantId = getTenantValue(ctx, this.config.tenant);
674
- return this.unitOfWork.withTransaction(async (session) => {
675
- const voucher = await this.voucherPort.getByCode(input.code, tenantId);
676
- if (!voucher) throw new VoucherNotFoundError(input.code);
677
- this.assertVoucherUsable(voucher);
678
- if (input.idempotencyKey) {
679
- if (await this.voucherPort.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
680
- }
681
- const redemption = {
682
- orderId: input.orderId,
683
- customerId: input.customerId,
684
- discountAmount: input.discountAmount,
685
- redeemedAt: /* @__PURE__ */ new Date(),
686
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
687
- };
688
- const updated = await this.voucherPort.incrementUsage(voucher._id, redemption, tenantId, session);
689
- if (updated.usedCount >= updated.usageLimit) await this.voucherPort.update(voucher._id, { status: "used" }, tenantId, session);
690
- this.events.emit(PromoEvents.VOUCHER_REDEEMED, {
691
- voucherId: voucher._id,
692
- code: voucher.code,
693
- orderId: input.orderId,
694
- discountAmount: input.discountAmount,
695
- customerId: input.customerId
696
- });
697
- return updated;
698
- });
699
- }
700
- async getBalance(code, ctx) {
701
- assertTenantContext(ctx, this.config.tenant);
702
- const voucher = await this.voucherPort.getByCode(code, getTenantValue(ctx, this.config.tenant));
703
- if (!voucher) throw new VoucherNotFoundError(code);
704
- if (voucher.initialBalance === void 0) throw new ValidationError("Voucher is not a gift card");
705
- return {
706
- code: voucher.code,
707
- initialBalance: voucher.initialBalance ?? 0,
708
- currentBalance: voucher.currentBalance ?? 0,
709
- spent: (voucher.initialBalance ?? 0) - (voucher.currentBalance ?? 0),
710
- voucherId: voucher._id
711
- };
712
- }
713
- async spend(input, ctx) {
714
- assertTenantContext(ctx, this.config.tenant);
715
- const tenantId = getTenantValue(ctx, this.config.tenant);
716
- if (input.amount <= 0) throw new ValidationError("Spend amount must be positive");
717
- return this.unitOfWork.withTransaction(async (session) => {
718
- const voucher = await this.voucherPort.getByCode(input.code, tenantId);
719
- if (!voucher) throw new VoucherNotFoundError(input.code);
720
- this.assertVoucherUsable(voucher);
721
- const balance = voucher.currentBalance ?? 0;
722
- if (!this.config.giftCard.allowNegativeBalance && balance < input.amount) throw new InsufficientBalanceError(input.code, balance, input.amount);
723
- if (input.idempotencyKey) {
724
- if (await this.voucherPort.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
725
- }
726
- const entry = {
727
- amount: -input.amount,
728
- orderId: input.orderId,
729
- description: input.description ?? `Spent on order ${input.orderId}`,
730
- createdAt: /* @__PURE__ */ new Date(),
731
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
732
- };
733
- const updated = await this.voucherPort.addLedgerEntry(voucher._id, entry, -input.amount, tenantId, session);
734
- const newBalance = updated.currentBalance ?? 0;
735
- this.events.emit(PromoEvents.GIFT_CARD_SPENT, {
736
- voucherId: voucher._id,
737
- code: voucher.code,
738
- amount: input.amount,
739
- remainingBalance: newBalance,
740
- orderId: input.orderId
741
- });
742
- if (newBalance <= 0) {
743
- await this.voucherPort.update(voucher._id, { status: "used" }, tenantId, session);
744
- this.events.emit(PromoEvents.GIFT_CARD_EXHAUSTED, {
745
- voucherId: voucher._id,
746
- code: voucher.code,
747
- status: "used"
748
- });
749
- }
750
- return {
751
- code: updated.code,
752
- initialBalance: updated.initialBalance ?? 0,
753
- currentBalance: newBalance,
754
- spent: (updated.initialBalance ?? 0) - newBalance,
755
- voucherId: updated._id
756
- };
757
- });
758
- }
759
- async topUp(input, ctx) {
760
- assertTenantContext(ctx, this.config.tenant);
761
- const tenantId = getTenantValue(ctx, this.config.tenant);
762
- if (input.amount <= 0) throw new ValidationError("Top-up amount must be positive");
763
- const voucher = await this.voucherPort.getByCode(input.code, tenantId);
764
- if (!voucher) throw new VoucherNotFoundError(input.code);
765
- const maxBalance = this.config.giftCard.maxBalance;
766
- if (maxBalance !== null && (voucher.currentBalance ?? 0) + input.amount > maxBalance) throw new ValidationError(`Top-up would exceed max balance of ${maxBalance}`);
767
- if (input.idempotencyKey) {
768
- if (await this.voucherPort.hasIdempotencyKey(voucher._id, input.idempotencyKey)) throw new DuplicateRedemptionError(input.idempotencyKey);
769
- }
770
- const entry = {
771
- amount: input.amount,
772
- description: input.description ?? "Top-up",
773
- createdAt: /* @__PURE__ */ new Date(),
774
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
775
- };
776
- const updated = await this.voucherPort.addLedgerEntry(voucher._id, entry, input.amount, tenantId);
777
- if (voucher.status === "used") await this.voucherPort.update(voucher._id, { status: "active" }, tenantId);
778
- this.events.emit(PromoEvents.GIFT_CARD_TOPPED_UP, {
779
- voucherId: voucher._id,
780
- code: voucher.code,
781
- amount: input.amount,
782
- newBalance: updated.currentBalance ?? 0
783
- });
784
- return {
785
- code: updated.code,
786
- initialBalance: updated.initialBalance ?? 0,
787
- currentBalance: updated.currentBalance ?? 0,
788
- spent: (updated.initialBalance ?? 0) - (updated.currentBalance ?? 0),
789
- voucherId: updated._id
790
- };
791
- }
792
- async expireExpired(ctx) {
793
- assertTenantContext(ctx, this.config.tenant);
794
- return this.voucherPort.expireByDate(/* @__PURE__ */ new Date(), getTenantValue(ctx, this.config.tenant));
795
- }
796
- assertVoucherUsable(voucher) {
797
- if (voucher.status === "expired") throw new VoucherExpiredError(voucher.code);
798
- if (voucher.status === "cancelled") throw new VoucherExpiredError(voucher.code);
799
- if (voucher.status === "used") throw new VoucherExhaustedError(voucher.code);
800
- if (voucher.expiresAt && voucher.expiresAt < /* @__PURE__ */ new Date()) throw new VoucherExpiredError(voucher.code);
801
- if (voucher.usedCount >= voucher.usageLimit) throw new VoucherExhaustedError(voucher.code);
802
- }
803
- };
804
- //#endregion
805
- //#region src/services/index.ts
806
- function createServices(deps) {
807
- const { repositories, unitOfWork, events, config } = deps;
808
- return {
809
- program: new ProgramService(repositories.program, repositories.rule, repositories.reward, events, config),
810
- voucher: new VoucherService(repositories.voucher, repositories.program, unitOfWork, events, config),
811
- evaluation: new EvaluationService(repositories.program, repositories.rule, repositories.reward, repositories.voucher, unitOfWork, events, config)
812
- };
813
- }
814
- //#endregion
815
- export { EvaluationService as i, VoucherService as n, ProgramService as r, createServices as t };