@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/config-aNigy4ec.d.mts +90 -0
  4. package/dist/constants-C75GvizO.mjs +49 -0
  5. package/dist/domain/enums/index.d.mts +2 -0
  6. package/dist/domain/enums/index.mjs +2 -0
  7. package/dist/domain/index.d.mts +90 -0
  8. package/dist/domain/index.mjs +4 -0
  9. package/dist/domain-errors-D7q-2LYD.mjs +120 -0
  10. package/dist/event-emitter.port-CLYPoMni.d.mts +8 -0
  11. package/dist/event-types-xQBtYBbX.mjs +24 -0
  12. package/dist/events/index.d.mts +2 -0
  13. package/dist/events/index.mjs +3 -0
  14. package/dist/events-CprEWlN7.mjs +25 -0
  15. package/dist/index-B6ZstxSP.d.mts +2357 -0
  16. package/dist/index-BcoGeDco.d.mts +127 -0
  17. package/dist/index-CaH4GhSF.d.mts +19 -0
  18. package/dist/index-DqzTjeEB.d.mts +148 -0
  19. package/dist/index-O7TL4R2P.d.mts +86 -0
  20. package/dist/index-_pg4J11t.d.mts +191 -0
  21. package/dist/index.d.mts +21 -0
  22. package/dist/index.mjs +95 -0
  23. package/dist/models/index.d.mts +2 -0
  24. package/dist/models/index.mjs +2 -0
  25. package/dist/models-CrZPmVxt.mjs +417 -0
  26. package/dist/notification.port-BzUsH0It.d.mts +13 -0
  27. package/dist/notifications/index.d.mts +21 -0
  28. package/dist/notifications/index.mjs +19 -0
  29. package/dist/repositories/index.d.mts +2 -0
  30. package/dist/repositories/index.mjs +2 -0
  31. package/dist/repositories-qMX7Xh_y.mjs +440 -0
  32. package/dist/services/index.d.mts +2 -0
  33. package/dist/services/index.mjs +2 -0
  34. package/dist/services-CoTqC4E6.mjs +1098 -0
  35. package/dist/tier-definition-Djf72UPa.d.mts +120 -0
  36. package/dist/tier-definition.port-BzrHYBRo.d.mts +143 -0
  37. package/dist/types/index.d.mts +3 -0
  38. package/dist/types/index.mjs +1 -0
  39. 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 };