@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.
- package/CHANGELOG.md +128 -0
- package/README.md +226 -22
- package/dist/{index-J5BC20DN.d.mts → constants-CrbSSQG5.d.mts} +1 -3
- package/dist/{constants-BVajdyL3.mjs → constants-D0Rntp2f.mjs} +1 -3
- package/dist/index.d.mts +1301 -10
- package/dist/index.mjs +2165 -46
- package/dist/schemas/index.d.mts +253 -0
- package/dist/schemas/index.mjs +134 -0
- package/package.json +23 -37
- package/dist/config-iZjn_8pp.d.mts +0 -71
- package/dist/domain/enums/index.d.mts +0 -2
- package/dist/domain/enums/index.mjs +0 -2
- package/dist/domain/index.d.mts +0 -61
- package/dist/domain/index.mjs +0 -4
- package/dist/domain-errors-BEkXvy5O.mjs +0 -80
- package/dist/event-emitter.port-DaodlJSG.d.mts +0 -8
- package/dist/event-types-CsTV1FKX.mjs +0 -25
- package/dist/events/index.d.mts +0 -2
- package/dist/events/index.mjs +0 -3
- package/dist/events-CprEWlN7.mjs +0 -25
- package/dist/index-B7lLH19a.d.mts +0 -13
- package/dist/index-C52zSBkI.d.mts +0 -96
- package/dist/index-Cu9iwy4v.d.mts +0 -99
- package/dist/index-l09KqnlE.d.mts +0 -81
- package/dist/models/index.d.mts +0 -2
- package/dist/models/index.mjs +0 -2
- package/dist/models-DdBNae7h.mjs +0 -277
- package/dist/repositories/index.d.mts +0 -2
- package/dist/repositories/index.mjs +0 -2
- package/dist/repositories-DgZIY9wD.mjs +0 -295
- package/dist/results-Ca5ZCNbN.d.mts +0 -218
- package/dist/services/index.d.mts +0 -2
- package/dist/services/index.mjs +0 -2
- package/dist/services-Cz0gHrmX.mjs +0 -815
- package/dist/types/index.d.mts +0 -3
- package/dist/types/index.mjs +0 -1
- package/dist/unit-of-work.port-DaMW8WZK.d.mts +0 -7
- 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 };
|