@classytic/loyalty 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/config-DoZPGBfW.d.mts +136 -0
  3. package/dist/{constants-C75GvizO.mjs → constants-DkQYkOK1.mjs} +3 -2
  4. package/dist/domain/enums/index.d.mts +1 -1
  5. package/dist/domain/enums/index.mjs +1 -1
  6. package/dist/events/index.d.mts +2 -2
  7. package/dist/events/index.mjs +3 -3
  8. package/dist/events-BtZOdiPw.mjs +311 -0
  9. package/dist/index-BOWqBSWx.d.mts +281 -0
  10. package/dist/index-C4jFHS7m.d.mts +400 -0
  11. package/dist/index-ClPKmtxh.d.mts +361 -0
  12. package/dist/{index-CaH4GhSF.d.mts → index-DMK8a98J.d.mts} +1 -1
  13. package/dist/index.d.mts +28 -12
  14. package/dist/index.mjs +30 -26
  15. package/dist/loyalty-events-Cyt9TwX6.mjs +50 -0
  16. package/dist/models/index.d.mts +1225 -2
  17. package/dist/models/index.mjs +2 -2
  18. package/dist/{models-CrZPmVxt.mjs → models-uhh9c-7k.mjs} +104 -41
  19. package/dist/notifications/index.d.mts +1 -1
  20. package/dist/repositories/index.d.mts +2 -2
  21. package/dist/repositories/index.mjs +1 -1
  22. package/dist/repositories-DBSxe9st.mjs +1919 -0
  23. package/dist/types/index.d.mts +2 -2
  24. package/package.json +22 -17
  25. package/dist/config-aNigy4ec.d.mts +0 -90
  26. package/dist/domain/index.d.mts +0 -90
  27. package/dist/domain/index.mjs +0 -4
  28. package/dist/domain-errors-D7q-2LYD.mjs +0 -120
  29. package/dist/event-emitter.port-CLYPoMni.d.mts +0 -8
  30. package/dist/event-types-xQBtYBbX.mjs +0 -24
  31. package/dist/events-CprEWlN7.mjs +0 -25
  32. package/dist/index-B6ZstxSP.d.mts +0 -2357
  33. package/dist/index-BcoGeDco.d.mts +0 -127
  34. package/dist/index-DqzTjeEB.d.mts +0 -148
  35. package/dist/index-O7TL4R2P.d.mts +0 -86
  36. package/dist/index-_pg4J11t.d.mts +0 -191
  37. package/dist/repositories-qMX7Xh_y.mjs +0 -440
  38. package/dist/services/index.d.mts +0 -2
  39. package/dist/services/index.mjs +0 -2
  40. package/dist/services-CoTqC4E6.mjs +0 -1098
  41. package/dist/tier-definition-Djf72UPa.d.mts +0 -120
  42. package/dist/tier-definition.port-BzrHYBRo.d.mts +0 -143
  43. /package/dist/{notification.port-BzUsH0It.d.mts → notification.port-CJ9nXj0k.d.mts} +0 -0
@@ -0,0 +1,1919 @@
1
+ import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-Cyt9TwX6.mjs";
2
+ import { Repository, multiTenantPlugin } from "@classytic/mongokit";
3
+ import { randomBytes } from "node:crypto";
4
+ //#region src/domain/errors/base.ts
5
+ var LoyaltyError = class extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = this.constructor.name;
9
+ }
10
+ toJSON() {
11
+ return {
12
+ code: this.code,
13
+ message: this.message,
14
+ name: this.name
15
+ };
16
+ }
17
+ };
18
+ //#endregion
19
+ //#region src/domain/errors/domain-errors.ts
20
+ var InsufficientPointsError = class extends LoyaltyError {
21
+ code = "INSUFFICIENT_POINTS";
22
+ httpStatus = 400;
23
+ constructor(available, requested) {
24
+ super(`Insufficient points. Available: ${available}, requested: ${requested}`);
25
+ }
26
+ };
27
+ var MemberNotFoundError = class extends LoyaltyError {
28
+ code = "MEMBER_NOT_FOUND";
29
+ httpStatus = 404;
30
+ constructor(id) {
31
+ super(`Member not found: ${id}`);
32
+ }
33
+ };
34
+ var MemberAlreadyEnrolledError = class extends LoyaltyError {
35
+ code = "MEMBER_ALREADY_ENROLLED";
36
+ httpStatus = 409;
37
+ constructor(externalId) {
38
+ super(`Member already enrolled: ${externalId}`);
39
+ }
40
+ };
41
+ var MemberInactiveError = class extends LoyaltyError {
42
+ code = "MEMBER_INACTIVE";
43
+ httpStatus = 422;
44
+ constructor(id) {
45
+ super(`Member is not active: ${id}`);
46
+ }
47
+ };
48
+ var TierNotFoundError = class extends LoyaltyError {
49
+ code = "TIER_NOT_FOUND";
50
+ httpStatus = 404;
51
+ constructor(id) {
52
+ super(`Tier not found: ${id}`);
53
+ }
54
+ };
55
+ var RedemptionNotFoundError = class extends LoyaltyError {
56
+ code = "REDEMPTION_NOT_FOUND";
57
+ httpStatus = 404;
58
+ constructor(id) {
59
+ super(`Redemption not found: ${id}`);
60
+ }
61
+ };
62
+ var RedemptionExpiredError = class extends LoyaltyError {
63
+ code = "REDEMPTION_EXPIRED";
64
+ httpStatus = 410;
65
+ constructor(id) {
66
+ super(`Redemption expired: ${id}`);
67
+ }
68
+ };
69
+ var ReferralNotFoundError = class extends LoyaltyError {
70
+ code = "REFERRAL_NOT_FOUND";
71
+ httpStatus = 404;
72
+ constructor(id) {
73
+ super(`Referral not found: ${id}`);
74
+ }
75
+ };
76
+ var DuplicateReferralError = class extends LoyaltyError {
77
+ code = "DUPLICATE_REFERRAL";
78
+ httpStatus = 409;
79
+ constructor(refereeId) {
80
+ super(`Referee already has a referral: ${refereeId}`);
81
+ }
82
+ };
83
+ var ReferralLimitExceededError = class extends LoyaltyError {
84
+ code = "REFERRAL_LIMIT_EXCEEDED";
85
+ httpStatus = 429;
86
+ constructor(limit, periodDays) {
87
+ super(`Referral limit of ${limit} per ${periodDays} days exceeded`);
88
+ }
89
+ };
90
+ var SelfReferralError = class extends LoyaltyError {
91
+ code = "SELF_REFERRAL";
92
+ httpStatus = 422;
93
+ constructor() {
94
+ super("Cannot refer yourself");
95
+ }
96
+ };
97
+ var CircularReferralError = class extends LoyaltyError {
98
+ code = "CIRCULAR_REFERRAL";
99
+ httpStatus = 422;
100
+ constructor() {
101
+ super("Circular referral detected");
102
+ }
103
+ };
104
+ var ValidationError = class extends LoyaltyError {
105
+ code = "VALIDATION_ERROR";
106
+ httpStatus = 400;
107
+ };
108
+ var TenantIsolationError = class extends LoyaltyError {
109
+ code = "TENANT_ISOLATION";
110
+ httpStatus = 403;
111
+ constructor() {
112
+ super("Tenant context is required but not provided");
113
+ }
114
+ };
115
+ //#endregion
116
+ //#region src/utils/tenant-guard.ts
117
+ /**
118
+ * Throw `TenantIsolationError` when the mongokit-layer multi-tenant plugin
119
+ * is wired and the caller didn't provide the tenant id. Host-layer scoping
120
+ * (arc preset + BaseController) runs independently with `tenant.enabled:
121
+ * false`, so we skip the assertion in that mode.
122
+ */
123
+ function assertTenantContext(ctx, tenant) {
124
+ if (!tenant.enabled) return;
125
+ if (!ctx[tenant.contextKey]) throw new TenantIsolationError();
126
+ }
127
+ /**
128
+ * Extract the tenant id from ctx, regardless of whether the mongokit plugin
129
+ * is enabled. The `tenantField` / `contextKey` on `ResolvedTenant` is
130
+ * always populated by `resolveConfig`, so this safely reaches the value
131
+ * whether the host opts into the auto-wired plugin or scopes at its own
132
+ * framework layer (arc's preset + BaseController).
133
+ */
134
+ function getTenantValue(ctx, tenant) {
135
+ return ctx[tenant.contextKey];
136
+ }
137
+ /**
138
+ * Build a repo-level context object carrying the tenant id.
139
+ *
140
+ * Spread into mongokit options. When the mongokit `multiTenantPlugin` is
141
+ * wired it auto-reads the value from `options.organizationId`. When the
142
+ * plugin is OFF (arc-host pattern), the repo-level `create`/`createMany`
143
+ * overrides and the `tenantField`-aware lookups (see `memberRepo.loadByX`,
144
+ * etc.) still pick it up — matching the `@classytic/order` /
145
+ * `@classytic/promo` pattern where "child repos set `organizationId`
146
+ * explicitly on the doc".
147
+ */
148
+ function tenantCtx(tenant, tenantId) {
149
+ if (!tenantId) return {};
150
+ return { [tenant.contextKey]: tenantId };
151
+ }
152
+ /**
153
+ * Copy the tenant id from the repo options into the write payload.
154
+ *
155
+ * Called from repository `create` / `createMany` overrides so the document
156
+ * persists with the correct tenant id even when the host has opted OUT
157
+ * of the auto-wired `multiTenantPlugin`. The options object is populated
158
+ * by `tenantCtx()` which writes under `tenant.contextKey`, so the read
159
+ * here MUST use `contextKey` — not `tenantField`. They coincide in the
160
+ * default config (`organizationId === organizationId`) which masked the
161
+ * mismatch; with a divergent contextKey the copy silently never fired
162
+ * and writes landed without a tenant on the doc.
163
+ *
164
+ * When the payload already has the field (e.g. arc's `BaseController.create`
165
+ * pre-writes it under `tenantField`), this is a no-op.
166
+ */
167
+ function injectTenantIntoData(data, options, tenant) {
168
+ const field = tenant.tenantField;
169
+ if (!field) return;
170
+ if (data[field] != null) return;
171
+ const value = options?.[tenant.contextKey] ?? options?.[field];
172
+ if (value != null) data[field] = value;
173
+ }
174
+ //#endregion
175
+ //#region src/utils/validation.ts
176
+ function validatePoints(points, label = "points") {
177
+ if (!Number.isFinite(points)) throw new ValidationError(`${label} must be a finite number`);
178
+ if (!Number.isSafeInteger(points)) throw new ValidationError(`${label} must be a safe integer`);
179
+ }
180
+ function validatePositivePoints(points, label = "points") {
181
+ validatePoints(points, label);
182
+ if (points <= 0) throw new ValidationError(`${label} must be positive`);
183
+ }
184
+ function validateOrderTotal(total) {
185
+ if (!Number.isFinite(total) || total < 0) throw new ValidationError("orderTotal must be a non-negative finite number");
186
+ }
187
+ //#endregion
188
+ //#region src/repositories/earning-rule.repository.ts
189
+ var EarningRuleRepository = class extends Repository {
190
+ memberRepo;
191
+ txRepo;
192
+ config;
193
+ constructor(model, plugins = []) {
194
+ super(model, plugins);
195
+ }
196
+ /** Auto-inject tenant id from options into doc payload. See MemberRepository. */
197
+ async create(data, options) {
198
+ injectTenantIntoData(data, options, this.config.tenant);
199
+ return super.create(data, options);
200
+ }
201
+ async createMany(docs, options) {
202
+ const opts = options;
203
+ for (const doc of docs) injectTenantIntoData(doc, opts, this.config.tenant);
204
+ return super.createMany(docs, options);
205
+ }
206
+ async evaluateOrder(input, ctx) {
207
+ validateOrderTotal(input.orderTotal);
208
+ assertTenantContext(ctx, this.config.tenant);
209
+ const tenantId = getTenantValue(ctx, this.config.tenant);
210
+ const tc = tenantCtx(this.config.tenant, tenantId);
211
+ const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
212
+ const orderRules = (await this.findAll({
213
+ status: "active",
214
+ programId: member.programId
215
+ }, {
216
+ sort: { priority: 1 },
217
+ ...tc
218
+ })).filter((r) => [
219
+ "order",
220
+ "category",
221
+ "tier_bonus"
222
+ ].includes(r.type)).sort((a, b) => a.priority - b.priority);
223
+ const breakdown = [];
224
+ const transactions = [];
225
+ let accumulatedPoints = 0;
226
+ for (const rule of orderRules) {
227
+ if (!this.matchesConditions(rule, member, input.orderTotal)) continue;
228
+ let points = 0;
229
+ if (rule.type === "order") points = this.calculateOrderPoints(rule, input.orderTotal);
230
+ else if (rule.type === "category" && input.items) points = this.calculateCategoryPoints(rule, input.items);
231
+ else if (rule.type === "tier_bonus") {
232
+ const multiplier = rule.reward.multiplier ?? 1;
233
+ points = Math.floor(accumulatedPoints * (multiplier - 1));
234
+ }
235
+ if (points <= 0) continue;
236
+ if (rule.reward.maxPointsPerTransaction && points > rule.reward.maxPointsPerTransaction) points = rule.reward.maxPointsPerTransaction;
237
+ const idempotencyKey = `${input.orderId}:${rule._id}`;
238
+ const tx = await this.txRepo.earnPoints({
239
+ memberId: input.memberId,
240
+ points,
241
+ description: `Earning rule: ${rule.name}`,
242
+ referenceType: "order",
243
+ referenceId: input.orderId,
244
+ earningRuleId: rule._id,
245
+ idempotencyKey
246
+ }, ctx);
247
+ accumulatedPoints += points;
248
+ breakdown.push({
249
+ ruleId: rule._id,
250
+ ruleName: rule.name,
251
+ points
252
+ });
253
+ transactions.push(tx);
254
+ }
255
+ return {
256
+ totalPoints: accumulatedPoints,
257
+ breakdown,
258
+ transactions
259
+ };
260
+ }
261
+ async evaluateAction(input, ctx) {
262
+ assertTenantContext(ctx, this.config.tenant);
263
+ const tenantId = getTenantValue(ctx, this.config.tenant);
264
+ const tc = tenantCtx(this.config.tenant, tenantId);
265
+ const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
266
+ const actionRules = (await this.findAll({
267
+ status: "active",
268
+ programId: member.programId
269
+ }, {
270
+ sort: { priority: 1 },
271
+ ...tc
272
+ })).filter((r) => r.type === "action" && r.conditions.actions?.includes(input.action));
273
+ const breakdown = [];
274
+ const transactions = [];
275
+ let totalPoints = 0;
276
+ for (const rule of actionRules) {
277
+ const points = rule.reward.fixedPoints ?? 0;
278
+ if (points <= 0) continue;
279
+ const tx = await this.txRepo.earnPoints({
280
+ memberId: input.memberId,
281
+ points,
282
+ description: `Action: ${input.action} (${rule.name})`,
283
+ referenceType: "action",
284
+ referenceId: input.referenceId,
285
+ earningRuleId: rule._id
286
+ }, ctx);
287
+ totalPoints += points;
288
+ breakdown.push({
289
+ ruleId: rule._id,
290
+ ruleName: rule.name,
291
+ points
292
+ });
293
+ transactions.push(tx);
294
+ }
295
+ return {
296
+ totalPoints,
297
+ breakdown,
298
+ transactions
299
+ };
300
+ }
301
+ matchesConditions(rule, member, orderTotal) {
302
+ const { conditions } = rule;
303
+ const now = /* @__PURE__ */ new Date();
304
+ if (conditions.minOrderAmount && orderTotal < conditions.minOrderAmount) return false;
305
+ if (conditions.tiers && conditions.tiers.length > 0) {
306
+ const memberTier = member.tierOverride ?? member.tier;
307
+ if (!memberTier || !conditions.tiers.includes(memberTier)) return false;
308
+ }
309
+ if (conditions.dateRange) {
310
+ if (conditions.dateRange.start && now < conditions.dateRange.start) return false;
311
+ if (conditions.dateRange.end && now > conditions.dateRange.end) return false;
312
+ }
313
+ if (conditions.dayOfWeek && conditions.dayOfWeek.length > 0) {
314
+ if (!conditions.dayOfWeek.includes(now.getDay())) return false;
315
+ }
316
+ return true;
317
+ }
318
+ calculateOrderPoints(rule, orderTotal) {
319
+ const { reward } = rule;
320
+ let points = 0;
321
+ if (reward.pointsPerAmount) points = orderTotal * reward.pointsPerAmount;
322
+ else if (reward.amountPerPoint && reward.amountPerPoint > 0) points = orderTotal / reward.amountPerPoint;
323
+ return this.applyRounding(points, reward.roundingMode);
324
+ }
325
+ calculateCategoryPoints(rule, items) {
326
+ const { conditions, reward } = rule;
327
+ let points = 0;
328
+ for (const item of items) if (conditions.categories && item.categoryId && conditions.categories.includes(item.categoryId)) {
329
+ if (reward.pointsPerAmount) points += item.amount * reward.pointsPerAmount;
330
+ else if (reward.amountPerPoint) points += item.amount / reward.amountPerPoint;
331
+ }
332
+ return this.applyRounding(points, reward.roundingMode);
333
+ }
334
+ applyRounding(value, mode) {
335
+ switch (mode) {
336
+ case "ceil": return Math.ceil(value);
337
+ case "round": return Math.round(value);
338
+ default: return Math.floor(value);
339
+ }
340
+ }
341
+ };
342
+ //#endregion
343
+ //#region src/events/dispatch.ts
344
+ async function dispatchLoyaltyEvent(deps, event, ctx) {
345
+ if (deps.outbox) try {
346
+ const saveOptions = {};
347
+ if (ctx.session !== void 0) saveOptions.session = ctx.session;
348
+ await deps.outbox.save(event, saveOptions);
349
+ } catch (_err) {}
350
+ if (deps.events) try {
351
+ await deps.events.publish(event);
352
+ } catch (_err) {}
353
+ }
354
+ //#endregion
355
+ //#region src/utils/code-generator.ts
356
+ const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
357
+ function generateReferralCode(length) {
358
+ if (!Number.isInteger(length) || length < 4 || length > 32) throw new Error("Referral code length must be an integer between 4 and 32");
359
+ const bytes = randomBytes(length);
360
+ let code = "";
361
+ for (let i = 0; i < length; i++) code += CHARSET[bytes[i] % 32];
362
+ return code;
363
+ }
364
+ //#endregion
365
+ //#region src/repositories/member.repository.ts
366
+ var MemberRepository = class extends Repository {
367
+ events;
368
+ outbox;
369
+ notifications = null;
370
+ config;
371
+ /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
372
+ async _dispatch(event, ctx) {
373
+ await dispatchLoyaltyEvent({
374
+ events: this.events,
375
+ outbox: this.outbox
376
+ }, event, ctx);
377
+ }
378
+ constructor(model, plugins = []) {
379
+ super(model, plugins);
380
+ }
381
+ /**
382
+ * Auto-inject the tenant id from the repo options into the doc so writes
383
+ * persist with the correct `organizationId` even when the host has opted
384
+ * OUT of the auto-wired `multiTenantPlugin` (e.g. arc hosts that scope at
385
+ * the framework layer). Matches `@classytic/order` / `@classytic/promo`.
386
+ * No-op when the payload already carries the field.
387
+ */
388
+ async create(data, options) {
389
+ injectTenantIntoData(data, options, this.config.tenant);
390
+ return super.create(data, options);
391
+ }
392
+ async createMany(docs, options) {
393
+ const opts = options;
394
+ for (const doc of docs) injectTenantIntoData(doc, opts, this.config.tenant);
395
+ return super.createMany(docs, options);
396
+ }
397
+ /**
398
+ * Atomic balance increment -- uses $inc to prevent TOCTOU races.
399
+ * Returns the updated member document.
400
+ */
401
+ async updateBalance(id, balanceUpdate, ctx) {
402
+ const inc = {};
403
+ for (const [key, val] of Object.entries(balanceUpdate)) inc[`balance.${key}`] = val;
404
+ const updated = await this.update(id, { $inc: inc }, {
405
+ throwOnNotFound: true,
406
+ lean: true,
407
+ ...ctx
408
+ });
409
+ if (!updated) throw new MemberNotFoundError(id);
410
+ return updated;
411
+ }
412
+ /** Fetches a member by ID (tenant-scoped via plugin) or throws MemberNotFoundError. */
413
+ async requireMember(memberId, ctx) {
414
+ const member = await this.getById(memberId, {
415
+ throwOnNotFound: false,
416
+ ...ctx
417
+ });
418
+ if (!member) throw new MemberNotFoundError(memberId);
419
+ return member;
420
+ }
421
+ /** Fetches an active member (tenant-scoped via plugin) or throws MemberNotFoundError / MemberInactiveError. */
422
+ async requireActiveMember(memberId, ctx) {
423
+ const member = await this.requireMember(memberId, ctx);
424
+ if (member.status !== "active") throw new MemberInactiveError(memberId);
425
+ return member;
426
+ }
427
+ async enroll(input, ctx) {
428
+ assertTenantContext(ctx, this.config.tenant);
429
+ const tenantId = getTenantValue(ctx, this.config.tenant);
430
+ const tc = tenantCtx(this.config.tenant, tenantId);
431
+ if (await this.getByQuery({
432
+ externalId: input.externalId,
433
+ externalType: input.externalType
434
+ }, {
435
+ throwOnNotFound: false,
436
+ ...tc
437
+ })) throw new MemberAlreadyEnrolledError(input.externalId);
438
+ const referralCode = generateReferralCode(this.config.referral.codeLength);
439
+ const now = /* @__PURE__ */ new Date();
440
+ const data = {
441
+ programId: input.programId ?? this.config.programId,
442
+ externalId: input.externalId,
443
+ externalType: input.externalType,
444
+ status: "active",
445
+ cardId: input.cardId,
446
+ referralCode,
447
+ tier: input.tier ?? null,
448
+ tierOverride: null,
449
+ balance: {
450
+ current: 0,
451
+ lifetime: 0,
452
+ redeemed: 0,
453
+ expired: 0
454
+ },
455
+ enrolledAt: now,
456
+ metadata: input.metadata
457
+ };
458
+ const member = await this.create(data, tc);
459
+ const payload = {
460
+ memberId: member._id,
461
+ externalId: member.externalId,
462
+ externalType: member.externalType,
463
+ status: member.status
464
+ };
465
+ await this._dispatch(createEvent(LoyaltyEvents.MEMBER_ENROLLED, payload, ctx, {
466
+ resource: "member",
467
+ resourceId: member._id
468
+ }), ctx);
469
+ this.notifications?.notify(LoyaltyEvents.MEMBER_ENROLLED, { ...payload });
470
+ return member;
471
+ }
472
+ async deactivate(memberId, ctx) {
473
+ assertTenantContext(ctx, this.config.tenant);
474
+ const tenantId = getTenantValue(ctx, this.config.tenant);
475
+ const tc = tenantCtx(this.config.tenant, tenantId);
476
+ await this.requireMember(memberId, tc);
477
+ const updated = await this.update(memberId, {
478
+ status: "inactive",
479
+ deactivatedAt: /* @__PURE__ */ new Date()
480
+ }, {
481
+ throwOnNotFound: true,
482
+ lean: true,
483
+ ...tc
484
+ });
485
+ if (!updated) throw new MemberNotFoundError(memberId);
486
+ const payload = {
487
+ memberId: updated._id,
488
+ externalId: updated.externalId,
489
+ externalType: updated.externalType,
490
+ status: updated.status
491
+ };
492
+ await this._dispatch(createEvent(LoyaltyEvents.MEMBER_DEACTIVATED, payload, ctx, {
493
+ resource: "member",
494
+ resourceId: updated._id
495
+ }), ctx);
496
+ this.notifications?.notify(LoyaltyEvents.MEMBER_DEACTIVATED, { ...payload });
497
+ return updated;
498
+ }
499
+ async reactivate(memberId, ctx) {
500
+ assertTenantContext(ctx, this.config.tenant);
501
+ const tenantId = getTenantValue(ctx, this.config.tenant);
502
+ const tc = tenantCtx(this.config.tenant, tenantId);
503
+ await this.requireMember(memberId, tc);
504
+ const updated = await this.update(memberId, {
505
+ status: "active",
506
+ deactivatedAt: null
507
+ }, {
508
+ throwOnNotFound: true,
509
+ lean: true,
510
+ ...tc
511
+ });
512
+ if (!updated) throw new MemberNotFoundError(memberId);
513
+ const payload = {
514
+ memberId: updated._id,
515
+ externalId: updated.externalId,
516
+ externalType: updated.externalType,
517
+ status: updated.status
518
+ };
519
+ await this._dispatch(createEvent(LoyaltyEvents.MEMBER_REACTIVATED, payload, ctx, {
520
+ resource: "member",
521
+ resourceId: updated._id
522
+ }), ctx);
523
+ this.notifications?.notify(LoyaltyEvents.MEMBER_REACTIVATED, { ...payload });
524
+ return updated;
525
+ }
526
+ async suspend(memberId, reason, ctx) {
527
+ assertTenantContext(ctx, this.config.tenant);
528
+ const tenantId = getTenantValue(ctx, this.config.tenant);
529
+ const tc = tenantCtx(this.config.tenant, tenantId);
530
+ const member = await this.requireMember(memberId, tc);
531
+ const updated = await this.update(memberId, {
532
+ status: "suspended",
533
+ metadata: {
534
+ ...member.metadata,
535
+ suspendReason: reason
536
+ }
537
+ }, {
538
+ throwOnNotFound: true,
539
+ lean: true,
540
+ ...tc
541
+ });
542
+ if (!updated) throw new MemberNotFoundError(memberId);
543
+ const payload = {
544
+ memberId: updated._id,
545
+ externalId: updated.externalId,
546
+ externalType: updated.externalType,
547
+ status: updated.status
548
+ };
549
+ await this._dispatch(createEvent(LoyaltyEvents.MEMBER_SUSPENDED, payload, ctx, {
550
+ resource: "member",
551
+ resourceId: updated._id
552
+ }), ctx);
553
+ this.notifications?.notify(LoyaltyEvents.MEMBER_SUSPENDED, { ...payload });
554
+ return updated;
555
+ }
556
+ };
557
+ //#endregion
558
+ //#region src/repositories/point-transaction.repository.ts
559
+ var PointTransactionRepository = class extends Repository {
560
+ memberRepo;
561
+ events;
562
+ outbox;
563
+ notifications = null;
564
+ unitOfWork;
565
+ config;
566
+ constructor(model, plugins = []) {
567
+ super(model, plugins);
568
+ }
569
+ /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
570
+ async _dispatch(event, ctx) {
571
+ await dispatchLoyaltyEvent({
572
+ events: this.events,
573
+ outbox: this.outbox
574
+ }, event, ctx);
575
+ }
576
+ /**
577
+ * Auto-inject tenant id from options into doc payload. Matches
578
+ * `@classytic/order` / `@classytic/promo` — see MemberRepository.
579
+ */
580
+ async create(data, options) {
581
+ injectTenantIntoData(data, options, this.config.tenant);
582
+ return super.create(data, options);
583
+ }
584
+ async createMany(docs, options) {
585
+ const opts = options;
586
+ for (const doc of docs) injectTenantIntoData(doc, opts, this.config.tenant);
587
+ return super.createMany(docs, options);
588
+ }
589
+ async earnPoints(input, ctx) {
590
+ validatePositivePoints(input.points);
591
+ assertTenantContext(ctx, this.config.tenant);
592
+ const tenantId = getTenantValue(ctx, this.config.tenant);
593
+ const tc = tenantCtx(this.config.tenant, tenantId);
594
+ if (input.idempotencyKey) {
595
+ const existing = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
596
+ throwOnNotFound: false,
597
+ ...tc
598
+ });
599
+ if (existing) return existing;
600
+ }
601
+ const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
602
+ try {
603
+ const tx = await this.unitOfWork.withTransaction(async (session) => {
604
+ if (input.idempotencyKey) {
605
+ const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
606
+ throwOnNotFound: false,
607
+ ...tc
608
+ });
609
+ if (dup) return dup;
610
+ }
611
+ const updatedMember = await this.memberRepo.updateBalance(input.memberId, {
612
+ current: input.points,
613
+ lifetime: input.points
614
+ }, {
615
+ session,
616
+ ...tc
617
+ });
618
+ const data = {
619
+ memberId: input.memberId,
620
+ programId: member.programId,
621
+ type: "earn",
622
+ points: input.points,
623
+ balanceAfter: updatedMember.balance.current,
624
+ description: input.description,
625
+ referenceType: input.referenceType,
626
+ referenceId: input.referenceId,
627
+ earningRuleId: input.earningRuleId,
628
+ expiresAt: input.expiresAt,
629
+ actorId: ctx.actorId,
630
+ ...input.metadata ? { metadata: input.metadata } : {}
631
+ };
632
+ if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
633
+ return this.create(data, {
634
+ session,
635
+ ...tc
636
+ });
637
+ });
638
+ const payload = {
639
+ memberId: member._id,
640
+ externalId: member.externalId,
641
+ externalType: member.externalType,
642
+ points: input.points,
643
+ balanceAfter: tx.balanceAfter,
644
+ transactionId: tx._id,
645
+ referenceType: input.referenceType,
646
+ referenceId: input.referenceId
647
+ };
648
+ await this._dispatch(createEvent(LoyaltyEvents.POINTS_EARNED, payload, ctx, {
649
+ resource: "pointTransaction",
650
+ resourceId: tx._id
651
+ }), ctx);
652
+ this.notifications?.notify(LoyaltyEvents.POINTS_EARNED, { ...payload });
653
+ return tx;
654
+ } catch (err) {
655
+ if (input.idempotencyKey && (isDuplicateKey(err) || isWriteConflict(err))) {
656
+ const existing = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
657
+ throwOnNotFound: false,
658
+ ...tc
659
+ });
660
+ if (existing) return existing;
661
+ }
662
+ throw err;
663
+ }
664
+ }
665
+ /**
666
+ * Fans out `earnPoints` across many members for bulk-issuance campaigns
667
+ * (welcome bonuses, holiday boosts, win-back). Per-member failures do
668
+ * NOT abort the batch — they surface in `errors[]` with the memberId +
669
+ * message, matching `@classytic/commission`'s `ingestSplit` shape.
670
+ *
671
+ * Idempotency: each row derives its key as `${source.eventId}:${memberId}`
672
+ * so retrying the whole batch collapses onto the same transactions.
673
+ *
674
+ * Events: emits exactly ONE `CAMPAIGN_AWARDED` with aggregate counts —
675
+ * NOT one-per-member, to avoid an event storm for large batches. The
676
+ * underlying `POINTS_EARNED` events still fire (one per successful row)
677
+ * for ledger consumers that need per-member granularity.
678
+ */
679
+ async awardBatch(members, source, ctx) {
680
+ assertTenantContext(ctx, this.config.tenant);
681
+ let awarded = 0;
682
+ let skipped = 0;
683
+ let totalPoints = 0;
684
+ const errors = [];
685
+ for (const row of members) {
686
+ try {
687
+ validatePositivePoints(row.points);
688
+ } catch (err) {
689
+ errors.push({
690
+ memberId: row.memberId,
691
+ error: err instanceof Error ? err.message : String(err)
692
+ });
693
+ skipped++;
694
+ continue;
695
+ }
696
+ try {
697
+ const input = {
698
+ memberId: row.memberId,
699
+ points: row.points,
700
+ description: source.description,
701
+ idempotencyKey: `${source.eventId}:${row.memberId}`,
702
+ metadata: {
703
+ campaignId: source.campaignId,
704
+ ...source.metadata ?? {}
705
+ }
706
+ };
707
+ if (source.referenceType !== void 0) input.referenceType = source.referenceType;
708
+ if (source.referenceId !== void 0) input.referenceId = source.referenceId;
709
+ if (source.expiresAt !== void 0) input.expiresAt = source.expiresAt;
710
+ await this.earnPoints(input, ctx);
711
+ awarded++;
712
+ totalPoints += row.points;
713
+ } catch (err) {
714
+ errors.push({
715
+ memberId: row.memberId,
716
+ error: err instanceof Error ? err.message : String(err)
717
+ });
718
+ skipped++;
719
+ }
720
+ }
721
+ await this._dispatch(createEvent(LoyaltyEvents.CAMPAIGN_AWARDED, {
722
+ campaignId: source.campaignId,
723
+ memberCount: members.length,
724
+ awarded,
725
+ skipped,
726
+ totalPoints
727
+ }, ctx, {
728
+ resource: "campaign",
729
+ resourceId: source.campaignId
730
+ }), ctx);
731
+ this.notifications?.notify(LoyaltyEvents.CAMPAIGN_AWARDED, {
732
+ campaignId: source.campaignId,
733
+ memberCount: members.length,
734
+ awarded,
735
+ skipped,
736
+ totalPoints
737
+ });
738
+ return {
739
+ awarded,
740
+ skipped,
741
+ errors,
742
+ totalPoints
743
+ };
744
+ }
745
+ async adjustPoints(input, ctx) {
746
+ validatePoints(input.points);
747
+ if (input.points === 0) throw new ValidationError("Adjustment points cannot be zero");
748
+ assertTenantContext(ctx, this.config.tenant);
749
+ const tenantId = getTenantValue(ctx, this.config.tenant);
750
+ const tc = tenantCtx(this.config.tenant, tenantId);
751
+ if (input.idempotencyKey) {
752
+ const existing = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
753
+ throwOnNotFound: false,
754
+ ...tc
755
+ });
756
+ if (existing) return existing;
757
+ }
758
+ const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
759
+ if (input.points < 0 && member.balance.current < Math.abs(input.points)) throw new InsufficientPointsError(member.balance.current, Math.abs(input.points));
760
+ const tx = await this.unitOfWork.withTransaction(async (session) => {
761
+ if (input.idempotencyKey) {
762
+ const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
763
+ throwOnNotFound: false,
764
+ ...tc
765
+ });
766
+ if (dup) return dup;
767
+ }
768
+ const balanceUpdate = { current: input.points };
769
+ if (input.points > 0) balanceUpdate.lifetime = input.points;
770
+ const updatedMember = await this.memberRepo.updateBalance(input.memberId, balanceUpdate, {
771
+ session,
772
+ ...tc
773
+ });
774
+ if (updatedMember.balance.current < 0) throw new InsufficientPointsError(updatedMember.balance.current - input.points, Math.abs(input.points));
775
+ const data = {
776
+ memberId: input.memberId,
777
+ programId: member.programId,
778
+ type: "adjust",
779
+ points: input.points,
780
+ balanceAfter: updatedMember.balance.current,
781
+ description: input.description,
782
+ referenceType: input.referenceType,
783
+ referenceId: input.referenceId,
784
+ metadata: {
785
+ reason: input.reason,
786
+ ...input.metadata || {}
787
+ },
788
+ actorId: ctx.actorId
789
+ };
790
+ if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
791
+ return this.create(data, {
792
+ session,
793
+ ...tc
794
+ });
795
+ });
796
+ const payload = {
797
+ memberId: member._id,
798
+ externalId: member.externalId,
799
+ points: input.points,
800
+ balanceAfter: tx.balanceAfter,
801
+ transactionId: tx._id,
802
+ description: input.description
803
+ };
804
+ await this._dispatch(createEvent(LoyaltyEvents.POINTS_ADJUSTED, payload, ctx, {
805
+ resource: "pointTransaction",
806
+ resourceId: tx._id
807
+ }), ctx);
808
+ this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
809
+ return tx;
810
+ }
811
+ async processExpirations(ctx) {
812
+ assertTenantContext(ctx, this.config.tenant);
813
+ const tenantId = getTenantValue(ctx, this.config.tenant);
814
+ const tc = tenantCtx(this.config.tenant, tenantId);
815
+ const now = /* @__PURE__ */ new Date();
816
+ const expiredTxs = await this.findAll({
817
+ expiresAt: { $lte: now },
818
+ expiredAt: { $exists: false }
819
+ }, {
820
+ sort: { createdAt: 1 },
821
+ limit: 100,
822
+ ...tc
823
+ });
824
+ let totalExpired = 0;
825
+ let transactionCount = 0;
826
+ const affectedMemberIds = /* @__PURE__ */ new Set();
827
+ for (const tx of expiredTxs) {
828
+ await this.unitOfWork.withTransaction(async (session) => {
829
+ await this.create({
830
+ memberId: tx.memberId,
831
+ programId: tx.programId,
832
+ type: "expire",
833
+ points: -tx.points,
834
+ balanceAfter: 0,
835
+ description: `Points expired from transaction ${tx._id}`,
836
+ referenceType: "expiration",
837
+ referenceId: tx._id,
838
+ actorId: ctx.actorId
839
+ }, {
840
+ session,
841
+ ...tc
842
+ });
843
+ await this.update(tx._id, { expiredAt: now }, {
844
+ session,
845
+ ...tc
846
+ });
847
+ await this.memberRepo.updateBalance(tx.memberId, {
848
+ current: -tx.points,
849
+ expired: tx.points
850
+ }, {
851
+ session,
852
+ ...tc
853
+ });
854
+ });
855
+ affectedMemberIds.add(tx.memberId);
856
+ totalExpired += tx.points;
857
+ transactionCount++;
858
+ }
859
+ if (totalExpired > 0) await this._dispatch(createEvent(LoyaltyEvents.POINTS_EXPIRED, {
860
+ membersAffected: affectedMemberIds.size,
861
+ pointsExpired: totalExpired,
862
+ transactionCount
863
+ }, ctx), ctx);
864
+ return {
865
+ membersAffected: affectedMemberIds.size,
866
+ pointsExpired: totalExpired,
867
+ transactionCount
868
+ };
869
+ }
870
+ };
871
+ /** E11000 across the raw driver + mongokit wrapper shapes. */
872
+ function isDuplicateKey(err) {
873
+ if (typeof err !== "object" || err === null) return false;
874
+ const e = err;
875
+ if (e.code === 11e3) return true;
876
+ if (e.status === 409) return true;
877
+ if (e.cause !== void 0) return isDuplicateKey(e.cause);
878
+ return false;
879
+ }
880
+ /**
881
+ * Transient MongoDB WriteConflict — surfaces under concurrent webhook
882
+ * replay when two transactions try to update the same member balance.
883
+ * With an idempotency key present, the winning insert is the source of
884
+ * truth; losers are short-circuited to its doc rather than leaking the
885
+ * raw driver error.
886
+ */
887
+ function isWriteConflict(err) {
888
+ if (typeof err !== "object" || err === null) return false;
889
+ const e = err;
890
+ if (e.code === 112 || e.codeName === "WriteConflict") return true;
891
+ if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
892
+ if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
893
+ if (e.cause !== void 0) return isWriteConflict(e.cause);
894
+ return false;
895
+ }
896
+ //#endregion
897
+ //#region src/repositories/redemption.repository.ts
898
+ var RedemptionRepository = class extends Repository {
899
+ memberRepo;
900
+ txRepo;
901
+ events;
902
+ outbox;
903
+ notifications = null;
904
+ unitOfWork;
905
+ config;
906
+ constructor(model, plugins = []) {
907
+ super(model, plugins);
908
+ }
909
+ /** Auto-inject tenant id from options into doc payload. See MemberRepository. */
910
+ async create(data, options) {
911
+ injectTenantIntoData(data, options, this.config.tenant);
912
+ return super.create(data, options);
913
+ }
914
+ async createMany(docs, options) {
915
+ const opts = options;
916
+ for (const doc of docs) injectTenantIntoData(doc, opts, this.config.tenant);
917
+ return super.createMany(docs, options);
918
+ }
919
+ /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
920
+ async _dispatch(event, ctx) {
921
+ await dispatchLoyaltyEvent({
922
+ events: this.events,
923
+ outbox: this.outbox
924
+ }, event, ctx);
925
+ }
926
+ /**
927
+ * Atomic compare-and-set status transition.
928
+ * Returns null if the current status doesn't match `expectedStatus` (CAS mismatch).
929
+ */
930
+ async atomicStatusTransition(id, expectedStatus, data, ctx) {
931
+ try {
932
+ return await this.update(id, data, {
933
+ lean: true,
934
+ query: { status: expectedStatus },
935
+ throwOnNotFound: false,
936
+ ...ctx
937
+ });
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
942
+ async validate(input, ctx) {
943
+ validatePositivePoints(input.pointsToRedeem, "pointsToRedeem");
944
+ validateOrderTotal(input.orderTotal);
945
+ assertTenantContext(ctx, this.config.tenant);
946
+ const tenantId = getTenantValue(ctx, this.config.tenant);
947
+ const tc = tenantCtx(this.config.tenant, tenantId);
948
+ const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
949
+ const { minPoints, minOrderAmount, maxRedeemPercent } = this.config.redemption;
950
+ const { conversionRate } = this.config;
951
+ if (input.pointsToRedeem < minPoints) return {
952
+ valid: false,
953
+ error: `Minimum ${minPoints} points required`,
954
+ pointsToRedeem: 0,
955
+ discountAmount: 0,
956
+ maxAllowedPoints: 0
957
+ };
958
+ if (input.orderTotal < minOrderAmount) return {
959
+ valid: false,
960
+ error: `Minimum order amount of ${minOrderAmount} required`,
961
+ pointsToRedeem: 0,
962
+ discountAmount: 0,
963
+ maxAllowedPoints: 0
964
+ };
965
+ if (input.pointsToRedeem > member.balance.current) return {
966
+ valid: false,
967
+ error: `Insufficient points. Available: ${member.balance.current}`,
968
+ pointsToRedeem: 0,
969
+ discountAmount: 0,
970
+ maxAllowedPoints: member.balance.current
971
+ };
972
+ const maxDiscount = Math.floor(input.orderTotal * maxRedeemPercent / 100);
973
+ const requestedDiscount = Math.floor(input.pointsToRedeem / conversionRate);
974
+ const discountAmount = Math.min(requestedDiscount, maxDiscount);
975
+ const maxAllowedPoints = Math.floor(maxDiscount * conversionRate);
976
+ return {
977
+ valid: true,
978
+ pointsToRedeem: discountAmount < requestedDiscount ? Math.floor(discountAmount * conversionRate) : input.pointsToRedeem,
979
+ discountAmount,
980
+ maxAllowedPoints
981
+ };
982
+ }
983
+ async reserve(input, ctx) {
984
+ assertTenantContext(ctx, this.config.tenant);
985
+ const tenantId = getTenantValue(ctx, this.config.tenant);
986
+ const tc = tenantCtx(this.config.tenant, tenantId);
987
+ const validation = await this.validate({
988
+ memberId: input.memberId,
989
+ pointsToRedeem: input.pointsToRedeem,
990
+ orderTotal: input.orderTotal
991
+ }, ctx);
992
+ if (!validation.valid) throw new ValidationError(validation.error ?? "Redemption validation failed");
993
+ const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
994
+ const expiresAt = input.expiresAt ?? new Date(Date.now() + this.config.redemption.reservationTtlMinutes * 60 * 1e3);
995
+ const redemption = await this.unitOfWork.withTransaction(async (session) => {
996
+ const updatedMember = await this.memberRepo.updateBalance(input.memberId, { current: -validation.pointsToRedeem }, {
997
+ session,
998
+ ...tc
999
+ });
1000
+ if (updatedMember.balance.current < 0) throw new ValidationError(`Insufficient points after concurrent reservation. Available: ${updatedMember.balance.current + validation.pointsToRedeem}`);
1001
+ const data = {
1002
+ memberId: input.memberId,
1003
+ programId: member.programId,
1004
+ pointsReserved: validation.pointsToRedeem,
1005
+ pointsConfirmed: 0,
1006
+ discountAmount: validation.discountAmount,
1007
+ conversionRate: this.config.conversionRate,
1008
+ status: "reserved",
1009
+ ownerType: input.ownerType,
1010
+ ownerId: input.ownerId,
1011
+ expiresAt
1012
+ };
1013
+ return this.create(data, {
1014
+ session,
1015
+ ...tc
1016
+ });
1017
+ });
1018
+ const payload = {
1019
+ memberId: member._id,
1020
+ externalId: member.externalId,
1021
+ redemptionId: redemption._id,
1022
+ points: validation.pointsToRedeem,
1023
+ discountAmount: validation.discountAmount,
1024
+ ownerType: input.ownerType,
1025
+ ownerId: input.ownerId
1026
+ };
1027
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RESERVED, payload, ctx, {
1028
+ resource: "redemption",
1029
+ resourceId: redemption._id
1030
+ }), ctx);
1031
+ this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
1032
+ return redemption;
1033
+ }
1034
+ async confirm(redemptionId, ctx) {
1035
+ assertTenantContext(ctx, this.config.tenant);
1036
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1037
+ const tc = tenantCtx(this.config.tenant, tenantId);
1038
+ const redemption = await this.getById(redemptionId, {
1039
+ throwOnNotFound: false,
1040
+ ...tc
1041
+ });
1042
+ if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1043
+ if (redemption.expiresAt && redemption.expiresAt < /* @__PURE__ */ new Date()) throw new RedemptionExpiredError(redemptionId);
1044
+ const updated = await this.unitOfWork.withTransaction(async (session) => {
1045
+ if (!await this.atomicStatusTransition(redemptionId, "reserved", {
1046
+ status: "confirmed",
1047
+ pointsConfirmed: redemption.pointsReserved,
1048
+ confirmedAt: /* @__PURE__ */ new Date()
1049
+ }, {
1050
+ session,
1051
+ ...tc
1052
+ })) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
1053
+ const member = await this.memberRepo.requireActiveMember(redemption.memberId, tc);
1054
+ const tx = await this.txRepo.create({
1055
+ memberId: redemption.memberId,
1056
+ programId: member.programId,
1057
+ type: "redeem",
1058
+ points: -redemption.pointsReserved,
1059
+ balanceAfter: member.balance.current,
1060
+ description: `Redemption confirmed: ${redemptionId}`,
1061
+ referenceType: redemption.ownerType,
1062
+ referenceId: redemption.ownerId,
1063
+ actorId: ctx.actorId
1064
+ }, {
1065
+ session,
1066
+ ...tc
1067
+ });
1068
+ await this.memberRepo.updateBalance(redemption.memberId, { redeemed: redemption.pointsReserved }, {
1069
+ session,
1070
+ ...tc
1071
+ });
1072
+ const confirmed = await this.update(redemptionId, { transactionId: tx._id }, {
1073
+ throwOnNotFound: true,
1074
+ lean: true,
1075
+ session,
1076
+ ...tc
1077
+ });
1078
+ if (!confirmed) throw new RedemptionNotFoundError(redemptionId);
1079
+ return confirmed;
1080
+ });
1081
+ const member = await this.memberRepo.getById(updated.memberId, {
1082
+ throwOnNotFound: false,
1083
+ ...tc
1084
+ });
1085
+ const payload = {
1086
+ memberId: updated.memberId,
1087
+ externalId: member?.externalId ?? "",
1088
+ redemptionId: updated._id,
1089
+ points: updated.pointsConfirmed,
1090
+ discountAmount: updated.discountAmount,
1091
+ ownerType: updated.ownerType,
1092
+ ownerId: updated.ownerId
1093
+ };
1094
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_CONFIRMED, payload, ctx, {
1095
+ resource: "redemption",
1096
+ resourceId: updated._id
1097
+ }), ctx);
1098
+ this.notifications?.notify(LoyaltyEvents.REDEMPTION_CONFIRMED, { ...payload });
1099
+ return updated;
1100
+ }
1101
+ async release(redemptionId, ctx) {
1102
+ assertTenantContext(ctx, this.config.tenant);
1103
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1104
+ const tc = tenantCtx(this.config.tenant, tenantId);
1105
+ const redemption = await this.getById(redemptionId, {
1106
+ throwOnNotFound: false,
1107
+ ...tc
1108
+ });
1109
+ if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1110
+ const updated = await this.unitOfWork.withTransaction(async (session) => {
1111
+ const transitioned = await this.atomicStatusTransition(redemptionId, "reserved", {
1112
+ status: "released",
1113
+ releasedAt: /* @__PURE__ */ new Date()
1114
+ }, {
1115
+ session,
1116
+ ...tc
1117
+ });
1118
+ if (!transitioned) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
1119
+ await this.memberRepo.updateBalance(redemption.memberId, { current: redemption.pointsReserved }, {
1120
+ session,
1121
+ ...tc
1122
+ });
1123
+ return transitioned;
1124
+ });
1125
+ const member = await this.memberRepo.getById(updated.memberId, {
1126
+ throwOnNotFound: false,
1127
+ ...tc
1128
+ });
1129
+ const payload = {
1130
+ memberId: updated.memberId,
1131
+ externalId: member?.externalId ?? "",
1132
+ redemptionId: updated._id,
1133
+ points: updated.pointsReserved,
1134
+ discountAmount: updated.discountAmount,
1135
+ ownerType: updated.ownerType,
1136
+ ownerId: updated.ownerId
1137
+ };
1138
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RELEASED, payload, ctx, {
1139
+ resource: "redemption",
1140
+ resourceId: updated._id
1141
+ }), ctx);
1142
+ this.notifications?.notify(LoyaltyEvents.REDEMPTION_RELEASED, { ...payload });
1143
+ return updated;
1144
+ }
1145
+ /**
1146
+ * Reverses a confirmed redemption. Credits points back to the member as
1147
+ * a compensating `adjust` transaction, marks the redemption `reversed`,
1148
+ * and emits `REDEMPTION_REVERSED`. Guarded by an atomic CAS on
1149
+ * `status === 'confirmed'` so idempotent callers never double-credit.
1150
+ *
1151
+ * @param options.proportional - when true + the redemption has a
1152
+ * `remainingBalance` + `initialBalance` (voucher-backed), credits
1153
+ * back `(remainingBalance / initialBalance) × pointsConfirmed`.
1154
+ * When false (default), credits back the full `pointsConfirmed`.
1155
+ * When true but the redemption has no voucher metadata, falls back
1156
+ * to a full reversal and stamps `metadata.proportional_fallback`.
1157
+ * @param options.reason - free-text audit note; persisted on the
1158
+ * redemption + the compensating point_transaction metadata.
1159
+ */
1160
+ async reverseConfirmed(redemptionId, ctx, options) {
1161
+ assertTenantContext(ctx, this.config.tenant);
1162
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1163
+ const tc = tenantCtx(this.config.tenant, tenantId);
1164
+ const redemption = await this.getById(redemptionId, {
1165
+ throwOnNotFound: false,
1166
+ ...tc
1167
+ });
1168
+ if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1169
+ if (redemption.status !== "confirmed") throw new ValidationError(`Redemption status must be 'confirmed', got '${redemption.status}'`);
1170
+ const proportional = options?.proportional === true;
1171
+ const originalPoints = redemption.pointsConfirmed;
1172
+ let pointsToCredit = originalPoints;
1173
+ let usedProportional = false;
1174
+ let proportionalFallback = false;
1175
+ if (proportional) {
1176
+ const { initialBalance, remainingBalance } = redemption;
1177
+ if (typeof initialBalance === "number" && initialBalance > 0 && typeof remainingBalance === "number" && remainingBalance >= 0) {
1178
+ const ratio = Math.min(1, remainingBalance / initialBalance);
1179
+ pointsToCredit = Math.floor(originalPoints * ratio);
1180
+ usedProportional = true;
1181
+ } else proportionalFallback = true;
1182
+ }
1183
+ const reversed = await this.unitOfWork.withTransaction(async (session) => {
1184
+ const transitioned = await this.atomicStatusTransition(redemptionId, "confirmed", {
1185
+ status: "reversed",
1186
+ reversedAt: /* @__PURE__ */ new Date(),
1187
+ ...options?.reason ? { reversalReason: options.reason } : {}
1188
+ }, {
1189
+ session,
1190
+ ...tc
1191
+ });
1192
+ if (!transitioned) throw new ValidationError(`Redemption status must be 'confirmed', got '${redemption.status}'`);
1193
+ let creditTxId = "";
1194
+ if (pointsToCredit > 0) {
1195
+ const member = await this.memberRepo.requireMember(redemption.memberId, tc);
1196
+ const updatedMember = await this.memberRepo.updateBalance(redemption.memberId, {
1197
+ current: pointsToCredit,
1198
+ redeemed: -pointsToCredit
1199
+ }, {
1200
+ session,
1201
+ ...tc
1202
+ });
1203
+ const txData = {
1204
+ memberId: redemption.memberId,
1205
+ programId: member.programId,
1206
+ type: "adjust",
1207
+ points: pointsToCredit,
1208
+ balanceAfter: updatedMember.balance.current,
1209
+ description: `Redemption reversed: ${redemptionId}`,
1210
+ referenceType: "redemption_reversal",
1211
+ referenceId: redemptionId,
1212
+ actorId: ctx.actorId,
1213
+ metadata: {
1214
+ reverses: redemptionId,
1215
+ originalPoints,
1216
+ proportional: usedProportional,
1217
+ ...proportionalFallback ? { proportional_fallback: true } : {},
1218
+ ...options?.reason ? { reason: options.reason } : {}
1219
+ }
1220
+ };
1221
+ creditTxId = (await this.txRepo.create(txData, {
1222
+ session,
1223
+ ...tc
1224
+ }))._id;
1225
+ }
1226
+ const finalUpdate = {};
1227
+ if (creditTxId) finalUpdate.reversalTransactionId = creditTxId;
1228
+ if (Object.keys(finalUpdate).length > 0) {
1229
+ const updated = await this.update(redemptionId, finalUpdate, {
1230
+ throwOnNotFound: true,
1231
+ lean: true,
1232
+ session,
1233
+ ...tc
1234
+ });
1235
+ if (!updated) throw new RedemptionNotFoundError(redemptionId);
1236
+ return updated;
1237
+ }
1238
+ return transitioned;
1239
+ });
1240
+ const member = await this.memberRepo.getById(reversed.memberId, {
1241
+ throwOnNotFound: false,
1242
+ ...tc
1243
+ });
1244
+ const payload = {
1245
+ memberId: reversed.memberId,
1246
+ externalId: member?.externalId ?? "",
1247
+ redemptionId: reversed._id,
1248
+ pointsCredited: pointsToCredit,
1249
+ originalPoints,
1250
+ proportional: usedProportional,
1251
+ transactionId: reversed.reversalTransactionId ?? ""
1252
+ };
1253
+ if (options?.reason) payload.reason = options.reason;
1254
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_REVERSED, payload, ctx, {
1255
+ resource: "redemption",
1256
+ resourceId: reversed._id
1257
+ }), ctx);
1258
+ this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
1259
+ return reversed;
1260
+ }
1261
+ async cleanupExpired(ctx) {
1262
+ assertTenantContext(ctx, this.config.tenant);
1263
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1264
+ const tc = tenantCtx(this.config.tenant, tenantId);
1265
+ const now = /* @__PURE__ */ new Date();
1266
+ const expired = await this.findAll({
1267
+ status: "reserved",
1268
+ expiresAt: { $lte: now }
1269
+ }, tc);
1270
+ let released = 0;
1271
+ for (const redemption of expired) {
1272
+ await this.release(redemption._id, ctx);
1273
+ released++;
1274
+ }
1275
+ return released;
1276
+ }
1277
+ };
1278
+ //#endregion
1279
+ //#region src/repositories/referral.repository.ts
1280
+ var ReferralRepository = class extends Repository {
1281
+ memberRepo;
1282
+ txRepo;
1283
+ events;
1284
+ outbox;
1285
+ notifications = null;
1286
+ config;
1287
+ constructor(model, plugins = []) {
1288
+ super(model, plugins);
1289
+ }
1290
+ /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
1291
+ async _dispatch(event, ctx) {
1292
+ await dispatchLoyaltyEvent({
1293
+ events: this.events,
1294
+ outbox: this.outbox
1295
+ }, event, ctx);
1296
+ }
1297
+ /** Auto-inject tenant id from options into doc payload. See MemberRepository. */
1298
+ async create(data, options) {
1299
+ injectTenantIntoData(data, options, this.config.tenant);
1300
+ return super.create(data, options);
1301
+ }
1302
+ async createMany(docs, options) {
1303
+ const opts = options;
1304
+ for (const doc of docs) injectTenantIntoData(doc, opts, this.config.tenant);
1305
+ return super.createMany(docs, options);
1306
+ }
1307
+ /**
1308
+ * Atomic compare-and-set status transition.
1309
+ * Returns null if the current status doesn't match `expectedStatus`.
1310
+ */
1311
+ async atomicStatusTransition(id, expectedStatus, data, ctx) {
1312
+ try {
1313
+ return await this.update(id, data, {
1314
+ lean: true,
1315
+ query: { status: expectedStatus },
1316
+ throwOnNotFound: false,
1317
+ ...ctx
1318
+ });
1319
+ } catch {
1320
+ return null;
1321
+ }
1322
+ }
1323
+ async generateCode(memberId, ctx) {
1324
+ assertTenantContext(ctx, this.config.tenant);
1325
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1326
+ const tc = tenantCtx(this.config.tenant, tenantId);
1327
+ const member = await this.memberRepo.requireMember(memberId, tc);
1328
+ if (member.referralCode) return member.referralCode;
1329
+ const code = generateReferralCode(this.config.referral.codeLength);
1330
+ await this.memberRepo.update(memberId, { referralCode: code }, {
1331
+ lean: true,
1332
+ ...tc
1333
+ });
1334
+ return code;
1335
+ }
1336
+ async lookupByCode(code, ctx) {
1337
+ assertTenantContext(ctx, this.config.tenant);
1338
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1339
+ const tc = tenantCtx(this.config.tenant, tenantId);
1340
+ return this.memberRepo.getByQuery({ referralCode: code }, {
1341
+ throwOnNotFound: false,
1342
+ ...tc
1343
+ });
1344
+ }
1345
+ async recordReferral(input, ctx) {
1346
+ assertTenantContext(ctx, this.config.tenant);
1347
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1348
+ const tc = tenantCtx(this.config.tenant, tenantId);
1349
+ const referrer = await this.memberRepo.getByQuery({ referralCode: input.referralCode }, {
1350
+ throwOnNotFound: false,
1351
+ ...tc
1352
+ });
1353
+ if (!referrer) throw new MemberNotFoundError(`referral code: ${input.referralCode}`);
1354
+ const referee = await this.memberRepo.getByQuery({
1355
+ externalId: input.refereeExternalId,
1356
+ externalType: input.refereeExternalType
1357
+ }, {
1358
+ throwOnNotFound: false,
1359
+ ...tc
1360
+ });
1361
+ if (!referee) throw new MemberNotFoundError(`referee: ${input.refereeExternalId}`);
1362
+ if (String(referrer._id) === String(referee._id)) throw new SelfReferralError();
1363
+ if (await this.getByQuery({ refereeId: referee._id }, {
1364
+ throwOnNotFound: false,
1365
+ ...tc
1366
+ })) throw new DuplicateReferralError(referee._id);
1367
+ const reverseReferral = await this.getByQuery({ refereeId: referrer._id }, {
1368
+ throwOnNotFound: false,
1369
+ ...tc
1370
+ });
1371
+ if (reverseReferral && String(reverseReferral.referrerId) === String(referee._id)) throw new CircularReferralError();
1372
+ const periodStart = /* @__PURE__ */ new Date();
1373
+ periodStart.setDate(periodStart.getDate() - this.config.referral.periodDays);
1374
+ if (await this.count({
1375
+ referrerId: referrer._id,
1376
+ createdAt: { $gte: periodStart }
1377
+ }, tc) >= this.config.referral.maxPerPeriod) throw new ReferralLimitExceededError(this.config.referral.maxPerPeriod, this.config.referral.periodDays);
1378
+ const autoApprove = !this.config.referral.requireApproval;
1379
+ const status = autoApprove ? "approved" : "pending";
1380
+ const data = {
1381
+ programId: referrer.programId,
1382
+ referrerId: referrer._id,
1383
+ refereeId: referee._id,
1384
+ referralCode: input.referralCode,
1385
+ status,
1386
+ referrerRewardPoints: this.config.referral.referrerRewardPoints,
1387
+ refereeRewardPoints: this.config.referral.refereeRewardPoints
1388
+ };
1389
+ if (autoApprove) data.approvedAt = /* @__PURE__ */ new Date();
1390
+ const referral = await this.create(data, tc);
1391
+ if (!referee.referredBy) await this.memberRepo.update(referee._id, { referredBy: referrer._id }, {
1392
+ lean: true,
1393
+ ...tc
1394
+ });
1395
+ const payload = {
1396
+ referralId: referral._id,
1397
+ referrerId: referrer._id,
1398
+ refereeId: referee._id,
1399
+ referralCode: input.referralCode,
1400
+ status: referral.status
1401
+ };
1402
+ await this._dispatch(createEvent(LoyaltyEvents.REFERRAL_RECORDED, payload, ctx, {
1403
+ resource: "referral",
1404
+ resourceId: referral._id
1405
+ }), ctx);
1406
+ this.notifications?.notify(LoyaltyEvents.REFERRAL_RECORDED, { ...payload });
1407
+ if (autoApprove) await this.rewardBothSides(referral, referrer, referee, ctx);
1408
+ return referral;
1409
+ }
1410
+ async approve(referralId, ctx) {
1411
+ assertTenantContext(ctx, this.config.tenant);
1412
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1413
+ const tc = tenantCtx(this.config.tenant, tenantId);
1414
+ const referral = await this.requireReferral(referralId, tc);
1415
+ const updated = await this.atomicStatusTransition(referralId, "pending", {
1416
+ status: "approved",
1417
+ approvedAt: /* @__PURE__ */ new Date()
1418
+ }, tc);
1419
+ if (!updated) throw new ValidationError(`Referral status must be 'pending', got '${referral.status}'`);
1420
+ const referrer = await this.memberRepo.requireMember(referral.referrerId, tc);
1421
+ const referee = await this.memberRepo.requireMember(referral.refereeId, tc);
1422
+ const payload = {
1423
+ referralId: updated._id,
1424
+ referrerId: referrer._id,
1425
+ refereeId: referee._id,
1426
+ referralCode: updated.referralCode,
1427
+ status: updated.status
1428
+ };
1429
+ await this._dispatch(createEvent(LoyaltyEvents.REFERRAL_APPROVED, payload, ctx, {
1430
+ resource: "referral",
1431
+ resourceId: updated._id
1432
+ }), ctx);
1433
+ this.notifications?.notify(LoyaltyEvents.REFERRAL_APPROVED, { ...payload });
1434
+ await this.rewardBothSides(updated, referrer, referee, ctx);
1435
+ return updated;
1436
+ }
1437
+ async reject(referralId, reason, ctx) {
1438
+ assertTenantContext(ctx, this.config.tenant);
1439
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1440
+ const tc = tenantCtx(this.config.tenant, tenantId);
1441
+ const referral = await this.requireReferral(referralId, tc);
1442
+ const updated = await this.atomicStatusTransition(referralId, "pending", {
1443
+ status: "rejected",
1444
+ rejectedAt: /* @__PURE__ */ new Date(),
1445
+ rejectionReason: reason
1446
+ }, tc);
1447
+ if (!updated) throw new ValidationError(`Referral status must be 'pending', got '${referral.status}'`);
1448
+ const payload = {
1449
+ referralId: updated._id,
1450
+ referrerId: referral.referrerId,
1451
+ refereeId: referral.refereeId,
1452
+ referralCode: updated.referralCode,
1453
+ status: updated.status
1454
+ };
1455
+ await this._dispatch(createEvent(LoyaltyEvents.REFERRAL_REJECTED, payload, ctx, {
1456
+ resource: "referral",
1457
+ resourceId: updated._id
1458
+ }), ctx);
1459
+ this.notifications?.notify(LoyaltyEvents.REFERRAL_REJECTED, { ...payload });
1460
+ return updated;
1461
+ }
1462
+ async cleanupExpired(ctx) {
1463
+ assertTenantContext(ctx, this.config.tenant);
1464
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1465
+ const tc = tenantCtx(this.config.tenant, tenantId);
1466
+ const now = /* @__PURE__ */ new Date();
1467
+ const expired = await this.findAll({
1468
+ status: "pending",
1469
+ expiresAt: { $lte: now }
1470
+ }, tc);
1471
+ let count = 0;
1472
+ for (const referral of expired) {
1473
+ await this.update(referral._id, { status: "expired" }, tc);
1474
+ count++;
1475
+ }
1476
+ return count;
1477
+ }
1478
+ async rewardBothSides(referral, referrer, referee, ctx) {
1479
+ const { referrerRewardPoints, refereeRewardPoints, maxDepth, tierRates } = this.config.referral;
1480
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1481
+ const tc = tenantCtx(this.config.tenant, tenantId);
1482
+ if (referrerRewardPoints > 0) {
1483
+ const referrerTx = await this.txRepo.earnPoints({
1484
+ memberId: referrer._id,
1485
+ points: referrerRewardPoints,
1486
+ description: `Referral reward: referred ${referee.externalId}`,
1487
+ referenceType: "referral",
1488
+ referenceId: referral._id,
1489
+ idempotencyKey: `referral:${referral._id}:referrer`
1490
+ }, ctx);
1491
+ await this.update(referral._id, { referrerTransactionId: referrerTx._id }, tc);
1492
+ }
1493
+ if (refereeRewardPoints > 0) {
1494
+ const refereeTx = await this.txRepo.earnPoints({
1495
+ memberId: referee._id,
1496
+ points: refereeRewardPoints,
1497
+ description: `Referral welcome bonus from ${referrer.externalId}`,
1498
+ referenceType: "referral",
1499
+ referenceId: referral._id,
1500
+ idempotencyKey: `referral:${referral._id}:referee`
1501
+ }, ctx);
1502
+ await this.update(referral._id, { refereeTransactionId: refereeTx._id }, tc);
1503
+ }
1504
+ const rewardPayload = {
1505
+ referralId: referral._id,
1506
+ referrerId: referrer._id,
1507
+ refereeId: referee._id,
1508
+ referralCode: referral.referralCode,
1509
+ status: "approved",
1510
+ referrerRewardPoints,
1511
+ refereeRewardPoints
1512
+ };
1513
+ await this._dispatch(createEvent(LoyaltyEvents.REFERRAL_REWARDED, rewardPayload, ctx, {
1514
+ resource: "referral",
1515
+ resourceId: referral._id
1516
+ }), ctx);
1517
+ this.notifications?.notify(LoyaltyEvents.REFERRAL_REWARDED, { ...rewardPayload });
1518
+ if (maxDepth > 1 && referrerRewardPoints > 0) {
1519
+ const awards = [];
1520
+ let currentId = referrer.referredBy;
1521
+ const visited = new Set([referrer._id, referee._id]);
1522
+ for (let depth = 2; depth <= maxDepth && currentId; depth++) {
1523
+ if (visited.has(currentId)) break;
1524
+ visited.add(currentId);
1525
+ const ancestor = await this.memberRepo.getById(currentId, {
1526
+ throwOnNotFound: false,
1527
+ ...tc
1528
+ });
1529
+ if (!ancestor || ancestor.status !== "active") break;
1530
+ const ratePct = tierRates[depth - 1];
1531
+ if (typeof ratePct === "number" && ratePct > 0) {
1532
+ const points = Math.floor(referrerRewardPoints * (ratePct / 100));
1533
+ if (points > 0) {
1534
+ const tx = await this.txRepo.earnPoints({
1535
+ memberId: ancestor._id,
1536
+ points,
1537
+ description: `Multi-tier referral reward (depth ${depth})`,
1538
+ referenceType: "referral",
1539
+ referenceId: referral._id,
1540
+ idempotencyKey: `referral:${referral._id}:ancestor:${depth}`,
1541
+ metadata: {
1542
+ depth,
1543
+ descendantId: referee._id
1544
+ }
1545
+ }, ctx);
1546
+ awards.push({
1547
+ memberId: ancestor._id,
1548
+ depth,
1549
+ points,
1550
+ transactionId: tx._id
1551
+ });
1552
+ }
1553
+ }
1554
+ currentId = ancestor.referredBy;
1555
+ }
1556
+ if (awards.length > 0) {
1557
+ await this._dispatch(createEvent(LoyaltyEvents.MULTI_TIER_REFERRAL_AWARDED, {
1558
+ referralId: referral._id,
1559
+ refereeId: referee._id,
1560
+ awards
1561
+ }, ctx, {
1562
+ resource: "referral",
1563
+ resourceId: referral._id
1564
+ }), ctx);
1565
+ this.notifications?.notify(LoyaltyEvents.MULTI_TIER_REFERRAL_AWARDED, {
1566
+ referralId: referral._id,
1567
+ refereeId: referee._id,
1568
+ awards
1569
+ });
1570
+ }
1571
+ }
1572
+ }
1573
+ async requireReferral(referralId, tc) {
1574
+ const referral = await this.getById(referralId, {
1575
+ throwOnNotFound: false,
1576
+ ...tc
1577
+ });
1578
+ if (!referral) throw new ReferralNotFoundError(referralId);
1579
+ return referral;
1580
+ }
1581
+ };
1582
+ //#endregion
1583
+ //#region src/repositories/tier-definition.repository.ts
1584
+ var TierDefinitionRepository = class extends Repository {
1585
+ memberRepo;
1586
+ events;
1587
+ outbox;
1588
+ notifications = null;
1589
+ config;
1590
+ constructor(model, plugins = []) {
1591
+ super(model, plugins);
1592
+ }
1593
+ /** Auto-inject tenant id from options into doc payload. See MemberRepository. */
1594
+ async create(data, options) {
1595
+ injectTenantIntoData(data, options, this.config.tenant);
1596
+ return super.create(data, options);
1597
+ }
1598
+ async createMany(docs, options) {
1599
+ const opts = options;
1600
+ for (const doc of docs) injectTenantIntoData(doc, opts, this.config.tenant);
1601
+ return super.createMany(docs, options);
1602
+ }
1603
+ /** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
1604
+ async _dispatch(event, ctx) {
1605
+ await dispatchLoyaltyEvent({
1606
+ events: this.events,
1607
+ outbox: this.outbox
1608
+ }, event, ctx);
1609
+ }
1610
+ async evaluateMember(memberId, ctx) {
1611
+ assertTenantContext(ctx, this.config.tenant);
1612
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1613
+ const tc = tenantCtx(this.config.tenant, tenantId);
1614
+ const member = await this.memberRepo.requireMember(memberId, tc);
1615
+ const sortedTiers = [...await this.findAll({ programId: member.programId }, {
1616
+ sort: { rank: 1 },
1617
+ ...tc
1618
+ })].sort((a, b) => b.rank - a.rank);
1619
+ const ttl = member.tierOverrideExpiresAt;
1620
+ if (member.tierOverride !== null && member.tierOverride !== void 0 && !(ttl !== void 0 && ttl !== null && ttl.getTime() <= Date.now())) return {
1621
+ memberId,
1622
+ previousTier: member.tier,
1623
+ newTier: member.tier,
1624
+ action: "unchanged"
1625
+ };
1626
+ const qualifiedTier = sortedTiers.find((t) => t.qualificationCriteria.minLifetimePoints !== void 0 && member.balance.lifetime >= t.qualificationCriteria.minLifetimePoints) ?? null;
1627
+ const currentTierDef = member.tier ? sortedTiers.find((t) => t.name === member.tier) ?? null : null;
1628
+ const currentRank = currentTierDef?.rank ?? -1;
1629
+ const newRank = qualifiedTier?.rank ?? -1;
1630
+ if (newRank > currentRank && qualifiedTier) return this.applyUpgrade(memberId, member, qualifiedTier.name, tc, ctx);
1631
+ if (newRank < currentRank && currentTierDef) return this.applyDowngrade(memberId, member, currentTierDef, qualifiedTier?.name ?? null, tc, ctx);
1632
+ return {
1633
+ memberId,
1634
+ previousTier: member.tier,
1635
+ newTier: member.tier,
1636
+ action: "unchanged"
1637
+ };
1638
+ }
1639
+ async evaluateAll(ctx) {
1640
+ assertTenantContext(ctx, this.config.tenant);
1641
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1642
+ const tc = tenantCtx(this.config.tenant, tenantId);
1643
+ const members = await this.memberRepo.findAll({ status: "active" }, tc);
1644
+ let upgraded = 0;
1645
+ let downgraded = 0;
1646
+ let unchanged = 0;
1647
+ for (const member of members) switch ((await this.evaluateMember(member._id, ctx)).action) {
1648
+ case "upgraded":
1649
+ upgraded++;
1650
+ break;
1651
+ case "downgraded":
1652
+ downgraded++;
1653
+ break;
1654
+ default:
1655
+ unchanged++;
1656
+ break;
1657
+ }
1658
+ return {
1659
+ upgraded,
1660
+ downgraded,
1661
+ unchanged
1662
+ };
1663
+ }
1664
+ async setOverride(memberId, tierName, reason, ctx, options) {
1665
+ assertTenantContext(ctx, this.config.tenant);
1666
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1667
+ const tc = tenantCtx(this.config.tenant, tenantId);
1668
+ const member = await this.memberRepo.requireMember(memberId, tc);
1669
+ if (!(await this.findAll({ programId: member.programId }, {
1670
+ sort: { rank: 1 },
1671
+ ...tc
1672
+ })).some((t) => t.name === tierName)) throw new TierNotFoundError(tierName);
1673
+ const update = {
1674
+ tierOverride: tierName,
1675
+ tierOverrideReason: reason,
1676
+ tierOverrideExpiresAt: options?.expiresAt ?? null
1677
+ };
1678
+ const updated = await this.memberRepo.update(memberId, update, {
1679
+ throwOnNotFound: true,
1680
+ lean: true,
1681
+ ...tc
1682
+ });
1683
+ if (!updated) throw new MemberNotFoundError(memberId);
1684
+ return updated;
1685
+ }
1686
+ async clearOverride(memberId, ctx) {
1687
+ assertTenantContext(ctx, this.config.tenant);
1688
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1689
+ const tc = tenantCtx(this.config.tenant, tenantId);
1690
+ const member = await this.memberRepo.requireMember(memberId, tc);
1691
+ const previousOverride = member.tierOverride;
1692
+ const previousReason = member.tierOverrideReason;
1693
+ await this.memberRepo.update(memberId, {
1694
+ tierOverride: null,
1695
+ tierOverrideReason: null,
1696
+ tierOverrideExpiresAt: null
1697
+ }, {
1698
+ lean: true,
1699
+ ...tc
1700
+ });
1701
+ if (previousOverride) {
1702
+ const payload = {
1703
+ memberId,
1704
+ externalId: member.externalId,
1705
+ tier: previousOverride
1706
+ };
1707
+ if (previousReason !== void 0) payload.reason = previousReason;
1708
+ await this._dispatch(createEvent(LoyaltyEvents.TIER_OVERRIDE_CLEARED, payload, ctx, {
1709
+ resource: "member",
1710
+ resourceId: memberId
1711
+ }), ctx);
1712
+ this.notifications?.notify(LoyaltyEvents.TIER_OVERRIDE_CLEARED, { ...payload });
1713
+ }
1714
+ return this.evaluateMember(memberId, ctx);
1715
+ }
1716
+ /**
1717
+ * Sweeps members whose `tierOverrideExpiresAt` is past `now` and clears
1718
+ * the override. Emits `TIER_OVERRIDE_EXPIRED` per member — distinct from
1719
+ * the explicit `TIER_OVERRIDE_CLEARED` event — so audit logs can tell
1720
+ * admin-initiated clears from TTL sweeps.
1721
+ *
1722
+ * Hosts call this on a cron schedule (e.g. hourly). Returns the number
1723
+ * of overrides cleared. No-op when nothing matches.
1724
+ */
1725
+ async sweepExpiredOverrides(ctx, now = /* @__PURE__ */ new Date()) {
1726
+ assertTenantContext(ctx, this.config.tenant);
1727
+ const tenantId = getTenantValue(ctx, this.config.tenant);
1728
+ const tc = tenantCtx(this.config.tenant, tenantId);
1729
+ const expired = await this.memberRepo.findAll({
1730
+ tierOverride: { $ne: null },
1731
+ tierOverrideExpiresAt: { $lte: now }
1732
+ }, tc);
1733
+ let cleared = 0;
1734
+ for (const member of expired) {
1735
+ const previousOverride = member.tierOverride;
1736
+ if (!previousOverride) continue;
1737
+ await this.memberRepo.update(member._id, {
1738
+ tierOverride: null,
1739
+ tierOverrideReason: null,
1740
+ tierOverrideExpiresAt: null
1741
+ }, {
1742
+ lean: true,
1743
+ ...tc
1744
+ });
1745
+ const payload = {
1746
+ memberId: member._id,
1747
+ externalId: member.externalId,
1748
+ tier: previousOverride,
1749
+ expiredAt: now.toISOString()
1750
+ };
1751
+ if (member.tierOverrideReason !== void 0) payload.reason = member.tierOverrideReason;
1752
+ await this._dispatch(createEvent(LoyaltyEvents.TIER_OVERRIDE_EXPIRED, payload, ctx, {
1753
+ resource: "member",
1754
+ resourceId: member._id
1755
+ }), ctx);
1756
+ this.notifications?.notify(LoyaltyEvents.TIER_OVERRIDE_EXPIRED, { ...payload });
1757
+ await this.evaluateMember(member._id, ctx);
1758
+ cleared++;
1759
+ }
1760
+ return { cleared };
1761
+ }
1762
+ async applyUpgrade(memberId, member, newTierName, tc, ctx) {
1763
+ await this.memberRepo.update(memberId, {
1764
+ tier: newTierName,
1765
+ tierQualifiedAt: /* @__PURE__ */ new Date(),
1766
+ tierExpiresAt: null
1767
+ }, {
1768
+ lean: true,
1769
+ ...tc
1770
+ });
1771
+ const payload = {
1772
+ memberId,
1773
+ externalId: member.externalId,
1774
+ previousTier: member.tier,
1775
+ newTier: newTierName,
1776
+ lifetimePoints: member.balance.lifetime
1777
+ };
1778
+ await this._dispatch(createEvent(LoyaltyEvents.TIER_UPGRADED, payload, ctx, {
1779
+ resource: "member",
1780
+ resourceId: memberId
1781
+ }), ctx);
1782
+ this.notifications?.notify(LoyaltyEvents.TIER_UPGRADED, { ...payload });
1783
+ return {
1784
+ memberId,
1785
+ previousTier: member.tier,
1786
+ newTier: newTierName,
1787
+ action: "upgraded"
1788
+ };
1789
+ }
1790
+ async applyDowngrade(memberId, member, currentTierDef, qualifiedTierName, tc, ctx) {
1791
+ const { downgrade } = currentTierDef;
1792
+ if (!downgrade.enabled) return {
1793
+ memberId,
1794
+ previousTier: member.tier,
1795
+ newTier: member.tier,
1796
+ action: "unchanged"
1797
+ };
1798
+ if (downgrade.gracePeriodDays > 0 && !member.tierExpiresAt) {
1799
+ const graceExpiresAt = /* @__PURE__ */ new Date();
1800
+ graceExpiresAt.setDate(graceExpiresAt.getDate() + downgrade.gracePeriodDays);
1801
+ await this.memberRepo.update(memberId, { tierExpiresAt: graceExpiresAt }, {
1802
+ lean: true,
1803
+ ...tc
1804
+ });
1805
+ const payload = {
1806
+ memberId,
1807
+ externalId: member.externalId,
1808
+ previousTier: member.tier,
1809
+ newTier: member.tier,
1810
+ lifetimePoints: member.balance.lifetime
1811
+ };
1812
+ await this._dispatch(createEvent(LoyaltyEvents.TIER_GRACE_STARTED, payload, ctx, {
1813
+ resource: "member",
1814
+ resourceId: memberId
1815
+ }), ctx);
1816
+ this.notifications?.notify(LoyaltyEvents.TIER_GRACE_STARTED, { ...payload });
1817
+ return {
1818
+ memberId,
1819
+ previousTier: member.tier,
1820
+ newTier: member.tier,
1821
+ action: "grace_period",
1822
+ graceExpiresAt
1823
+ };
1824
+ }
1825
+ if (!(downgrade.gracePeriodDays === 0 || member.tierExpiresAt && member.tierExpiresAt <= /* @__PURE__ */ new Date())) return {
1826
+ memberId,
1827
+ previousTier: member.tier,
1828
+ newTier: member.tier,
1829
+ action: "unchanged"
1830
+ };
1831
+ await this.memberRepo.update(memberId, {
1832
+ tier: qualifiedTierName,
1833
+ tierQualifiedAt: qualifiedTierName ? /* @__PURE__ */ new Date() : null,
1834
+ tierExpiresAt: null
1835
+ }, {
1836
+ lean: true,
1837
+ ...tc
1838
+ });
1839
+ const payload = {
1840
+ memberId,
1841
+ externalId: member.externalId,
1842
+ previousTier: member.tier,
1843
+ newTier: qualifiedTierName,
1844
+ lifetimePoints: member.balance.lifetime
1845
+ };
1846
+ await this._dispatch(createEvent(LoyaltyEvents.TIER_DOWNGRADED, payload, ctx, {
1847
+ resource: "member",
1848
+ resourceId: memberId
1849
+ }), ctx);
1850
+ this.notifications?.notify(LoyaltyEvents.TIER_DOWNGRADED, { ...payload });
1851
+ return {
1852
+ memberId,
1853
+ previousTier: member.tier,
1854
+ newTier: qualifiedTierName,
1855
+ action: "downgraded"
1856
+ };
1857
+ }
1858
+ };
1859
+ //#endregion
1860
+ //#region src/repositories/index.ts
1861
+ function asPlugins(p) {
1862
+ return p ?? [];
1863
+ }
1864
+ function createRepositories(input) {
1865
+ const { models, plugins, tenant, config, events, outbox, notifications, unitOfWork } = input;
1866
+ const tenantPlugins = tenant?.enabled ? [multiTenantPlugin({
1867
+ tenantField: tenant.tenantField,
1868
+ contextKey: tenant.contextKey,
1869
+ fieldType: tenant.fieldType
1870
+ })] : [];
1871
+ const withTenant = (userPlugins) => [...tenantPlugins, ...asPlugins(userPlugins)];
1872
+ const member = new MemberRepository(models.Member, withTenant(plugins?.member));
1873
+ const pointTransaction = new PointTransactionRepository(models.PointTransaction, withTenant(plugins?.pointTransaction));
1874
+ const earningRule = new EarningRuleRepository(models.EarningRule, withTenant(plugins?.earningRule));
1875
+ const tierDefinition = new TierDefinitionRepository(models.TierDefinition, withTenant(plugins?.tierDefinition));
1876
+ const redemption = new RedemptionRepository(models.Redemption, withTenant(plugins?.redemption));
1877
+ const referral = new ReferralRepository(models.Referral, withTenant(plugins?.referral));
1878
+ member.events = events;
1879
+ member.outbox = outbox;
1880
+ member.notifications = notifications;
1881
+ member.config = config;
1882
+ pointTransaction.memberRepo = member;
1883
+ pointTransaction.events = events;
1884
+ pointTransaction.outbox = outbox;
1885
+ pointTransaction.notifications = notifications;
1886
+ pointTransaction.unitOfWork = unitOfWork;
1887
+ pointTransaction.config = config;
1888
+ earningRule.memberRepo = member;
1889
+ earningRule.txRepo = pointTransaction;
1890
+ earningRule.config = config;
1891
+ tierDefinition.memberRepo = member;
1892
+ tierDefinition.events = events;
1893
+ tierDefinition.outbox = outbox;
1894
+ tierDefinition.notifications = notifications;
1895
+ tierDefinition.config = config;
1896
+ redemption.memberRepo = member;
1897
+ redemption.txRepo = pointTransaction;
1898
+ redemption.events = events;
1899
+ redemption.outbox = outbox;
1900
+ redemption.notifications = notifications;
1901
+ redemption.unitOfWork = unitOfWork;
1902
+ redemption.config = config;
1903
+ referral.memberRepo = member;
1904
+ referral.txRepo = pointTransaction;
1905
+ referral.events = events;
1906
+ referral.outbox = outbox;
1907
+ referral.notifications = notifications;
1908
+ referral.config = config;
1909
+ return {
1910
+ member,
1911
+ pointTransaction,
1912
+ earningRule,
1913
+ tierDefinition,
1914
+ redemption,
1915
+ referral
1916
+ };
1917
+ }
1918
+ //#endregion
1919
+ export { PointTransactionRepository as a, ValidationError as c, RedemptionRepository as i, TierDefinitionRepository as n, MemberRepository as o, ReferralRepository as r, EarningRuleRepository as s, createRepositories as t };