@classytic/loyalty 0.6.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 +180 -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-CR2k7jQ8.d.mts → index-BvyzKU7R.d.mts} +294 -34
- package/dist/{index-D6GvuvmF.d.mts → index-CorB0JL6.d.mts} +259 -113
- 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 +63 -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-B676z4bE.mjs → repositories-ER68JmUo.mjs} +1178 -102
- 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
|
}
|
|
@@ -485,6 +534,16 @@ var EarningRuleRepository = class extends Repository {
|
|
|
485
534
|
*/
|
|
486
535
|
const PENDING_EVENTS = Symbol.for("@classytic/loyalty:pendingEvents");
|
|
487
536
|
/**
|
|
537
|
+
* Set when the unit of work was PERMITTED to run without a transaction
|
|
538
|
+
* (`allowNonTransactional`), so the session-in-transaction guard knows the
|
|
539
|
+
* degradation was chosen rather than stumbled into.
|
|
540
|
+
*
|
|
541
|
+
* Carried on the context because the guard also runs from
|
|
542
|
+
* `dispatchLoyaltyEvent`, which never sees the unit of work. Internal and
|
|
543
|
+
* `Symbol.for`, so no host can set it by accident or on purpose.
|
|
544
|
+
*/
|
|
545
|
+
const NON_TRANSACTIONAL_CONSENTED = Symbol.for("@classytic/loyalty:nonTransactionalConsented");
|
|
546
|
+
/**
|
|
488
547
|
* A caller-supplied `ctx.session` is only evidence of a unit of work if it
|
|
489
548
|
* has an ACTIVE transaction. A bare `startSession()` commits every write
|
|
490
549
|
* independently, so treating it as atomic is how a balance `$inc` lands with
|
|
@@ -497,6 +556,7 @@ const PENDING_EVENTS = Symbol.for("@classytic/loyalty:pendingEvents");
|
|
|
497
556
|
*/
|
|
498
557
|
function assertSessionInTransaction(ctx) {
|
|
499
558
|
if (!ctx.session) return;
|
|
559
|
+
if (ctx[NON_TRANSACTIONAL_CONSENTED]) return;
|
|
500
560
|
const session = ctx.session;
|
|
501
561
|
if (typeof session.inTransaction !== "function" || !session.inTransaction()) throw new SessionNotInTransactionError();
|
|
502
562
|
}
|
|
@@ -549,11 +609,14 @@ async function runLoyaltyUnit(unitOfWork, deps, ctx, body) {
|
|
|
549
609
|
const pending = [];
|
|
550
610
|
const result = await unitOfWork.withTransaction(async (session) => {
|
|
551
611
|
pending.length = 0;
|
|
552
|
-
|
|
612
|
+
const innerCtx = {
|
|
553
613
|
...internalCtx,
|
|
554
614
|
session,
|
|
555
|
-
[PENDING_EVENTS]: pending
|
|
556
|
-
|
|
615
|
+
[PENDING_EVENTS]: pending,
|
|
616
|
+
...unitOfWork.allowsNonTransactional ? { [NON_TRANSACTIONAL_CONSENTED]: true } : {}
|
|
617
|
+
};
|
|
618
|
+
assertSessionInTransaction(innerCtx);
|
|
619
|
+
return body(innerCtx);
|
|
557
620
|
});
|
|
558
621
|
await flushPendingLoyaltyEvents(deps.events, pending, deps.logger);
|
|
559
622
|
return result;
|
|
@@ -589,6 +652,7 @@ var MemberRepository = class extends Repository {
|
|
|
589
652
|
outbox;
|
|
590
653
|
notifications = null;
|
|
591
654
|
config;
|
|
655
|
+
tierRepo;
|
|
592
656
|
/** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
|
|
593
657
|
async _dispatch(event, ctx) {
|
|
594
658
|
await dispatchLoyaltyEvent({
|
|
@@ -658,14 +722,16 @@ var MemberRepository = class extends Repository {
|
|
|
658
722
|
})) throw new MemberAlreadyEnrolledError(input.externalId);
|
|
659
723
|
const referralCode = generateReferralCode(this.config.referral.codeLength);
|
|
660
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);
|
|
661
727
|
const data = {
|
|
662
|
-
programId
|
|
728
|
+
programId,
|
|
663
729
|
externalId: input.externalId,
|
|
664
730
|
externalType: input.externalType,
|
|
665
731
|
status: "active",
|
|
666
732
|
cardId: input.cardId,
|
|
667
733
|
referralCode,
|
|
668
|
-
tier
|
|
734
|
+
tier,
|
|
669
735
|
tierOverride: null,
|
|
670
736
|
balance: {
|
|
671
737
|
current: 0,
|
|
@@ -776,7 +842,35 @@ var MemberRepository = class extends Repository {
|
|
|
776
842
|
}
|
|
777
843
|
};
|
|
778
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
|
|
779
872
|
//#region src/repositories/point-transaction.repository.ts
|
|
873
|
+
const EXPIRY_PAGE = 100;
|
|
780
874
|
var PointTransactionRepository = class extends Repository {
|
|
781
875
|
memberRepo;
|
|
782
876
|
events;
|
|
@@ -784,9 +878,15 @@ var PointTransactionRepository = class extends Repository {
|
|
|
784
878
|
notifications = null;
|
|
785
879
|
unitOfWork;
|
|
786
880
|
config;
|
|
881
|
+
program;
|
|
787
882
|
constructor(model, plugins = []) {
|
|
788
883
|
super(model, [...plugins, appendOnlyPlugin()]);
|
|
789
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
|
+
}
|
|
790
890
|
/** Session-bound outbox save + transport publish (PACKAGE_RULES §5.5 + P8). */
|
|
791
891
|
async _dispatch(event, ctx) {
|
|
792
892
|
await dispatchLoyaltyEvent({
|
|
@@ -815,6 +915,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
815
915
|
}
|
|
816
916
|
async earnPoints(input, ctx) {
|
|
817
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(", ")}`);
|
|
818
919
|
assertTenantContext(ctx, this.config.tenant);
|
|
819
920
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
820
921
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
@@ -826,6 +927,8 @@ var PointTransactionRepository = class extends Repository {
|
|
|
826
927
|
if (existing) return existing;
|
|
827
928
|
}
|
|
828
929
|
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
930
|
+
const program = await this.program();
|
|
931
|
+
const expiresAt = input.expiresAt ?? this.defaultExpiry(program);
|
|
829
932
|
try {
|
|
830
933
|
let payload;
|
|
831
934
|
const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
@@ -849,12 +952,14 @@ var PointTransactionRepository = class extends Repository {
|
|
|
849
952
|
programId: member.programId,
|
|
850
953
|
type: "earn",
|
|
851
954
|
points: input.points,
|
|
955
|
+
source: input.source,
|
|
956
|
+
pointValueMinor: program.pointValueMinor,
|
|
852
957
|
balanceAfter: updatedMember.balance.current,
|
|
853
958
|
description: input.description,
|
|
854
959
|
referenceType: input.referenceType,
|
|
855
960
|
referenceId: input.referenceId,
|
|
856
961
|
earningRuleId: input.earningRuleId,
|
|
857
|
-
expiresAt
|
|
962
|
+
expiresAt,
|
|
858
963
|
actorId: ctx.actorId,
|
|
859
964
|
...input.metadata ? { metadata: input.metadata } : {}
|
|
860
965
|
};
|
|
@@ -868,6 +973,8 @@ var PointTransactionRepository = class extends Repository {
|
|
|
868
973
|
externalId: member.externalId,
|
|
869
974
|
externalType: member.externalType,
|
|
870
975
|
points: input.points,
|
|
976
|
+
source: input.source,
|
|
977
|
+
pointValueMinor: program.pointValueMinor,
|
|
871
978
|
balanceAfter: created.balanceAfter,
|
|
872
979
|
transactionId: created._id,
|
|
873
980
|
referenceType: input.referenceType,
|
|
@@ -928,6 +1035,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
928
1035
|
const input = {
|
|
929
1036
|
memberId: row.memberId,
|
|
930
1037
|
points: row.points,
|
|
1038
|
+
source: "campaign",
|
|
931
1039
|
description: source.description,
|
|
932
1040
|
idempotencyKey: `${source.eventId}:${row.memberId}`,
|
|
933
1041
|
metadata: {
|
|
@@ -984,9 +1092,10 @@ var PointTransactionRepository = class extends Repository {
|
|
|
984
1092
|
throwOnNotFound: false,
|
|
985
1093
|
...tc
|
|
986
1094
|
});
|
|
987
|
-
if (existing) return existing;
|
|
1095
|
+
if (existing) return sameAdjustment(existing, input);
|
|
988
1096
|
}
|
|
989
1097
|
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
1098
|
+
const { pointValueMinor } = await this.program();
|
|
990
1099
|
if (input.points < 0 && member.balance.current < Math.abs(input.points)) throw new InsufficientPointsError(member.balance.current, Math.abs(input.points));
|
|
991
1100
|
let payload;
|
|
992
1101
|
const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
@@ -996,7 +1105,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
996
1105
|
throwOnNotFound: false,
|
|
997
1106
|
...tc
|
|
998
1107
|
});
|
|
999
|
-
if (dup) return dup;
|
|
1108
|
+
if (dup) return sameAdjustment(dup, input);
|
|
1000
1109
|
}
|
|
1001
1110
|
const balanceUpdate = { current: input.points };
|
|
1002
1111
|
if (input.points > 0) balanceUpdate.lifetime = input.points;
|
|
@@ -1010,6 +1119,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1010
1119
|
programId: member.programId,
|
|
1011
1120
|
type: "adjust",
|
|
1012
1121
|
points: input.points,
|
|
1122
|
+
pointValueMinor,
|
|
1013
1123
|
balanceAfter: updatedMember.balance.current,
|
|
1014
1124
|
description: input.description,
|
|
1015
1125
|
referenceType: input.referenceType,
|
|
@@ -1029,6 +1139,7 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1029
1139
|
memberId: member._id,
|
|
1030
1140
|
externalId: member.externalId,
|
|
1031
1141
|
points: input.points,
|
|
1142
|
+
pointValueMinor,
|
|
1032
1143
|
balanceAfter: created.balanceAfter,
|
|
1033
1144
|
transactionId: created._id,
|
|
1034
1145
|
description: input.description
|
|
@@ -1042,65 +1153,244 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1042
1153
|
if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
|
|
1043
1154
|
return tx;
|
|
1044
1155
|
}
|
|
1045
|
-
|
|
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");
|
|
1046
1172
|
assertTenantContext(ctx, this.config.tenant);
|
|
1047
|
-
const
|
|
1048
|
-
const
|
|
1049
|
-
const
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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, {
|
|
1065
1232
|
session,
|
|
1066
1233
|
...tc
|
|
1067
1234
|
});
|
|
1068
|
-
const
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
...tc,
|
|
1073
|
-
bypassAppendOnly: true
|
|
1074
|
-
});
|
|
1075
|
-
if (amount <= 0) return 0;
|
|
1076
|
-
const updatedMember = await this.memberRepo.updateBalance(tx.memberId, {
|
|
1077
|
-
current: -amount,
|
|
1078
|
-
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
|
|
1079
1239
|
}, {
|
|
1080
1240
|
session,
|
|
1081
1241
|
...tc
|
|
1082
1242
|
});
|
|
1083
|
-
if (
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
+
}
|
|
1094
1265
|
}, {
|
|
1095
1266
|
session,
|
|
1096
1267
|
...tc
|
|
1097
1268
|
});
|
|
1098
|
-
|
|
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);
|
|
1099
1286
|
});
|
|
1100
|
-
if (
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
+
}
|
|
1104
1394
|
}
|
|
1105
1395
|
}
|
|
1106
1396
|
if (totalExpired > 0) await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
@@ -1117,30 +1407,16 @@ var PointTransactionRepository = class extends Repository {
|
|
|
1117
1407
|
};
|
|
1118
1408
|
}
|
|
1119
1409
|
};
|
|
1120
|
-
/** E11000 across the raw driver + mongokit wrapper shapes. */
|
|
1121
|
-
function isDuplicateKey(err) {
|
|
1122
|
-
if (typeof err !== "object" || err === null) return false;
|
|
1123
|
-
const e = err;
|
|
1124
|
-
if (e.code === 11e3) return true;
|
|
1125
|
-
if (e.status === 409) return true;
|
|
1126
|
-
if (e.cause !== void 0) return isDuplicateKey(e.cause);
|
|
1127
|
-
return false;
|
|
1128
|
-
}
|
|
1129
1410
|
/**
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
* With an idempotency key present, the winning insert is the source of
|
|
1133
|
-
* truth; losers are short-circuited to its doc rather than leaking the
|
|
1134
|
-
* 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.
|
|
1135
1413
|
*/
|
|
1136
|
-
function
|
|
1137
|
-
if (
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
if (e.cause !== void 0) return isWriteConflict(e.cause);
|
|
1143
|
-
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;
|
|
1144
1420
|
}
|
|
1145
1421
|
//#endregion
|
|
1146
1422
|
//#region src/repositories/redemption.repository.ts
|
|
@@ -1152,6 +1428,7 @@ var RedemptionRepository = class extends Repository {
|
|
|
1152
1428
|
notifications = null;
|
|
1153
1429
|
unitOfWork;
|
|
1154
1430
|
config;
|
|
1431
|
+
program;
|
|
1155
1432
|
constructor(model, plugins = []) {
|
|
1156
1433
|
super(model, plugins);
|
|
1157
1434
|
}
|
|
@@ -1179,6 +1456,38 @@ var RedemptionRepository = class extends Repository {
|
|
|
1179
1456
|
};
|
|
1180
1457
|
}
|
|
1181
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
|
+
/**
|
|
1182
1491
|
* Atomic compare-and-set status transition.
|
|
1183
1492
|
*
|
|
1184
1493
|
* `null` means ONE thing and one thing only: the guarded update matched
|
|
@@ -1197,10 +1506,13 @@ var RedemptionRepository = class extends Repository {
|
|
|
1197
1506
|
*
|
|
1198
1507
|
* Anything that throws in here is a real error and must propagate.
|
|
1199
1508
|
*/
|
|
1200
|
-
async atomicStatusTransition(id, expectedStatus, data, ctx) {
|
|
1509
|
+
async atomicStatusTransition(id, expectedStatus, data, ctx, guard) {
|
|
1201
1510
|
return this.update(id, data, {
|
|
1202
1511
|
lean: true,
|
|
1203
|
-
query: {
|
|
1512
|
+
query: {
|
|
1513
|
+
...guard,
|
|
1514
|
+
status: expectedStatus
|
|
1515
|
+
},
|
|
1204
1516
|
throwOnNotFound: false,
|
|
1205
1517
|
...ctx
|
|
1206
1518
|
});
|
|
@@ -1217,8 +1529,9 @@ var RedemptionRepository = class extends Repository {
|
|
|
1217
1529
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1218
1530
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
1219
1531
|
const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
|
|
1220
|
-
const
|
|
1221
|
-
const {
|
|
1532
|
+
const program = await this.program();
|
|
1533
|
+
const { minPoints, minOrderAmount, maxRedeemPercent } = program.redemption;
|
|
1534
|
+
const { pointValueMinor } = program;
|
|
1222
1535
|
if (input.pointsToRedeem < minPoints) return {
|
|
1223
1536
|
valid: false,
|
|
1224
1537
|
error: `Minimum ${minPoints} points required`,
|
|
@@ -1245,10 +1558,18 @@ var RedemptionRepository = class extends Repository {
|
|
|
1245
1558
|
const cappedDiscountMinor = Math.min(requestedDiscountMinor, maxDiscountMinor);
|
|
1246
1559
|
const maxAllowedPoints = Math.floor(maxDiscountMinor / pointValueMinor);
|
|
1247
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
|
+
};
|
|
1248
1569
|
return {
|
|
1249
1570
|
valid: true,
|
|
1250
1571
|
pointsToRedeem: actualPoints,
|
|
1251
|
-
discountAmount
|
|
1572
|
+
discountAmount,
|
|
1252
1573
|
maxAllowedPoints
|
|
1253
1574
|
};
|
|
1254
1575
|
}
|
|
@@ -1284,7 +1605,7 @@ var RedemptionRepository = class extends Repository {
|
|
|
1284
1605
|
pointsReserved: validation.pointsToRedeem,
|
|
1285
1606
|
pointsConfirmed: 0,
|
|
1286
1607
|
discountAmount: validation.discountAmount,
|
|
1287
|
-
pointValueMinor:
|
|
1608
|
+
pointValueMinor: validation.discountAmount / Math.max(1, validation.pointsToRedeem),
|
|
1288
1609
|
status: "reserved",
|
|
1289
1610
|
ownerType: input.ownerType,
|
|
1290
1611
|
ownerId: input.ownerId,
|
|
@@ -1312,7 +1633,17 @@ var RedemptionRepository = class extends Repository {
|
|
|
1312
1633
|
if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
|
|
1313
1634
|
return redemption;
|
|
1314
1635
|
}
|
|
1315
|
-
|
|
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) {
|
|
1316
1647
|
assertTenantContext(ctx, this.config.tenant);
|
|
1317
1648
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1318
1649
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
@@ -1321,18 +1652,30 @@ var RedemptionRepository = class extends Repository {
|
|
|
1321
1652
|
...tc
|
|
1322
1653
|
});
|
|
1323
1654
|
if (!redemption) throw new RedemptionNotFoundError(redemptionId);
|
|
1324
|
-
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;
|
|
1325
1657
|
let payload;
|
|
1326
1658
|
const updated = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
|
|
1327
1659
|
const session = txCtx.session;
|
|
1328
|
-
|
|
1660
|
+
const transitioned = await this.atomicStatusTransition(redemptionId, "reserved", {
|
|
1329
1661
|
status: "confirmed",
|
|
1330
1662
|
pointsConfirmed: redemption.pointsReserved,
|
|
1331
|
-
confirmedAt: /* @__PURE__ */ new Date()
|
|
1663
|
+
confirmedAt: /* @__PURE__ */ new Date(),
|
|
1664
|
+
...rebind ? {
|
|
1665
|
+
ownerType: rebind.to.ownerType,
|
|
1666
|
+
ownerId: rebind.to.ownerId
|
|
1667
|
+
} : {}
|
|
1332
1668
|
}, {
|
|
1333
1669
|
session,
|
|
1334
1670
|
...tc
|
|
1335
|
-
}
|
|
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
|
+
}
|
|
1336
1679
|
const member = await this.memberRepo.requireActiveMember(redemption.memberId, {
|
|
1337
1680
|
session,
|
|
1338
1681
|
...tc
|
|
@@ -1344,8 +1687,8 @@ var RedemptionRepository = class extends Repository {
|
|
|
1344
1687
|
points: -redemption.pointsReserved,
|
|
1345
1688
|
balanceAfter: member.balance.current,
|
|
1346
1689
|
description: `Redemption confirmed: ${redemptionId}`,
|
|
1347
|
-
referenceType:
|
|
1348
|
-
referenceId:
|
|
1690
|
+
referenceType: transitioned.ownerType,
|
|
1691
|
+
referenceId: transitioned.ownerId,
|
|
1349
1692
|
actorId: ctx.actorId
|
|
1350
1693
|
}, {
|
|
1351
1694
|
session,
|
|
@@ -1533,7 +1876,8 @@ var RedemptionRepository = class extends Repository {
|
|
|
1533
1876
|
pointsCredited: pointsToCredit,
|
|
1534
1877
|
originalPoints,
|
|
1535
1878
|
proportional: usedProportional,
|
|
1536
|
-
|
|
1879
|
+
pointValueMinor: redemption.pointValueMinor,
|
|
1880
|
+
...result.reversalTransactionId ? { transactionId: String(result.reversalTransactionId) } : {}
|
|
1537
1881
|
};
|
|
1538
1882
|
if (options?.reason) payload.reason = options.reason;
|
|
1539
1883
|
await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_REVERSED, payload, txCtx, {
|
|
@@ -1545,6 +1889,90 @@ var RedemptionRepository = class extends Repository {
|
|
|
1545
1889
|
if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
|
|
1546
1890
|
return reversed;
|
|
1547
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
|
+
}
|
|
1548
1976
|
async cleanupExpired(ctx) {
|
|
1549
1977
|
assertTenantContext(ctx, this.config.tenant);
|
|
1550
1978
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
@@ -1777,6 +2205,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1777
2205
|
memberId: referrer._id,
|
|
1778
2206
|
points: referrerRewardPoints,
|
|
1779
2207
|
description: `Referral reward: referred ${referee.externalId}`,
|
|
2208
|
+
source: "referral",
|
|
1780
2209
|
referenceType: "referral",
|
|
1781
2210
|
referenceId: referral._id,
|
|
1782
2211
|
idempotencyKey: `referral:${referral._id}:referrer`
|
|
@@ -1788,6 +2217,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1788
2217
|
memberId: referee._id,
|
|
1789
2218
|
points: refereeRewardPoints,
|
|
1790
2219
|
description: `Referral welcome bonus from ${referrer.externalId}`,
|
|
2220
|
+
source: "referral",
|
|
1791
2221
|
referenceType: "referral",
|
|
1792
2222
|
referenceId: referral._id,
|
|
1793
2223
|
idempotencyKey: `referral:${referral._id}:referee`
|
|
@@ -1828,6 +2258,7 @@ var ReferralRepository = class extends Repository {
|
|
|
1828
2258
|
memberId: ancestor._id,
|
|
1829
2259
|
points,
|
|
1830
2260
|
description: `Multi-tier referral reward (depth ${depth})`,
|
|
2261
|
+
source: "referral",
|
|
1831
2262
|
referenceType: "referral",
|
|
1832
2263
|
referenceId: referral._id,
|
|
1833
2264
|
idempotencyKey: `referral:${referral._id}:ancestor:${depth}`,
|
|
@@ -1873,7 +2304,619 @@ var ReferralRepository = class extends Repository {
|
|
|
1873
2304
|
}
|
|
1874
2305
|
};
|
|
1875
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
|
|
1876
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
|
+
}
|
|
1877
2920
|
var TierDefinitionRepository = class extends Repository {
|
|
1878
2921
|
memberRepo;
|
|
1879
2922
|
events;
|
|
@@ -1900,7 +2943,18 @@ var TierDefinitionRepository = class extends Repository {
|
|
|
1900
2943
|
outbox: this.outbox
|
|
1901
2944
|
}, event, ctx);
|
|
1902
2945
|
}
|
|
1903
|
-
|
|
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 = {}) {
|
|
1904
2958
|
assertTenantContext(ctx, this.config.tenant);
|
|
1905
2959
|
const tenantId = getTenantValue(ctx, this.config.tenant);
|
|
1906
2960
|
const tc = tenantCtx(this.config.tenant, tenantId);
|
|
@@ -1916,12 +2970,12 @@ var TierDefinitionRepository = class extends Repository {
|
|
|
1916
2970
|
newTier: member.tier,
|
|
1917
2971
|
action: "unchanged"
|
|
1918
2972
|
};
|
|
1919
|
-
const qualifiedTier = sortedTiers
|
|
2973
|
+
const qualifiedTier = qualifiedAmong(sortedTiers, member.balance.lifetime);
|
|
1920
2974
|
const currentTierDef = member.tier ? sortedTiers.find((t) => t.name === member.tier) ?? null : null;
|
|
1921
2975
|
const currentRank = currentTierDef?.rank ?? -1;
|
|
1922
2976
|
const newRank = qualifiedTier?.rank ?? -1;
|
|
1923
2977
|
if (newRank > currentRank && qualifiedTier) return this.applyUpgrade(memberId, member, qualifiedTier.name, tc, ctx);
|
|
1924
|
-
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);
|
|
1925
2979
|
return {
|
|
1926
2980
|
memberId,
|
|
1927
2981
|
previousTier: member.tier,
|
|
@@ -2156,7 +3210,7 @@ function asPlugins(p) {
|
|
|
2156
3210
|
return p ?? [];
|
|
2157
3211
|
}
|
|
2158
3212
|
function createRepositories(input) {
|
|
2159
|
-
const { models, plugins, tenant, config, events, outbox, notifications, unitOfWork } = input;
|
|
3213
|
+
const { models, plugins, tenant, config, events, outbox, notifications, unitOfWork, programSettings, rewardIssuer } = input;
|
|
2160
3214
|
const tenantPlugins = tenant?.enabled ? [multiTenantPlugin({
|
|
2161
3215
|
tenantField: tenant.tenantField,
|
|
2162
3216
|
contextKey: tenant.contextKey,
|
|
@@ -2169,10 +3223,14 @@ function createRepositories(input) {
|
|
|
2169
3223
|
const tierDefinition = new TierDefinitionRepository(models.TierDefinition, withTenant(plugins?.tierDefinition));
|
|
2170
3224
|
const redemption = new RedemptionRepository(models.Redemption, withTenant(plugins?.redemption));
|
|
2171
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));
|
|
2172
3229
|
member.events = events;
|
|
2173
3230
|
member.outbox = outbox;
|
|
2174
3231
|
member.notifications = notifications;
|
|
2175
3232
|
member.config = config;
|
|
3233
|
+
member.tierRepo = tierDefinition;
|
|
2176
3234
|
pointTransaction.memberRepo = member;
|
|
2177
3235
|
pointTransaction.events = events;
|
|
2178
3236
|
pointTransaction.outbox = outbox;
|
|
@@ -2181,6 +3239,9 @@ function createRepositories(input) {
|
|
|
2181
3239
|
pointTransaction.config = config;
|
|
2182
3240
|
earningRule.memberRepo = member;
|
|
2183
3241
|
earningRule.txRepo = pointTransaction;
|
|
3242
|
+
earningRule.tierRepo = tierDefinition;
|
|
3243
|
+
pointTransaction.program = programSettings;
|
|
3244
|
+
redemption.program = programSettings;
|
|
2184
3245
|
earningRule.config = config;
|
|
2185
3246
|
tierDefinition.memberRepo = member;
|
|
2186
3247
|
tierDefinition.events = events;
|
|
@@ -2200,14 +3261,29 @@ function createRepositories(input) {
|
|
|
2200
3261
|
referral.outbox = outbox;
|
|
2201
3262
|
referral.notifications = notifications;
|
|
2202
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;
|
|
2203
3276
|
return {
|
|
2204
3277
|
member,
|
|
2205
3278
|
pointTransaction,
|
|
2206
3279
|
earningRule,
|
|
2207
3280
|
tierDefinition,
|
|
2208
3281
|
redemption,
|
|
2209
|
-
referral
|
|
3282
|
+
referral,
|
|
3283
|
+
stampProgram,
|
|
3284
|
+
stamps,
|
|
3285
|
+
stampAward
|
|
2210
3286
|
};
|
|
2211
3287
|
}
|
|
2212
3288
|
//#endregion
|
|
2213
|
-
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 };
|