@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.
- package/CHANGELOG.md +68 -0
- package/dist/config-DoZPGBfW.d.mts +136 -0
- package/dist/{constants-C75GvizO.mjs → constants-DkQYkOK1.mjs} +3 -2
- package/dist/domain/enums/index.d.mts +1 -1
- package/dist/domain/enums/index.mjs +1 -1
- package/dist/events/index.d.mts +2 -2
- package/dist/events/index.mjs +3 -3
- package/dist/events-BtZOdiPw.mjs +311 -0
- package/dist/index-BOWqBSWx.d.mts +281 -0
- package/dist/index-C4jFHS7m.d.mts +400 -0
- package/dist/index-ClPKmtxh.d.mts +361 -0
- package/dist/{index-CaH4GhSF.d.mts → index-DMK8a98J.d.mts} +1 -1
- package/dist/index.d.mts +28 -12
- package/dist/index.mjs +30 -26
- package/dist/loyalty-events-Cyt9TwX6.mjs +50 -0
- package/dist/models/index.d.mts +1225 -2
- package/dist/models/index.mjs +2 -2
- package/dist/{models-CrZPmVxt.mjs → models-uhh9c-7k.mjs} +104 -41
- package/dist/notifications/index.d.mts +1 -1
- package/dist/repositories/index.d.mts +2 -2
- package/dist/repositories/index.mjs +1 -1
- package/dist/repositories-DBSxe9st.mjs +1919 -0
- package/dist/types/index.d.mts +2 -2
- package/package.json +22 -17
- package/dist/config-aNigy4ec.d.mts +0 -90
- package/dist/domain/index.d.mts +0 -90
- package/dist/domain/index.mjs +0 -4
- package/dist/domain-errors-D7q-2LYD.mjs +0 -120
- package/dist/event-emitter.port-CLYPoMni.d.mts +0 -8
- package/dist/event-types-xQBtYBbX.mjs +0 -24
- package/dist/events-CprEWlN7.mjs +0 -25
- package/dist/index-B6ZstxSP.d.mts +0 -2357
- package/dist/index-BcoGeDco.d.mts +0 -127
- package/dist/index-DqzTjeEB.d.mts +0 -148
- package/dist/index-O7TL4R2P.d.mts +0 -86
- package/dist/index-_pg4J11t.d.mts +0 -191
- package/dist/repositories-qMX7Xh_y.mjs +0 -440
- package/dist/services/index.d.mts +0 -2
- package/dist/services/index.mjs +0 -2
- package/dist/services-CoTqC4E6.mjs +0 -1098
- package/dist/tier-definition-Djf72UPa.d.mts +0 -120
- package/dist/tier-definition.port-BzrHYBRo.d.mts +0 -143
- /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 };
|