@classytic/loyalty 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +321 -231
- package/LICENSE +75 -21
- package/README.md +2 -2
- package/dist/{config-Dtun1duw.d.mts → config-CtkGj3xZ.d.mts} +41 -22
- package/dist/{constants-DkQYkOK1.mjs → constants-DOPBh6kt.mjs} +1 -3
- 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 +2 -2
- package/dist/{events-DN5_p6T0.mjs → events-BLjg3uLA.mjs} +22 -31
- package/dist/{index-Dngaj21_.d.mts → index-CR2k7jQ8.d.mts} +81 -14
- package/dist/{index-YSsi4e_G.d.mts → index-Cp_sXNpG.d.mts} +35 -4
- package/dist/index-D6GvuvmF.d.mts +692 -0
- package/dist/{index-D_5Ojc2c.d.mts → index-YUFamhDk.d.mts} +7 -17
- package/dist/index.d.mts +60 -29
- package/dist/index.mjs +99 -52
- package/dist/models/index.d.mts +3 -1273
- package/dist/models/index.mjs +2 -2
- package/dist/{models-Ddh62lgO.mjs → models-weWqS5Bc.mjs} +152 -70
- package/dist/notifications/index.d.mts +1 -2
- package/dist/repositories/index.d.mts +1 -1
- package/dist/repositories/index.mjs +1 -1
- package/dist/{repositories-CjWfUlrA.mjs → repositories-B676z4bE.mjs} +181 -66
- package/dist/types/index.d.mts +3 -3
- package/package.json +119 -116
- /package/dist/{index-DMK8a98J.d.mts → index-D_Oe3aDk.d.mts} +0 -0
- /package/dist/{loyalty-events-C38JA3cU.mjs → loyalty-events-DXirolM_.mjs} +0 -0
- /package/dist/{notification.port-CJ9nXj0k.d.mts → notification.port-wY4IKimz.d.mts} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-
|
|
1
|
+
import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-DXirolM_.mjs";
|
|
2
2
|
import { Repository, appendOnlyPlugin, multiTenantPlugin } from "@classytic/mongokit";
|
|
3
3
|
import { localTimeParts } from "@classytic/primitives/timezone";
|
|
4
4
|
import { addDays, isoWeekday } from "@classytic/primitives/calendar";
|
|
@@ -152,6 +152,27 @@ var UnmanagedSessionError = class extends LoyaltyError {
|
|
|
152
152
|
}
|
|
153
153
|
};
|
|
154
154
|
/**
|
|
155
|
+
* A host passed `ctx.session`, but that session has no ACTIVE transaction.
|
|
156
|
+
*
|
|
157
|
+
* A bare `startSession()` provides NO atomicity — every write inside it
|
|
158
|
+
* commits independently. Accepting it as proof of a unit of work is the
|
|
159
|
+
* silent-permissiveness failure in its purest form: loyalty then saves the
|
|
160
|
+
* outbox row "in-session" and applies the balance `$inc` "in-session", and
|
|
161
|
+
* if the outbox save fails the balance mutation still stands with no ledger
|
|
162
|
+
* row behind it. Nothing throws; the points are simply wrong.
|
|
163
|
+
*
|
|
164
|
+
* `session.inTransaction()` is the driver's own answer, so this check cannot
|
|
165
|
+
* drift from what MongoDB actually thinks. `@classytic/access` refuses
|
|
166
|
+
* exactly this at `entitlement.repository.ts` — same rule, same reason.
|
|
167
|
+
*/
|
|
168
|
+
var SessionNotInTransactionError = class extends LoyaltyError {
|
|
169
|
+
code = "loyalty.transaction.session_not_in_transaction";
|
|
170
|
+
status = 409;
|
|
171
|
+
constructor() {
|
|
172
|
+
super("[loyalty] ctx.session was supplied but has no ACTIVE transaction — a bare session provides no atomicity (each write commits independently), so a failing outbox.save would leave the points mutation standing with the event lost. Drive it with session.withTransaction(...) / startTransaction(), or omit ctx.session and let loyalty open its own transaction.");
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
155
176
|
* The backend doesn't declare the runtime capabilities loyalty's
|
|
156
177
|
* transactional verbs require (earn / adjust / reserve / confirm / release /
|
|
157
178
|
* reverse all run inside `withTransaction`). MongoDB must run as a replica set
|
|
@@ -161,8 +182,18 @@ var UnmanagedSessionError = class extends LoyaltyError {
|
|
|
161
182
|
var LoyaltyCapabilityError = class extends LoyaltyError {
|
|
162
183
|
code = "loyalty.engine.missing_capabilities";
|
|
163
184
|
status = 500;
|
|
164
|
-
|
|
165
|
-
|
|
185
|
+
/**
|
|
186
|
+
* `true` when the backend never ANSWERED (bound before the connection
|
|
187
|
+
* opened, a driver exposing no topology) rather than answering "no". Both
|
|
188
|
+
* refuse — an unobserved outcome is never a positive one (AGENTS.md FAIL
|
|
189
|
+
* LOUD rule 3) — but the operator's fix differs, so the message must.
|
|
190
|
+
*/
|
|
191
|
+
indeterminate;
|
|
192
|
+
constructor(missing, opts = {}) {
|
|
193
|
+
const indeterminate = opts.indeterminate === true;
|
|
194
|
+
const backendMissing = missing.filter((flag) => !flag.startsWith("outbox."));
|
|
195
|
+
super(indeterminate ? `[loyalty] could NOT determine whether the backend supports: ${missing.join(", ")}. An unconfirmed answer is treated as unsupported — point earn / redemption / reversal paths are transactional. Bind AFTER \`await mongoose.connect(...)\`, or call \`await probeMongoCapabilities(connection)\` first, or set \`allowNonTransactional: true\`.` : `[loyalty] missing required capabilities: ${missing.join(", ")}.` + (backendMissing.length > 0 ? " Point earn / redemption / reversal paths are transactional — MongoDB must run as a replica set or sharded cluster, or set `allowNonTransactional: true`." : ""));
|
|
196
|
+
this.indeterminate = indeterminate;
|
|
166
197
|
}
|
|
167
198
|
};
|
|
168
199
|
//#endregion
|
|
@@ -234,8 +265,9 @@ function validatePositivePoints(points, label = "points") {
|
|
|
234
265
|
validatePoints(points, label);
|
|
235
266
|
if (points <= 0) throw new ValidationError(`${label} must be positive`);
|
|
236
267
|
}
|
|
268
|
+
/** `orderTotal` is an integer in the host's minor currency unit (paisa for BDT). */
|
|
237
269
|
function validateOrderTotal(total) {
|
|
238
|
-
if (!Number.
|
|
270
|
+
if (!Number.isSafeInteger(total) || total < 0) throw new ValidationError("orderTotal must be a non-negative safe integer (minor units)");
|
|
239
271
|
}
|
|
240
272
|
//#endregion
|
|
241
273
|
//#region src/repositories/earning-rule.repository.ts
|
|
@@ -251,7 +283,7 @@ var EarningRuleRepository = class extends Repository {
|
|
|
251
283
|
injectTenantIntoData(data, options, this.config.tenant);
|
|
252
284
|
}
|
|
253
285
|
/**
|
|
254
|
-
* Auto-injects `programId` from `
|
|
286
|
+
* Auto-injects `programId` from `LoyaltyShape.program.id` and the tenant id
|
|
255
287
|
* from repo options when omitted. This keeps custom-program engines aligned
|
|
256
288
|
* with `MemberRepository.enroll(...)`, which uses the same config default.
|
|
257
289
|
*/
|
|
@@ -264,6 +296,14 @@ var EarningRuleRepository = class extends Repository {
|
|
|
264
296
|
for (const doc of docs) this.applyCreateDefaults(doc, opts);
|
|
265
297
|
return super.createMany(docs, options);
|
|
266
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Evaluates every active order/category/tier_bonus rule against an order.
|
|
301
|
+
*
|
|
302
|
+
* `orderTotal` and `items[].amount` are **integers in the host's minor
|
|
303
|
+
* currency unit** (paisa for BDT) — `50000` = ৳500. Rates on the rule
|
|
304
|
+
* (`amountPerPoint`, `pointsPerAmount` + `rateBasis`) are quoted in the
|
|
305
|
+
* same minor units.
|
|
306
|
+
*/
|
|
267
307
|
async evaluateOrder(input, ctx) {
|
|
268
308
|
validateOrderTotal(input.orderTotal);
|
|
269
309
|
assertTenantContext(ctx, this.config.tenant);
|
|
@@ -394,18 +434,34 @@ var EarningRuleRepository = class extends Repository {
|
|
|
394
434
|
weekday(instant) {
|
|
395
435
|
return (this.config.timezone ? localTimeParts(instant, this.config.timezone).isoWeekday : isoWeekday(instant)) % 7;
|
|
396
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* `orderTotal` is integer minor units. Two rate shapes:
|
|
439
|
+
* - `pointsPerAmount` + `rateBasis`: `points = pointsPerAmount × (orderTotal / rateBasis)`
|
|
440
|
+
* - `amountPerPoint` (minor units per point): `points = orderTotal / amountPerPoint`
|
|
441
|
+
* Rounding is applied ONCE at the end, per the rule's `roundingMode`.
|
|
442
|
+
*/
|
|
397
443
|
calculateOrderPoints(rule, orderTotal) {
|
|
398
444
|
const { reward } = rule;
|
|
399
445
|
let points = 0;
|
|
400
|
-
if (reward.pointsPerAmount) points =
|
|
446
|
+
if (reward.pointsPerAmount) points = reward.pointsPerAmount * (orderTotal / this.requireRateBasis(rule));
|
|
401
447
|
else if (reward.amountPerPoint && reward.amountPerPoint > 0) points = orderTotal / reward.amountPerPoint;
|
|
402
448
|
return this.applyRounding(points, reward.roundingMode);
|
|
403
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* `pointsPerAmount` has NO implicit basis — a rule that carries it without
|
|
452
|
+
* an explicit integer minor-unit `rateBasis` is malformed (the schema
|
|
453
|
+
* enforces the pairing on write; this guards legacy/hand-written docs).
|
|
454
|
+
*/
|
|
455
|
+
requireRateBasis(rule) {
|
|
456
|
+
const rateBasis = rule.reward.rateBasis;
|
|
457
|
+
if (typeof rateBasis !== "number" || !Number.isInteger(rateBasis) || rateBasis < 1) throw new ValidationError(`Earning rule '${rule.name}' (${rule._id}) sets reward.pointsPerAmount without a valid integer reward.rateBasis (minor units) — points = pointsPerAmount × (orderTotal / rateBasis)`);
|
|
458
|
+
return rateBasis;
|
|
459
|
+
}
|
|
404
460
|
calculateCategoryPoints(rule, items) {
|
|
405
461
|
const { conditions, reward } = rule;
|
|
406
462
|
let points = 0;
|
|
407
463
|
for (const item of items) if (conditions.categories && item.categoryId && conditions.categories.includes(item.categoryId)) {
|
|
408
|
-
if (reward.pointsPerAmount) points += item.amount
|
|
464
|
+
if (reward.pointsPerAmount) points += reward.pointsPerAmount * (item.amount / this.requireRateBasis(rule));
|
|
409
465
|
else if (reward.amountPerPoint) points += item.amount / reward.amountPerPoint;
|
|
410
466
|
}
|
|
411
467
|
return this.applyRounding(points, reward.roundingMode);
|
|
@@ -428,7 +484,24 @@ var EarningRuleRepository = class extends Repository {
|
|
|
428
484
|
* host-supplied context fields and never appears in the public type.
|
|
429
485
|
*/
|
|
430
486
|
const PENDING_EVENTS = Symbol.for("@classytic/loyalty:pendingEvents");
|
|
487
|
+
/**
|
|
488
|
+
* A caller-supplied `ctx.session` is only evidence of a unit of work if it
|
|
489
|
+
* has an ACTIVE transaction. A bare `startSession()` commits every write
|
|
490
|
+
* independently, so treating it as atomic is how a balance `$inc` lands with
|
|
491
|
+
* no ledger row behind it when the outbox save fails — silently, because the
|
|
492
|
+
* business write already succeeded.
|
|
493
|
+
*
|
|
494
|
+
* `inTransaction()` is the driver's own answer, so this cannot drift from
|
|
495
|
+
* what Mongo actually thinks. Same guard, same rationale as
|
|
496
|
+
* `@classytic/access`'s `atomically()`.
|
|
497
|
+
*/
|
|
498
|
+
function assertSessionInTransaction(ctx) {
|
|
499
|
+
if (!ctx.session) return;
|
|
500
|
+
const session = ctx.session;
|
|
501
|
+
if (typeof session.inTransaction !== "function" || !session.inTransaction()) throw new SessionNotInTransactionError();
|
|
502
|
+
}
|
|
431
503
|
async function dispatchLoyaltyEvent(deps, event, ctx) {
|
|
504
|
+
assertSessionInTransaction(ctx);
|
|
432
505
|
const pending = ctx[PENDING_EVENTS];
|
|
433
506
|
if (deps.outbox) try {
|
|
434
507
|
const saveOptions = {};
|
|
@@ -469,7 +542,10 @@ async function dispatchLoyaltyEvent(deps, event, ctx) {
|
|
|
469
542
|
*/
|
|
470
543
|
async function runLoyaltyUnit(unitOfWork, deps, ctx, body) {
|
|
471
544
|
const internalCtx = ctx;
|
|
472
|
-
if (ctx.session)
|
|
545
|
+
if (ctx.session) {
|
|
546
|
+
assertSessionInTransaction(ctx);
|
|
547
|
+
return body(internalCtx);
|
|
548
|
+
}
|
|
473
549
|
const pending = [];
|
|
474
550
|
const result = await unitOfWork.withTransaction(async (session) => {
|
|
475
551
|
pending.length = 0;
|
|
@@ -984,43 +1060,56 @@ var PointTransactionRepository = class extends Repository {
|
|
|
984
1060
|
const affectedMemberIds = /* @__PURE__ */ new Set();
|
|
985
1061
|
for (const tx of expiredTxs) {
|
|
986
1062
|
throwIfAborted(ctx.signal);
|
|
987
|
-
await this.unitOfWork.withTransaction(async (session) => {
|
|
988
|
-
await this.
|
|
989
|
-
memberId: tx.memberId,
|
|
990
|
-
programId: tx.programId,
|
|
991
|
-
type: "expire",
|
|
992
|
-
points: -tx.points,
|
|
993
|
-
balanceAfter: 0,
|
|
994
|
-
description: `Points expired from transaction ${tx._id}`,
|
|
995
|
-
referenceType: "expiration",
|
|
996
|
-
referenceId: tx._id,
|
|
997
|
-
actorId: ctx.actorId
|
|
998
|
-
}, {
|
|
1063
|
+
const expiredAmount = await this.unitOfWork.withTransaction(async (session) => {
|
|
1064
|
+
const member = await this.memberRepo.requireMember(tx.memberId, {
|
|
999
1065
|
session,
|
|
1000
1066
|
...tc
|
|
1001
1067
|
});
|
|
1068
|
+
const available = Math.max(0, member.balance.current);
|
|
1069
|
+
const amount = Math.min(tx.points, available);
|
|
1002
1070
|
await this.update(tx._id, { expiredAt: now }, {
|
|
1003
1071
|
session,
|
|
1004
1072
|
...tc,
|
|
1005
1073
|
bypassAppendOnly: true
|
|
1006
1074
|
});
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1075
|
+
if (amount <= 0) return 0;
|
|
1076
|
+
const updatedMember = await this.memberRepo.updateBalance(tx.memberId, {
|
|
1077
|
+
current: -amount,
|
|
1078
|
+
expired: amount
|
|
1079
|
+
}, {
|
|
1080
|
+
session,
|
|
1081
|
+
...tc
|
|
1082
|
+
});
|
|
1083
|
+
if (updatedMember.balance.current < 0) throw new InsufficientPointsError(updatedMember.balance.current + amount, amount);
|
|
1084
|
+
await this.create({
|
|
1085
|
+
memberId: tx.memberId,
|
|
1086
|
+
programId: tx.programId,
|
|
1087
|
+
type: "expire",
|
|
1088
|
+
points: -amount,
|
|
1089
|
+
balanceAfter: updatedMember.balance.current,
|
|
1090
|
+
description: `Points expired from transaction ${tx._id}`,
|
|
1091
|
+
referenceType: "expiration",
|
|
1092
|
+
referenceId: tx._id,
|
|
1093
|
+
actorId: ctx.actorId
|
|
1010
1094
|
}, {
|
|
1011
1095
|
session,
|
|
1012
1096
|
...tc
|
|
1013
1097
|
});
|
|
1098
|
+
return amount;
|
|
1014
1099
|
});
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1100
|
+
if (expiredAmount > 0) {
|
|
1101
|
+
affectedMemberIds.add(tx.memberId);
|
|
1102
|
+
totalExpired += expiredAmount;
|
|
1103
|
+
transactionCount++;
|
|
1104
|
+
}
|
|
1018
1105
|
}
|
|
1019
|
-
if (totalExpired > 0) await this.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1106
|
+
if (totalExpired > 0) await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
1107
|
+
await this._dispatch(createEvent(LoyaltyEvents.POINTS_EXPIRED, {
|
|
1108
|
+
membersAffected: affectedMemberIds.size,
|
|
1109
|
+
pointsExpired: totalExpired,
|
|
1110
|
+
transactionCount
|
|
1111
|
+
}, txCtx), txCtx);
|
|
1112
|
+
});
|
|
1024
1113
|
return {
|
|
1025
1114
|
membersAffected: affectedMemberIds.size,
|
|
1026
1115
|
pointsExpired: totalExpired,
|
|
@@ -1091,20 +1180,36 @@ var RedemptionRepository = class extends Repository {
|
|
|
1091
1180
|
}
|
|
1092
1181
|
/**
|
|
1093
1182
|
* Atomic compare-and-set status transition.
|
|
1094
|
-
*
|
|
1183
|
+
*
|
|
1184
|
+
* `null` means ONE thing and one thing only: the guarded update matched
|
|
1185
|
+
* no document, i.e. the status was not `expectedStatus`. `throwOnNotFound:
|
|
1186
|
+
* false` already produces that `null`, so there is nothing legitimate left
|
|
1187
|
+
* for a `catch` to convert.
|
|
1188
|
+
*
|
|
1189
|
+
* There used to be a bare `} catch { return null; }` here. It laundered
|
|
1190
|
+
* every real failure into a CAS miss, and callers map a CAS miss to
|
|
1191
|
+
* `ValidationError("Redemption status must be 'reserved', got '<status>'")`
|
|
1192
|
+
* — a NON-retryable 4xx built from the status read BEFORE the write. So a
|
|
1193
|
+
* routine Mongo `WriteConflict` (a retryable transient, the normal outcome
|
|
1194
|
+
* of two concurrent confirms) surfaced as a client error whose message
|
|
1195
|
+
* could literally read "must be 'reserved', got 'reserved'": an operator
|
|
1196
|
+
* chasing a contradiction while the actual fix was to retry.
|
|
1197
|
+
*
|
|
1198
|
+
* Anything that throws in here is a real error and must propagate.
|
|
1095
1199
|
*/
|
|
1096
1200
|
async atomicStatusTransition(id, expectedStatus, data, ctx) {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
});
|
|
1104
|
-
} catch {
|
|
1105
|
-
return null;
|
|
1106
|
-
}
|
|
1201
|
+
return this.update(id, data, {
|
|
1202
|
+
lean: true,
|
|
1203
|
+
query: { status: expectedStatus },
|
|
1204
|
+
throwOnNotFound: false,
|
|
1205
|
+
...ctx
|
|
1206
|
+
});
|
|
1107
1207
|
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Validates a redemption request. `orderTotal` is an **integer in the
|
|
1210
|
+
* host's minor currency unit** (paisa for BDT); the returned
|
|
1211
|
+
* `discountAmount` is in the same minor units.
|
|
1212
|
+
*/
|
|
1108
1213
|
async validate(input, ctx) {
|
|
1109
1214
|
validatePositivePoints(input.pointsToRedeem, "pointsToRedeem");
|
|
1110
1215
|
validateOrderTotal(input.orderTotal);
|
|
@@ -1113,7 +1218,7 @@ var RedemptionRepository = class extends Repository {
|
|
|
1113
1218
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
1114
1219
|
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
1115
1220
|
const { minPoints, minOrderAmount, maxRedeemPercent } = this.config.redemption;
|
|
1116
|
-
const {
|
|
1221
|
+
const { pointValueMinor } = this.config;
|
|
1117
1222
|
if (input.pointsToRedeem < minPoints) return {
|
|
1118
1223
|
valid: false,
|
|
1119
1224
|
error: `Minimum ${minPoints} points required`,
|
|
@@ -1135,17 +1240,24 @@ var RedemptionRepository = class extends Repository {
|
|
|
1135
1240
|
discountAmount: 0,
|
|
1136
1241
|
maxAllowedPoints: member.balance.current
|
|
1137
1242
|
};
|
|
1138
|
-
const
|
|
1139
|
-
const
|
|
1140
|
-
const
|
|
1141
|
-
const maxAllowedPoints = Math.floor(
|
|
1243
|
+
const maxDiscountMinor = Math.floor(input.orderTotal * maxRedeemPercent / 100);
|
|
1244
|
+
const requestedDiscountMinor = input.pointsToRedeem * pointValueMinor;
|
|
1245
|
+
const cappedDiscountMinor = Math.min(requestedDiscountMinor, maxDiscountMinor);
|
|
1246
|
+
const maxAllowedPoints = Math.floor(maxDiscountMinor / pointValueMinor);
|
|
1247
|
+
const actualPoints = cappedDiscountMinor < requestedDiscountMinor ? Math.floor(cappedDiscountMinor / pointValueMinor) : input.pointsToRedeem;
|
|
1142
1248
|
return {
|
|
1143
1249
|
valid: true,
|
|
1144
|
-
pointsToRedeem:
|
|
1145
|
-
discountAmount,
|
|
1250
|
+
pointsToRedeem: actualPoints,
|
|
1251
|
+
discountAmount: actualPoints * pointValueMinor,
|
|
1146
1252
|
maxAllowedPoints
|
|
1147
1253
|
};
|
|
1148
1254
|
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Reserves points against an order. `orderTotal` is an **integer in the
|
|
1257
|
+
* host's minor currency unit** (paisa for BDT). The persisted
|
|
1258
|
+
* `discountAmount` is minor units and the reserve-time `pointValueMinor`
|
|
1259
|
+
* rate is snapshotted on the doc.
|
|
1260
|
+
*/
|
|
1149
1261
|
async reserve(input, ctx) {
|
|
1150
1262
|
assertTenantContext(ctx, this.config.tenant);
|
|
1151
1263
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
@@ -1172,7 +1284,7 @@ var RedemptionRepository = class extends Repository {
|
|
|
1172
1284
|
pointsReserved: validation.pointsToRedeem,
|
|
1173
1285
|
pointsConfirmed: 0,
|
|
1174
1286
|
discountAmount: validation.discountAmount,
|
|
1175
|
-
|
|
1287
|
+
pointValueMinor: this.config.pointValueMinor,
|
|
1176
1288
|
status: "reserved",
|
|
1177
1289
|
ownerType: input.ownerType,
|
|
1178
1290
|
ownerId: input.ownerId,
|
|
@@ -1482,19 +1594,24 @@ var ReferralRepository = class extends Repository {
|
|
|
1482
1594
|
}
|
|
1483
1595
|
/**
|
|
1484
1596
|
* Atomic compare-and-set status transition.
|
|
1485
|
-
*
|
|
1597
|
+
*
|
|
1598
|
+
* `null` means ONE thing: the guarded update matched no document, i.e.
|
|
1599
|
+
* the status was not `expectedStatus`. `throwOnNotFound: false` already
|
|
1600
|
+
* returns that `null`, so the bare `} catch { return null; }` that used
|
|
1601
|
+
* to sit here could only ever hide REAL errors — most damagingly a
|
|
1602
|
+
* retryable Mongo `WriteConflict`, which callers then re-threw as a
|
|
1603
|
+
* non-retryable `ValidationError` describing a status that had not
|
|
1604
|
+
* actually gone stale.
|
|
1605
|
+
*
|
|
1606
|
+
* Anything that throws in here is a real error and must propagate.
|
|
1486
1607
|
*/
|
|
1487
1608
|
async atomicStatusTransition(id, expectedStatus, data, ctx) {
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
});
|
|
1495
|
-
} catch {
|
|
1496
|
-
return null;
|
|
1497
|
-
}
|
|
1609
|
+
return this.update(id, data, {
|
|
1610
|
+
lean: true,
|
|
1611
|
+
query: { status: expectedStatus },
|
|
1612
|
+
throwOnNotFound: false,
|
|
1613
|
+
...ctx
|
|
1614
|
+
});
|
|
1498
1615
|
}
|
|
1499
1616
|
async generateCode(memberId, ctx) {
|
|
1500
1617
|
assertTenantContext(ctx, this.config.tenant);
|
|
@@ -1694,7 +1811,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1694
1811
|
if (maxDepth > 1 && referrerRewardPoints > 0) {
|
|
1695
1812
|
const awards = [];
|
|
1696
1813
|
let currentId = referrer.referredBy;
|
|
1697
|
-
const visited = new Set([referrer._id, referee._id]);
|
|
1814
|
+
const visited = /* @__PURE__ */ new Set([referrer._id, referee._id]);
|
|
1698
1815
|
for (let depth = 2; depth <= maxDepth && currentId; depth++) {
|
|
1699
1816
|
if (visited.has(currentId)) break;
|
|
1700
1817
|
visited.add(currentId);
|
|
@@ -1829,9 +1946,7 @@ var TierDefinitionRepository = class extends Repository {
|
|
|
1829
1946
|
case "downgraded":
|
|
1830
1947
|
downgraded++;
|
|
1831
1948
|
break;
|
|
1832
|
-
default:
|
|
1833
|
-
unchanged++;
|
|
1834
|
-
break;
|
|
1949
|
+
default: unchanged++;
|
|
1835
1950
|
}
|
|
1836
1951
|
}
|
|
1837
1952
|
return {
|
|
@@ -2095,4 +2210,4 @@ function createRepositories(input) {
|
|
|
2095
2210
|
};
|
|
2096
2211
|
}
|
|
2097
2212
|
//#endregion
|
|
2098
|
-
export {
|
|
2213
|
+
export { SessionNotInTransactionError as C, ValidationError as D, UnmanagedSessionError as E, SelfReferralError as S, TierNotFoundError as T, RedemptionExpiredError as _, PointTransactionRepository as a, ReferralNotFoundError as b, flushPendingLoyaltyEvents as c, DuplicateReferralError as d, InsufficientPointsError as f, MemberNotFoundError as g, MemberInactiveError as h, RedemptionRepository as i, EarningRuleRepository as l, MemberAlreadyEnrolledError as m, TierDefinitionRepository as n, MemberRepository as o, LoyaltyCapabilityError as p, ReferralRepository as r, PENDING_EVENTS as s, createRepositories as t, CircularReferralError as u, RedemptionNotFoundError as v, TenantIsolationError as w, RuleNotFoundError as x, ReferralLimitExceededError as y };
|
package/dist/types/index.d.mts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { _ as TierEvaluationBatch, a as EnrollInput, c as UpdateTierInput, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, m as PaginatedResult, n as CreateEarningRuleInput, o as LoyaltyContext, p as ListQuery, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, u as EarningBreakdown } from "../index-
|
|
2
|
-
import { a as
|
|
3
|
-
export { AdjustPointsInput, BalanceSummary, CreateEarningRuleInput, CreateTierInput, EarnPointsInput, EarningBreakdown, EarningResult, EnrollInput, ExpirationResult, IndexDefinition, ListQuery,
|
|
1
|
+
import { _ as TierEvaluationBatch, a as EnrollInput, c as UpdateTierInput, d as EarningResult, f as ExpirationResult, g as TierEvaluation, h as RedemptionValidation, i as EarnPointsInput, l as BalanceSummary, m as PaginatedResult, n as CreateEarningRuleInput, o as LoyaltyContext, p as ListQuery, r as CreateTierInput, s as UpdateEarningRuleInput, t as AdjustPointsInput, u as EarningBreakdown } from "../index-Cp_sXNpG.mjs";
|
|
2
|
+
import { a as ModelName, c as ResolvedConfig, i as LoyaltyShape, l as ResolvedTenant, o as PluginType, r as LoyaltyRuntime, s as RepositoryPlugins, t as IndexDefinition, u as TenantConfig } from "../config-CtkGj3xZ.mjs";
|
|
3
|
+
export { AdjustPointsInput, type BalanceSummary, CreateEarningRuleInput, CreateTierInput, EarnPointsInput, type EarningBreakdown, type EarningResult, EnrollInput, type ExpirationResult, type IndexDefinition, type ListQuery, LoyaltyContext, type LoyaltyRuntime, type LoyaltyShape, type ModelName, type PaginatedResult, type PluginType, type RedemptionValidation, type RepositoryPlugins, type ResolvedConfig, type ResolvedTenant, type TenantConfig, type TierEvaluation, type TierEvaluationBatch, UpdateEarningRuleInput, UpdateTierInput };
|
package/package.json
CHANGED
|
@@ -1,116 +1,119 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@classytic/loyalty",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB",
|
|
5
|
-
"author": "Classytic",
|
|
6
|
-
"homepage": "https://www.npmjs.com/package/@classytic/loyalty",
|
|
7
|
-
"bugs": {
|
|
8
|
-
"url": "https://github.com/classytic/loyalty/issues"
|
|
9
|
-
},
|
|
10
|
-
"repository": {
|
|
11
|
-
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/classytic/loyalty.git"
|
|
13
|
-
},
|
|
14
|
-
"publishConfig": {
|
|
15
|
-
"access": "public"
|
|
16
|
-
},
|
|
17
|
-
"type": "module",
|
|
18
|
-
"sideEffects": false,
|
|
19
|
-
"engines": {
|
|
20
|
-
"node": ">=22"
|
|
21
|
-
},
|
|
22
|
-
"exports": {
|
|
23
|
-
".": {
|
|
24
|
-
"types": "./dist/index.d.mts",
|
|
25
|
-
"default": "./dist/index.mjs"
|
|
26
|
-
},
|
|
27
|
-
"./domain/enums": {
|
|
28
|
-
"types": "./dist/domain/enums/index.d.mts",
|
|
29
|
-
"default": "./dist/domain/enums/index.mjs"
|
|
30
|
-
},
|
|
31
|
-
"./models": {
|
|
32
|
-
"types": "./dist/models/index.d.mts",
|
|
33
|
-
"default": "./dist/models/index.mjs"
|
|
34
|
-
},
|
|
35
|
-
"./repositories": {
|
|
36
|
-
"types": "./dist/repositories/index.d.mts",
|
|
37
|
-
"default": "./dist/repositories/index.mjs"
|
|
38
|
-
},
|
|
39
|
-
"./events": {
|
|
40
|
-
"types": "./dist/events/index.d.mts",
|
|
41
|
-
"default": "./dist/events/index.mjs"
|
|
42
|
-
},
|
|
43
|
-
"./types": {
|
|
44
|
-
"types": "./dist/types/index.d.mts",
|
|
45
|
-
"default": "./dist/types/index.mjs"
|
|
46
|
-
},
|
|
47
|
-
"./notifications": {
|
|
48
|
-
"types": "./dist/notifications/index.d.mts",
|
|
49
|
-
"default": "./dist/notifications/index.mjs"
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
"main": "./dist/index.mjs",
|
|
53
|
-
"types": "./dist/index.d.mts",
|
|
54
|
-
"files": [
|
|
55
|
-
"dist",
|
|
56
|
-
"README.md",
|
|
57
|
-
"CHANGELOG.md",
|
|
58
|
-
"LICENSE"
|
|
59
|
-
],
|
|
60
|
-
"scripts": {
|
|
61
|
-
"build": "tsdown",
|
|
62
|
-
"dev": "tsdown --watch",
|
|
63
|
-
"test": "vitest run --project unit --project integration",
|
|
64
|
-
"test:unit": "vitest run --project unit",
|
|
65
|
-
"test:integration": "vitest run --project integration",
|
|
66
|
-
"test:scenarios": "vitest run --project scenarios",
|
|
67
|
-
"test:all": "vitest run",
|
|
68
|
-
"test:watch": "vitest --project unit --project integration",
|
|
69
|
-
"test:coverage": "vitest run --coverage",
|
|
70
|
-
"typecheck": "tsc --noEmit",
|
|
71
|
-
"lint": "biome check src/ tests/",
|
|
72
|
-
"lint:fix": "biome check --write src/ tests/",
|
|
73
|
-
"format": "biome format --write src/ tests/",
|
|
74
|
-
"check": "biome check --write --unsafe src/ tests/",
|
|
75
|
-
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
|
|
76
|
-
"push": "classytic-push",
|
|
77
|
-
"release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
|
|
78
|
-
"release": "npm run push -- main && npm run release:tag && npm publish"
|
|
79
|
-
},
|
|
80
|
-
"peerDependencies": {
|
|
81
|
-
"@classytic/mongokit": ">=3.
|
|
82
|
-
"@classytic/primitives": ">=0.
|
|
83
|
-
"@classytic/repo-core": ">=0.
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"@
|
|
90
|
-
"@classytic/
|
|
91
|
-
"@classytic/
|
|
92
|
-
"@classytic/
|
|
93
|
-
"@
|
|
94
|
-
"@
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@classytic/loyalty",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB",
|
|
5
|
+
"author": "Classytic",
|
|
6
|
+
"homepage": "https://www.npmjs.com/package/@classytic/loyalty",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/classytic/loyalty/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/classytic/loyalty.git"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.mts",
|
|
25
|
+
"default": "./dist/index.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./domain/enums": {
|
|
28
|
+
"types": "./dist/domain/enums/index.d.mts",
|
|
29
|
+
"default": "./dist/domain/enums/index.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./models": {
|
|
32
|
+
"types": "./dist/models/index.d.mts",
|
|
33
|
+
"default": "./dist/models/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./repositories": {
|
|
36
|
+
"types": "./dist/repositories/index.d.mts",
|
|
37
|
+
"default": "./dist/repositories/index.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./events": {
|
|
40
|
+
"types": "./dist/events/index.d.mts",
|
|
41
|
+
"default": "./dist/events/index.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./types": {
|
|
44
|
+
"types": "./dist/types/index.d.mts",
|
|
45
|
+
"default": "./dist/types/index.mjs"
|
|
46
|
+
},
|
|
47
|
+
"./notifications": {
|
|
48
|
+
"types": "./dist/notifications/index.d.mts",
|
|
49
|
+
"default": "./dist/notifications/index.mjs"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"main": "./dist/index.mjs",
|
|
53
|
+
"types": "./dist/index.d.mts",
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"README.md",
|
|
57
|
+
"CHANGELOG.md",
|
|
58
|
+
"LICENSE"
|
|
59
|
+
],
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsdown",
|
|
62
|
+
"dev": "tsdown --watch",
|
|
63
|
+
"test": "vitest run --project unit --project integration",
|
|
64
|
+
"test:unit": "vitest run --project unit",
|
|
65
|
+
"test:integration": "vitest run --project integration",
|
|
66
|
+
"test:scenarios": "vitest run --project scenarios",
|
|
67
|
+
"test:all": "vitest run",
|
|
68
|
+
"test:watch": "vitest --project unit --project integration",
|
|
69
|
+
"test:coverage": "vitest run --coverage",
|
|
70
|
+
"typecheck": "tsc --noEmit",
|
|
71
|
+
"lint": "biome check src/ tests/",
|
|
72
|
+
"lint:fix": "biome check --write src/ tests/",
|
|
73
|
+
"format": "biome format --write src/ tests/",
|
|
74
|
+
"check": "biome check --write --unsafe src/ tests/",
|
|
75
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
|
|
76
|
+
"push": "classytic-push",
|
|
77
|
+
"release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
|
|
78
|
+
"release": "npm run push -- main && npm run release:tag && npm publish"
|
|
79
|
+
},
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"@classytic/mongokit": ">=3.33.0",
|
|
82
|
+
"@classytic/primitives": ">=0.23.0",
|
|
83
|
+
"@classytic/repo-core": ">=0.24.0",
|
|
84
|
+
"@classytic/validation": ">=0.2.0",
|
|
85
|
+
"mongoose": ">=9.4.1",
|
|
86
|
+
"zod": ">=4.0.0"
|
|
87
|
+
},
|
|
88
|
+
"devDependencies": {
|
|
89
|
+
"@biomejs/biome": "^2.4.9",
|
|
90
|
+
"@classytic/dev-tools": "^0.2.0",
|
|
91
|
+
"@classytic/mongokit": "^3.33.0",
|
|
92
|
+
"@classytic/primitives": ">=0.23.0",
|
|
93
|
+
"@classytic/repo-core": ">=0.24.0",
|
|
94
|
+
"@classytic/validation": ">=0.4.0",
|
|
95
|
+
"@types/node": "^25.5.0",
|
|
96
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
97
|
+
"knip": "^6.3.0",
|
|
98
|
+
"mongodb-memory-server": "^10.2.3",
|
|
99
|
+
"mongoose": "^9.4.1",
|
|
100
|
+
"publint": "^0.3.23",
|
|
101
|
+
"tsdown": "^0.22.14",
|
|
102
|
+
"typescript": "^5.7.0",
|
|
103
|
+
"vitest": "^3.0.0",
|
|
104
|
+
"zod": "^4.3.6"
|
|
105
|
+
},
|
|
106
|
+
"keywords": [
|
|
107
|
+
"loyalty",
|
|
108
|
+
"points",
|
|
109
|
+
"rewards",
|
|
110
|
+
"tiers",
|
|
111
|
+
"referral",
|
|
112
|
+
"membership",
|
|
113
|
+
"earning-rules",
|
|
114
|
+
"redemption",
|
|
115
|
+
"mongodb",
|
|
116
|
+
"mongoose"
|
|
117
|
+
],
|
|
118
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
119
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|