@classytic/loyalty 0.2.3 → 0.3.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.
@@ -1,7 +1,18 @@
1
- import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-Cyt9TwX6.mjs";
1
+ import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-C38JA3cU.mjs";
2
2
  import { Repository, multiTenantPlugin } from "@classytic/mongokit";
3
3
  import { randomBytes } from "node:crypto";
4
+ import { throwIfAborted } from "@classytic/repo-core/repository";
4
5
  //#region src/domain/errors/base.ts
6
+ /**
7
+ * Base for every loyalty throwable. Implements `HttpError` from
8
+ * `@classytic/repo-core/errors` (the org-canonical throwable contract) so arc
9
+ * catches and serializes them to `ErrorContract` via `toErrorContract()` with
10
+ * the right HTTP `status` and a stable, machine-readable `code`.
11
+ *
12
+ * Codes are hierarchical + lowercase per PACKAGE_RULES ("Errors and wire
13
+ * envelopes"): `loyalty.<entity>.<problem>`. The `status` field (not the old
14
+ * `httpStatus`) is what `HttpError` / arc read — a clean break, no alias.
15
+ */
5
16
  var LoyaltyError = class extends Error {
6
17
  constructor(message) {
7
18
  super(message);
@@ -11,107 +22,147 @@ var LoyaltyError = class extends Error {
11
22
  return {
12
23
  code: this.code,
13
24
  message: this.message,
14
- name: this.name
25
+ name: this.name,
26
+ status: this.status
15
27
  };
16
28
  }
17
29
  };
18
30
  //#endregion
19
31
  //#region src/domain/errors/domain-errors.ts
20
32
  var InsufficientPointsError = class extends LoyaltyError {
21
- code = "INSUFFICIENT_POINTS";
22
- httpStatus = 400;
33
+ code = "loyalty.points.insufficient";
34
+ status = 400;
23
35
  constructor(available, requested) {
24
36
  super(`Insufficient points. Available: ${available}, requested: ${requested}`);
25
37
  }
26
38
  };
27
39
  var MemberNotFoundError = class extends LoyaltyError {
28
- code = "MEMBER_NOT_FOUND";
29
- httpStatus = 404;
40
+ code = "loyalty.member.not_found";
41
+ status = 404;
30
42
  constructor(id) {
31
43
  super(`Member not found: ${id}`);
32
44
  }
33
45
  };
34
46
  var MemberAlreadyEnrolledError = class extends LoyaltyError {
35
- code = "MEMBER_ALREADY_ENROLLED";
36
- httpStatus = 409;
47
+ code = "loyalty.member.already_enrolled";
48
+ status = 409;
37
49
  constructor(externalId) {
38
50
  super(`Member already enrolled: ${externalId}`);
39
51
  }
40
52
  };
41
53
  var MemberInactiveError = class extends LoyaltyError {
42
- code = "MEMBER_INACTIVE";
43
- httpStatus = 422;
54
+ code = "loyalty.member.inactive";
55
+ status = 422;
44
56
  constructor(id) {
45
57
  super(`Member is not active: ${id}`);
46
58
  }
47
59
  };
60
+ var RuleNotFoundError = class extends LoyaltyError {
61
+ code = "loyalty.earning_rule.not_found";
62
+ status = 404;
63
+ constructor(id) {
64
+ super(`Earning rule not found: ${id}`);
65
+ }
66
+ };
48
67
  var TierNotFoundError = class extends LoyaltyError {
49
- code = "TIER_NOT_FOUND";
50
- httpStatus = 404;
68
+ code = "loyalty.tier.not_found";
69
+ status = 404;
51
70
  constructor(id) {
52
71
  super(`Tier not found: ${id}`);
53
72
  }
54
73
  };
55
74
  var RedemptionNotFoundError = class extends LoyaltyError {
56
- code = "REDEMPTION_NOT_FOUND";
57
- httpStatus = 404;
75
+ code = "loyalty.redemption.not_found";
76
+ status = 404;
58
77
  constructor(id) {
59
78
  super(`Redemption not found: ${id}`);
60
79
  }
61
80
  };
62
81
  var RedemptionExpiredError = class extends LoyaltyError {
63
- code = "REDEMPTION_EXPIRED";
64
- httpStatus = 410;
82
+ code = "loyalty.redemption.expired";
83
+ status = 410;
65
84
  constructor(id) {
66
85
  super(`Redemption expired: ${id}`);
67
86
  }
68
87
  };
69
88
  var ReferralNotFoundError = class extends LoyaltyError {
70
- code = "REFERRAL_NOT_FOUND";
71
- httpStatus = 404;
89
+ code = "loyalty.referral.not_found";
90
+ status = 404;
72
91
  constructor(id) {
73
92
  super(`Referral not found: ${id}`);
74
93
  }
75
94
  };
76
95
  var DuplicateReferralError = class extends LoyaltyError {
77
- code = "DUPLICATE_REFERRAL";
78
- httpStatus = 409;
96
+ code = "loyalty.referral.duplicate";
97
+ status = 409;
79
98
  constructor(refereeId) {
80
99
  super(`Referee already has a referral: ${refereeId}`);
81
100
  }
82
101
  };
83
102
  var ReferralLimitExceededError = class extends LoyaltyError {
84
- code = "REFERRAL_LIMIT_EXCEEDED";
85
- httpStatus = 429;
103
+ code = "loyalty.referral.limit_exceeded";
104
+ status = 429;
86
105
  constructor(limit, periodDays) {
87
106
  super(`Referral limit of ${limit} per ${periodDays} days exceeded`);
88
107
  }
89
108
  };
90
109
  var SelfReferralError = class extends LoyaltyError {
91
- code = "SELF_REFERRAL";
92
- httpStatus = 422;
110
+ code = "loyalty.referral.self";
111
+ status = 422;
93
112
  constructor() {
94
113
  super("Cannot refer yourself");
95
114
  }
96
115
  };
97
116
  var CircularReferralError = class extends LoyaltyError {
98
- code = "CIRCULAR_REFERRAL";
99
- httpStatus = 422;
117
+ code = "loyalty.referral.circular";
118
+ status = 422;
100
119
  constructor() {
101
120
  super("Circular referral detected");
102
121
  }
103
122
  };
104
123
  var ValidationError = class extends LoyaltyError {
105
- code = "VALIDATION_ERROR";
106
- httpStatus = 400;
124
+ code = "loyalty.validation.invalid";
125
+ status = 400;
107
126
  };
108
127
  var TenantIsolationError = class extends LoyaltyError {
109
- code = "TENANT_ISOLATION";
110
- httpStatus = 403;
128
+ code = "loyalty.tenant.isolation";
129
+ status = 403;
111
130
  constructor() {
112
131
  super("Tenant context is required but not provided");
113
132
  }
114
133
  };
134
+ /**
135
+ * A host passed `ctx.session` (it owns the outer transaction) but did NOT
136
+ * attach a `[PENDING_EVENTS]` queue. Publishing to in-process subscribers
137
+ * BEFORE the host's commit would leak ghost events on rollback, and the
138
+ * outbox save would not be coordinated with the host's unit of work.
139
+ *
140
+ * Two safe patterns: (1) let loyalty own the transaction — call the verb
141
+ * WITHOUT `ctx.session`; or (2) attach a fresh `[PENDING_EVENTS]` array via
142
+ * `{ ...ctx, [PENDING_EVENTS]: queue }` and call `flushPendingLoyaltyEvents`
143
+ * after your transaction commits. Both are exported from the package root.
144
+ */
145
+ var UnmanagedSessionError = class extends LoyaltyError {
146
+ code = "loyalty.transaction.unmanaged_session";
147
+ status = 400;
148
+ constructor() {
149
+ super("[loyalty] ctx.session was provided without a PENDING_EVENTS queue. When you own the outer transaction you must also coordinate event publishing — otherwise loyalty would publish to in-process subscribers (and save to the outbox) before your transaction commits, leaking ghost events on rollback. Either call the verb without ctx.session (loyalty owns the unit of work), or attach `{ ...ctx, [PENDING_EVENTS]: queue }` and call `flushPendingLoyaltyEvents(transport, queue)` after your commit. Both `PENDING_EVENTS` and `flushPendingLoyaltyEvents` are exported from `@classytic/loyalty`.");
150
+ }
151
+ };
152
+ /**
153
+ * The backend doesn't declare the runtime capabilities loyalty's
154
+ * transactional verbs require (earn / adjust / reserve / confirm / release /
155
+ * reverse all run inside `withTransaction`). MongoDB must run as a replica set
156
+ * or sharded cluster. Thrown at engine creation — fail loud at boot, not with
157
+ * a cryptic driver error on the first transactional call.
158
+ */
159
+ var LoyaltyCapabilityError = class extends LoyaltyError {
160
+ code = "loyalty.engine.missing_capabilities";
161
+ status = 500;
162
+ constructor(missing) {
163
+ super(`[loyalty] backend missing required capabilities: ${missing.join(", ")}. Point earn / redemption / reversal paths are transactional — MongoDB must run as a replica set or sharded cluster.`);
164
+ }
165
+ };
115
166
  //#endregion
116
167
  //#region src/utils/tenant-guard.ts
117
168
  /**
@@ -289,8 +340,8 @@ var EarningRuleRepository = class extends Repository {
289
340
  points,
290
341
  description: `Action: ${input.action} (${rule.name})`,
291
342
  referenceType: "action",
292
- referenceId: input.referenceId,
293
- earningRuleId: rule._id
343
+ earningRuleId: rule._id,
344
+ ...input.referenceId !== void 0 ? { referenceId: input.referenceId } : {}
294
345
  }, ctx);
295
346
  totalPoints += points;
296
347
  breakdown.push({
@@ -349,7 +400,17 @@ var EarningRuleRepository = class extends Repository {
349
400
  };
350
401
  //#endregion
351
402
  //#region src/events/dispatch.ts
403
+ /**
404
+ * Internal key under which the post-commit event-publish queue is threaded
405
+ * through the context. When set, `dispatchLoyaltyEvent` APPENDS to this queue
406
+ * instead of publishing immediately — the verb that owns the transaction
407
+ * flushes it after commit. A `Symbol.for` so it never collides with
408
+ * host-supplied context fields and never appears in the public type.
409
+ */
410
+ const PENDING_EVENTS = Symbol.for("@classytic/loyalty:pendingEvents");
352
411
  async function dispatchLoyaltyEvent(deps, event, ctx) {
412
+ const pending = ctx[PENDING_EVENTS];
413
+ if (ctx.session && !pending) throw new UnmanagedSessionError();
353
414
  if (deps.outbox) try {
354
415
  const saveOptions = {};
355
416
  if (ctx.session !== void 0) saveOptions.session = ctx.session;
@@ -358,12 +419,63 @@ async function dispatchLoyaltyEvent(deps, event, ctx) {
358
419
  deps.logger?.error(`[loyalty] outbox.save failed for ${event.type}:`, err);
359
420
  throw err;
360
421
  }
422
+ if (pending) {
423
+ pending.push(event);
424
+ return;
425
+ }
361
426
  if (deps.events) try {
362
427
  await deps.events.publish(event);
363
428
  } catch (err) {
364
429
  deps.logger?.error(`[loyalty] events.publish failed for ${event.type}:`, err);
365
430
  }
366
431
  }
432
+ /**
433
+ * Runs a multi-write domain verb inside a unit-of-work transaction with the
434
+ * P8 post-commit-publish discipline (PACKAGE_RULES §P8 phased variant):
435
+ *
436
+ * 1. A fresh `[PENDING_EVENTS]` queue is attached to the inner ctx along
437
+ * with the transaction `session`. Dispatches inside `body` save the
438
+ * outbox row in-session (atomic with the business write) and queue the
439
+ * transport publish instead of firing it.
440
+ * 2. The queue is reset at the START of each attempt — a transient-error
441
+ * retry of `body` can't leak ghost events.
442
+ * 3. After the transaction COMMITS, the queue is flushed to the transport.
443
+ *
444
+ * When the host already owns the outer transaction (`ctx.session` set), it
445
+ * MUST have attached a `[PENDING_EVENTS]` queue — `body` runs directly against
446
+ * the host's session+queue and the host flushes after its own commit. A bare
447
+ * `ctx.session` with no queue throws `UnmanagedSessionError` (raised inside
448
+ * `dispatchLoyaltyEvent` at the first emit).
449
+ */
450
+ async function runLoyaltyUnit(unitOfWork, deps, ctx, body) {
451
+ const internalCtx = ctx;
452
+ if (ctx.session) return body(internalCtx);
453
+ const pending = [];
454
+ const result = await unitOfWork.withTransaction(async (session) => {
455
+ pending.length = 0;
456
+ return body({
457
+ ...internalCtx,
458
+ session,
459
+ [PENDING_EVENTS]: pending
460
+ });
461
+ });
462
+ await flushPendingLoyaltyEvents(deps.events, pending, deps.logger);
463
+ return result;
464
+ }
465
+ /**
466
+ * Drains a queue of deferred events (the one attached under `[PENDING_EVENTS]`)
467
+ * through the transport, swallowing per-event errors. Called by transactional
468
+ * verbs AFTER `withTransaction` commits, and exported so hosts that own the
469
+ * outer transaction can flush after their own commit.
470
+ */
471
+ async function flushPendingLoyaltyEvents(events, queue, logger) {
472
+ if (!events || queue.length === 0) return;
473
+ for (const event of queue) try {
474
+ await events.publish(event);
475
+ } catch (err) {
476
+ logger?.error(`[loyalty] events.publish failed for ${event.type}:`, err);
477
+ }
478
+ }
367
479
  //#endregion
368
480
  //#region src/utils/code-generator.ts
369
481
  const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -586,6 +698,12 @@ var PointTransactionRepository = class extends Repository {
586
698
  outbox: this.outbox
587
699
  }, event, ctx);
588
700
  }
701
+ get _dispatchDeps() {
702
+ return {
703
+ events: this.events,
704
+ outbox: this.outbox
705
+ };
706
+ }
589
707
  /**
590
708
  * Auto-inject tenant id from options into doc payload. Matches
591
709
  * `@classytic/order` / `@classytic/promo` — see MemberRepository.
@@ -613,7 +731,9 @@ var PointTransactionRepository = class extends Repository {
613
731
  }
614
732
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
615
733
  try {
616
- const tx = await this.unitOfWork.withTransaction(async (session) => {
734
+ let payload;
735
+ const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
736
+ const session = txCtx.session;
617
737
  if (input.idempotencyKey) {
618
738
  const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
619
739
  throwOnNotFound: false,
@@ -643,26 +763,27 @@ var PointTransactionRepository = class extends Repository {
643
763
  ...input.metadata ? { metadata: input.metadata } : {}
644
764
  };
645
765
  if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
646
- return this.create(data, {
766
+ const created = await this.create(data, {
647
767
  session,
648
768
  ...tc
649
769
  });
770
+ payload = {
771
+ memberId: member._id,
772
+ externalId: member.externalId,
773
+ externalType: member.externalType,
774
+ points: input.points,
775
+ balanceAfter: created.balanceAfter,
776
+ transactionId: created._id,
777
+ referenceType: input.referenceType,
778
+ referenceId: input.referenceId
779
+ };
780
+ await this._dispatch(createEvent(LoyaltyEvents.POINTS_EARNED, payload, txCtx, {
781
+ resource: "pointTransaction",
782
+ resourceId: created._id
783
+ }), txCtx);
784
+ return created;
650
785
  });
651
- const payload = {
652
- memberId: member._id,
653
- externalId: member.externalId,
654
- externalType: member.externalType,
655
- points: input.points,
656
- balanceAfter: tx.balanceAfter,
657
- transactionId: tx._id,
658
- referenceType: input.referenceType,
659
- referenceId: input.referenceId
660
- };
661
- await this._dispatch(createEvent(LoyaltyEvents.POINTS_EARNED, payload, ctx, {
662
- resource: "pointTransaction",
663
- resourceId: tx._id
664
- }), ctx);
665
- this.notifications?.notify(LoyaltyEvents.POINTS_EARNED, { ...payload });
786
+ if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_EARNED, { ...payload });
666
787
  return tx;
667
788
  } catch (err) {
668
789
  if (input.idempotencyKey && (isDuplicateKey(err) || isWriteConflict(err))) {
@@ -696,6 +817,7 @@ var PointTransactionRepository = class extends Repository {
696
817
  let totalPoints = 0;
697
818
  const errors = [];
698
819
  for (const row of members) {
820
+ throwIfAborted(ctx.signal);
699
821
  try {
700
822
  validatePositivePoints(row.points);
701
823
  } catch (err) {
@@ -770,7 +892,9 @@ var PointTransactionRepository = class extends Repository {
770
892
  }
771
893
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
772
894
  if (input.points < 0 && member.balance.current < Math.abs(input.points)) throw new InsufficientPointsError(member.balance.current, Math.abs(input.points));
773
- const tx = await this.unitOfWork.withTransaction(async (session) => {
895
+ let payload;
896
+ const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
897
+ const session = txCtx.session;
774
898
  if (input.idempotencyKey) {
775
899
  const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
776
900
  throwOnNotFound: false,
@@ -801,24 +925,25 @@ var PointTransactionRepository = class extends Repository {
801
925
  actorId: ctx.actorId
802
926
  };
803
927
  if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
804
- return this.create(data, {
928
+ const created = await this.create(data, {
805
929
  session,
806
930
  ...tc
807
931
  });
932
+ payload = {
933
+ memberId: member._id,
934
+ externalId: member.externalId,
935
+ points: input.points,
936
+ balanceAfter: created.balanceAfter,
937
+ transactionId: created._id,
938
+ description: input.description
939
+ };
940
+ await this._dispatch(createEvent(LoyaltyEvents.POINTS_ADJUSTED, payload, txCtx, {
941
+ resource: "pointTransaction",
942
+ resourceId: created._id
943
+ }), txCtx);
944
+ return created;
808
945
  });
809
- const payload = {
810
- memberId: member._id,
811
- externalId: member.externalId,
812
- points: input.points,
813
- balanceAfter: tx.balanceAfter,
814
- transactionId: tx._id,
815
- description: input.description
816
- };
817
- await this._dispatch(createEvent(LoyaltyEvents.POINTS_ADJUSTED, payload, ctx, {
818
- resource: "pointTransaction",
819
- resourceId: tx._id
820
- }), ctx);
821
- this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
946
+ if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
822
947
  return tx;
823
948
  }
824
949
  async processExpirations(ctx) {
@@ -838,6 +963,7 @@ var PointTransactionRepository = class extends Repository {
838
963
  let transactionCount = 0;
839
964
  const affectedMemberIds = /* @__PURE__ */ new Set();
840
965
  for (const tx of expiredTxs) {
966
+ throwIfAborted(ctx.signal);
841
967
  await this.unitOfWork.withTransaction(async (session) => {
842
968
  await this.create({
843
969
  memberId: tx.memberId,
@@ -936,6 +1062,12 @@ var RedemptionRepository = class extends Repository {
936
1062
  outbox: this.outbox
937
1063
  }, event, ctx);
938
1064
  }
1065
+ get _dispatchDeps() {
1066
+ return {
1067
+ events: this.events,
1068
+ outbox: this.outbox
1069
+ };
1070
+ }
939
1071
  /**
940
1072
  * Atomic compare-and-set status transition.
941
1073
  * Returns null if the current status doesn't match `expectedStatus` (CAS mismatch).
@@ -1005,7 +1137,9 @@ var RedemptionRepository = class extends Repository {
1005
1137
  if (!validation.valid) throw new ValidationError(validation.error ?? "Redemption validation failed");
1006
1138
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
1007
1139
  const expiresAt = input.expiresAt ?? new Date(Date.now() + this.config.redemption.reservationTtlMinutes * 60 * 1e3);
1008
- const redemption = await this.unitOfWork.withTransaction(async (session) => {
1140
+ let payload;
1141
+ const redemption = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1142
+ const session = txCtx.session;
1009
1143
  const updatedMember = await this.memberRepo.updateBalance(input.memberId, { current: -validation.pointsToRedeem }, {
1010
1144
  session,
1011
1145
  ...tc
@@ -1023,25 +1157,26 @@ var RedemptionRepository = class extends Repository {
1023
1157
  ownerId: input.ownerId,
1024
1158
  expiresAt
1025
1159
  };
1026
- return this.create(data, {
1160
+ const created = await this.create(data, {
1027
1161
  session,
1028
1162
  ...tc
1029
1163
  });
1164
+ payload = {
1165
+ memberId: member._id,
1166
+ externalId: member.externalId,
1167
+ redemptionId: created._id,
1168
+ points: validation.pointsToRedeem,
1169
+ discountAmount: validation.discountAmount,
1170
+ ownerType: input.ownerType,
1171
+ ownerId: input.ownerId
1172
+ };
1173
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RESERVED, payload, txCtx, {
1174
+ resource: "redemption",
1175
+ resourceId: created._id
1176
+ }), txCtx);
1177
+ return created;
1030
1178
  });
1031
- const payload = {
1032
- memberId: member._id,
1033
- externalId: member.externalId,
1034
- redemptionId: redemption._id,
1035
- points: validation.pointsToRedeem,
1036
- discountAmount: validation.discountAmount,
1037
- ownerType: input.ownerType,
1038
- ownerId: input.ownerId
1039
- };
1040
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RESERVED, payload, ctx, {
1041
- resource: "redemption",
1042
- resourceId: redemption._id
1043
- }), ctx);
1044
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
1179
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
1045
1180
  return redemption;
1046
1181
  }
1047
1182
  async confirm(redemptionId, ctx) {
@@ -1054,7 +1189,9 @@ var RedemptionRepository = class extends Repository {
1054
1189
  });
1055
1190
  if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1056
1191
  if (redemption.expiresAt && redemption.expiresAt < /* @__PURE__ */ new Date()) throw new RedemptionExpiredError(redemptionId);
1057
- const updated = await this.unitOfWork.withTransaction(async (session) => {
1192
+ let payload;
1193
+ const updated = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1194
+ const session = txCtx.session;
1058
1195
  if (!await this.atomicStatusTransition(redemptionId, "reserved", {
1059
1196
  status: "confirmed",
1060
1197
  pointsConfirmed: redemption.pointsReserved,
@@ -1063,7 +1200,10 @@ var RedemptionRepository = class extends Repository {
1063
1200
  session,
1064
1201
  ...tc
1065
1202
  })) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
1066
- const member = await this.memberRepo.requireActiveMember(redemption.memberId, tc);
1203
+ const member = await this.memberRepo.requireActiveMember(redemption.memberId, {
1204
+ session,
1205
+ ...tc
1206
+ });
1067
1207
  const tx = await this.txRepo.create({
1068
1208
  memberId: redemption.memberId,
1069
1209
  programId: member.programId,
@@ -1089,26 +1229,22 @@ var RedemptionRepository = class extends Repository {
1089
1229
  ...tc
1090
1230
  });
1091
1231
  if (!confirmed) throw new RedemptionNotFoundError(redemptionId);
1232
+ payload = {
1233
+ memberId: confirmed.memberId,
1234
+ externalId: member.externalId,
1235
+ redemptionId: confirmed._id,
1236
+ points: confirmed.pointsConfirmed,
1237
+ discountAmount: confirmed.discountAmount,
1238
+ ownerType: confirmed.ownerType,
1239
+ ownerId: confirmed.ownerId
1240
+ };
1241
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_CONFIRMED, payload, txCtx, {
1242
+ resource: "redemption",
1243
+ resourceId: confirmed._id
1244
+ }), txCtx);
1092
1245
  return confirmed;
1093
1246
  });
1094
- const member = await this.memberRepo.getById(updated.memberId, {
1095
- throwOnNotFound: false,
1096
- ...tc
1097
- });
1098
- const payload = {
1099
- memberId: updated.memberId,
1100
- externalId: member?.externalId ?? "",
1101
- redemptionId: updated._id,
1102
- points: updated.pointsConfirmed,
1103
- discountAmount: updated.discountAmount,
1104
- ownerType: updated.ownerType,
1105
- ownerId: updated.ownerId
1106
- };
1107
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_CONFIRMED, payload, ctx, {
1108
- resource: "redemption",
1109
- resourceId: updated._id
1110
- }), ctx);
1111
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_CONFIRMED, { ...payload });
1247
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_CONFIRMED, { ...payload });
1112
1248
  return updated;
1113
1249
  }
1114
1250
  async release(redemptionId, ctx) {
@@ -1120,7 +1256,9 @@ var RedemptionRepository = class extends Repository {
1120
1256
  ...tc
1121
1257
  });
1122
1258
  if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1123
- const updated = await this.unitOfWork.withTransaction(async (session) => {
1259
+ let payload;
1260
+ const updated = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1261
+ const session = txCtx.session;
1124
1262
  const transitioned = await this.atomicStatusTransition(redemptionId, "reserved", {
1125
1263
  status: "released",
1126
1264
  releasedAt: /* @__PURE__ */ new Date()
@@ -1133,26 +1271,27 @@ var RedemptionRepository = class extends Repository {
1133
1271
  session,
1134
1272
  ...tc
1135
1273
  });
1274
+ const member = await this.memberRepo.getById(transitioned.memberId, {
1275
+ throwOnNotFound: false,
1276
+ session,
1277
+ ...tc
1278
+ });
1279
+ payload = {
1280
+ memberId: transitioned.memberId,
1281
+ externalId: member?.externalId ?? "",
1282
+ redemptionId: transitioned._id,
1283
+ points: transitioned.pointsReserved,
1284
+ discountAmount: transitioned.discountAmount,
1285
+ ownerType: transitioned.ownerType,
1286
+ ownerId: transitioned.ownerId
1287
+ };
1288
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RELEASED, payload, txCtx, {
1289
+ resource: "redemption",
1290
+ resourceId: transitioned._id
1291
+ }), txCtx);
1136
1292
  return transitioned;
1137
1293
  });
1138
- const member = await this.memberRepo.getById(updated.memberId, {
1139
- throwOnNotFound: false,
1140
- ...tc
1141
- });
1142
- const payload = {
1143
- memberId: updated.memberId,
1144
- externalId: member?.externalId ?? "",
1145
- redemptionId: updated._id,
1146
- points: updated.pointsReserved,
1147
- discountAmount: updated.discountAmount,
1148
- ownerType: updated.ownerType,
1149
- ownerId: updated.ownerId
1150
- };
1151
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RELEASED, payload, ctx, {
1152
- resource: "redemption",
1153
- resourceId: updated._id
1154
- }), ctx);
1155
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_RELEASED, { ...payload });
1294
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RELEASED, { ...payload });
1156
1295
  return updated;
1157
1296
  }
1158
1297
  /**
@@ -1193,7 +1332,9 @@ var RedemptionRepository = class extends Repository {
1193
1332
  usedProportional = true;
1194
1333
  } else proportionalFallback = true;
1195
1334
  }
1196
- const reversed = await this.unitOfWork.withTransaction(async (session) => {
1335
+ let payload;
1336
+ const reversed = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1337
+ const session = txCtx.session;
1197
1338
  const transitioned = await this.atomicStatusTransition(redemptionId, "confirmed", {
1198
1339
  status: "reversed",
1199
1340
  reversedAt: /* @__PURE__ */ new Date(),
@@ -1203,9 +1344,12 @@ var RedemptionRepository = class extends Repository {
1203
1344
  ...tc
1204
1345
  });
1205
1346
  if (!transitioned) throw new ValidationError(`Redemption status must be 'confirmed', got '${redemption.status}'`);
1347
+ const member = await this.memberRepo.requireMember(redemption.memberId, {
1348
+ session,
1349
+ ...tc
1350
+ });
1206
1351
  let creditTxId = "";
1207
1352
  if (pointsToCredit > 0) {
1208
- const member = await this.memberRepo.requireMember(redemption.memberId, tc);
1209
1353
  const updatedMember = await this.memberRepo.updateBalance(redemption.memberId, {
1210
1354
  current: pointsToCredit,
1211
1355
  redeemed: -pointsToCredit
@@ -1238,6 +1382,7 @@ var RedemptionRepository = class extends Repository {
1238
1382
  }
1239
1383
  const finalUpdate = {};
1240
1384
  if (creditTxId) finalUpdate.reversalTransactionId = creditTxId;
1385
+ let result = transitioned;
1241
1386
  if (Object.keys(finalUpdate).length > 0) {
1242
1387
  const updated = await this.update(redemptionId, finalUpdate, {
1243
1388
  throwOnNotFound: true,
@@ -1246,29 +1391,25 @@ var RedemptionRepository = class extends Repository {
1246
1391
  ...tc
1247
1392
  });
1248
1393
  if (!updated) throw new RedemptionNotFoundError(redemptionId);
1249
- return updated;
1394
+ result = updated;
1250
1395
  }
1251
- return transitioned;
1252
- });
1253
- const member = await this.memberRepo.getById(reversed.memberId, {
1254
- throwOnNotFound: false,
1255
- ...tc
1396
+ payload = {
1397
+ memberId: result.memberId,
1398
+ externalId: member.externalId,
1399
+ redemptionId: result._id,
1400
+ pointsCredited: pointsToCredit,
1401
+ originalPoints,
1402
+ proportional: usedProportional,
1403
+ transactionId: result.reversalTransactionId ?? ""
1404
+ };
1405
+ if (options?.reason) payload.reason = options.reason;
1406
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_REVERSED, payload, txCtx, {
1407
+ resource: "redemption",
1408
+ resourceId: result._id
1409
+ }), txCtx);
1410
+ return result;
1256
1411
  });
1257
- const payload = {
1258
- memberId: reversed.memberId,
1259
- externalId: member?.externalId ?? "",
1260
- redemptionId: reversed._id,
1261
- pointsCredited: pointsToCredit,
1262
- originalPoints,
1263
- proportional: usedProportional,
1264
- transactionId: reversed.reversalTransactionId ?? ""
1265
- };
1266
- if (options?.reason) payload.reason = options.reason;
1267
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_REVERSED, payload, ctx, {
1268
- resource: "redemption",
1269
- resourceId: reversed._id
1270
- }), ctx);
1271
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
1412
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
1272
1413
  return reversed;
1273
1414
  }
1274
1415
  async cleanupExpired(ctx) {
@@ -1282,6 +1423,7 @@ var RedemptionRepository = class extends Repository {
1282
1423
  }, tc);
1283
1424
  let released = 0;
1284
1425
  for (const redemption of expired) {
1426
+ throwIfAborted(ctx.signal);
1285
1427
  await this.release(redemption._id, ctx);
1286
1428
  released++;
1287
1429
  }
@@ -1483,6 +1625,7 @@ var ReferralRepository = class extends Repository {
1483
1625
  }, tc);
1484
1626
  let count = 0;
1485
1627
  for (const referral of expired) {
1628
+ throwIfAborted(ctx.signal);
1486
1629
  await this.update(referral._id, { status: "expired" }, tc);
1487
1630
  count++;
1488
1631
  }
@@ -1657,16 +1800,19 @@ var TierDefinitionRepository = class extends Repository {
1657
1800
  let upgraded = 0;
1658
1801
  let downgraded = 0;
1659
1802
  let unchanged = 0;
1660
- for (const member of members) switch ((await this.evaluateMember(member._id, ctx)).action) {
1661
- case "upgraded":
1662
- upgraded++;
1663
- break;
1664
- case "downgraded":
1665
- downgraded++;
1666
- break;
1667
- default:
1668
- unchanged++;
1669
- break;
1803
+ for (const member of members) {
1804
+ throwIfAborted(ctx.signal);
1805
+ switch ((await this.evaluateMember(member._id, ctx)).action) {
1806
+ case "upgraded":
1807
+ upgraded++;
1808
+ break;
1809
+ case "downgraded":
1810
+ downgraded++;
1811
+ break;
1812
+ default:
1813
+ unchanged++;
1814
+ break;
1815
+ }
1670
1816
  }
1671
1817
  return {
1672
1818
  upgraded,
@@ -1745,6 +1891,7 @@ var TierDefinitionRepository = class extends Repository {
1745
1891
  }, tc);
1746
1892
  let cleared = 0;
1747
1893
  for (const member of expired) {
1894
+ throwIfAborted(ctx.signal);
1748
1895
  const previousOverride = member.tierOverride;
1749
1896
  if (!previousOverride) continue;
1750
1897
  await this.memberRepo.update(member._id, {
@@ -1929,4 +2076,4 @@ function createRepositories(input) {
1929
2076
  };
1930
2077
  }
1931
2078
  //#endregion
1932
- export { PointTransactionRepository as a, ValidationError as c, RedemptionRepository as i, TierDefinitionRepository as n, MemberRepository as o, ReferralRepository as r, EarningRuleRepository as s, createRepositories as t };
2079
+ export { TenantIsolationError as C, ValidationError as E, SelfReferralError as S, UnmanagedSessionError 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, TierNotFoundError as w, RuleNotFoundError as x, ReferralLimitExceededError as y };