@classytic/loyalty 0.7.0 → 0.10.0

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