@classytic/loyalty 0.7.0 → 0.10.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 +134 -0
- package/dist/constants-DfrENxFG.mjs +75 -0
- package/dist/domain/earn-base.d.mts +35 -0
- package/dist/domain/earn-base.mjs +37 -0
- package/dist/domain/enums/index.d.mts +2 -2
- package/dist/domain/enums/index.mjs +1 -2
- package/dist/domain/rule-schedule.d.mts +24 -0
- package/dist/domain/rule-schedule.mjs +12 -0
- package/dist/events/index.d.mts +2 -2
- package/dist/events/index.mjs +3 -3
- package/dist/{events-BLjg3uLA.mjs → events-DhZolW61.mjs} +104 -4
- package/dist/{index-BIPHUsfR.d.mts → index-BvyzKU7R.d.mts} +283 -34
- package/dist/{index-BIhVuwIZ.d.mts → index-CorB0JL6.d.mts} +255 -109
- package/dist/index-DRrEhE0r.d.mts +604 -0
- package/dist/{index-YUFamhDk.d.mts → index-Db5Gb7x_.d.mts} +187 -39
- package/dist/index-tjMMmPzU.d.mts +36 -0
- package/dist/index.d.mts +121 -30
- package/dist/index.mjs +59 -10
- package/dist/{loyalty-events-DXirolM_.mjs → loyalty-events-j29B66YW.mjs} +20 -13
- package/dist/models/index.d.mts +2 -2
- package/dist/models/index.mjs +1 -2
- package/dist/{models-weWqS5Bc.mjs → models-BEGLduz1.mjs} +276 -7
- package/dist/notifications/index.d.mts +3 -4
- package/dist/repositories/index.d.mts +2 -2
- package/dist/repositories/index.mjs +1 -2
- package/dist/{repositories-DErtHQou.mjs → repositories-ER68JmUo.mjs} +1161 -99
- package/dist/types/index.d.mts +2 -3
- package/package.json +39 -22
- package/dist/config-CtkGj3xZ.d.mts +0 -178
- package/dist/constants-DOPBh6kt.mjs +0 -48
- package/dist/index-Cp_sXNpG.d.mts +0 -349
- package/dist/index-D_Oe3aDk.d.mts +0 -19
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { d as STAMP_PROGRAM_STATUSES, r as EARN_SOURCES, u as STAMP_EARN_UNITS } from "./constants-DfrENxFG.mjs";
|
|
2
|
+
import { isRuleInSchedule } from "./domain/rule-schedule.mjs";
|
|
3
|
+
import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-j29B66YW.mjs";
|
|
2
4
|
import { Repository, appendOnlyPlugin, multiTenantPlugin } from "@classytic/mongokit";
|
|
3
|
-
import { localTimeParts } from "@classytic/primitives/timezone";
|
|
5
|
+
import { civilDateOf, localTimeParts } from "@classytic/primitives/timezone";
|
|
4
6
|
import { addDays, isoWeekday } from "@classytic/primitives/calendar";
|
|
5
7
|
import { randomBytes } from "node:crypto";
|
|
6
8
|
import { throwIfAborted } from "@classytic/repo-core/repository";
|
|
9
|
+
import { z } from "zod";
|
|
7
10
|
//#region src/domain/errors/base.ts
|
|
8
11
|
/**
|
|
9
12
|
* Base for every loyalty throwable. Implements `HttpError` from
|
|
@@ -274,6 +277,7 @@ function validateOrderTotal(total) {
|
|
|
274
277
|
var EarningRuleRepository = class extends Repository {
|
|
275
278
|
memberRepo;
|
|
276
279
|
txRepo;
|
|
280
|
+
tierRepo;
|
|
277
281
|
config;
|
|
278
282
|
constructor(model, plugins = []) {
|
|
279
283
|
super(model, plugins);
|
|
@@ -340,6 +344,7 @@ var EarningRuleRepository = class extends Repository {
|
|
|
340
344
|
memberId: input.memberId,
|
|
341
345
|
points,
|
|
342
346
|
description: `Earning rule: ${rule.name}`,
|
|
347
|
+
source: "order",
|
|
343
348
|
referenceType: "order",
|
|
344
349
|
referenceId: input.orderId,
|
|
345
350
|
earningRuleId: rule._id,
|
|
@@ -353,12 +358,58 @@ var EarningRuleRepository = class extends Repository {
|
|
|
353
358
|
});
|
|
354
359
|
transactions.push(tx);
|
|
355
360
|
}
|
|
361
|
+
const tierBonus = await this.tierMultiplierBonus(member, input.orderId, accumulatedPoints, ctx, tc);
|
|
362
|
+
if (tierBonus) {
|
|
363
|
+
accumulatedPoints += tierBonus.points;
|
|
364
|
+
breakdown.push(tierBonus.breakdown);
|
|
365
|
+
transactions.push(tierBonus.tx);
|
|
366
|
+
}
|
|
356
367
|
return {
|
|
357
368
|
totalPoints: accumulatedPoints,
|
|
358
369
|
breakdown,
|
|
359
370
|
transactions
|
|
360
371
|
};
|
|
361
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* The member's tier benefit `benefits.pointsMultiplier`: above 1, the order earns
|
|
375
|
+
* (m − 1) × what the rules awarded, as its own ledger row. Keyed on the order
|
|
376
|
+
* alone, so a retry after a tier change cannot award a second bonus.
|
|
377
|
+
*/
|
|
378
|
+
async tierMultiplierBonus(member, orderId, rulePoints, ctx, tc) {
|
|
379
|
+
const tierName = member.tierOverride ?? member.tier;
|
|
380
|
+
if (!tierName || rulePoints <= 0) return null;
|
|
381
|
+
const multiplier = (await this.tierRepo.getByQuery({
|
|
382
|
+
programId: member.programId,
|
|
383
|
+
name: tierName
|
|
384
|
+
}, {
|
|
385
|
+
throwOnNotFound: false,
|
|
386
|
+
...tc
|
|
387
|
+
}))?.benefits?.pointsMultiplier ?? 0;
|
|
388
|
+
const points = multiplier > 1 ? Math.floor(rulePoints * (multiplier - 1)) : 0;
|
|
389
|
+
if (points <= 0) return null;
|
|
390
|
+
const tx = await this.txRepo.earnPoints({
|
|
391
|
+
memberId: String(member._id),
|
|
392
|
+
points,
|
|
393
|
+
description: `Tier bonus: ${tierName} ×${multiplier}`,
|
|
394
|
+
source: "order",
|
|
395
|
+
referenceType: "order",
|
|
396
|
+
referenceId: orderId,
|
|
397
|
+
idempotencyKey: `${orderId}:tier-multiplier`,
|
|
398
|
+
metadata: {
|
|
399
|
+
tier: tierName,
|
|
400
|
+
multiplier
|
|
401
|
+
}
|
|
402
|
+
}, ctx);
|
|
403
|
+
return {
|
|
404
|
+
points,
|
|
405
|
+
breakdown: {
|
|
406
|
+
ruleId: `tier:${tierName}`,
|
|
407
|
+
ruleName: `Tier ${tierName} ×${multiplier}`,
|
|
408
|
+
points
|
|
409
|
+
},
|
|
410
|
+
tx
|
|
411
|
+
};
|
|
412
|
+
}
|
|
362
413
|
async evaluateAction(input, ctx) {
|
|
363
414
|
assertTenantContext(ctx, this.config.tenant);
|
|
364
415
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
@@ -382,6 +433,7 @@ var EarningRuleRepository = class extends Repository {
|
|
|
382
433
|
memberId: input.memberId,
|
|
383
434
|
points,
|
|
384
435
|
description: `Action: ${input.action} (${rule.name})`,
|
|
436
|
+
source: "action",
|
|
385
437
|
referenceType: "action",
|
|
386
438
|
earningRuleId: rule._id,
|
|
387
439
|
...input.referenceId !== void 0 ? {
|
|
@@ -415,10 +467,7 @@ var EarningRuleRepository = class extends Repository {
|
|
|
415
467
|
const memberTier = member.tierOverride ?? member.tier;
|
|
416
468
|
if (!memberTier || !conditions.tiers.includes(memberTier)) return false;
|
|
417
469
|
}
|
|
418
|
-
if (
|
|
419
|
-
if (conditions.dateRange.start && now < conditions.dateRange.start) return false;
|
|
420
|
-
if (conditions.dateRange.end && now > conditions.dateRange.end) return false;
|
|
421
|
-
}
|
|
470
|
+
if (!isRuleInSchedule(rule, now)) return false;
|
|
422
471
|
if (conditions.dayOfWeek && conditions.dayOfWeek.length > 0) {
|
|
423
472
|
if (!conditions.dayOfWeek.includes(this.weekday(now))) return false;
|
|
424
473
|
}
|
|
@@ -603,6 +652,7 @@ var MemberRepository = class extends Repository {
|
|
|
603
652
|
outbox;
|
|
604
653
|
notifications = null;
|
|
605
654
|
config;
|
|
655
|
+
tierRepo;
|
|
606
656
|
/** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
|
|
607
657
|
async _dispatch(event, ctx) {
|
|
608
658
|
await dispatchLoyaltyEvent({
|
|
@@ -672,14 +722,16 @@ var MemberRepository = class extends Repository {
|
|
|
672
722
|
})) throw new MemberAlreadyEnrolledError(input.externalId);
|
|
673
723
|
const referralCode = generateReferralCode(this.config.referral.codeLength);
|
|
674
724
|
const now = /* @__PURE__ */ new Date();
|
|
725
|
+
const programId = input.programId ?? this.config.programId;
|
|
726
|
+
const tier = input.tier ?? await this.tierRepo.qualifiedTierName(programId, 0, tc);
|
|
675
727
|
const data = {
|
|
676
|
-
programId
|
|
728
|
+
programId,
|
|
677
729
|
externalId: input.externalId,
|
|
678
730
|
externalType: input.externalType,
|
|
679
731
|
status: "active",
|
|
680
732
|
cardId: input.cardId,
|
|
681
733
|
referralCode,
|
|
682
|
-
tier
|
|
734
|
+
tier,
|
|
683
735
|
tierOverride: null,
|
|
684
736
|
balance: {
|
|
685
737
|
current: 0,
|
|
@@ -790,7 +842,35 @@ var MemberRepository = class extends Repository {
|
|
|
790
842
|
}
|
|
791
843
|
};
|
|
792
844
|
//#endregion
|
|
845
|
+
//#region src/utils/mongo-errors.ts
|
|
846
|
+
/** E11000 across the raw driver + mongokit wrapper shapes. */
|
|
847
|
+
function isDuplicateKey(err) {
|
|
848
|
+
if (typeof err !== "object" || err === null) return false;
|
|
849
|
+
const e = err;
|
|
850
|
+
if (e.code === 11e3) return true;
|
|
851
|
+
if (e.status === 409) return true;
|
|
852
|
+
if (e.cause !== void 0) return isDuplicateKey(e.cause);
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Transient MongoDB WriteConflict — surfaces under concurrent webhook
|
|
857
|
+
* replay when two transactions try to update the same member balance.
|
|
858
|
+
* With an idempotency key present, the winning insert is the source of
|
|
859
|
+
* truth; losers are short-circuited to its doc rather than leaking the
|
|
860
|
+
* raw driver error.
|
|
861
|
+
*/
|
|
862
|
+
function isWriteConflict(err) {
|
|
863
|
+
if (typeof err !== "object" || err === null) return false;
|
|
864
|
+
const e = err;
|
|
865
|
+
if (e.code === 112 || e.codeName === "WriteConflict") return true;
|
|
866
|
+
if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
|
|
867
|
+
if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
|
|
868
|
+
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
793
872
|
//#region src/repositories/point-transaction.repository.ts
|
|
873
|
+
const EXPIRY_PAGE = 100;
|
|
794
874
|
var PointTransactionRepository = class extends Repository {
|
|
795
875
|
memberRepo;
|
|
796
876
|
events;
|
|
@@ -798,9 +878,15 @@ var PointTransactionRepository = class extends Repository {
|
|
|
798
878
|
notifications = null;
|
|
799
879
|
unitOfWork;
|
|
800
880
|
config;
|
|
881
|
+
program;
|
|
801
882
|
constructor(model, plugins = []) {
|
|
802
883
|
super(model, [...plugins, appendOnlyPlugin()]);
|
|
803
884
|
}
|
|
885
|
+
/** `now + expiry.defaultDays` when the program expires points, else none. */
|
|
886
|
+
defaultExpiry(program) {
|
|
887
|
+
const { enabled, defaultDays } = program.expiry;
|
|
888
|
+
return enabled && defaultDays ? new Date(Date.now() + defaultDays * 864e5) : void 0;
|
|
889
|
+
}
|
|
804
890
|
/** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
|
|
805
891
|
async _dispatch(event, ctx) {
|
|
806
892
|
await dispatchLoyaltyEvent({
|
|
@@ -829,6 +915,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
829
915
|
}
|
|
830
916
|
async earnPoints(input, ctx) {
|
|
831
917
|
validatePositivePoints(input.points);
|
|
918
|
+
if (!EARN_SOURCES.includes(input.source)) throw new ValidationError(`Unknown earn source '${String(input.source)}' — expected one of ${EARN_SOURCES.join(", ")}`);
|
|
832
919
|
assertTenantContext(ctx, this.config.tenant);
|
|
833
920
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
834
921
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
@@ -840,6 +927,8 @@ var PointTransactionRepository = class extends Repository {
|
|
|
840
927
|
if (existing) return existing;
|
|
841
928
|
}
|
|
842
929
|
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
930
|
+
const program = await this.program();
|
|
931
|
+
const expiresAt = input.expiresAt ?? this.defaultExpiry(program);
|
|
843
932
|
try {
|
|
844
933
|
let payload;
|
|
845
934
|
const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
@@ -863,12 +952,14 @@ var PointTransactionRepository = class extends Repository {
|
|
|
863
952
|
programId: member.programId,
|
|
864
953
|
type: "earn",
|
|
865
954
|
points: input.points,
|
|
955
|
+
source: input.source,
|
|
956
|
+
pointValueMinor: program.pointValueMinor,
|
|
866
957
|
balanceAfter: updatedMember.balance.current,
|
|
867
958
|
description: input.description,
|
|
868
959
|
referenceType: input.referenceType,
|
|
869
960
|
referenceId: input.referenceId,
|
|
870
961
|
earningRuleId: input.earningRuleId,
|
|
871
|
-
expiresAt
|
|
962
|
+
expiresAt,
|
|
872
963
|
actorId: ctx.actorId,
|
|
873
964
|
...input.metadata ? { metadata: input.metadata } : {}
|
|
874
965
|
};
|
|
@@ -882,6 +973,8 @@ var PointTransactionRepository = class extends Repository {
|
|
|
882
973
|
externalId: member.externalId,
|
|
883
974
|
externalType: member.externalType,
|
|
884
975
|
points: input.points,
|
|
976
|
+
source: input.source,
|
|
977
|
+
pointValueMinor: program.pointValueMinor,
|
|
885
978
|
balanceAfter: created.balanceAfter,
|
|
886
979
|
transactionId: created._id,
|
|
887
980
|
referenceType: input.referenceType,
|
|
@@ -942,6 +1035,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
942
1035
|
const input = {
|
|
943
1036
|
memberId: row.memberId,
|
|
944
1037
|
points: row.points,
|
|
1038
|
+
source: "campaign",
|
|
945
1039
|
description: source.description,
|
|
946
1040
|
idempotencyKey: `${source.eventId}:${row.memberId}`,
|
|
947
1041
|
metadata: {
|
|
@@ -998,9 +1092,10 @@ var PointTransactionRepository = class extends Repository {
|
|
|
998
1092
|
throwOnNotFound: false,
|
|
999
1093
|
...tc
|
|
1000
1094
|
});
|
|
1001
|
-
if (existing) return existing;
|
|
1095
|
+
if (existing) return sameAdjustment(existing, input);
|
|
1002
1096
|
}
|
|
1003
1097
|
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
1098
|
+
const { pointValueMinor } = await this.program();
|
|
1004
1099
|
if (input.points < 0 && member.balance.current < Math.abs(input.points)) throw new InsufficientPointsError(member.balance.current, Math.abs(input.points));
|
|
1005
1100
|
let payload;
|
|
1006
1101
|
const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
@@ -1010,7 +1105,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1010
1105
|
throwOnNotFound: false,
|
|
1011
1106
|
...tc
|
|
1012
1107
|
});
|
|
1013
|
-
if (dup) return dup;
|
|
1108
|
+
if (dup) return sameAdjustment(dup, input);
|
|
1014
1109
|
}
|
|
1015
1110
|
const balanceUpdate = { current: input.points };
|
|
1016
1111
|
if (input.points > 0) balanceUpdate.lifetime = input.points;
|
|
@@ -1024,6 +1119,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1024
1119
|
programId: member.programId,
|
|
1025
1120
|
type: "adjust",
|
|
1026
1121
|
points: input.points,
|
|
1122
|
+
pointValueMinor,
|
|
1027
1123
|
balanceAfter: updatedMember.balance.current,
|
|
1028
1124
|
description: input.description,
|
|
1029
1125
|
referenceType: input.referenceType,
|
|
@@ -1043,6 +1139,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1043
1139
|
memberId: member._id,
|
|
1044
1140
|
externalId: member.externalId,
|
|
1045
1141
|
points: input.points,
|
|
1142
|
+
pointValueMinor,
|
|
1046
1143
|
balanceAfter: created.balanceAfter,
|
|
1047
1144
|
transactionId: created._id,
|
|
1048
1145
|
description: input.description
|
|
@@ -1056,65 +1153,244 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1056
1153
|
if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
|
|
1057
1154
|
return tx;
|
|
1058
1155
|
}
|
|
1059
|
-
|
|
1156
|
+
/**
|
|
1157
|
+
* Take back points earned on a reference (an order) once it is refunded or canceled.
|
|
1158
|
+
*
|
|
1159
|
+
* CONVERGES BY STATE. The caller says how much of the purchase is undone
|
|
1160
|
+
* (`fraction`, 1 = all); this reverses `floor(earned × fraction)` minus what earlier
|
|
1161
|
+
* reversals already claimed. A replayed event reverses nothing extra, and the
|
|
1162
|
+
* idempotency key is derived from the target so concurrent callers collide on the
|
|
1163
|
+
* unique index instead of both debiting.
|
|
1164
|
+
*
|
|
1165
|
+
* A member cannot go below zero: points already spent are reported as `shortfall`
|
|
1166
|
+
* on the row and the event. `lifetime` drops by the full claim — tier status must
|
|
1167
|
+
* not keep credit for a sale that was undone.
|
|
1168
|
+
*/
|
|
1169
|
+
async reverseEarned(input, ctx) {
|
|
1170
|
+
if (!(input.fraction >= 0 && input.fraction <= 1)) throw new ValidationError(`fraction must be between 0 and 1, got ${input.fraction}`);
|
|
1171
|
+
if (!input.referenceId) throw new ValidationError("referenceId is required");
|
|
1060
1172
|
assertTenantContext(ctx, this.config.tenant);
|
|
1061
|
-
const
|
|
1062
|
-
const
|
|
1063
|
-
const
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1173
|
+
const tc = tenantCtx(this.config.tenant, getTenantValue(ctx, this.config.tenant));
|
|
1174
|
+
const { pointValueMinor } = await this.program();
|
|
1175
|
+
const results = [];
|
|
1176
|
+
for (const [memberId, share] of await this.tallyReference(input, tc)) {
|
|
1177
|
+
const target = Math.floor(share.earned * input.fraction);
|
|
1178
|
+
const base = {
|
|
1179
|
+
memberId,
|
|
1180
|
+
earned: share.earned,
|
|
1181
|
+
target,
|
|
1182
|
+
alreadyClaimed: share.claimed
|
|
1183
|
+
};
|
|
1184
|
+
const delta = target - share.claimed;
|
|
1185
|
+
results.push(delta > 0 ? {
|
|
1186
|
+
...base,
|
|
1187
|
+
...await this.claimBack({
|
|
1188
|
+
...input,
|
|
1189
|
+
memberId,
|
|
1190
|
+
target,
|
|
1191
|
+
delta,
|
|
1192
|
+
pointValueMinor
|
|
1193
|
+
}, ctx, tc)
|
|
1194
|
+
} : {
|
|
1195
|
+
...base,
|
|
1196
|
+
claimed: 0,
|
|
1197
|
+
collected: 0,
|
|
1198
|
+
shortfall: 0,
|
|
1199
|
+
transactionId: null
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
return results;
|
|
1203
|
+
}
|
|
1204
|
+
/** Per member: points earned on the reference, and points earlier reversals already claimed. */
|
|
1205
|
+
async tallyReference(ref, tc) {
|
|
1206
|
+
const rows = await this.findAll({
|
|
1207
|
+
referenceType: ref.referenceType,
|
|
1208
|
+
referenceId: ref.referenceId,
|
|
1209
|
+
type: { $in: ["earn", "reverse"] }
|
|
1210
|
+
}, tc);
|
|
1211
|
+
const byMember = /* @__PURE__ */ new Map();
|
|
1212
|
+
for (const row of rows) {
|
|
1213
|
+
const acc = byMember.get(String(row.memberId)) ?? {
|
|
1214
|
+
earned: 0,
|
|
1215
|
+
claimed: 0
|
|
1216
|
+
};
|
|
1217
|
+
if (row.type === "earn") acc.earned += row.points;
|
|
1218
|
+
else acc.claimed += Number(row.metadata?.claimed ?? 0);
|
|
1219
|
+
byMember.set(String(row.memberId), acc);
|
|
1220
|
+
}
|
|
1221
|
+
return byMember;
|
|
1222
|
+
}
|
|
1223
|
+
/** Debit one member's `delta` in its own unit of work; a lost race on the target key has converged. */
|
|
1224
|
+
async claimBack(input, ctx, tc) {
|
|
1225
|
+
const { memberId, target, delta } = input;
|
|
1226
|
+
const idempotencyKey = `reverse:${input.referenceType}:${input.referenceId}:${memberId}:${target}`;
|
|
1227
|
+
try {
|
|
1228
|
+
let payload;
|
|
1229
|
+
await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
1230
|
+
const session = txCtx.session;
|
|
1231
|
+
const member = await this.memberRepo.requireMember(memberId, {
|
|
1079
1232
|
session,
|
|
1080
1233
|
...tc
|
|
1081
1234
|
});
|
|
1082
|
-
const
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
...tc,
|
|
1087
|
-
bypassAppendOnly: true
|
|
1088
|
-
});
|
|
1089
|
-
if (amount <= 0) return 0;
|
|
1090
|
-
const updatedMember = await this.memberRepo.updateBalance(tx.memberId, {
|
|
1091
|
-
current: -amount,
|
|
1092
|
-
expired: amount
|
|
1235
|
+
const collected = Math.min(delta, Math.max(0, member.balance.current));
|
|
1236
|
+
const updated = await this.memberRepo.updateBalance(memberId, {
|
|
1237
|
+
current: -collected,
|
|
1238
|
+
lifetime: -delta
|
|
1093
1239
|
}, {
|
|
1094
1240
|
session,
|
|
1095
1241
|
...tc
|
|
1096
1242
|
});
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1243
|
+
if (updated.balance.current < 0) throw new InsufficientPointsError(updated.balance.current + collected, collected);
|
|
1244
|
+
const shortfall = delta - collected;
|
|
1245
|
+
const created = await this.create({
|
|
1246
|
+
memberId,
|
|
1247
|
+
programId: member.programId,
|
|
1248
|
+
type: "reverse",
|
|
1249
|
+
points: -collected,
|
|
1250
|
+
pointValueMinor: input.pointValueMinor,
|
|
1251
|
+
balanceAfter: updated.balance.current,
|
|
1252
|
+
description: `Reversed: ${input.reason}`,
|
|
1253
|
+
referenceType: input.referenceType,
|
|
1254
|
+
referenceId: input.referenceId,
|
|
1255
|
+
idempotencyKey,
|
|
1256
|
+
actorId: ctx.actorId,
|
|
1257
|
+
metadata: {
|
|
1258
|
+
claimed: delta,
|
|
1259
|
+
collected,
|
|
1260
|
+
shortfall,
|
|
1261
|
+
target,
|
|
1262
|
+
fraction: input.fraction,
|
|
1263
|
+
reason: input.reason
|
|
1264
|
+
}
|
|
1108
1265
|
}, {
|
|
1109
1266
|
session,
|
|
1110
1267
|
...tc
|
|
1111
1268
|
});
|
|
1112
|
-
|
|
1269
|
+
payload = {
|
|
1270
|
+
memberId,
|
|
1271
|
+
externalId: member.externalId,
|
|
1272
|
+
referenceType: input.referenceType,
|
|
1273
|
+
referenceId: input.referenceId,
|
|
1274
|
+
claimed: delta,
|
|
1275
|
+
collected,
|
|
1276
|
+
shortfall,
|
|
1277
|
+
pointValueMinor: input.pointValueMinor,
|
|
1278
|
+
balanceAfter: created.balanceAfter,
|
|
1279
|
+
transactionId: String(created._id),
|
|
1280
|
+
reason: input.reason
|
|
1281
|
+
};
|
|
1282
|
+
await this._dispatch(createEvent(LoyaltyEvents.POINTS_REVERSED, payload, txCtx, {
|
|
1283
|
+
resource: "pointTransaction",
|
|
1284
|
+
resourceId: created._id
|
|
1285
|
+
}), txCtx);
|
|
1113
1286
|
});
|
|
1114
|
-
if (
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1287
|
+
if (!payload) throw new ValidationError("reversal unit of work produced no transaction");
|
|
1288
|
+
this.notifications?.notify(LoyaltyEvents.POINTS_REVERSED, { ...payload });
|
|
1289
|
+
const { collected, shortfall, transactionId } = payload;
|
|
1290
|
+
return {
|
|
1291
|
+
claimed: delta,
|
|
1292
|
+
collected,
|
|
1293
|
+
shortfall,
|
|
1294
|
+
transactionId
|
|
1295
|
+
};
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
if (!(isDuplicateKey(err) || isWriteConflict(err))) throw err;
|
|
1298
|
+
const winner = await this.getByQuery({ idempotencyKey }, {
|
|
1299
|
+
throwOnNotFound: false,
|
|
1300
|
+
...tc
|
|
1301
|
+
});
|
|
1302
|
+
if (!winner) throw err;
|
|
1303
|
+
return {
|
|
1304
|
+
claimed: 0,
|
|
1305
|
+
collected: 0,
|
|
1306
|
+
shortfall: 0,
|
|
1307
|
+
transactionId: String(winner._id)
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
async processExpirations(ctx) {
|
|
1312
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
1313
|
+
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1314
|
+
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
1315
|
+
const now = /* @__PURE__ */ new Date();
|
|
1316
|
+
const { pointValueMinor } = await this.program();
|
|
1317
|
+
let totalExpired = 0;
|
|
1318
|
+
let transactionCount = 0;
|
|
1319
|
+
const affectedMemberIds = /* @__PURE__ */ new Set();
|
|
1320
|
+
for (;;) {
|
|
1321
|
+
const page = await this.findAll({
|
|
1322
|
+
expiresAt: { $lte: now },
|
|
1323
|
+
expiredAt: { $exists: false }
|
|
1324
|
+
}, {
|
|
1325
|
+
sort: {
|
|
1326
|
+
expiresAt: 1,
|
|
1327
|
+
_id: 1
|
|
1328
|
+
},
|
|
1329
|
+
limit: EXPIRY_PAGE,
|
|
1330
|
+
...tc
|
|
1331
|
+
});
|
|
1332
|
+
if (page.length === 0) break;
|
|
1333
|
+
for (const tx of page) {
|
|
1334
|
+
throwIfAborted(ctx.signal);
|
|
1335
|
+
let lotPayload;
|
|
1336
|
+
const expiredAmount = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
1337
|
+
const session = txCtx.session;
|
|
1338
|
+
const member = await this.memberRepo.requireMember(tx.memberId, {
|
|
1339
|
+
session,
|
|
1340
|
+
...tc
|
|
1341
|
+
});
|
|
1342
|
+
const available = Math.max(0, member.balance.current);
|
|
1343
|
+
const amount = Math.min(tx.points, available);
|
|
1344
|
+
await this.update(tx._id, { expiredAt: now }, {
|
|
1345
|
+
session,
|
|
1346
|
+
...tc,
|
|
1347
|
+
bypassAppendOnly: true
|
|
1348
|
+
});
|
|
1349
|
+
if (amount <= 0) return 0;
|
|
1350
|
+
const updatedMember = await this.memberRepo.updateBalance(tx.memberId, {
|
|
1351
|
+
current: -amount,
|
|
1352
|
+
expired: amount
|
|
1353
|
+
}, {
|
|
1354
|
+
session,
|
|
1355
|
+
...tc
|
|
1356
|
+
});
|
|
1357
|
+
if (updatedMember.balance.current < 0) throw new InsufficientPointsError(updatedMember.balance.current + amount, amount);
|
|
1358
|
+
const expireRow = await this.create({
|
|
1359
|
+
memberId: tx.memberId,
|
|
1360
|
+
programId: tx.programId,
|
|
1361
|
+
type: "expire",
|
|
1362
|
+
points: -amount,
|
|
1363
|
+
pointValueMinor,
|
|
1364
|
+
balanceAfter: updatedMember.balance.current,
|
|
1365
|
+
description: `Points expired from transaction ${tx._id}`,
|
|
1366
|
+
referenceType: "expiration",
|
|
1367
|
+
referenceId: tx._id,
|
|
1368
|
+
actorId: ctx.actorId
|
|
1369
|
+
}, {
|
|
1370
|
+
session,
|
|
1371
|
+
...tc
|
|
1372
|
+
});
|
|
1373
|
+
lotPayload = {
|
|
1374
|
+
memberId: String(tx.memberId),
|
|
1375
|
+
externalId: member.externalId,
|
|
1376
|
+
transactionId: String(expireRow._id),
|
|
1377
|
+
lotTransactionId: String(tx._id),
|
|
1378
|
+
points: amount,
|
|
1379
|
+
pointValueMinor,
|
|
1380
|
+
balanceAfter: expireRow.balanceAfter
|
|
1381
|
+
};
|
|
1382
|
+
await this._dispatch(createEvent(LoyaltyEvents.POINTS_LOT_EXPIRED, lotPayload, txCtx, {
|
|
1383
|
+
resource: "pointTransaction",
|
|
1384
|
+
resourceId: expireRow._id
|
|
1385
|
+
}), txCtx);
|
|
1386
|
+
return amount;
|
|
1387
|
+
});
|
|
1388
|
+
if (lotPayload) this.notifications?.notify(LoyaltyEvents.POINTS_LOT_EXPIRED, { ...lotPayload });
|
|
1389
|
+
if (expiredAmount > 0) {
|
|
1390
|
+
affectedMemberIds.add(tx.memberId);
|
|
1391
|
+
totalExpired += expiredAmount;
|
|
1392
|
+
transactionCount++;
|
|
1393
|
+
}
|
|
1118
1394
|
}
|
|
1119
1395
|
}
|
|
1120
1396
|
if (totalExpired > 0) await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
@@ -1131,30 +1407,16 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1131
1407
|
};
|
|
1132
1408
|
}
|
|
1133
1409
|
};
|
|
1134
|
-
/** E11000 across the raw driver + mongokit wrapper shapes. */
|
|
1135
|
-
function isDuplicateKey(err) {
|
|
1136
|
-
if (typeof err !== "object" || err === null) return false;
|
|
1137
|
-
const e = err;
|
|
1138
|
-
if (e.code === 11e3) return true;
|
|
1139
|
-
if (e.status === 409) return true;
|
|
1140
|
-
if (e.cause !== void 0) return isDuplicateKey(e.cause);
|
|
1141
|
-
return false;
|
|
1142
|
-
}
|
|
1143
1410
|
/**
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
* With an idempotency key present, the winning insert is the source of
|
|
1147
|
-
* truth; losers are short-circuited to its doc rather than leaking the
|
|
1148
|
-
* raw driver error.
|
|
1411
|
+
* A replay of an adjustment returns the booked transaction — but only when it IS the same
|
|
1412
|
+
* adjustment. The same key on another member or another amount is two commands sharing one key.
|
|
1149
1413
|
*/
|
|
1150
|
-
function
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
1157
|
-
return false;
|
|
1414
|
+
function sameAdjustment(booked, input) {
|
|
1415
|
+
if (String(booked.memberId) !== String(input.memberId) || booked.points !== input.points) throw Object.assign(/* @__PURE__ */ new Error(`idempotencyKey '${input.idempotencyKey}' was already used for a different adjustment`), {
|
|
1416
|
+
code: "IDEMPOTENCY_KEY_REUSED",
|
|
1417
|
+
status: 409
|
|
1418
|
+
});
|
|
1419
|
+
return booked;
|
|
1158
1420
|
}
|
|
1159
1421
|
//#endregion
|
|
1160
1422
|
//#region src/repositories/redemption.repository.ts
|
|
@@ -1166,6 +1428,7 @@ var RedemptionRepository = class extends Repository {
|
|
|
1166
1428
|
notifications = null;
|
|
1167
1429
|
unitOfWork;
|
|
1168
1430
|
config;
|
|
1431
|
+
program;
|
|
1169
1432
|
constructor(model, plugins = []) {
|
|
1170
1433
|
super(model, plugins);
|
|
1171
1434
|
}
|
|
@@ -1193,6 +1456,38 @@ var RedemptionRepository = class extends Repository {
|
|
|
1193
1456
|
};
|
|
1194
1457
|
}
|
|
1195
1458
|
/**
|
|
1459
|
+
* The points the program owes right now: every member's spendable balance plus the points
|
|
1460
|
+
* held by open reservations (a reservation has left the balance but not yet been used), at
|
|
1461
|
+
* the program's current point value. The measure a ledger's liability reconciles against.
|
|
1462
|
+
*/
|
|
1463
|
+
async outstandingLiability(ctx) {
|
|
1464
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
1465
|
+
const tc = tenantCtx(this.config.tenant, getTenantValue(ctx, this.config.tenant));
|
|
1466
|
+
const sum = async (repo, pipeline) => {
|
|
1467
|
+
const [row] = await repo.aggregatePipeline(pipeline, tc);
|
|
1468
|
+
return row?.total ?? 0;
|
|
1469
|
+
};
|
|
1470
|
+
const [spendablePoints, reservedPoints, program] = await Promise.all([
|
|
1471
|
+
sum(this.memberRepo, [{ $group: {
|
|
1472
|
+
_id: null,
|
|
1473
|
+
total: { $sum: "$balance.current" }
|
|
1474
|
+
} }]),
|
|
1475
|
+
sum(this, [{ $match: { status: "reserved" } }, { $group: {
|
|
1476
|
+
_id: null,
|
|
1477
|
+
total: { $sum: "$pointsReserved" }
|
|
1478
|
+
} }]),
|
|
1479
|
+
this.program()
|
|
1480
|
+
]);
|
|
1481
|
+
const outstandingPoints = spendablePoints + reservedPoints;
|
|
1482
|
+
return {
|
|
1483
|
+
spendablePoints,
|
|
1484
|
+
reservedPoints,
|
|
1485
|
+
outstandingPoints,
|
|
1486
|
+
pointValueMinor: program.pointValueMinor,
|
|
1487
|
+
valueMinor: outstandingPoints * program.pointValueMinor
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1196
1491
|
* Atomic compare-and-set status transition.
|
|
1197
1492
|
*
|
|
1198
1493
|
* `null` means ONE thing and one thing only: the guarded update matched
|
|
@@ -1211,10 +1506,13 @@ var RedemptionRepository = class extends Repository {
|
|
|
1211
1506
|
*
|
|
1212
1507
|
* Anything that throws in here is a real error and must propagate.
|
|
1213
1508
|
*/
|
|
1214
|
-
async atomicStatusTransition(id, expectedStatus, data, ctx) {
|
|
1509
|
+
async atomicStatusTransition(id, expectedStatus, data, ctx, guard) {
|
|
1215
1510
|
return this.update(id, data, {
|
|
1216
1511
|
lean: true,
|
|
1217
|
-
query: {
|
|
1512
|
+
query: {
|
|
1513
|
+
...guard,
|
|
1514
|
+
status: expectedStatus
|
|
1515
|
+
},
|
|
1218
1516
|
throwOnNotFound: false,
|
|
1219
1517
|
...ctx
|
|
1220
1518
|
});
|
|
@@ -1231,8 +1529,9 @@ var RedemptionRepository = class extends Repository {
|
|
|
1231
1529
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1232
1530
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
1233
1531
|
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
1234
|
-
const
|
|
1235
|
-
const {
|
|
1532
|
+
const program = await this.program();
|
|
1533
|
+
const { minPoints, minOrderAmount, maxRedeemPercent } = program.redemption;
|
|
1534
|
+
const { pointValueMinor } = program;
|
|
1236
1535
|
if (input.pointsToRedeem < minPoints) return {
|
|
1237
1536
|
valid: false,
|
|
1238
1537
|
error: `Minimum ${minPoints} points required`,
|
|
@@ -1259,10 +1558,18 @@ var RedemptionRepository = class extends Repository {
|
|
|
1259
1558
|
const cappedDiscountMinor = Math.min(requestedDiscountMinor, maxDiscountMinor);
|
|
1260
1559
|
const maxAllowedPoints = Math.floor(maxDiscountMinor / pointValueMinor);
|
|
1261
1560
|
const actualPoints = cappedDiscountMinor < requestedDiscountMinor ? Math.floor(cappedDiscountMinor / pointValueMinor) : input.pointsToRedeem;
|
|
1561
|
+
const discountAmount = actualPoints * pointValueMinor;
|
|
1562
|
+
if (actualPoints === 0 || actualPoints < minPoints) return {
|
|
1563
|
+
valid: false,
|
|
1564
|
+
error: maxRedeemPercent === 0 ? "Points redemption is switched off" : `This order allows at most ${maxAllowedPoints} points; the minimum is ${minPoints}`,
|
|
1565
|
+
pointsToRedeem: 0,
|
|
1566
|
+
discountAmount: 0,
|
|
1567
|
+
maxAllowedPoints
|
|
1568
|
+
};
|
|
1262
1569
|
return {
|
|
1263
1570
|
valid: true,
|
|
1264
1571
|
pointsToRedeem: actualPoints,
|
|
1265
|
-
discountAmount
|
|
1572
|
+
discountAmount,
|
|
1266
1573
|
maxAllowedPoints
|
|
1267
1574
|
};
|
|
1268
1575
|
}
|
|
@@ -1298,7 +1605,7 @@ var RedemptionRepository = class extends Repository {
|
|
|
1298
1605
|
pointsReserved: validation.pointsToRedeem,
|
|
1299
1606
|
pointsConfirmed: 0,
|
|
1300
1607
|
discountAmount: validation.discountAmount,
|
|
1301
|
-
pointValueMinor:
|
|
1608
|
+
pointValueMinor: validation.discountAmount / Math.max(1, validation.pointsToRedeem),
|
|
1302
1609
|
status: "reserved",
|
|
1303
1610
|
ownerType: input.ownerType,
|
|
1304
1611
|
ownerId: input.ownerId,
|
|
@@ -1326,7 +1633,17 @@ var RedemptionRepository = class extends Repository {
|
|
|
1326
1633
|
if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
|
|
1327
1634
|
return redemption;
|
|
1328
1635
|
}
|
|
1329
|
-
|
|
1636
|
+
/**
|
|
1637
|
+
* `ownerExists: true` skips the reservation-expiry refusal — for a caller that
|
|
1638
|
+
* knows the order was committed, where refusing would return points the customer
|
|
1639
|
+
* already spent as a discount.
|
|
1640
|
+
*
|
|
1641
|
+
* `rebind` hands the hold to the document it paid for (a checkout reserves under its
|
|
1642
|
+
* request key, the placed order owns it), inside the same CAS as the confirm; the CAS
|
|
1643
|
+
* also matches `rebind.from`, so a caller holding only an id cannot re-home another
|
|
1644
|
+
* owner's points. Absent ⇒ the reserve-time owner stays.
|
|
1645
|
+
*/
|
|
1646
|
+
async confirm(redemptionId, ctx, options) {
|
|
1330
1647
|
assertTenantContext(ctx, this.config.tenant);
|
|
1331
1648
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1332
1649
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
@@ -1335,18 +1652,30 @@ var RedemptionRepository = class extends Repository {
|
|
|
1335
1652
|
...tc
|
|
1336
1653
|
});
|
|
1337
1654
|
if (!redemption) throw new RedemptionNotFoundError(redemptionId);
|
|
1338
|
-
if (redemption.expiresAt && redemption.expiresAt < /* @__PURE__ */ new Date()) throw new RedemptionExpiredError(redemptionId);
|
|
1655
|
+
if (!options?.ownerExists && redemption.expiresAt && redemption.expiresAt < /* @__PURE__ */ new Date()) throw new RedemptionExpiredError(redemptionId);
|
|
1656
|
+
const rebind = options?.rebind;
|
|
1339
1657
|
let payload;
|
|
1340
1658
|
const updated = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
1341
1659
|
const session = txCtx.session;
|
|
1342
|
-
|
|
1660
|
+
const transitioned = await this.atomicStatusTransition(redemptionId, "reserved", {
|
|
1343
1661
|
status: "confirmed",
|
|
1344
1662
|
pointsConfirmed: redemption.pointsReserved,
|
|
1345
|
-
confirmedAt: /* @__PURE__ */ new Date()
|
|
1663
|
+
confirmedAt: /* @__PURE__ */ new Date(),
|
|
1664
|
+
...rebind ? {
|
|
1665
|
+
ownerType: rebind.to.ownerType,
|
|
1666
|
+
ownerId: rebind.to.ownerId
|
|
1667
|
+
} : {}
|
|
1346
1668
|
}, {
|
|
1347
1669
|
session,
|
|
1348
1670
|
...tc
|
|
1349
|
-
}
|
|
1671
|
+
}, rebind ? {
|
|
1672
|
+
ownerType: rebind.from.ownerType,
|
|
1673
|
+
ownerId: rebind.from.ownerId
|
|
1674
|
+
} : void 0);
|
|
1675
|
+
if (!transitioned) {
|
|
1676
|
+
if (rebind && redemption.status === "reserved" && redemption.ownerId !== rebind.from.ownerId) throw new ValidationError(`Redemption ${redemptionId} is held by ${redemption.ownerType} ${redemption.ownerId}, not ${rebind.from.ownerType} ${rebind.from.ownerId}`);
|
|
1677
|
+
throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
|
|
1678
|
+
}
|
|
1350
1679
|
const member = await this.memberRepo.requireActiveMember(redemption.memberId, {
|
|
1351
1680
|
session,
|
|
1352
1681
|
...tc
|
|
@@ -1358,8 +1687,8 @@ var RedemptionRepository = class extends Repository {
|
|
|
1358
1687
|
points: -redemption.pointsReserved,
|
|
1359
1688
|
balanceAfter: member.balance.current,
|
|
1360
1689
|
description: `Redemption confirmed: ${redemptionId}`,
|
|
1361
|
-
referenceType:
|
|
1362
|
-
referenceId:
|
|
1690
|
+
referenceType: transitioned.ownerType,
|
|
1691
|
+
referenceId: transitioned.ownerId,
|
|
1363
1692
|
actorId: ctx.actorId
|
|
1364
1693
|
}, {
|
|
1365
1694
|
session,
|
|
@@ -1547,7 +1876,8 @@ var RedemptionRepository = class extends Repository {
|
|
|
1547
1876
|
pointsCredited: pointsToCredit,
|
|
1548
1877
|
originalPoints,
|
|
1549
1878
|
proportional: usedProportional,
|
|
1550
|
-
|
|
1879
|
+
pointValueMinor: redemption.pointValueMinor,
|
|
1880
|
+
...result.reversalTransactionId ? { transactionId: String(result.reversalTransactionId) } : {}
|
|
1551
1881
|
};
|
|
1552
1882
|
if (options?.reason) payload.reason = options.reason;
|
|
1553
1883
|
await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_REVERSED, payload, txCtx, {
|
|
@@ -1559,6 +1889,90 @@ var RedemptionRepository = class extends Repository {
|
|
|
1559
1889
|
if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
|
|
1560
1890
|
return reversed;
|
|
1561
1891
|
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Confirm every redemption still `reserved` against an owner that now exists.
|
|
1894
|
+
*
|
|
1895
|
+
* The convergence net for a checkout whose inline confirm failed after the order
|
|
1896
|
+
* was committed: left `reserved`, the expiry sweep would RELEASE it and the
|
|
1897
|
+
* member would keep both the discount and the points. Safe to repeat — a status
|
|
1898
|
+
* CAS, and a redemption another caller already settled is skipped.
|
|
1899
|
+
*/
|
|
1900
|
+
async confirmForOwner(ownerIds, ctx, options) {
|
|
1901
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
1902
|
+
const tc = tenantCtx(this.config.tenant, getTenantValue(ctx, this.config.tenant));
|
|
1903
|
+
const ids = [...new Set(ownerIds.filter(Boolean))];
|
|
1904
|
+
if (ids.length === 0) return { confirmed: 0 };
|
|
1905
|
+
let confirmed = 0;
|
|
1906
|
+
const filter = {
|
|
1907
|
+
ownerType: "order",
|
|
1908
|
+
ownerId: { $in: ids },
|
|
1909
|
+
status: "reserved"
|
|
1910
|
+
};
|
|
1911
|
+
for (const redemption of await this.findAll(filter, tc)) try {
|
|
1912
|
+
await this.confirm(redemption._id, ctx, {
|
|
1913
|
+
ownerExists: true,
|
|
1914
|
+
...options?.rebindTo && options.rebindTo.ownerId !== redemption.ownerId ? { rebind: {
|
|
1915
|
+
from: {
|
|
1916
|
+
ownerType: redemption.ownerType,
|
|
1917
|
+
ownerId: redemption.ownerId
|
|
1918
|
+
},
|
|
1919
|
+
to: options.rebindTo
|
|
1920
|
+
} } : {}
|
|
1921
|
+
});
|
|
1922
|
+
confirmed++;
|
|
1923
|
+
} catch (err) {
|
|
1924
|
+
const now = await this.getById(redemption._id, {
|
|
1925
|
+
throwOnNotFound: false,
|
|
1926
|
+
...tc
|
|
1927
|
+
});
|
|
1928
|
+
if (now && now.status !== "reserved") continue;
|
|
1929
|
+
throw err;
|
|
1930
|
+
}
|
|
1931
|
+
return { confirmed };
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Undo every redemption held against an owner that was refunded or canceled:
|
|
1935
|
+
* confirmed ones credit their points back, reserved ones release their hold.
|
|
1936
|
+
*
|
|
1937
|
+
* `ownerIds` lists every identity the owner was reserved under (a till reserves
|
|
1938
|
+
* against its idempotency key, not the order number). Safe to repeat: each step is
|
|
1939
|
+
* a status CAS, and a redemption another caller already settled is skipped.
|
|
1940
|
+
*/
|
|
1941
|
+
async undoForOwner(ownerIds, ctx, options) {
|
|
1942
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
1943
|
+
const tc = tenantCtx(this.config.tenant, getTenantValue(ctx, this.config.tenant));
|
|
1944
|
+
const ids = [...new Set(ownerIds.filter(Boolean))];
|
|
1945
|
+
const out = {
|
|
1946
|
+
reversed: 0,
|
|
1947
|
+
released: 0,
|
|
1948
|
+
pointsReturned: 0
|
|
1949
|
+
};
|
|
1950
|
+
if (ids.length === 0) return out;
|
|
1951
|
+
const open = await this.findAll({
|
|
1952
|
+
ownerType: "order",
|
|
1953
|
+
ownerId: { $in: ids },
|
|
1954
|
+
status: { $in: ["reserved", "confirmed"] }
|
|
1955
|
+
}, tc);
|
|
1956
|
+
for (const redemption of open) try {
|
|
1957
|
+
if (redemption.status === "confirmed") {
|
|
1958
|
+
await this.reverseConfirmed(redemption._id, ctx, { reason: options.reason });
|
|
1959
|
+
out.reversed++;
|
|
1960
|
+
out.pointsReturned += redemption.pointsConfirmed;
|
|
1961
|
+
} else {
|
|
1962
|
+
await this.release(redemption._id, ctx);
|
|
1963
|
+
out.released++;
|
|
1964
|
+
out.pointsReturned += redemption.pointsReserved;
|
|
1965
|
+
}
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
const now = await this.getById(redemption._id, {
|
|
1968
|
+
throwOnNotFound: false,
|
|
1969
|
+
...tc
|
|
1970
|
+
});
|
|
1971
|
+
if (now && now.status !== redemption.status) continue;
|
|
1972
|
+
throw err;
|
|
1973
|
+
}
|
|
1974
|
+
return out;
|
|
1975
|
+
}
|
|
1562
1976
|
async cleanupExpired(ctx) {
|
|
1563
1977
|
assertTenantContext(ctx, this.config.tenant);
|
|
1564
1978
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
@@ -1791,6 +2205,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1791
2205
|
memberId: referrer._id,
|
|
1792
2206
|
points: referrerRewardPoints,
|
|
1793
2207
|
description: `Referral reward: referred ${referee.externalId}`,
|
|
2208
|
+
source: "referral",
|
|
1794
2209
|
referenceType: "referral",
|
|
1795
2210
|
referenceId: referral._id,
|
|
1796
2211
|
idempotencyKey: `referral:${referral._id}:referrer`
|
|
@@ -1802,6 +2217,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1802
2217
|
memberId: referee._id,
|
|
1803
2218
|
points: refereeRewardPoints,
|
|
1804
2219
|
description: `Referral welcome bonus from ${referrer.externalId}`,
|
|
2220
|
+
source: "referral",
|
|
1805
2221
|
referenceType: "referral",
|
|
1806
2222
|
referenceId: referral._id,
|
|
1807
2223
|
idempotencyKey: `referral:${referral._id}:referee`
|
|
@@ -1842,6 +2258,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1842
2258
|
memberId: ancestor._id,
|
|
1843
2259
|
points,
|
|
1844
2260
|
description: `Multi-tier referral reward (depth ${depth})`,
|
|
2261
|
+
source: "referral",
|
|
1845
2262
|
referenceType: "referral",
|
|
1846
2263
|
referenceId: referral._id,
|
|
1847
2264
|
idempotencyKey: `referral:${referral._id}:ancestor:${depth}`,
|
|
@@ -1887,7 +2304,619 @@ var ReferralRepository = class extends Repository {
|
|
|
1887
2304
|
}
|
|
1888
2305
|
};
|
|
1889
2306
|
//#endregion
|
|
2307
|
+
//#region src/domain/stamp-program.ts
|
|
2308
|
+
/**
|
|
2309
|
+
* Stamp-card program rules — the wire schemas, the write-time invariants, and the stamp count for
|
|
2310
|
+
* one order. Pure: no I/O.
|
|
2311
|
+
*/
|
|
2312
|
+
/** An allow-list: omit it to mean "any". Empty is refused — it would otherwise read as "any". */
|
|
2313
|
+
const allowList = z.array(z.string().trim().min(1)).min(1, "an empty list is refused — omit the field to allow any");
|
|
2314
|
+
const positiveInt = z.number().int().min(1);
|
|
2315
|
+
const minorAmount = z.number().int().min(0);
|
|
2316
|
+
const instant = z.iso.datetime({ offset: true });
|
|
2317
|
+
const stampEarnSchema = z.strictObject({
|
|
2318
|
+
unit: z.enum(STAMP_EARN_UNITS),
|
|
2319
|
+
categoryIds: allowList.optional(),
|
|
2320
|
+
productIds: allowList.optional(),
|
|
2321
|
+
perMinor: positiveInt.optional(),
|
|
2322
|
+
minOrderMinor: minorAmount.optional()
|
|
2323
|
+
}).superRefine((earn, ctx) => {
|
|
2324
|
+
if (earn.unit === "spend" && earn.perMinor === void 0) ctx.addIssue({
|
|
2325
|
+
code: "custom",
|
|
2326
|
+
path: ["perMinor"],
|
|
2327
|
+
message: "unit 'spend' requires perMinor (minor units per stamp)"
|
|
2328
|
+
});
|
|
2329
|
+
if (earn.unit !== "spend" && earn.perMinor !== void 0) ctx.addIssue({
|
|
2330
|
+
code: "custom",
|
|
2331
|
+
path: ["perMinor"],
|
|
2332
|
+
message: "perMinor applies to unit 'spend' only"
|
|
2333
|
+
});
|
|
2334
|
+
});
|
|
2335
|
+
const stampRewardSchema = z.discriminatedUnion("kind", [z.strictObject({
|
|
2336
|
+
kind: z.literal("points"),
|
|
2337
|
+
points: positiveInt
|
|
2338
|
+
}), z.strictObject({
|
|
2339
|
+
kind: z.literal("voucher"),
|
|
2340
|
+
voucherProgramId: z.string().trim().min(1)
|
|
2341
|
+
})]);
|
|
2342
|
+
const programFields = {
|
|
2343
|
+
name: z.string().trim().min(1),
|
|
2344
|
+
description: z.string().optional(),
|
|
2345
|
+
status: z.enum(STAMP_PROGRAM_STATUSES).optional(),
|
|
2346
|
+
earn: stampEarnSchema,
|
|
2347
|
+
stampsRequired: positiveInt,
|
|
2348
|
+
stampValidityDays: positiveInt.optional(),
|
|
2349
|
+
startsAt: instant.optional(),
|
|
2350
|
+
endsAt: instant.optional(),
|
|
2351
|
+
reward: stampRewardSchema,
|
|
2352
|
+
maxRewardsPerMember: positiveInt.optional(),
|
|
2353
|
+
branchIds: allowList.optional(),
|
|
2354
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
2355
|
+
};
|
|
2356
|
+
/** The create body — strict: an unknown key is a 400, never silently dropped. Dates are ISO strings. */
|
|
2357
|
+
const stampProgramInputSchema = z.strictObject(programFields);
|
|
2358
|
+
/** The patch body — strict, every field optional; nested `earn` / `reward` are replaced whole. */
|
|
2359
|
+
const stampProgramPatchSchema = z.strictObject(programFields).partial();
|
|
2360
|
+
/** Fields `assertStampProgram` judges; persistence and system keys pass through untouched. */
|
|
2361
|
+
const PROGRAM_KEYS = Object.keys(programFields);
|
|
2362
|
+
/**
|
|
2363
|
+
* Refuse a program that could never award correctly. Runs on the WHOLE document (a patch merged
|
|
2364
|
+
* over what is stored), so a partial update cannot produce an invalid program.
|
|
2365
|
+
*/
|
|
2366
|
+
function assertStampProgram(doc, opts) {
|
|
2367
|
+
const fields = {};
|
|
2368
|
+
for (const key of PROGRAM_KEYS) {
|
|
2369
|
+
const value = doc[key];
|
|
2370
|
+
if (value === void 0 || value === null) continue;
|
|
2371
|
+
fields[key] = value instanceof Date ? value.toISOString() : value;
|
|
2372
|
+
}
|
|
2373
|
+
const parsed = stampProgramInputSchema.safeParse(stripNullish(fields));
|
|
2374
|
+
if (!parsed.success) {
|
|
2375
|
+
const issue = parsed.error.issues[0];
|
|
2376
|
+
throw new ValidationError(`Invalid stamp program — ${issue?.path.length ? `${issue.path.join(".")}: ` : ""}${issue?.message ?? "malformed"}`);
|
|
2377
|
+
}
|
|
2378
|
+
const p = parsed.data;
|
|
2379
|
+
if (p.startsAt && p.endsAt && Date.parse(p.startsAt) >= Date.parse(p.endsAt)) throw new ValidationError("Invalid stamp program — startsAt must be before endsAt");
|
|
2380
|
+
if (p.reward.kind === "voucher" && !opts.rewardIssuerBound) throw new ValidationError("Invalid stamp program — a voucher reward needs a reward issuer, and this engine was bound without LoyaltyRuntime.rewardIssuer: the program would claim stamps and never issue anything");
|
|
2381
|
+
}
|
|
2382
|
+
/** Mongoose hands back `[]` for an unset array path and `{}`-ish subdocs; judge only what was set. */
|
|
2383
|
+
function stripNullish(value) {
|
|
2384
|
+
const out = {};
|
|
2385
|
+
for (const [k, v] of Object.entries(value)) {
|
|
2386
|
+
if (v === void 0 || v === null) continue;
|
|
2387
|
+
out[k] = v !== null && typeof v === "object" && !Array.isArray(v) ? stripNullish(plain(v)) : v;
|
|
2388
|
+
}
|
|
2389
|
+
return out;
|
|
2390
|
+
}
|
|
2391
|
+
function plain(v) {
|
|
2392
|
+
const maybe = v;
|
|
2393
|
+
return typeof maybe.toObject === "function" ? maybe.toObject() : v;
|
|
2394
|
+
}
|
|
2395
|
+
/** Whether the order falls inside the program's window, branch set and order minimum. */
|
|
2396
|
+
function programAppliesTo(program, order) {
|
|
2397
|
+
const at = order.occurredAt.getTime();
|
|
2398
|
+
if (program.startsAt && at < new Date(program.startsAt).getTime()) return false;
|
|
2399
|
+
if (program.endsAt && at >= new Date(program.endsAt).getTime()) return false;
|
|
2400
|
+
if (program.branchIds?.length) {
|
|
2401
|
+
if (!order.branchId || !program.branchIds.includes(order.branchId)) return false;
|
|
2402
|
+
}
|
|
2403
|
+
if (program.earn.minOrderMinor !== void 0 && order.orderTotalMinor < program.earn.minOrderMinor) return false;
|
|
2404
|
+
return true;
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* Stamps one order earns on a program: `order` / `visit` → 1 when a line qualifies, `item` → the
|
|
2408
|
+
* qualifying units (a line sold by measure counts once, see {@link itemStampsOf}), `spend` →
|
|
2409
|
+
* floor(qualifying spend / perMinor). With no product/category filter every line qualifies and
|
|
2410
|
+
* `spend` counts the order total.
|
|
2411
|
+
*/
|
|
2412
|
+
function stampsForOrder(program, order) {
|
|
2413
|
+
if (!programAppliesTo(program, order)) return 0;
|
|
2414
|
+
const { earn } = program;
|
|
2415
|
+
const filtered = Boolean(earn.categoryIds?.length || earn.productIds?.length);
|
|
2416
|
+
const lines = filtered ? order.lines.filter((l) => l.categoryId !== void 0 && earn.categoryIds?.includes(l.categoryId) || l.productId !== void 0 && earn.productIds?.includes(l.productId)) : order.lines;
|
|
2417
|
+
if (filtered && lines.length === 0) return 0;
|
|
2418
|
+
switch (earn.unit) {
|
|
2419
|
+
case "order":
|
|
2420
|
+
case "visit": return 1;
|
|
2421
|
+
case "item": return lines.reduce((n, l) => n + itemStampsOf(l), 0);
|
|
2422
|
+
case "spend": {
|
|
2423
|
+
const perMinor = earn.perMinor;
|
|
2424
|
+
if (!perMinor) throw new ValidationError(`Stamp program ${program._id} has unit 'spend' without perMinor`);
|
|
2425
|
+
const spend = filtered ? lines.reduce((n, l) => n + l.amountMinor, 0) : order.orderTotalMinor;
|
|
2426
|
+
return Math.floor(spend / perMinor);
|
|
2427
|
+
}
|
|
2428
|
+
default: throw new ValidationError(`Stamp program ${program._id} has unknown earn unit '${String(earn.unit)}'`);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
/** A line whose step is a whole number (absent = 1) is counted in units. */
|
|
2432
|
+
function isWholeUnitLine(line) {
|
|
2433
|
+
return line.quantityStep === void 0 || Number.isInteger(line.quantityStep);
|
|
2434
|
+
}
|
|
2435
|
+
/**
|
|
2436
|
+
* `item` stamps for one line: its units when it sells in whole units, ONE when it sells by measure
|
|
2437
|
+
* (2.35 kg of rice is one item bought, never 2 or 2350 stamps).
|
|
2438
|
+
*/
|
|
2439
|
+
function itemStampsOf(line) {
|
|
2440
|
+
if (isWholeUnitLine(line)) return line.quantity;
|
|
2441
|
+
return line.quantity > 0 ? 1 : 0;
|
|
2442
|
+
}
|
|
2443
|
+
/** Refuse an order input a stamp count would silently mis-read. */
|
|
2444
|
+
function assertStampOrderInput(input) {
|
|
2445
|
+
if (!input.memberId) throw new ValidationError("recordOrder: memberId is required");
|
|
2446
|
+
if (!input.orderRef) throw new ValidationError("recordOrder: orderRef is required");
|
|
2447
|
+
if (!(input.occurredAt instanceof Date) || Number.isNaN(input.occurredAt.getTime())) throw new ValidationError("recordOrder: occurredAt must be a valid Date");
|
|
2448
|
+
if (!Number.isSafeInteger(input.orderTotalMinor) || input.orderTotalMinor < 0) throw new ValidationError("recordOrder: orderTotalMinor must be a non-negative integer (minor units)");
|
|
2449
|
+
for (const line of input.lines) {
|
|
2450
|
+
if (!Number.isSafeInteger(line.amountMinor) || line.amountMinor < 0) throw new ValidationError("recordOrder: line amountMinor must be a non-negative integer (minor units)");
|
|
2451
|
+
if (!Number.isFinite(line.quantity) || line.quantity < 0) throw new ValidationError("recordOrder: line quantity must be a non-negative number");
|
|
2452
|
+
if (line.quantityStep !== void 0 && !(Number.isFinite(line.quantityStep) && line.quantityStep > 0)) throw new ValidationError("recordOrder: line quantityStep must be a positive number when given");
|
|
2453
|
+
if (isWholeUnitLine(line) && !Number.isInteger(line.quantity)) throw new ValidationError(`recordOrder: line quantity ${line.quantity} is fractional on a whole-unit line — send the line's quantityStep`);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
//#endregion
|
|
2457
|
+
//#region src/repositories/stamp.repository.ts
|
|
2458
|
+
const DAY_MS = 864e5;
|
|
2459
|
+
/** Lost races a settle retries before failing — each one means another settle made progress. */
|
|
2460
|
+
const MAX_SETTLE_CONFLICTS = 5;
|
|
2461
|
+
/** An entry's `consumed` moved between the read and the claim — re-read and try again. */
|
|
2462
|
+
var StampClaimConflict = class extends Error {};
|
|
2463
|
+
/**
|
|
2464
|
+
* Stamp-card ledger: one entry per (program, member, order), consumed oldest-first into awards.
|
|
2465
|
+
*
|
|
2466
|
+
* Every guard is an index or a CAS filter, never an `if` above a write: the order entry, the visit
|
|
2467
|
+
* day, the award sequence and each entry's `consumed` count.
|
|
2468
|
+
*/
|
|
2469
|
+
var StampRepository = class extends Repository {
|
|
2470
|
+
memberRepo;
|
|
2471
|
+
txRepo;
|
|
2472
|
+
programRepo;
|
|
2473
|
+
awardRepo;
|
|
2474
|
+
events;
|
|
2475
|
+
outbox;
|
|
2476
|
+
unitOfWork;
|
|
2477
|
+
config;
|
|
2478
|
+
rewardIssuer;
|
|
2479
|
+
constructor(model, plugins = []) {
|
|
2480
|
+
super(model, plugins);
|
|
2481
|
+
}
|
|
2482
|
+
get deps() {
|
|
2483
|
+
return {
|
|
2484
|
+
events: this.events,
|
|
2485
|
+
outbox: this.outbox
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
tc(ctx) {
|
|
2489
|
+
assertTenantContext(ctx, this.config.tenant);
|
|
2490
|
+
return tenantCtx(this.config.tenant, getTenantValue(ctx, this.config.tenant));
|
|
2491
|
+
}
|
|
2492
|
+
async create(data, options) {
|
|
2493
|
+
injectTenantIntoData(data, options, this.config.tenant);
|
|
2494
|
+
return super.create(data, options);
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Record one paid order against every active stamp program of the member's loyalty program,
|
|
2498
|
+
* then settle each. Re-recording the same order is a no-op that still settles, so a redelivery
|
|
2499
|
+
* after a crash finishes the award the first delivery started.
|
|
2500
|
+
*/
|
|
2501
|
+
async recordOrder(input, ctx) {
|
|
2502
|
+
assertStampOrderInput(input);
|
|
2503
|
+
const tc = this.tc(ctx);
|
|
2504
|
+
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
2505
|
+
const programs = await this.programRepo.findAll({
|
|
2506
|
+
status: "active",
|
|
2507
|
+
programId: member.programId
|
|
2508
|
+
}, {
|
|
2509
|
+
sort: { createdAt: 1 },
|
|
2510
|
+
...tc
|
|
2511
|
+
});
|
|
2512
|
+
const businessDay = civilDateOf(input.occurredAt, this.config.timezone ?? "UTC");
|
|
2513
|
+
const results = [];
|
|
2514
|
+
for (const program of programs) {
|
|
2515
|
+
const stamps = stampsForOrder(program, input);
|
|
2516
|
+
if (stamps <= 0) continue;
|
|
2517
|
+
const recorded = await this.insertEntry(program, member, input, stamps, businessDay, ctx, tc);
|
|
2518
|
+
const awards = await this.settle(String(program._id), input.memberId, ctx);
|
|
2519
|
+
results.push({
|
|
2520
|
+
stampProgramId: String(program._id),
|
|
2521
|
+
stamps: recorded ? stamps : 0,
|
|
2522
|
+
duplicate: !recorded,
|
|
2523
|
+
awards
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
return results;
|
|
2527
|
+
}
|
|
2528
|
+
/** Insert the order's entry; `false` when the unique index says it (or its visit day) exists. */
|
|
2529
|
+
async insertEntry(program, member, input, stamps, businessDay, ctx, tc) {
|
|
2530
|
+
const expiresAt = program.stampValidityDays ? new Date(input.occurredAt.getTime() + program.stampValidityDays * DAY_MS) : void 0;
|
|
2531
|
+
const data = {
|
|
2532
|
+
stampProgramId: program._id,
|
|
2533
|
+
memberId: input.memberId,
|
|
2534
|
+
orderRef: input.orderRef,
|
|
2535
|
+
stamps,
|
|
2536
|
+
consumed: 0,
|
|
2537
|
+
earnedAt: input.occurredAt,
|
|
2538
|
+
businessDay,
|
|
2539
|
+
...expiresAt ? { expiresAt } : {},
|
|
2540
|
+
...program.earn.unit === "visit" ? { visitKey: businessDay } : {},
|
|
2541
|
+
...input.branchId ? { branchId: input.branchId } : {}
|
|
2542
|
+
};
|
|
2543
|
+
try {
|
|
2544
|
+
await runLoyaltyUnit(this.unitOfWork, this.deps, ctx, async (txCtx) => {
|
|
2545
|
+
const entry = await this.create(data, {
|
|
2546
|
+
session: txCtx.session,
|
|
2547
|
+
...tc
|
|
2548
|
+
});
|
|
2549
|
+
const payload = {
|
|
2550
|
+
stampProgramId: String(program._id),
|
|
2551
|
+
memberId: String(member._id),
|
|
2552
|
+
externalId: member.externalId,
|
|
2553
|
+
entryId: String(entry._id),
|
|
2554
|
+
orderRef: input.orderRef,
|
|
2555
|
+
stamps,
|
|
2556
|
+
businessDay,
|
|
2557
|
+
...expiresAt ? { expiresAt: expiresAt.toISOString() } : {}
|
|
2558
|
+
};
|
|
2559
|
+
await dispatchLoyaltyEvent(this.deps, createEvent(LoyaltyEvents.STAMP_EARNED, payload, txCtx, {
|
|
2560
|
+
resource: "stampEntry",
|
|
2561
|
+
resourceId: String(entry._id)
|
|
2562
|
+
}), txCtx);
|
|
2563
|
+
});
|
|
2564
|
+
return true;
|
|
2565
|
+
} catch (err) {
|
|
2566
|
+
if (isDuplicateKey(err) && !ctx.session) return false;
|
|
2567
|
+
throw err;
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Exchange full cards for rewards until the member's stamps (or `maxRewardsPerMember`) run out.
|
|
2572
|
+
* Voucher awards claimed earlier but not yet issued are resumed first.
|
|
2573
|
+
*/
|
|
2574
|
+
async settle(stampProgramId, memberId, ctx) {
|
|
2575
|
+
const tc = this.tc(ctx);
|
|
2576
|
+
const out = [];
|
|
2577
|
+
const pending = await this.awardRepo.findAll({
|
|
2578
|
+
stampProgramId,
|
|
2579
|
+
memberId,
|
|
2580
|
+
status: "pending"
|
|
2581
|
+
}, {
|
|
2582
|
+
sort: { sequence: 1 },
|
|
2583
|
+
...tc
|
|
2584
|
+
});
|
|
2585
|
+
for (const award of pending) out.push(await this.issueVoucher(award, ctx, tc));
|
|
2586
|
+
let conflicts = 0;
|
|
2587
|
+
for (;;) {
|
|
2588
|
+
let claimed;
|
|
2589
|
+
try {
|
|
2590
|
+
claimed = await this.claimAward(stampProgramId, memberId, ctx, tc);
|
|
2591
|
+
} catch (err) {
|
|
2592
|
+
if ((err instanceof StampClaimConflict || isDuplicateKey(err) || isWriteConflict(err)) && !ctx.session && ++conflicts <= MAX_SETTLE_CONFLICTS) continue;
|
|
2593
|
+
throw err;
|
|
2594
|
+
}
|
|
2595
|
+
if (!claimed) return out;
|
|
2596
|
+
out.push(claimed.status === "pending" ? await this.issueVoucher(claimed, ctx, tc) : claimed);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* ONE award in ONE unit of work: consume the oldest stamps (CAS per entry), insert the award at
|
|
2601
|
+
* the next sequence (unique — the race fence), and for points credit them in the same
|
|
2602
|
+
* transaction. A voucher award commits as `pending`; its issuance happens after commit.
|
|
2603
|
+
*/
|
|
2604
|
+
async claimAward(stampProgramId, memberId, ctx, tc) {
|
|
2605
|
+
return runLoyaltyUnit(this.unitOfWork, this.deps, ctx, async (txCtx) => {
|
|
2606
|
+
const opts = {
|
|
2607
|
+
session: txCtx.session,
|
|
2608
|
+
...tc
|
|
2609
|
+
};
|
|
2610
|
+
const program = await this.programRepo.getById(stampProgramId, {
|
|
2611
|
+
...opts,
|
|
2612
|
+
throwOnNotFound: false
|
|
2613
|
+
});
|
|
2614
|
+
if (!program || program.status !== "active") return null;
|
|
2615
|
+
const awarded = await this.awardRepo.count({
|
|
2616
|
+
stampProgramId,
|
|
2617
|
+
memberId
|
|
2618
|
+
}, opts);
|
|
2619
|
+
if (program.maxRewardsPerMember !== void 0 && awarded >= program.maxRewardsPerMember) return null;
|
|
2620
|
+
const open = await this.openEntries(stampProgramId, memberId, /* @__PURE__ */ new Date(), opts);
|
|
2621
|
+
if (open.reduce((n, e) => n + (e.stamps - e.consumed), 0) < program.stampsRequired) return null;
|
|
2622
|
+
if (program.reward.kind === "voucher" && !this.rewardIssuer) throw new ValidationError(`Stamp program ${stampProgramId} rewards a voucher but the engine has no rewardIssuer — refusing to consume stamps`);
|
|
2623
|
+
let remaining = program.stampsRequired;
|
|
2624
|
+
const taken = [];
|
|
2625
|
+
for (const entry of open) {
|
|
2626
|
+
if (remaining === 0) break;
|
|
2627
|
+
const take = Math.min(entry.stamps - entry.consumed, remaining);
|
|
2628
|
+
if (!await this.claim(entry._id, {
|
|
2629
|
+
field: "consumed",
|
|
2630
|
+
from: entry.consumed,
|
|
2631
|
+
to: entry.consumed + take,
|
|
2632
|
+
where: { reversedAt: { $exists: false } }
|
|
2633
|
+
}, {}, opts)) throw new StampClaimConflict();
|
|
2634
|
+
taken.push({
|
|
2635
|
+
entryId: String(entry._id),
|
|
2636
|
+
stamps: take
|
|
2637
|
+
});
|
|
2638
|
+
remaining -= take;
|
|
2639
|
+
}
|
|
2640
|
+
const sequence = awarded + 1;
|
|
2641
|
+
const awardKey = `stamp:${stampProgramId}:${memberId}:${sequence}`;
|
|
2642
|
+
const now = /* @__PURE__ */ new Date();
|
|
2643
|
+
const reward = program.reward;
|
|
2644
|
+
const award = await this.awardRepo.create({
|
|
2645
|
+
stampProgramId,
|
|
2646
|
+
memberId,
|
|
2647
|
+
sequence,
|
|
2648
|
+
awardKey,
|
|
2649
|
+
entries: taken,
|
|
2650
|
+
stampsConsumed: program.stampsRequired,
|
|
2651
|
+
status: reward.kind === "points" ? "issued" : "pending",
|
|
2652
|
+
reward: reward.kind === "points" ? {
|
|
2653
|
+
kind: "points",
|
|
2654
|
+
points: reward.points
|
|
2655
|
+
} : {
|
|
2656
|
+
kind: "voucher",
|
|
2657
|
+
voucherProgramId: reward.voucherProgramId
|
|
2658
|
+
},
|
|
2659
|
+
awardedAt: now,
|
|
2660
|
+
...reward.kind === "points" ? { issuedAt: now } : {}
|
|
2661
|
+
}, opts);
|
|
2662
|
+
if (reward.kind !== "points") return award;
|
|
2663
|
+
const tx = await this.txRepo.earnPoints({
|
|
2664
|
+
memberId,
|
|
2665
|
+
points: reward.points,
|
|
2666
|
+
source: "campaign",
|
|
2667
|
+
description: `Stamp card: ${program.name} (#${sequence})`,
|
|
2668
|
+
referenceType: "stamp_award",
|
|
2669
|
+
referenceId: awardKey,
|
|
2670
|
+
idempotencyKey: awardKey,
|
|
2671
|
+
metadata: {
|
|
2672
|
+
stampProgramId,
|
|
2673
|
+
awardId: String(award._id),
|
|
2674
|
+
sequence
|
|
2675
|
+
}
|
|
2676
|
+
}, txCtx);
|
|
2677
|
+
const issued = await this.awardRepo.update(award._id, { "reward.pointTransactionId": tx._id }, {
|
|
2678
|
+
...opts,
|
|
2679
|
+
throwOnNotFound: true
|
|
2680
|
+
});
|
|
2681
|
+
const member = await this.memberRepo.requireMember(memberId, opts);
|
|
2682
|
+
await this.dispatchAwardIssued(award, member.externalId, {
|
|
2683
|
+
kind: "points",
|
|
2684
|
+
points: reward.points,
|
|
2685
|
+
pointTransactionId: String(tx._id)
|
|
2686
|
+
}, txCtx);
|
|
2687
|
+
return issued ?? award;
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
/** Issue a claimed voucher award through the host port (idempotent on `awardKey`), then mark it. */
|
|
2691
|
+
async issueVoucher(award, ctx, tc) {
|
|
2692
|
+
if (award.status !== "pending") return award;
|
|
2693
|
+
const issuer = this.rewardIssuer;
|
|
2694
|
+
const voucherProgramId = award.reward.voucherProgramId;
|
|
2695
|
+
if (!issuer || !voucherProgramId) throw new ValidationError(`Stamp award ${award.awardKey} is a pending voucher but the engine has no rewardIssuer (or the award names no voucher program)`);
|
|
2696
|
+
const member = await this.memberRepo.requireMember(String(award.memberId), tc);
|
|
2697
|
+
const issued = await issuer.issueVoucher({
|
|
2698
|
+
voucherProgramId,
|
|
2699
|
+
stampProgramId: String(award.stampProgramId),
|
|
2700
|
+
memberId: String(award.memberId),
|
|
2701
|
+
externalId: member.externalId,
|
|
2702
|
+
externalType: member.externalType,
|
|
2703
|
+
awardKey: award.awardKey
|
|
2704
|
+
}, ctx);
|
|
2705
|
+
if (!issued || typeof issued.voucherId !== "string" || !issued.voucherId || typeof issued.code !== "string" || !issued.code) throw new ValidationError(`rewardIssuer.issueVoucher returned no voucherId/code for award ${award.awardKey}`);
|
|
2706
|
+
return runLoyaltyUnit(this.unitOfWork, this.deps, ctx, async (txCtx) => {
|
|
2707
|
+
const opts = {
|
|
2708
|
+
session: txCtx.session,
|
|
2709
|
+
...tc
|
|
2710
|
+
};
|
|
2711
|
+
const marked = await this.awardRepo.claim(award._id, {
|
|
2712
|
+
from: "pending",
|
|
2713
|
+
to: "issued"
|
|
2714
|
+
}, {
|
|
2715
|
+
"reward.voucherId": issued.voucherId,
|
|
2716
|
+
"reward.code": issued.code,
|
|
2717
|
+
issuedAt: /* @__PURE__ */ new Date()
|
|
2718
|
+
}, opts);
|
|
2719
|
+
if (!marked) return await this.awardRepo.getById(award._id, {
|
|
2720
|
+
...opts,
|
|
2721
|
+
throwOnNotFound: true
|
|
2722
|
+
}) ?? award;
|
|
2723
|
+
await this.dispatchAwardIssued(award, member.externalId, {
|
|
2724
|
+
kind: "voucher",
|
|
2725
|
+
voucherProgramId,
|
|
2726
|
+
voucherId: issued.voucherId,
|
|
2727
|
+
code: issued.code
|
|
2728
|
+
}, txCtx);
|
|
2729
|
+
return marked;
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
async dispatchAwardIssued(award, externalId, reward, txCtx) {
|
|
2733
|
+
const payload = {
|
|
2734
|
+
stampProgramId: String(award.stampProgramId),
|
|
2735
|
+
memberId: String(award.memberId),
|
|
2736
|
+
externalId,
|
|
2737
|
+
awardId: String(award._id),
|
|
2738
|
+
sequence: award.sequence,
|
|
2739
|
+
stampsConsumed: award.stampsConsumed,
|
|
2740
|
+
reward
|
|
2741
|
+
};
|
|
2742
|
+
await dispatchLoyaltyEvent(this.deps, createEvent(LoyaltyEvents.STAMP_AWARD_ISSUED, payload, txCtx, {
|
|
2743
|
+
resource: "stampAward",
|
|
2744
|
+
resourceId: String(award._id)
|
|
2745
|
+
}), txCtx);
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Reverse an undone order's stamps. Policy: unconsumed stamps are removed; stamps already
|
|
2749
|
+
* consumed into an award are kept (`retainedInAwards`, on the result and the event) — never clawed back.
|
|
2750
|
+
*/
|
|
2751
|
+
async reverseOrder(orderRef, ctx) {
|
|
2752
|
+
if (!orderRef) throw new ValidationError("reverseOrder: orderRef is required");
|
|
2753
|
+
const tc = this.tc(ctx);
|
|
2754
|
+
const entries = await this.findAll({
|
|
2755
|
+
orderRef,
|
|
2756
|
+
reversedAt: { $exists: false }
|
|
2757
|
+
}, tc);
|
|
2758
|
+
const out = [];
|
|
2759
|
+
for (const entry of entries) {
|
|
2760
|
+
const reversal = await this.reverseEntry(entry, ctx, tc);
|
|
2761
|
+
if (reversal) out.push(reversal);
|
|
2762
|
+
}
|
|
2763
|
+
return out;
|
|
2764
|
+
}
|
|
2765
|
+
async reverseEntry(entry, ctx, tc) {
|
|
2766
|
+
for (let attempt = 0; attempt <= MAX_SETTLE_CONFLICTS; attempt++) {
|
|
2767
|
+
const current = attempt === 0 ? entry : await this.getById(entry._id, {
|
|
2768
|
+
...tc,
|
|
2769
|
+
throwOnNotFound: false
|
|
2770
|
+
});
|
|
2771
|
+
if (!current || current.reversedAt) return null;
|
|
2772
|
+
const reversal = {
|
|
2773
|
+
stampProgramId: String(current.stampProgramId),
|
|
2774
|
+
memberId: String(current.memberId),
|
|
2775
|
+
entryId: String(current._id),
|
|
2776
|
+
reversed: current.stamps - current.consumed,
|
|
2777
|
+
retainedInAwards: current.consumed
|
|
2778
|
+
};
|
|
2779
|
+
if (await runLoyaltyUnit(this.unitOfWork, this.deps, ctx, async (txCtx) => {
|
|
2780
|
+
if (!await this.claim(current._id, {
|
|
2781
|
+
field: "consumed",
|
|
2782
|
+
from: current.consumed,
|
|
2783
|
+
to: current.consumed,
|
|
2784
|
+
where: { reversedAt: { $exists: false } }
|
|
2785
|
+
}, {
|
|
2786
|
+
$set: {
|
|
2787
|
+
reversedAt: /* @__PURE__ */ new Date(),
|
|
2788
|
+
reversedStamps: reversal.reversed
|
|
2789
|
+
},
|
|
2790
|
+
$unset: { visitKey: "" }
|
|
2791
|
+
}, {
|
|
2792
|
+
session: txCtx.session,
|
|
2793
|
+
...tc
|
|
2794
|
+
})) return false;
|
|
2795
|
+
const payload = {
|
|
2796
|
+
...reversal,
|
|
2797
|
+
orderRef: current.orderRef
|
|
2798
|
+
};
|
|
2799
|
+
await dispatchLoyaltyEvent(this.deps, createEvent(LoyaltyEvents.STAMP_REVERSED, payload, txCtx, {
|
|
2800
|
+
resource: "stampEntry",
|
|
2801
|
+
resourceId: String(current._id)
|
|
2802
|
+
}), txCtx);
|
|
2803
|
+
return true;
|
|
2804
|
+
})) return reversal;
|
|
2805
|
+
}
|
|
2806
|
+
throw new StampClaimConflict(`reverseOrder: entry ${String(entry._id)} kept changing under the reversal`);
|
|
2807
|
+
}
|
|
2808
|
+
/** The member's card on every active program of their loyalty program. */
|
|
2809
|
+
async progress(memberId, ctx) {
|
|
2810
|
+
const tc = this.tc(ctx);
|
|
2811
|
+
const member = await this.memberRepo.requireMember(memberId, tc);
|
|
2812
|
+
const programs = await this.programRepo.findAll({
|
|
2813
|
+
status: "active",
|
|
2814
|
+
programId: member.programId
|
|
2815
|
+
}, {
|
|
2816
|
+
sort: { createdAt: 1 },
|
|
2817
|
+
...tc
|
|
2818
|
+
});
|
|
2819
|
+
const now = /* @__PURE__ */ new Date();
|
|
2820
|
+
const out = [];
|
|
2821
|
+
for (const program of programs) {
|
|
2822
|
+
const stampProgramId = String(program._id);
|
|
2823
|
+
const open = await this.openEntries(stampProgramId, memberId, now, tc);
|
|
2824
|
+
const expiries = open.flatMap((e) => e.expiresAt ? [new Date(e.expiresAt).getTime()] : []);
|
|
2825
|
+
const rewardsEarned = await this.awardRepo.count({
|
|
2826
|
+
stampProgramId,
|
|
2827
|
+
memberId
|
|
2828
|
+
}, tc);
|
|
2829
|
+
out.push({
|
|
2830
|
+
stampProgramId,
|
|
2831
|
+
name: program.name,
|
|
2832
|
+
stampsRequired: program.stampsRequired,
|
|
2833
|
+
stamps: open.reduce((n, e) => n + (e.stamps - e.consumed), 0),
|
|
2834
|
+
...expiries.length > 0 ? { nextExpiry: new Date(Math.min(...expiries)) } : {},
|
|
2835
|
+
rewardsEarned,
|
|
2836
|
+
...program.maxRewardsPerMember !== void 0 ? { maxRewardsPerMember: program.maxRewardsPerMember } : {},
|
|
2837
|
+
reward: program.reward
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
return out;
|
|
2841
|
+
}
|
|
2842
|
+
/** Unreversed, unexpired entries with stamps left, oldest first. */
|
|
2843
|
+
openEntries(stampProgramId, memberId, now, opts) {
|
|
2844
|
+
return this.findAll({
|
|
2845
|
+
stampProgramId,
|
|
2846
|
+
memberId,
|
|
2847
|
+
reversedAt: { $exists: false },
|
|
2848
|
+
$expr: { $lt: ["$consumed", "$stamps"] },
|
|
2849
|
+
$or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: now } }]
|
|
2850
|
+
}, {
|
|
2851
|
+
sort: {
|
|
2852
|
+
earnedAt: 1,
|
|
2853
|
+
_id: 1
|
|
2854
|
+
},
|
|
2855
|
+
...opts
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
};
|
|
2859
|
+
//#endregion
|
|
2860
|
+
//#region src/repositories/stamp-award.repository.ts
|
|
2861
|
+
/** Awards are written only by `stamps.settle`; this repository is their read surface. */
|
|
2862
|
+
var StampAwardRepository = class extends Repository {
|
|
2863
|
+
config;
|
|
2864
|
+
constructor(model, plugins = []) {
|
|
2865
|
+
super(model, plugins);
|
|
2866
|
+
}
|
|
2867
|
+
async create(data, options) {
|
|
2868
|
+
injectTenantIntoData(data, options, this.config.tenant);
|
|
2869
|
+
return super.create(data, options);
|
|
2870
|
+
}
|
|
2871
|
+
};
|
|
2872
|
+
//#endregion
|
|
2873
|
+
//#region src/repositories/stamp-program.repository.ts
|
|
2874
|
+
/**
|
|
2875
|
+
* Stamp-card programs. Every write is judged whole by `assertStampProgram` — a create, and a patch
|
|
2876
|
+
* merged over the stored program — so no path (HTTP or direct) stores a program that cannot award.
|
|
2877
|
+
*/
|
|
2878
|
+
var StampProgramRepository = class extends Repository {
|
|
2879
|
+
config;
|
|
2880
|
+
rewardIssuer;
|
|
2881
|
+
constructor(model, plugins = []) {
|
|
2882
|
+
super(model, plugins);
|
|
2883
|
+
}
|
|
2884
|
+
async create(data, options) {
|
|
2885
|
+
const doc = data;
|
|
2886
|
+
doc.programId ??= this.config.programId;
|
|
2887
|
+
injectTenantIntoData(doc, options, this.config.tenant);
|
|
2888
|
+
assertStampProgram(doc, { rewardIssuerBound: this.rewardIssuer !== void 0 });
|
|
2889
|
+
return super.create(data, options);
|
|
2890
|
+
}
|
|
2891
|
+
async createMany(docs, options) {
|
|
2892
|
+
for (const doc of docs) {
|
|
2893
|
+
doc.programId ??= this.config.programId;
|
|
2894
|
+
injectTenantIntoData(doc, options, this.config.tenant);
|
|
2895
|
+
assertStampProgram(doc, { rewardIssuerBound: this.rewardIssuer !== void 0 });
|
|
2896
|
+
}
|
|
2897
|
+
return super.createMany(docs, options);
|
|
2898
|
+
}
|
|
2899
|
+
/** A flat patch only: nested `earn` / `reward` are replaced whole, then the result is judged. */
|
|
2900
|
+
async update(id, data, options) {
|
|
2901
|
+
if (Object.keys(data).some((k) => k.startsWith("$"))) throw new ValidationError("Stamp program updates take a flat patch, not update operators");
|
|
2902
|
+
const current = await this.getById(id, {
|
|
2903
|
+
...options,
|
|
2904
|
+
lean: true,
|
|
2905
|
+
throwOnNotFound: false
|
|
2906
|
+
});
|
|
2907
|
+
if (current) assertStampProgram({
|
|
2908
|
+
...current,
|
|
2909
|
+
...data
|
|
2910
|
+
}, { rewardIssuerBound: this.rewardIssuer !== void 0 });
|
|
2911
|
+
return super.update(id, data, options);
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
//#endregion
|
|
1890
2915
|
//#region src/repositories/tier-definition.repository.ts
|
|
2916
|
+
/** The highest-ranked tier whose `minLifetimePoints` the lifetime reaches. */
|
|
2917
|
+
function qualifiedAmong(tiers, lifetime) {
|
|
2918
|
+
return [...tiers].sort((a, b) => b.rank - a.rank).find((t) => t.qualificationCriteria.minLifetimePoints !== void 0 && lifetime >= t.qualificationCriteria.minLifetimePoints) ?? null;
|
|
2919
|
+
}
|
|
1891
2920
|
var TierDefinitionRepository = class extends Repository {
|
|
1892
2921
|
memberRepo;
|
|
1893
2922
|
events;
|
|
@@ -1914,7 +2943,18 @@ var TierDefinitionRepository = class extends Repository {
|
|
|
1914
2943
|
outbox: this.outbox
|
|
1915
2944
|
}, event, ctx);
|
|
1916
2945
|
}
|
|
1917
|
-
|
|
2946
|
+
/** The highest tier `lifetime` points qualify for in a program, or null. */
|
|
2947
|
+
async qualifiedTierName(programId, lifetime, tc) {
|
|
2948
|
+
return qualifiedAmong(await this.findAll({ programId }, {
|
|
2949
|
+
sort: { rank: 1 },
|
|
2950
|
+
...tc
|
|
2951
|
+
}), lifetime)?.name ?? null;
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* Re-place a member by lifetime points. `upgradeOnly` is the earn path: a credit
|
|
2955
|
+
* may promote at once, while a downgrade waits for the periodic evaluation.
|
|
2956
|
+
*/
|
|
2957
|
+
async evaluateMember(memberId, ctx, options = {}) {
|
|
1918
2958
|
assertTenantContext(ctx, this.config.tenant);
|
|
1919
2959
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1920
2960
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
@@ -1930,12 +2970,12 @@ var TierDefinitionRepository = class extends Repository {
|
|
|
1930
2970
|
newTier: member.tier,
|
|
1931
2971
|
action: "unchanged"
|
|
1932
2972
|
};
|
|
1933
|
-
const qualifiedTier = sortedTiers
|
|
2973
|
+
const qualifiedTier = qualifiedAmong(sortedTiers, member.balance.lifetime);
|
|
1934
2974
|
const currentTierDef = member.tier ? sortedTiers.find((t) => t.name === member.tier) ?? null : null;
|
|
1935
2975
|
const currentRank = currentTierDef?.rank ?? -1;
|
|
1936
2976
|
const newRank = qualifiedTier?.rank ?? -1;
|
|
1937
2977
|
if (newRank > currentRank && qualifiedTier) return this.applyUpgrade(memberId, member, qualifiedTier.name, tc, ctx);
|
|
1938
|
-
if (newRank < currentRank && currentTierDef) return this.applyDowngrade(memberId, member, currentTierDef, qualifiedTier?.name ?? null, tc, ctx);
|
|
2978
|
+
if (newRank < currentRank && currentTierDef && !options.upgradeOnly) return this.applyDowngrade(memberId, member, currentTierDef, qualifiedTier?.name ?? null, tc, ctx);
|
|
1939
2979
|
return {
|
|
1940
2980
|
memberId,
|
|
1941
2981
|
previousTier: member.tier,
|
|
@@ -2170,7 +3210,7 @@ function asPlugins(p) {
|
|
|
2170
3210
|
return p ?? [];
|
|
2171
3211
|
}
|
|
2172
3212
|
function createRepositories(input) {
|
|
2173
|
-
const { models, plugins, tenant, config, events, outbox, notifications, unitOfWork } = input;
|
|
3213
|
+
const { models, plugins, tenant, config, events, outbox, notifications, unitOfWork, programSettings, rewardIssuer } = input;
|
|
2174
3214
|
const tenantPlugins = tenant?.enabled ? [multiTenantPlugin({
|
|
2175
3215
|
tenantField: tenant.tenantField,
|
|
2176
3216
|
contextKey: tenant.contextKey,
|
|
@@ -2183,10 +3223,14 @@ function createRepositories(input) {
|
|
|
2183
3223
|
const tierDefinition = new TierDefinitionRepository(models.TierDefinition, withTenant(plugins?.tierDefinition));
|
|
2184
3224
|
const redemption = new RedemptionRepository(models.Redemption, withTenant(plugins?.redemption));
|
|
2185
3225
|
const referral = new ReferralRepository(models.Referral, withTenant(plugins?.referral));
|
|
3226
|
+
const stampProgram = new StampProgramRepository(models.StampProgram, withTenant(plugins?.stampProgram));
|
|
3227
|
+
const stamps = new StampRepository(models.StampEntry, withTenant(plugins?.stampEntry));
|
|
3228
|
+
const stampAward = new StampAwardRepository(models.StampAward, withTenant(plugins?.stampAward));
|
|
2186
3229
|
member.events = events;
|
|
2187
3230
|
member.outbox = outbox;
|
|
2188
3231
|
member.notifications = notifications;
|
|
2189
3232
|
member.config = config;
|
|
3233
|
+
member.tierRepo = tierDefinition;
|
|
2190
3234
|
pointTransaction.memberRepo = member;
|
|
2191
3235
|
pointTransaction.events = events;
|
|
2192
3236
|
pointTransaction.outbox = outbox;
|
|
@@ -2195,6 +3239,9 @@ function createRepositories(input) {
|
|
|
2195
3239
|
pointTransaction.config = config;
|
|
2196
3240
|
earningRule.memberRepo = member;
|
|
2197
3241
|
earningRule.txRepo = pointTransaction;
|
|
3242
|
+
earningRule.tierRepo = tierDefinition;
|
|
3243
|
+
pointTransaction.program = programSettings;
|
|
3244
|
+
redemption.program = programSettings;
|
|
2198
3245
|
earningRule.config = config;
|
|
2199
3246
|
tierDefinition.memberRepo = member;
|
|
2200
3247
|
tierDefinition.events = events;
|
|
@@ -2214,14 +3261,29 @@ function createRepositories(input) {
|
|
|
2214
3261
|
referral.outbox = outbox;
|
|
2215
3262
|
referral.notifications = notifications;
|
|
2216
3263
|
referral.config = config;
|
|
3264
|
+
stampProgram.config = config;
|
|
3265
|
+
stampProgram.rewardIssuer = rewardIssuer;
|
|
3266
|
+
stampAward.config = config;
|
|
3267
|
+
stamps.memberRepo = member;
|
|
3268
|
+
stamps.txRepo = pointTransaction;
|
|
3269
|
+
stamps.programRepo = stampProgram;
|
|
3270
|
+
stamps.awardRepo = stampAward;
|
|
3271
|
+
stamps.events = events;
|
|
3272
|
+
stamps.outbox = outbox;
|
|
3273
|
+
stamps.unitOfWork = unitOfWork;
|
|
3274
|
+
stamps.config = config;
|
|
3275
|
+
stamps.rewardIssuer = rewardIssuer;
|
|
2217
3276
|
return {
|
|
2218
3277
|
member,
|
|
2219
3278
|
pointTransaction,
|
|
2220
3279
|
earningRule,
|
|
2221
3280
|
tierDefinition,
|
|
2222
3281
|
redemption,
|
|
2223
|
-
referral
|
|
3282
|
+
referral,
|
|
3283
|
+
stampProgram,
|
|
3284
|
+
stamps,
|
|
3285
|
+
stampAward
|
|
2224
3286
|
};
|
|
2225
3287
|
}
|
|
2226
3288
|
//#endregion
|
|
2227
|
-
export { SessionNotInTransactionError as C,
|
|
3289
|
+
export { SessionNotInTransactionError as A, MemberNotFoundError as C, ReferralNotFoundError as D, ReferralLimitExceededError as E, TierNotFoundError as M, UnmanagedSessionError as N, RuleNotFoundError as O, ValidationError as P, MemberInactiveError as S, RedemptionNotFoundError as T, CircularReferralError as _, StampRepository as a, LoyaltyCapabilityError as b, stampProgramPatchSchema as c, RedemptionRepository as d, PointTransactionRepository as f, EarningRuleRepository as g, flushPendingLoyaltyEvents as h, StampAwardRepository as i, TenantIsolationError as j, SelfReferralError as k, stampsForOrder as l, PENDING_EVENTS as m, TierDefinitionRepository as n, assertStampProgram as o, MemberRepository as p, StampProgramRepository as r, stampProgramInputSchema as s, createRepositories as t, ReferralRepository as u, DuplicateReferralError as v, RedemptionExpiredError as w, MemberAlreadyEnrolledError as x, InsufficientPointsError as y };
|