@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/dist/constants-DfrENxFG.mjs +75 -0
  3. package/dist/domain/earn-base.d.mts +35 -0
  4. package/dist/domain/earn-base.mjs +37 -0
  5. package/dist/domain/enums/index.d.mts +2 -2
  6. package/dist/domain/enums/index.mjs +1 -2
  7. package/dist/domain/rule-schedule.d.mts +24 -0
  8. package/dist/domain/rule-schedule.mjs +12 -0
  9. package/dist/events/index.d.mts +2 -2
  10. package/dist/events/index.mjs +3 -3
  11. package/dist/{events-BLjg3uLA.mjs → events-DhZolW61.mjs} +104 -4
  12. package/dist/{index-CR2k7jQ8.d.mts → index-BvyzKU7R.d.mts} +294 -34
  13. package/dist/{index-D6GvuvmF.d.mts → index-CorB0JL6.d.mts} +259 -113
  14. package/dist/index-DRrEhE0r.d.mts +604 -0
  15. package/dist/{index-YUFamhDk.d.mts → index-Db5Gb7x_.d.mts} +187 -39
  16. package/dist/index-tjMMmPzU.d.mts +36 -0
  17. package/dist/index.d.mts +121 -30
  18. package/dist/index.mjs +63 -10
  19. package/dist/{loyalty-events-DXirolM_.mjs → loyalty-events-j29B66YW.mjs} +20 -13
  20. package/dist/models/index.d.mts +2 -2
  21. package/dist/models/index.mjs +1 -2
  22. package/dist/{models-weWqS5Bc.mjs → models-BEGLduz1.mjs} +276 -7
  23. package/dist/notifications/index.d.mts +3 -4
  24. package/dist/repositories/index.d.mts +2 -2
  25. package/dist/repositories/index.mjs +1 -2
  26. package/dist/{repositories-B676z4bE.mjs → repositories-ER68JmUo.mjs} +1178 -102
  27. package/dist/types/index.d.mts +2 -3
  28. package/package.json +39 -22
  29. package/dist/config-CtkGj3xZ.d.mts +0 -178
  30. package/dist/constants-DOPBh6kt.mjs +0 -48
  31. package/dist/index-Cp_sXNpG.d.mts +0 -349
  32. package/dist/index-D_Oe3aDk.d.mts +0 -19
@@ -1,9 +1,12 @@
1
- import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-DXirolM_.mjs";
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 (conditions.dateRange) {
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
- return body({
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: input.programId ?? this.config.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: input.tier ?? null,
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: input.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
- async processExpirations(ctx) {
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 tenantId = getTenantValue(ctx, this.config.tenant);
1048
- const tc = tenantCtx(this.config.tenant, tenantId);
1049
- const now = /* @__PURE__ */ new Date();
1050
- const expiredTxs = await this.findAll({
1051
- expiresAt: { $lte: now },
1052
- expiredAt: { $exists: false }
1053
- }, {
1054
- sort: { createdAt: 1 },
1055
- limit: 100,
1056
- ...tc
1057
- });
1058
- let totalExpired = 0;
1059
- let transactionCount = 0;
1060
- const affectedMemberIds = /* @__PURE__ */ new Set();
1061
- for (const tx of expiredTxs) {
1062
- throwIfAborted(ctx.signal);
1063
- const expiredAmount = await this.unitOfWork.withTransaction(async (session) => {
1064
- const member = await this.memberRepo.requireMember(tx.memberId, {
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 available = Math.max(0, member.balance.current);
1069
- const amount = Math.min(tx.points, available);
1070
- await this.update(tx._id, { expiredAt: now }, {
1071
- session,
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 (updatedMember.balance.current < 0) throw new InsufficientPointsError(updatedMember.balance.current + amount, amount);
1084
- await this.create({
1085
- memberId: tx.memberId,
1086
- programId: tx.programId,
1087
- type: "expire",
1088
- points: -amount,
1089
- balanceAfter: updatedMember.balance.current,
1090
- description: `Points expired from transaction ${tx._id}`,
1091
- referenceType: "expiration",
1092
- referenceId: tx._id,
1093
- actorId: ctx.actorId
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
- return amount;
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 (expiredAmount > 0) {
1101
- affectedMemberIds.add(tx.memberId);
1102
- totalExpired += expiredAmount;
1103
- transactionCount++;
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
- * Transient MongoDB WriteConflict — surfaces under concurrent webhook
1131
- * replay when two transactions try to update the same member balance.
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 isWriteConflict(err) {
1137
- if (typeof err !== "object" || err === null) return false;
1138
- const e = err;
1139
- if (e.code === 112 || e.codeName === "WriteConflict") return true;
1140
- if (Array.isArray(e.errorLabels) && e.errorLabels.includes("TransientTransactionError")) return true;
1141
- if (typeof e.message === "string" && /write conflict/i.test(e.message)) return true;
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: { status: expectedStatus },
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 { minPoints, minOrderAmount, maxRedeemPercent } = this.config.redemption;
1221
- const { pointValueMinor } = this.config;
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: actualPoints * pointValueMinor,
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: this.config.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
- async confirm(redemptionId, ctx) {
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
- if (!await this.atomicStatusTransition(redemptionId, "reserved", {
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
- })) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
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: redemption.ownerType,
1348
- referenceId: redemption.ownerId,
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
- transactionId: result.reversalTransactionId ?? ""
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
- async evaluateMember(memberId, ctx) {
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.find((t) => t.qualificationCriteria.minLifetimePoints !== void 0 && member.balance.lifetime >= t.qualificationCriteria.minLifetimePoints) ?? null;
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, ValidationError as D, UnmanagedSessionError as E, SelfReferralError as S, TierNotFoundError as T, RedemptionExpiredError as _, PointTransactionRepository as a, ReferralNotFoundError as b, flushPendingLoyaltyEvents as c, DuplicateReferralError as d, InsufficientPointsError as f, MemberNotFoundError as g, MemberInactiveError as h, RedemptionRepository as i, EarningRuleRepository as l, MemberAlreadyEnrolledError as m, TierDefinitionRepository as n, MemberRepository as o, LoyaltyCapabilityError as p, ReferralRepository as r, PENDING_EVENTS as s, createRepositories as t, CircularReferralError as u, RedemptionNotFoundError as v, TenantIsolationError as w, RuleNotFoundError as x, ReferralLimitExceededError as y };
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 };