@classytic/loyalty 0.1.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.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/config-aNigy4ec.d.mts +90 -0
- package/dist/constants-C75GvizO.mjs +49 -0
- package/dist/domain/enums/index.d.mts +2 -0
- package/dist/domain/enums/index.mjs +2 -0
- package/dist/domain/index.d.mts +90 -0
- package/dist/domain/index.mjs +4 -0
- package/dist/domain-errors-D7q-2LYD.mjs +120 -0
- package/dist/event-emitter.port-CLYPoMni.d.mts +8 -0
- package/dist/event-types-xQBtYBbX.mjs +24 -0
- package/dist/events/index.d.mts +2 -0
- package/dist/events/index.mjs +3 -0
- package/dist/events-CprEWlN7.mjs +25 -0
- package/dist/index-B6ZstxSP.d.mts +2357 -0
- package/dist/index-BcoGeDco.d.mts +127 -0
- package/dist/index-CaH4GhSF.d.mts +19 -0
- package/dist/index-DqzTjeEB.d.mts +148 -0
- package/dist/index-O7TL4R2P.d.mts +86 -0
- package/dist/index-_pg4J11t.d.mts +191 -0
- package/dist/index.d.mts +21 -0
- package/dist/index.mjs +95 -0
- package/dist/models/index.d.mts +2 -0
- package/dist/models/index.mjs +2 -0
- package/dist/models-CrZPmVxt.mjs +417 -0
- package/dist/notification.port-BzUsH0It.d.mts +13 -0
- package/dist/notifications/index.d.mts +21 -0
- package/dist/notifications/index.mjs +19 -0
- package/dist/repositories/index.d.mts +2 -0
- package/dist/repositories/index.mjs +2 -0
- package/dist/repositories-qMX7Xh_y.mjs +440 -0
- package/dist/services/index.d.mts +2 -0
- package/dist/services/index.mjs +2 -0
- package/dist/services-CoTqC4E6.mjs +1098 -0
- package/dist/tier-definition-Djf72UPa.d.mts +120 -0
- package/dist/tier-definition.port-BzrHYBRo.d.mts +143 -0
- package/dist/types/index.d.mts +3 -0
- package/dist/types/index.mjs +1 -0
- package/package.json +111 -0
|
@@ -0,0 +1,1098 @@
|
|
|
1
|
+
import { a as MemberInactiveError, c as RedemptionNotFoundError, d as RuleNotFoundError, f as SelfReferralError, h as ValidationError, i as MemberAlreadyEnrolledError, l as ReferralLimitExceededError, m as TierNotFoundError, n as DuplicateReferralError, o as MemberNotFoundError, p as TenantIsolationError, r as InsufficientPointsError, s as RedemptionExpiredError, t as CircularReferralError, u as ReferralNotFoundError } from "./domain-errors-D7q-2LYD.mjs";
|
|
2
|
+
import { t as LoyaltyEvents } from "./event-types-xQBtYBbX.mjs";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
//#region src/utils/tenant-guard.ts
|
|
5
|
+
function assertTenantContext(ctx, tenant) {
|
|
6
|
+
if (!tenant.enabled) return;
|
|
7
|
+
if (!ctx[tenant.contextKey]) throw new TenantIsolationError();
|
|
8
|
+
}
|
|
9
|
+
function getTenantValue(ctx, tenant) {
|
|
10
|
+
if (!tenant.enabled) return void 0;
|
|
11
|
+
return ctx[tenant.contextKey];
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/utils/validation.ts
|
|
15
|
+
function validatePoints(points, label = "points") {
|
|
16
|
+
if (!Number.isFinite(points)) throw new ValidationError(`${label} must be a finite number`);
|
|
17
|
+
if (!Number.isSafeInteger(points)) throw new ValidationError(`${label} must be a safe integer`);
|
|
18
|
+
}
|
|
19
|
+
function validatePositivePoints(points, label = "points") {
|
|
20
|
+
validatePoints(points, label);
|
|
21
|
+
if (points <= 0) throw new ValidationError(`${label} must be positive`);
|
|
22
|
+
}
|
|
23
|
+
function validateOrderTotal(total) {
|
|
24
|
+
if (!Number.isFinite(total) || total < 0) throw new ValidationError("orderTotal must be a non-negative finite number");
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/services/member-guard.ts
|
|
28
|
+
/** Fetches a member by ID (tenant-scoped) or throws MemberNotFoundError. */
|
|
29
|
+
async function requireMember(memberPort, memberId, tenantId) {
|
|
30
|
+
const member = await memberPort.getById(memberId, tenantId);
|
|
31
|
+
if (!member) throw new MemberNotFoundError(memberId);
|
|
32
|
+
return member;
|
|
33
|
+
}
|
|
34
|
+
/** Fetches an active member (tenant-scoped) or throws MemberNotFoundError / MemberInactiveError. */
|
|
35
|
+
async function requireActiveMember(memberPort, memberId, tenantId) {
|
|
36
|
+
const member = await requireMember(memberPort, memberId, tenantId);
|
|
37
|
+
if (member.status !== "active") throw new MemberInactiveError(memberId);
|
|
38
|
+
return member;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/services/earning.service.ts
|
|
42
|
+
var EarningService = class {
|
|
43
|
+
constructor(rulePort, ledger, memberPort, config) {
|
|
44
|
+
this.rulePort = rulePort;
|
|
45
|
+
this.ledger = ledger;
|
|
46
|
+
this.memberPort = memberPort;
|
|
47
|
+
this.config = config;
|
|
48
|
+
}
|
|
49
|
+
async evaluateOrder(input, ctx) {
|
|
50
|
+
validateOrderTotal(input.orderTotal);
|
|
51
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
52
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
53
|
+
const member = await requireActiveMember(this.memberPort, input.memberId, tenantId);
|
|
54
|
+
const orderRules = (await this.rulePort.findActive(tenantId, member.programId)).filter((r) => [
|
|
55
|
+
"order",
|
|
56
|
+
"category",
|
|
57
|
+
"tier_bonus"
|
|
58
|
+
].includes(r.type)).sort((a, b) => a.priority - b.priority);
|
|
59
|
+
const breakdown = [];
|
|
60
|
+
const transactions = [];
|
|
61
|
+
let accumulatedPoints = 0;
|
|
62
|
+
for (const rule of orderRules) {
|
|
63
|
+
if (!this.matchesConditions(rule, member, input.orderTotal)) continue;
|
|
64
|
+
let points = 0;
|
|
65
|
+
if (rule.type === "order") points = this.calculateOrderPoints(rule, input.orderTotal);
|
|
66
|
+
else if (rule.type === "category" && input.items) points = this.calculateCategoryPoints(rule, input.items);
|
|
67
|
+
else if (rule.type === "tier_bonus") {
|
|
68
|
+
const multiplier = rule.reward.multiplier ?? 1;
|
|
69
|
+
points = Math.floor(accumulatedPoints * (multiplier - 1));
|
|
70
|
+
}
|
|
71
|
+
if (points <= 0) continue;
|
|
72
|
+
if (rule.reward.maxPointsPerTransaction && points > rule.reward.maxPointsPerTransaction) points = rule.reward.maxPointsPerTransaction;
|
|
73
|
+
const idempotencyKey = `${input.orderId}:${rule._id}`;
|
|
74
|
+
const tx = await this.ledger.earnPoints({
|
|
75
|
+
memberId: input.memberId,
|
|
76
|
+
points,
|
|
77
|
+
description: `Earning rule: ${rule.name}`,
|
|
78
|
+
referenceType: "order",
|
|
79
|
+
referenceId: input.orderId,
|
|
80
|
+
earningRuleId: rule._id,
|
|
81
|
+
idempotencyKey
|
|
82
|
+
}, ctx);
|
|
83
|
+
accumulatedPoints += points;
|
|
84
|
+
breakdown.push({
|
|
85
|
+
ruleId: rule._id,
|
|
86
|
+
ruleName: rule.name,
|
|
87
|
+
points
|
|
88
|
+
});
|
|
89
|
+
transactions.push(tx);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
totalPoints: accumulatedPoints,
|
|
93
|
+
breakdown,
|
|
94
|
+
transactions
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async evaluateAction(input, ctx) {
|
|
98
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
99
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
100
|
+
const member = await requireActiveMember(this.memberPort, input.memberId, tenantId);
|
|
101
|
+
const actionRules = (await this.rulePort.findActive(tenantId, member.programId)).filter((r) => r.type === "action" && r.conditions.actions?.includes(input.action));
|
|
102
|
+
const breakdown = [];
|
|
103
|
+
const transactions = [];
|
|
104
|
+
let totalPoints = 0;
|
|
105
|
+
for (const rule of actionRules) {
|
|
106
|
+
const points = rule.reward.fixedPoints ?? 0;
|
|
107
|
+
if (points <= 0) continue;
|
|
108
|
+
const tx = await this.ledger.earnPoints({
|
|
109
|
+
memberId: input.memberId,
|
|
110
|
+
points,
|
|
111
|
+
description: `Action: ${input.action} (${rule.name})`,
|
|
112
|
+
referenceType: "action",
|
|
113
|
+
referenceId: input.referenceId,
|
|
114
|
+
earningRuleId: rule._id
|
|
115
|
+
}, ctx);
|
|
116
|
+
totalPoints += points;
|
|
117
|
+
breakdown.push({
|
|
118
|
+
ruleId: rule._id,
|
|
119
|
+
ruleName: rule.name,
|
|
120
|
+
points
|
|
121
|
+
});
|
|
122
|
+
transactions.push(tx);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
totalPoints,
|
|
126
|
+
breakdown,
|
|
127
|
+
transactions
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async createRule(input, ctx) {
|
|
131
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
132
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
133
|
+
const data = {
|
|
134
|
+
...input,
|
|
135
|
+
programId: input.programId ?? this.config.programId
|
|
136
|
+
};
|
|
137
|
+
if (tenantId) data[this.config.tenant.field] = tenantId;
|
|
138
|
+
return this.rulePort.create(data);
|
|
139
|
+
}
|
|
140
|
+
async updateRule(ruleId, input, ctx) {
|
|
141
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
142
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
143
|
+
if (!await this.rulePort.getById(ruleId, tenantId)) throw new RuleNotFoundError(ruleId);
|
|
144
|
+
return this.rulePort.update(ruleId, { ...input }, tenantId);
|
|
145
|
+
}
|
|
146
|
+
async deactivateRule(ruleId, ctx) {
|
|
147
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
148
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
149
|
+
if (!await this.rulePort.getById(ruleId, tenantId)) throw new RuleNotFoundError(ruleId);
|
|
150
|
+
return this.rulePort.update(ruleId, { status: "paused" }, tenantId);
|
|
151
|
+
}
|
|
152
|
+
async listRules(query, ctx) {
|
|
153
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
154
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
155
|
+
const filter = {};
|
|
156
|
+
if (tenantId) filter[this.config.tenant.field] = tenantId;
|
|
157
|
+
const result = await this.rulePort.paginate(filter, {
|
|
158
|
+
page: query.page ?? 1,
|
|
159
|
+
limit: query.limit ?? 20,
|
|
160
|
+
sort: query.sort ?? { priority: 1 }
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
docs: result.docs,
|
|
164
|
+
total: result.total,
|
|
165
|
+
page: result.page,
|
|
166
|
+
pages: result.pages,
|
|
167
|
+
hasNext: result.page < result.pages,
|
|
168
|
+
hasPrev: result.page > 1
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
matchesConditions(rule, member, orderTotal) {
|
|
172
|
+
const { conditions } = rule;
|
|
173
|
+
const now = /* @__PURE__ */ new Date();
|
|
174
|
+
if (conditions.minOrderAmount && orderTotal < conditions.minOrderAmount) return false;
|
|
175
|
+
if (conditions.tiers && conditions.tiers.length > 0) {
|
|
176
|
+
const memberTier = member.tierOverride ?? member.tier;
|
|
177
|
+
if (!memberTier || !conditions.tiers.includes(memberTier)) return false;
|
|
178
|
+
}
|
|
179
|
+
if (conditions.dateRange) {
|
|
180
|
+
if (conditions.dateRange.start && now < conditions.dateRange.start) return false;
|
|
181
|
+
if (conditions.dateRange.end && now > conditions.dateRange.end) return false;
|
|
182
|
+
}
|
|
183
|
+
if (conditions.dayOfWeek && conditions.dayOfWeek.length > 0) {
|
|
184
|
+
if (!conditions.dayOfWeek.includes(now.getDay())) return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
calculateOrderPoints(rule, orderTotal) {
|
|
189
|
+
const { reward } = rule;
|
|
190
|
+
let points = 0;
|
|
191
|
+
if (reward.pointsPerAmount) points = orderTotal * reward.pointsPerAmount;
|
|
192
|
+
else if (reward.amountPerPoint && reward.amountPerPoint > 0) points = orderTotal / reward.amountPerPoint;
|
|
193
|
+
return this.applyRounding(points, reward.roundingMode);
|
|
194
|
+
}
|
|
195
|
+
calculateCategoryPoints(rule, items) {
|
|
196
|
+
const { conditions, reward } = rule;
|
|
197
|
+
let points = 0;
|
|
198
|
+
for (const item of items) if (conditions.categories && item.categoryId && conditions.categories.includes(item.categoryId)) {
|
|
199
|
+
if (reward.pointsPerAmount) points += item.amount * reward.pointsPerAmount;
|
|
200
|
+
else if (reward.amountPerPoint) points += item.amount / reward.amountPerPoint;
|
|
201
|
+
}
|
|
202
|
+
return this.applyRounding(points, reward.roundingMode);
|
|
203
|
+
}
|
|
204
|
+
applyRounding(value, mode) {
|
|
205
|
+
switch (mode) {
|
|
206
|
+
case "ceil": return Math.ceil(value);
|
|
207
|
+
case "round": return Math.round(value);
|
|
208
|
+
default: return Math.floor(value);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/services/ledger.service.ts
|
|
214
|
+
var LedgerService = class {
|
|
215
|
+
constructor(txPort, memberPort, events, notifications, unitOfWork, config) {
|
|
216
|
+
this.txPort = txPort;
|
|
217
|
+
this.memberPort = memberPort;
|
|
218
|
+
this.events = events;
|
|
219
|
+
this.notifications = notifications;
|
|
220
|
+
this.unitOfWork = unitOfWork;
|
|
221
|
+
this.config = config;
|
|
222
|
+
}
|
|
223
|
+
async earnPoints(input, ctx) {
|
|
224
|
+
validatePositivePoints(input.points);
|
|
225
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
226
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
227
|
+
if (input.idempotencyKey) {
|
|
228
|
+
const existing = await this.txPort.getByIdempotencyKey(input.idempotencyKey, tenantId);
|
|
229
|
+
if (existing) return existing;
|
|
230
|
+
}
|
|
231
|
+
const member = await requireActiveMember(this.memberPort, input.memberId, tenantId);
|
|
232
|
+
try {
|
|
233
|
+
const tx = await this.unitOfWork.withTransaction(async (session) => {
|
|
234
|
+
if (input.idempotencyKey) {
|
|
235
|
+
const dup = await this.txPort.getByIdempotencyKey(input.idempotencyKey, tenantId);
|
|
236
|
+
if (dup) return dup;
|
|
237
|
+
}
|
|
238
|
+
const updatedMember = await this.memberPort.updateBalance(input.memberId, {
|
|
239
|
+
current: input.points,
|
|
240
|
+
lifetime: input.points
|
|
241
|
+
}, tenantId, session);
|
|
242
|
+
const data = {
|
|
243
|
+
memberId: input.memberId,
|
|
244
|
+
programId: member.programId,
|
|
245
|
+
type: "earn",
|
|
246
|
+
points: input.points,
|
|
247
|
+
balanceAfter: updatedMember.balance.current,
|
|
248
|
+
description: input.description,
|
|
249
|
+
referenceType: input.referenceType,
|
|
250
|
+
referenceId: input.referenceId,
|
|
251
|
+
earningRuleId: input.earningRuleId,
|
|
252
|
+
expiresAt: input.expiresAt,
|
|
253
|
+
actorId: ctx.actorId,
|
|
254
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
255
|
+
};
|
|
256
|
+
if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
|
|
257
|
+
if (tenantId) data[this.config.tenant.field] = tenantId;
|
|
258
|
+
return this.txPort.create(data, session);
|
|
259
|
+
});
|
|
260
|
+
const payload = {
|
|
261
|
+
memberId: member._id,
|
|
262
|
+
externalId: member.externalId,
|
|
263
|
+
externalType: member.externalType,
|
|
264
|
+
points: input.points,
|
|
265
|
+
balanceAfter: tx.balanceAfter,
|
|
266
|
+
transactionId: tx._id,
|
|
267
|
+
referenceType: input.referenceType,
|
|
268
|
+
referenceId: input.referenceId
|
|
269
|
+
};
|
|
270
|
+
this.events.emit(LoyaltyEvents.POINTS_EARNED, payload);
|
|
271
|
+
this.notifications?.notify(LoyaltyEvents.POINTS_EARNED, { ...payload });
|
|
272
|
+
return tx;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err?.code === 11e3 && input.idempotencyKey) {
|
|
275
|
+
const existing = await this.txPort.getByIdempotencyKey(input.idempotencyKey, tenantId);
|
|
276
|
+
if (existing) return existing;
|
|
277
|
+
}
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async adjustPoints(input, ctx) {
|
|
282
|
+
validatePoints(input.points);
|
|
283
|
+
if (input.points === 0) throw new ValidationError("Adjustment points cannot be zero");
|
|
284
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
285
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
286
|
+
if (input.idempotencyKey) {
|
|
287
|
+
const existing = await this.txPort.getByIdempotencyKey(input.idempotencyKey, tenantId);
|
|
288
|
+
if (existing) return existing;
|
|
289
|
+
}
|
|
290
|
+
const member = await requireActiveMember(this.memberPort, input.memberId, tenantId);
|
|
291
|
+
if (input.points < 0 && member.balance.current < Math.abs(input.points)) throw new InsufficientPointsError(member.balance.current, Math.abs(input.points));
|
|
292
|
+
const tx = await this.unitOfWork.withTransaction(async (session) => {
|
|
293
|
+
if (input.idempotencyKey) {
|
|
294
|
+
const dup = await this.txPort.getByIdempotencyKey(input.idempotencyKey, tenantId);
|
|
295
|
+
if (dup) return dup;
|
|
296
|
+
}
|
|
297
|
+
const balanceUpdate = { current: input.points };
|
|
298
|
+
if (input.points > 0) balanceUpdate.lifetime = input.points;
|
|
299
|
+
const updatedMember = await this.memberPort.updateBalance(input.memberId, balanceUpdate, tenantId, session);
|
|
300
|
+
if (updatedMember.balance.current < 0) throw new InsufficientPointsError(updatedMember.balance.current - input.points, Math.abs(input.points));
|
|
301
|
+
const data = {
|
|
302
|
+
memberId: input.memberId,
|
|
303
|
+
programId: member.programId,
|
|
304
|
+
type: "adjust",
|
|
305
|
+
points: input.points,
|
|
306
|
+
balanceAfter: updatedMember.balance.current,
|
|
307
|
+
description: input.description,
|
|
308
|
+
referenceType: input.referenceType,
|
|
309
|
+
referenceId: input.referenceId,
|
|
310
|
+
metadata: {
|
|
311
|
+
reason: input.reason,
|
|
312
|
+
...input.metadata || {}
|
|
313
|
+
},
|
|
314
|
+
actorId: ctx.actorId
|
|
315
|
+
};
|
|
316
|
+
if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
|
|
317
|
+
if (tenantId) data[this.config.tenant.field] = tenantId;
|
|
318
|
+
return this.txPort.create(data, session);
|
|
319
|
+
});
|
|
320
|
+
const payload = {
|
|
321
|
+
memberId: member._id,
|
|
322
|
+
externalId: member.externalId,
|
|
323
|
+
points: input.points,
|
|
324
|
+
balanceAfter: tx.balanceAfter,
|
|
325
|
+
transactionId: tx._id,
|
|
326
|
+
description: input.description
|
|
327
|
+
};
|
|
328
|
+
this.events.emit(LoyaltyEvents.POINTS_ADJUSTED, payload);
|
|
329
|
+
this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
|
|
330
|
+
return tx;
|
|
331
|
+
}
|
|
332
|
+
async getHistory(memberId, query, ctx) {
|
|
333
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
334
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
335
|
+
await requireActiveMember(this.memberPort, memberId, tenantId);
|
|
336
|
+
const filter = { memberId };
|
|
337
|
+
if (tenantId) filter[this.config.tenant.field] = tenantId;
|
|
338
|
+
const result = await this.txPort.paginate(filter, {
|
|
339
|
+
page: query.page ?? 1,
|
|
340
|
+
limit: query.limit ?? 20,
|
|
341
|
+
sort: query.sort ?? { createdAt: -1 }
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
docs: result.docs,
|
|
345
|
+
total: result.total,
|
|
346
|
+
page: result.page,
|
|
347
|
+
pages: result.pages,
|
|
348
|
+
hasNext: result.page < result.pages,
|
|
349
|
+
hasPrev: result.page > 1
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async getBalance(memberId, ctx) {
|
|
353
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
354
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
355
|
+
return (await requireActiveMember(this.memberPort, memberId, tenantId)).balance.current;
|
|
356
|
+
}
|
|
357
|
+
async processExpirations(ctx) {
|
|
358
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
359
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
360
|
+
const now = /* @__PURE__ */ new Date();
|
|
361
|
+
const expiredTxs = await this.txPort.findExpired(now, tenantId);
|
|
362
|
+
let pointsExpired = 0;
|
|
363
|
+
let transactionCount = 0;
|
|
364
|
+
const affectedMemberIds = /* @__PURE__ */ new Set();
|
|
365
|
+
for (const tx of expiredTxs) {
|
|
366
|
+
await this.unitOfWork.withTransaction(async (session) => {
|
|
367
|
+
await this.txPort.create({
|
|
368
|
+
memberId: tx.memberId,
|
|
369
|
+
programId: tx.programId,
|
|
370
|
+
type: "expire",
|
|
371
|
+
points: -tx.points,
|
|
372
|
+
balanceAfter: 0,
|
|
373
|
+
description: `Points expired from transaction ${tx._id}`,
|
|
374
|
+
referenceType: "expiration",
|
|
375
|
+
referenceId: tx._id,
|
|
376
|
+
actorId: ctx.actorId,
|
|
377
|
+
...tenantId ? { [this.config.tenant.field]: tenantId } : {}
|
|
378
|
+
}, session);
|
|
379
|
+
await this.txPort.update(tx._id, { expiredAt: now }, session);
|
|
380
|
+
await this.memberPort.updateBalance(tx.memberId, {
|
|
381
|
+
current: -tx.points,
|
|
382
|
+
expired: tx.points
|
|
383
|
+
}, tenantId, session);
|
|
384
|
+
});
|
|
385
|
+
affectedMemberIds.add(tx.memberId);
|
|
386
|
+
pointsExpired += tx.points;
|
|
387
|
+
transactionCount++;
|
|
388
|
+
}
|
|
389
|
+
if (pointsExpired > 0) this.events.emit(LoyaltyEvents.POINTS_EXPIRED, {
|
|
390
|
+
membersAffected: affectedMemberIds.size,
|
|
391
|
+
pointsExpired,
|
|
392
|
+
transactionCount
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
membersAffected: affectedMemberIds.size,
|
|
396
|
+
pointsExpired,
|
|
397
|
+
transactionCount
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/utils/code-generator.ts
|
|
403
|
+
const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
404
|
+
function generateReferralCode(length) {
|
|
405
|
+
if (!Number.isInteger(length) || length < 4 || length > 32) throw new Error("Referral code length must be an integer between 4 and 32");
|
|
406
|
+
const bytes = randomBytes(length);
|
|
407
|
+
let code = "";
|
|
408
|
+
for (let i = 0; i < length; i++) code += CHARSET[bytes[i] % 32];
|
|
409
|
+
return code;
|
|
410
|
+
}
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/services/member.service.ts
|
|
413
|
+
var MemberService = class {
|
|
414
|
+
constructor(memberPort, events, notifications, unitOfWork, config) {
|
|
415
|
+
this.memberPort = memberPort;
|
|
416
|
+
this.events = events;
|
|
417
|
+
this.notifications = notifications;
|
|
418
|
+
this.unitOfWork = unitOfWork;
|
|
419
|
+
this.config = config;
|
|
420
|
+
}
|
|
421
|
+
async enroll(input, ctx) {
|
|
422
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
423
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
424
|
+
if (await this.memberPort.getByExternalId(input.externalId, input.externalType, tenantId)) throw new MemberAlreadyEnrolledError(input.externalId);
|
|
425
|
+
const referralCode = generateReferralCode(this.config.referral.codeLength);
|
|
426
|
+
const now = /* @__PURE__ */ new Date();
|
|
427
|
+
const data = {
|
|
428
|
+
programId: input.programId ?? this.config.programId,
|
|
429
|
+
externalId: input.externalId,
|
|
430
|
+
externalType: input.externalType,
|
|
431
|
+
status: "active",
|
|
432
|
+
cardId: input.cardId,
|
|
433
|
+
referralCode,
|
|
434
|
+
tier: input.tier ?? null,
|
|
435
|
+
tierOverride: null,
|
|
436
|
+
balance: {
|
|
437
|
+
current: 0,
|
|
438
|
+
lifetime: 0,
|
|
439
|
+
redeemed: 0,
|
|
440
|
+
expired: 0
|
|
441
|
+
},
|
|
442
|
+
enrolledAt: now,
|
|
443
|
+
metadata: input.metadata
|
|
444
|
+
};
|
|
445
|
+
if (tenantId) data[this.config.tenant.field] = tenantId;
|
|
446
|
+
const member = await this.memberPort.create(data);
|
|
447
|
+
const payload = {
|
|
448
|
+
memberId: member._id,
|
|
449
|
+
externalId: member.externalId,
|
|
450
|
+
externalType: member.externalType,
|
|
451
|
+
status: member.status
|
|
452
|
+
};
|
|
453
|
+
this.events.emit(LoyaltyEvents.MEMBER_ENROLLED, payload);
|
|
454
|
+
this.notifications?.notify(LoyaltyEvents.MEMBER_ENROLLED, { ...payload });
|
|
455
|
+
return member;
|
|
456
|
+
}
|
|
457
|
+
async deactivate(memberId, ctx) {
|
|
458
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
459
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
460
|
+
await requireMember(this.memberPort, memberId, tenantId);
|
|
461
|
+
const updated = await this.memberPort.update(memberId, {
|
|
462
|
+
status: "inactive",
|
|
463
|
+
deactivatedAt: /* @__PURE__ */ new Date()
|
|
464
|
+
}, tenantId);
|
|
465
|
+
const payload = {
|
|
466
|
+
memberId: updated._id,
|
|
467
|
+
externalId: updated.externalId,
|
|
468
|
+
externalType: updated.externalType,
|
|
469
|
+
status: updated.status
|
|
470
|
+
};
|
|
471
|
+
this.events.emit(LoyaltyEvents.MEMBER_DEACTIVATED, payload);
|
|
472
|
+
this.notifications?.notify(LoyaltyEvents.MEMBER_DEACTIVATED, { ...payload });
|
|
473
|
+
return updated;
|
|
474
|
+
}
|
|
475
|
+
async reactivate(memberId, ctx) {
|
|
476
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
477
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
478
|
+
await requireMember(this.memberPort, memberId, tenantId);
|
|
479
|
+
const updated = await this.memberPort.update(memberId, {
|
|
480
|
+
status: "active",
|
|
481
|
+
deactivatedAt: null
|
|
482
|
+
}, tenantId);
|
|
483
|
+
const payload = {
|
|
484
|
+
memberId: updated._id,
|
|
485
|
+
externalId: updated.externalId,
|
|
486
|
+
externalType: updated.externalType,
|
|
487
|
+
status: updated.status
|
|
488
|
+
};
|
|
489
|
+
this.events.emit(LoyaltyEvents.MEMBER_REACTIVATED, payload);
|
|
490
|
+
this.notifications?.notify(LoyaltyEvents.MEMBER_REACTIVATED, { ...payload });
|
|
491
|
+
return updated;
|
|
492
|
+
}
|
|
493
|
+
async suspend(memberId, reason, ctx) {
|
|
494
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
495
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
496
|
+
const member = await requireMember(this.memberPort, memberId, tenantId);
|
|
497
|
+
const updated = await this.memberPort.update(memberId, {
|
|
498
|
+
status: "suspended",
|
|
499
|
+
metadata: {
|
|
500
|
+
...member.metadata,
|
|
501
|
+
suspendReason: reason
|
|
502
|
+
}
|
|
503
|
+
}, tenantId);
|
|
504
|
+
const payload = {
|
|
505
|
+
memberId: updated._id,
|
|
506
|
+
externalId: updated.externalId,
|
|
507
|
+
externalType: updated.externalType,
|
|
508
|
+
status: updated.status
|
|
509
|
+
};
|
|
510
|
+
this.events.emit(LoyaltyEvents.MEMBER_SUSPENDED, payload);
|
|
511
|
+
this.notifications?.notify(LoyaltyEvents.MEMBER_SUSPENDED, { ...payload });
|
|
512
|
+
return updated;
|
|
513
|
+
}
|
|
514
|
+
async getById(memberId, ctx) {
|
|
515
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
516
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
517
|
+
return requireMember(this.memberPort, memberId, tenantId);
|
|
518
|
+
}
|
|
519
|
+
async getByExternalId(externalId, externalType, ctx) {
|
|
520
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
521
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
522
|
+
return this.memberPort.getByExternalId(externalId, externalType, tenantId);
|
|
523
|
+
}
|
|
524
|
+
async getByCardId(cardId, ctx) {
|
|
525
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
526
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
527
|
+
return this.memberPort.getByCardId(cardId, tenantId);
|
|
528
|
+
}
|
|
529
|
+
async getBalance(memberId, ctx) {
|
|
530
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
531
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
532
|
+
const member = await requireMember(this.memberPort, memberId, tenantId);
|
|
533
|
+
return {
|
|
534
|
+
...member.balance,
|
|
535
|
+
tier: member.tier,
|
|
536
|
+
tierOverride: member.tierOverride
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/services/redemption.service.ts
|
|
542
|
+
var RedemptionService = class {
|
|
543
|
+
constructor(redemptionPort, memberPort, txPort, events, notifications, unitOfWork, config) {
|
|
544
|
+
this.redemptionPort = redemptionPort;
|
|
545
|
+
this.memberPort = memberPort;
|
|
546
|
+
this.txPort = txPort;
|
|
547
|
+
this.events = events;
|
|
548
|
+
this.notifications = notifications;
|
|
549
|
+
this.unitOfWork = unitOfWork;
|
|
550
|
+
this.config = config;
|
|
551
|
+
}
|
|
552
|
+
async validate(input, ctx) {
|
|
553
|
+
validatePositivePoints(input.pointsToRedeem, "pointsToRedeem");
|
|
554
|
+
validateOrderTotal(input.orderTotal);
|
|
555
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
556
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
557
|
+
const member = await requireActiveMember(this.memberPort, input.memberId, tenantId);
|
|
558
|
+
const { minPoints, minOrderAmount, maxRedeemPercent } = this.config.redemption;
|
|
559
|
+
const { conversionRate } = this.config;
|
|
560
|
+
if (input.pointsToRedeem < minPoints) return {
|
|
561
|
+
valid: false,
|
|
562
|
+
error: `Minimum ${minPoints} points required`,
|
|
563
|
+
pointsToRedeem: 0,
|
|
564
|
+
discountAmount: 0,
|
|
565
|
+
maxAllowedPoints: 0
|
|
566
|
+
};
|
|
567
|
+
if (input.orderTotal < minOrderAmount) return {
|
|
568
|
+
valid: false,
|
|
569
|
+
error: `Minimum order amount of ${minOrderAmount} required`,
|
|
570
|
+
pointsToRedeem: 0,
|
|
571
|
+
discountAmount: 0,
|
|
572
|
+
maxAllowedPoints: 0
|
|
573
|
+
};
|
|
574
|
+
if (input.pointsToRedeem > member.balance.current) return {
|
|
575
|
+
valid: false,
|
|
576
|
+
error: `Insufficient points. Available: ${member.balance.current}`,
|
|
577
|
+
pointsToRedeem: 0,
|
|
578
|
+
discountAmount: 0,
|
|
579
|
+
maxAllowedPoints: member.balance.current
|
|
580
|
+
};
|
|
581
|
+
const maxDiscount = Math.floor(input.orderTotal * maxRedeemPercent / 100);
|
|
582
|
+
const requestedDiscount = Math.floor(input.pointsToRedeem / conversionRate);
|
|
583
|
+
const discountAmount = Math.min(requestedDiscount, maxDiscount);
|
|
584
|
+
const maxAllowedPoints = Math.floor(maxDiscount * conversionRate);
|
|
585
|
+
return {
|
|
586
|
+
valid: true,
|
|
587
|
+
pointsToRedeem: discountAmount < requestedDiscount ? Math.floor(discountAmount * conversionRate) : input.pointsToRedeem,
|
|
588
|
+
discountAmount,
|
|
589
|
+
maxAllowedPoints
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
async reserve(input, ctx) {
|
|
593
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
594
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
595
|
+
const validation = await this.validate({
|
|
596
|
+
memberId: input.memberId,
|
|
597
|
+
pointsToRedeem: input.pointsToRedeem,
|
|
598
|
+
orderTotal: input.orderTotal
|
|
599
|
+
}, ctx);
|
|
600
|
+
if (!validation.valid) throw new ValidationError(validation.error ?? "Redemption validation failed");
|
|
601
|
+
const member = await requireActiveMember(this.memberPort, input.memberId, tenantId);
|
|
602
|
+
const expiresAt = input.expiresAt ?? new Date(Date.now() + this.config.redemption.reservationTtlMinutes * 60 * 1e3);
|
|
603
|
+
const redemption = await this.unitOfWork.withTransaction(async (session) => {
|
|
604
|
+
const updatedMember = await this.memberPort.updateBalance(input.memberId, { current: -validation.pointsToRedeem }, tenantId, session);
|
|
605
|
+
if (updatedMember.balance.current < 0) throw new ValidationError(`Insufficient points after concurrent reservation. Available: ${updatedMember.balance.current + validation.pointsToRedeem}`);
|
|
606
|
+
const data = {
|
|
607
|
+
memberId: input.memberId,
|
|
608
|
+
programId: member.programId,
|
|
609
|
+
pointsReserved: validation.pointsToRedeem,
|
|
610
|
+
pointsConfirmed: 0,
|
|
611
|
+
discountAmount: validation.discountAmount,
|
|
612
|
+
conversionRate: this.config.conversionRate,
|
|
613
|
+
status: "reserved",
|
|
614
|
+
ownerType: input.ownerType,
|
|
615
|
+
ownerId: input.ownerId,
|
|
616
|
+
expiresAt
|
|
617
|
+
};
|
|
618
|
+
if (tenantId) data[this.config.tenant.field] = tenantId;
|
|
619
|
+
return this.redemptionPort.create(data, session);
|
|
620
|
+
});
|
|
621
|
+
const payload = {
|
|
622
|
+
memberId: member._id,
|
|
623
|
+
externalId: member.externalId,
|
|
624
|
+
redemptionId: redemption._id,
|
|
625
|
+
points: validation.pointsToRedeem,
|
|
626
|
+
discountAmount: validation.discountAmount,
|
|
627
|
+
ownerType: input.ownerType,
|
|
628
|
+
ownerId: input.ownerId
|
|
629
|
+
};
|
|
630
|
+
this.events.emit(LoyaltyEvents.REDEMPTION_RESERVED, payload);
|
|
631
|
+
this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
|
|
632
|
+
return redemption;
|
|
633
|
+
}
|
|
634
|
+
async confirm(redemptionId, ctx) {
|
|
635
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
636
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
637
|
+
const redemption = await this.redemptionPort.getById(redemptionId, tenantId);
|
|
638
|
+
if (!redemption) throw new RedemptionNotFoundError(redemptionId);
|
|
639
|
+
if (redemption.expiresAt && redemption.expiresAt < /* @__PURE__ */ new Date()) throw new RedemptionExpiredError(redemptionId);
|
|
640
|
+
const updated = await this.unitOfWork.withTransaction(async (session) => {
|
|
641
|
+
if (!await this.redemptionPort.atomicStatusTransition(redemptionId, "reserved", {
|
|
642
|
+
status: "confirmed",
|
|
643
|
+
pointsConfirmed: redemption.pointsReserved,
|
|
644
|
+
confirmedAt: /* @__PURE__ */ new Date()
|
|
645
|
+
}, tenantId, session)) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
|
|
646
|
+
const member = await requireActiveMember(this.memberPort, redemption.memberId, tenantId);
|
|
647
|
+
const tx = await this.txPort.create({
|
|
648
|
+
memberId: redemption.memberId,
|
|
649
|
+
programId: member.programId,
|
|
650
|
+
type: "redeem",
|
|
651
|
+
points: -redemption.pointsReserved,
|
|
652
|
+
balanceAfter: member.balance.current,
|
|
653
|
+
description: `Redemption confirmed: ${redemptionId}`,
|
|
654
|
+
referenceType: redemption.ownerType,
|
|
655
|
+
referenceId: redemption.ownerId,
|
|
656
|
+
actorId: ctx.actorId,
|
|
657
|
+
...tenantId ? { [this.config.tenant.field]: tenantId } : {}
|
|
658
|
+
}, session);
|
|
659
|
+
await this.memberPort.updateBalance(redemption.memberId, { redeemed: redemption.pointsReserved }, tenantId, session);
|
|
660
|
+
return this.redemptionPort.update(redemptionId, { transactionId: tx._id }, tenantId, session);
|
|
661
|
+
});
|
|
662
|
+
const member = await this.memberPort.getById(updated.memberId, tenantId);
|
|
663
|
+
const payload = {
|
|
664
|
+
memberId: updated.memberId,
|
|
665
|
+
externalId: member?.externalId ?? "",
|
|
666
|
+
redemptionId: updated._id,
|
|
667
|
+
points: updated.pointsConfirmed,
|
|
668
|
+
discountAmount: updated.discountAmount,
|
|
669
|
+
ownerType: updated.ownerType,
|
|
670
|
+
ownerId: updated.ownerId
|
|
671
|
+
};
|
|
672
|
+
this.events.emit(LoyaltyEvents.REDEMPTION_CONFIRMED, payload);
|
|
673
|
+
this.notifications?.notify(LoyaltyEvents.REDEMPTION_CONFIRMED, { ...payload });
|
|
674
|
+
return updated;
|
|
675
|
+
}
|
|
676
|
+
async release(redemptionId, ctx) {
|
|
677
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
678
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
679
|
+
const redemption = await this.redemptionPort.getById(redemptionId, tenantId);
|
|
680
|
+
if (!redemption) throw new RedemptionNotFoundError(redemptionId);
|
|
681
|
+
const updated = await this.unitOfWork.withTransaction(async (session) => {
|
|
682
|
+
const transitioned = await this.redemptionPort.atomicStatusTransition(redemptionId, "reserved", {
|
|
683
|
+
status: "released",
|
|
684
|
+
releasedAt: /* @__PURE__ */ new Date()
|
|
685
|
+
}, tenantId, session);
|
|
686
|
+
if (!transitioned) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
|
|
687
|
+
await this.memberPort.updateBalance(redemption.memberId, { current: redemption.pointsReserved }, tenantId, session);
|
|
688
|
+
return transitioned;
|
|
689
|
+
});
|
|
690
|
+
const member = await this.memberPort.getById(updated.memberId, tenantId);
|
|
691
|
+
const payload = {
|
|
692
|
+
memberId: updated.memberId,
|
|
693
|
+
externalId: member?.externalId ?? "",
|
|
694
|
+
redemptionId: updated._id,
|
|
695
|
+
points: updated.pointsReserved,
|
|
696
|
+
discountAmount: updated.discountAmount,
|
|
697
|
+
ownerType: updated.ownerType,
|
|
698
|
+
ownerId: updated.ownerId
|
|
699
|
+
};
|
|
700
|
+
this.events.emit(LoyaltyEvents.REDEMPTION_RELEASED, payload);
|
|
701
|
+
this.notifications?.notify(LoyaltyEvents.REDEMPTION_RELEASED, { ...payload });
|
|
702
|
+
return updated;
|
|
703
|
+
}
|
|
704
|
+
async cleanupExpired(ctx) {
|
|
705
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
706
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
707
|
+
const now = /* @__PURE__ */ new Date();
|
|
708
|
+
const expired = await this.redemptionPort.findExpired(now, tenantId);
|
|
709
|
+
let released = 0;
|
|
710
|
+
for (const redemption of expired) {
|
|
711
|
+
await this.release(redemption._id, ctx);
|
|
712
|
+
released++;
|
|
713
|
+
}
|
|
714
|
+
return released;
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/services/referral.service.ts
|
|
719
|
+
var ReferralService = class {
|
|
720
|
+
constructor(referralPort, memberPort, ledger, events, notifications, config) {
|
|
721
|
+
this.referralPort = referralPort;
|
|
722
|
+
this.memberPort = memberPort;
|
|
723
|
+
this.ledger = ledger;
|
|
724
|
+
this.events = events;
|
|
725
|
+
this.notifications = notifications;
|
|
726
|
+
this.config = config;
|
|
727
|
+
}
|
|
728
|
+
async generateCode(memberId, ctx) {
|
|
729
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
730
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
731
|
+
const member = await requireMember(this.memberPort, memberId, tenantId);
|
|
732
|
+
if (member.referralCode) return member.referralCode;
|
|
733
|
+
const code = generateReferralCode(this.config.referral.codeLength);
|
|
734
|
+
await this.memberPort.update(memberId, { referralCode: code }, tenantId);
|
|
735
|
+
return code;
|
|
736
|
+
}
|
|
737
|
+
async lookupByCode(code, ctx) {
|
|
738
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
739
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
740
|
+
return this.memberPort.getByReferralCode(code, tenantId);
|
|
741
|
+
}
|
|
742
|
+
async recordReferral(input, ctx) {
|
|
743
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
744
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
745
|
+
const referrer = await this.memberPort.getByReferralCode(input.referralCode, tenantId);
|
|
746
|
+
if (!referrer) throw new MemberNotFoundError(`referral code: ${input.referralCode}`);
|
|
747
|
+
const referee = await this.memberPort.getByExternalId(input.refereeExternalId, input.refereeExternalType, tenantId);
|
|
748
|
+
if (!referee) throw new MemberNotFoundError(`referee: ${input.refereeExternalId}`);
|
|
749
|
+
if (String(referrer._id) === String(referee._id)) throw new SelfReferralError();
|
|
750
|
+
if (await this.referralPort.getByReferee(referee._id, tenantId)) throw new DuplicateReferralError(referee._id);
|
|
751
|
+
const reverseReferral = await this.referralPort.getByReferee(referrer._id, tenantId);
|
|
752
|
+
if (reverseReferral && String(reverseReferral.referrerId) === String(referee._id)) throw new CircularReferralError();
|
|
753
|
+
const periodStart = /* @__PURE__ */ new Date();
|
|
754
|
+
periodStart.setDate(periodStart.getDate() - this.config.referral.periodDays);
|
|
755
|
+
if (await this.referralPort.countByReferrerInPeriod(referrer._id, periodStart, tenantId) >= this.config.referral.maxPerPeriod) throw new ReferralLimitExceededError(this.config.referral.maxPerPeriod, this.config.referral.periodDays);
|
|
756
|
+
const autoApprove = !this.config.referral.requireApproval;
|
|
757
|
+
const status = autoApprove ? "approved" : "pending";
|
|
758
|
+
const data = {
|
|
759
|
+
programId: referrer.programId,
|
|
760
|
+
referrerId: referrer._id,
|
|
761
|
+
refereeId: referee._id,
|
|
762
|
+
referralCode: input.referralCode,
|
|
763
|
+
status,
|
|
764
|
+
referrerRewardPoints: this.config.referral.referrerRewardPoints,
|
|
765
|
+
refereeRewardPoints: this.config.referral.refereeRewardPoints
|
|
766
|
+
};
|
|
767
|
+
if (tenantId) data[this.config.tenant.field] = tenantId;
|
|
768
|
+
if (autoApprove) data.approvedAt = /* @__PURE__ */ new Date();
|
|
769
|
+
const referral = await this.referralPort.create(data);
|
|
770
|
+
const payload = {
|
|
771
|
+
referralId: referral._id,
|
|
772
|
+
referrerId: referrer._id,
|
|
773
|
+
refereeId: referee._id,
|
|
774
|
+
referralCode: input.referralCode,
|
|
775
|
+
status: referral.status
|
|
776
|
+
};
|
|
777
|
+
this.events.emit(LoyaltyEvents.REFERRAL_RECORDED, payload);
|
|
778
|
+
this.notifications?.notify(LoyaltyEvents.REFERRAL_RECORDED, { ...payload });
|
|
779
|
+
if (autoApprove) await this.rewardBothSides(referral, referrer, referee, ctx);
|
|
780
|
+
return referral;
|
|
781
|
+
}
|
|
782
|
+
async approve(referralId, ctx) {
|
|
783
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
784
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
785
|
+
const referral = await this.requireReferral(referralId, tenantId);
|
|
786
|
+
const updated = await this.referralPort.atomicStatusTransition(referralId, "pending", {
|
|
787
|
+
status: "approved",
|
|
788
|
+
approvedAt: /* @__PURE__ */ new Date()
|
|
789
|
+
}, tenantId);
|
|
790
|
+
if (!updated) throw new ValidationError(`Referral status must be 'pending', got '${referral.status}'`);
|
|
791
|
+
const referrer = await requireMember(this.memberPort, referral.referrerId, tenantId);
|
|
792
|
+
const referee = await requireMember(this.memberPort, referral.refereeId, tenantId);
|
|
793
|
+
const payload = {
|
|
794
|
+
referralId: updated._id,
|
|
795
|
+
referrerId: referrer._id,
|
|
796
|
+
refereeId: referee._id,
|
|
797
|
+
referralCode: updated.referralCode,
|
|
798
|
+
status: updated.status
|
|
799
|
+
};
|
|
800
|
+
this.events.emit(LoyaltyEvents.REFERRAL_APPROVED, payload);
|
|
801
|
+
this.notifications?.notify(LoyaltyEvents.REFERRAL_APPROVED, { ...payload });
|
|
802
|
+
await this.rewardBothSides(updated, referrer, referee, ctx);
|
|
803
|
+
return updated;
|
|
804
|
+
}
|
|
805
|
+
async reject(referralId, reason, ctx) {
|
|
806
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
807
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
808
|
+
const referral = await this.requireReferral(referralId, tenantId);
|
|
809
|
+
const updated = await this.referralPort.atomicStatusTransition(referralId, "pending", {
|
|
810
|
+
status: "rejected",
|
|
811
|
+
rejectedAt: /* @__PURE__ */ new Date(),
|
|
812
|
+
rejectionReason: reason
|
|
813
|
+
}, tenantId);
|
|
814
|
+
if (!updated) throw new ValidationError(`Referral status must be 'pending', got '${referral.status}'`);
|
|
815
|
+
const payload = {
|
|
816
|
+
referralId: updated._id,
|
|
817
|
+
referrerId: referral.referrerId,
|
|
818
|
+
refereeId: referral.refereeId,
|
|
819
|
+
referralCode: updated.referralCode,
|
|
820
|
+
status: updated.status
|
|
821
|
+
};
|
|
822
|
+
this.events.emit(LoyaltyEvents.REFERRAL_REJECTED, payload);
|
|
823
|
+
this.notifications?.notify(LoyaltyEvents.REFERRAL_REJECTED, { ...payload });
|
|
824
|
+
return updated;
|
|
825
|
+
}
|
|
826
|
+
async getReferralsByReferrer(memberId, query, ctx) {
|
|
827
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
828
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
829
|
+
const result = await this.referralPort.findByReferrer(memberId, {
|
|
830
|
+
page: query.page ?? 1,
|
|
831
|
+
limit: query.limit ?? 20
|
|
832
|
+
}, tenantId);
|
|
833
|
+
return {
|
|
834
|
+
docs: result.docs,
|
|
835
|
+
total: result.total,
|
|
836
|
+
page: result.page,
|
|
837
|
+
pages: result.pages,
|
|
838
|
+
hasNext: result.page < result.pages,
|
|
839
|
+
hasPrev: result.page > 1
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
async cleanupExpired(ctx) {
|
|
843
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
844
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
845
|
+
const now = /* @__PURE__ */ new Date();
|
|
846
|
+
const expired = await this.referralPort.findExpired(now, tenantId);
|
|
847
|
+
let count = 0;
|
|
848
|
+
for (const referral of expired) {
|
|
849
|
+
await this.referralPort.update(referral._id, { status: "expired" });
|
|
850
|
+
count++;
|
|
851
|
+
}
|
|
852
|
+
return count;
|
|
853
|
+
}
|
|
854
|
+
async rewardBothSides(referral, referrer, referee, ctx) {
|
|
855
|
+
const { referrerRewardPoints, refereeRewardPoints } = this.config.referral;
|
|
856
|
+
if (referrerRewardPoints > 0) {
|
|
857
|
+
const referrerTx = await this.ledger.earnPoints({
|
|
858
|
+
memberId: referrer._id,
|
|
859
|
+
points: referrerRewardPoints,
|
|
860
|
+
description: `Referral reward: referred ${referee.externalId}`,
|
|
861
|
+
referenceType: "referral",
|
|
862
|
+
referenceId: referral._id,
|
|
863
|
+
idempotencyKey: `referral:${referral._id}:referrer`
|
|
864
|
+
}, ctx);
|
|
865
|
+
await this.referralPort.update(referral._id, { referrerTransactionId: referrerTx._id });
|
|
866
|
+
}
|
|
867
|
+
if (refereeRewardPoints > 0) {
|
|
868
|
+
const refereeTx = await this.ledger.earnPoints({
|
|
869
|
+
memberId: referee._id,
|
|
870
|
+
points: refereeRewardPoints,
|
|
871
|
+
description: `Referral welcome bonus from ${referrer.externalId}`,
|
|
872
|
+
referenceType: "referral",
|
|
873
|
+
referenceId: referral._id,
|
|
874
|
+
idempotencyKey: `referral:${referral._id}:referee`
|
|
875
|
+
}, ctx);
|
|
876
|
+
await this.referralPort.update(referral._id, { refereeTransactionId: refereeTx._id });
|
|
877
|
+
}
|
|
878
|
+
const rewardPayload = {
|
|
879
|
+
referralId: referral._id,
|
|
880
|
+
referrerId: referrer._id,
|
|
881
|
+
refereeId: referee._id,
|
|
882
|
+
referralCode: referral.referralCode,
|
|
883
|
+
status: "approved",
|
|
884
|
+
referrerRewardPoints,
|
|
885
|
+
refereeRewardPoints
|
|
886
|
+
};
|
|
887
|
+
this.events.emit(LoyaltyEvents.REFERRAL_REWARDED, rewardPayload);
|
|
888
|
+
this.notifications?.notify(LoyaltyEvents.REFERRAL_REWARDED, { ...rewardPayload });
|
|
889
|
+
}
|
|
890
|
+
async requireReferral(referralId, tenantId) {
|
|
891
|
+
const referral = await this.referralPort.getById(referralId, tenantId);
|
|
892
|
+
if (!referral) throw new ReferralNotFoundError(referralId);
|
|
893
|
+
return referral;
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region src/services/tier.service.ts
|
|
898
|
+
var TierService = class {
|
|
899
|
+
constructor(tierPort, memberPort, events, notifications, config) {
|
|
900
|
+
this.tierPort = tierPort;
|
|
901
|
+
this.memberPort = memberPort;
|
|
902
|
+
this.events = events;
|
|
903
|
+
this.notifications = notifications;
|
|
904
|
+
this.config = config;
|
|
905
|
+
}
|
|
906
|
+
async evaluateMember(memberId, ctx) {
|
|
907
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
908
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
909
|
+
const member = await requireMember(this.memberPort, memberId, tenantId);
|
|
910
|
+
const sortedTiers = [...await this.tierPort.findAll(tenantId, member.programId)].sort((a, b) => b.rank - a.rank);
|
|
911
|
+
if (member.tierOverride) return {
|
|
912
|
+
memberId,
|
|
913
|
+
previousTier: member.tier,
|
|
914
|
+
newTier: member.tier,
|
|
915
|
+
action: "unchanged"
|
|
916
|
+
};
|
|
917
|
+
const qualifiedTier = sortedTiers.find((t) => t.qualificationCriteria.minLifetimePoints !== void 0 && member.balance.lifetime >= t.qualificationCriteria.minLifetimePoints) ?? null;
|
|
918
|
+
const currentTierDef = member.tier ? sortedTiers.find((t) => t.name === member.tier) ?? null : null;
|
|
919
|
+
const currentRank = currentTierDef?.rank ?? -1;
|
|
920
|
+
const newRank = qualifiedTier?.rank ?? -1;
|
|
921
|
+
if (newRank > currentRank && qualifiedTier) return this.applyUpgrade(memberId, member, qualifiedTier.name, tenantId);
|
|
922
|
+
if (newRank < currentRank && currentTierDef) return this.applyDowngrade(memberId, member, currentTierDef, qualifiedTier?.name ?? null, tenantId);
|
|
923
|
+
return {
|
|
924
|
+
memberId,
|
|
925
|
+
previousTier: member.tier,
|
|
926
|
+
newTier: member.tier,
|
|
927
|
+
action: "unchanged"
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
async evaluateAll(ctx) {
|
|
931
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
932
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
933
|
+
const query = { status: "active" };
|
|
934
|
+
if (tenantId) query[this.config.tenant.field] = tenantId;
|
|
935
|
+
const members = await this.memberPort.findMany(query);
|
|
936
|
+
let upgraded = 0;
|
|
937
|
+
let downgraded = 0;
|
|
938
|
+
let unchanged = 0;
|
|
939
|
+
for (const member of members) switch ((await this.evaluateMember(member._id, ctx)).action) {
|
|
940
|
+
case "upgraded":
|
|
941
|
+
upgraded++;
|
|
942
|
+
break;
|
|
943
|
+
case "downgraded":
|
|
944
|
+
downgraded++;
|
|
945
|
+
break;
|
|
946
|
+
default:
|
|
947
|
+
unchanged++;
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
upgraded,
|
|
952
|
+
downgraded,
|
|
953
|
+
unchanged
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
async setOverride(memberId, tierName, reason, ctx) {
|
|
957
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
958
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
959
|
+
const member = await requireMember(this.memberPort, memberId, tenantId);
|
|
960
|
+
if (!(await this.tierPort.findAll(tenantId, member.programId)).some((t) => t.name === tierName)) throw new TierNotFoundError(tierName);
|
|
961
|
+
return this.memberPort.update(memberId, {
|
|
962
|
+
tierOverride: tierName,
|
|
963
|
+
tierOverrideReason: reason
|
|
964
|
+
}, tenantId);
|
|
965
|
+
}
|
|
966
|
+
async clearOverride(memberId, ctx) {
|
|
967
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
968
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
969
|
+
await requireMember(this.memberPort, memberId, tenantId);
|
|
970
|
+
await this.memberPort.update(memberId, {
|
|
971
|
+
tierOverride: null,
|
|
972
|
+
tierOverrideReason: null
|
|
973
|
+
}, tenantId);
|
|
974
|
+
return this.evaluateMember(memberId, ctx);
|
|
975
|
+
}
|
|
976
|
+
async createTier(input, ctx) {
|
|
977
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
978
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
979
|
+
const data = {
|
|
980
|
+
...input,
|
|
981
|
+
programId: input.programId ?? this.config.programId
|
|
982
|
+
};
|
|
983
|
+
if (tenantId) data[this.config.tenant.field] = tenantId;
|
|
984
|
+
return this.tierPort.create(data);
|
|
985
|
+
}
|
|
986
|
+
async updateTier(tierId, input, ctx) {
|
|
987
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
988
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
989
|
+
if (!await this.tierPort.getById(tierId, tenantId)) throw new TierNotFoundError(tierId);
|
|
990
|
+
return this.tierPort.update(tierId, { ...input }, tenantId);
|
|
991
|
+
}
|
|
992
|
+
async deleteTier(tierId, ctx) {
|
|
993
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
994
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
995
|
+
if (!await this.tierPort.getById(tierId, tenantId)) throw new TierNotFoundError(tierId);
|
|
996
|
+
return this.tierPort.delete(tierId, tenantId);
|
|
997
|
+
}
|
|
998
|
+
async listTiers(ctx) {
|
|
999
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
1000
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1001
|
+
return [...await this.tierPort.findAll(tenantId, this.config.programId)].sort((a, b) => a.rank - b.rank);
|
|
1002
|
+
}
|
|
1003
|
+
async applyUpgrade(memberId, member, newTierName, tenantId) {
|
|
1004
|
+
await this.memberPort.update(memberId, {
|
|
1005
|
+
tier: newTierName,
|
|
1006
|
+
tierQualifiedAt: /* @__PURE__ */ new Date(),
|
|
1007
|
+
tierExpiresAt: null
|
|
1008
|
+
}, tenantId);
|
|
1009
|
+
const payload = {
|
|
1010
|
+
memberId,
|
|
1011
|
+
externalId: member.externalId,
|
|
1012
|
+
previousTier: member.tier,
|
|
1013
|
+
newTier: newTierName,
|
|
1014
|
+
lifetimePoints: member.balance.lifetime
|
|
1015
|
+
};
|
|
1016
|
+
this.events.emit(LoyaltyEvents.TIER_UPGRADED, payload);
|
|
1017
|
+
this.notifications?.notify(LoyaltyEvents.TIER_UPGRADED, { ...payload });
|
|
1018
|
+
return {
|
|
1019
|
+
memberId,
|
|
1020
|
+
previousTier: member.tier,
|
|
1021
|
+
newTier: newTierName,
|
|
1022
|
+
action: "upgraded"
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
async applyDowngrade(memberId, member, currentTierDef, qualifiedTierName, tenantId) {
|
|
1026
|
+
const { downgrade } = currentTierDef;
|
|
1027
|
+
if (!downgrade.enabled) return {
|
|
1028
|
+
memberId,
|
|
1029
|
+
previousTier: member.tier,
|
|
1030
|
+
newTier: member.tier,
|
|
1031
|
+
action: "unchanged"
|
|
1032
|
+
};
|
|
1033
|
+
if (downgrade.gracePeriodDays > 0 && !member.tierExpiresAt) {
|
|
1034
|
+
const graceExpiresAt = /* @__PURE__ */ new Date();
|
|
1035
|
+
graceExpiresAt.setDate(graceExpiresAt.getDate() + downgrade.gracePeriodDays);
|
|
1036
|
+
await this.memberPort.update(memberId, { tierExpiresAt: graceExpiresAt }, tenantId);
|
|
1037
|
+
const payload = {
|
|
1038
|
+
memberId,
|
|
1039
|
+
externalId: member.externalId,
|
|
1040
|
+
previousTier: member.tier,
|
|
1041
|
+
newTier: member.tier,
|
|
1042
|
+
lifetimePoints: member.balance.lifetime
|
|
1043
|
+
};
|
|
1044
|
+
this.events.emit(LoyaltyEvents.TIER_GRACE_STARTED, payload);
|
|
1045
|
+
this.notifications?.notify(LoyaltyEvents.TIER_GRACE_STARTED, { ...payload });
|
|
1046
|
+
return {
|
|
1047
|
+
memberId,
|
|
1048
|
+
previousTier: member.tier,
|
|
1049
|
+
newTier: member.tier,
|
|
1050
|
+
action: "grace_period",
|
|
1051
|
+
graceExpiresAt
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
if (!(downgrade.gracePeriodDays === 0 || member.tierExpiresAt && member.tierExpiresAt <= /* @__PURE__ */ new Date())) return {
|
|
1055
|
+
memberId,
|
|
1056
|
+
previousTier: member.tier,
|
|
1057
|
+
newTier: member.tier,
|
|
1058
|
+
action: "unchanged"
|
|
1059
|
+
};
|
|
1060
|
+
await this.memberPort.update(memberId, {
|
|
1061
|
+
tier: qualifiedTierName,
|
|
1062
|
+
tierQualifiedAt: qualifiedTierName ? /* @__PURE__ */ new Date() : null,
|
|
1063
|
+
tierExpiresAt: null
|
|
1064
|
+
}, tenantId);
|
|
1065
|
+
const payload = {
|
|
1066
|
+
memberId,
|
|
1067
|
+
externalId: member.externalId,
|
|
1068
|
+
previousTier: member.tier,
|
|
1069
|
+
newTier: qualifiedTierName,
|
|
1070
|
+
lifetimePoints: member.balance.lifetime
|
|
1071
|
+
};
|
|
1072
|
+
this.events.emit(LoyaltyEvents.TIER_DOWNGRADED, payload);
|
|
1073
|
+
this.notifications?.notify(LoyaltyEvents.TIER_DOWNGRADED, { ...payload });
|
|
1074
|
+
return {
|
|
1075
|
+
memberId,
|
|
1076
|
+
previousTier: member.tier,
|
|
1077
|
+
newTier: qualifiedTierName,
|
|
1078
|
+
action: "downgraded"
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
//#endregion
|
|
1083
|
+
//#region src/services/index.ts
|
|
1084
|
+
function createServices(input) {
|
|
1085
|
+
const { repositories, unitOfWork, events, notifications, config } = input;
|
|
1086
|
+
const member = new MemberService(repositories.member, events, notifications, unitOfWork, config);
|
|
1087
|
+
const ledger = new LedgerService(repositories.pointTransaction, repositories.member, events, notifications, unitOfWork, config);
|
|
1088
|
+
return {
|
|
1089
|
+
member,
|
|
1090
|
+
ledger,
|
|
1091
|
+
earning: new EarningService(repositories.earningRule, ledger, repositories.member, config),
|
|
1092
|
+
tier: new TierService(repositories.tierDefinition, repositories.member, events, notifications, config),
|
|
1093
|
+
redemption: new RedemptionService(repositories.redemption, repositories.member, repositories.pointTransaction, events, notifications, unitOfWork, config),
|
|
1094
|
+
referral: new ReferralService(repositories.referral, repositories.member, ledger, events, notifications, config)
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
//#endregion
|
|
1098
|
+
export { MemberService as a, RedemptionService as i, TierService as n, LedgerService as o, ReferralService as r, EarningService as s, createServices as t };
|