@classytic/loyalty 0.2.2 → 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,15 +400,81 @@ 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;
356
417
  await deps.outbox.save(event, saveOptions);
357
- } catch (_err) {}
418
+ } catch (err) {
419
+ deps.logger?.error(`[loyalty] outbox.save failed for ${event.type}:`, err);
420
+ throw err;
421
+ }
422
+ if (pending) {
423
+ pending.push(event);
424
+ return;
425
+ }
358
426
  if (deps.events) try {
359
427
  await deps.events.publish(event);
360
- } catch (_err) {}
428
+ } catch (err) {
429
+ deps.logger?.error(`[loyalty] events.publish failed for ${event.type}:`, err);
430
+ }
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
+ }
361
478
  }
362
479
  //#endregion
363
480
  //#region src/utils/code-generator.ts
@@ -581,6 +698,12 @@ var PointTransactionRepository = class extends Repository {
581
698
  outbox: this.outbox
582
699
  }, event, ctx);
583
700
  }
701
+ get _dispatchDeps() {
702
+ return {
703
+ events: this.events,
704
+ outbox: this.outbox
705
+ };
706
+ }
584
707
  /**
585
708
  * Auto-inject tenant id from options into doc payload. Matches
586
709
  * `@classytic/order` / `@classytic/promo` — see MemberRepository.
@@ -608,7 +731,9 @@ var PointTransactionRepository = class extends Repository {
608
731
  }
609
732
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
610
733
  try {
611
- 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;
612
737
  if (input.idempotencyKey) {
613
738
  const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
614
739
  throwOnNotFound: false,
@@ -638,26 +763,27 @@ var PointTransactionRepository = class extends Repository {
638
763
  ...input.metadata ? { metadata: input.metadata } : {}
639
764
  };
640
765
  if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
641
- return this.create(data, {
766
+ const created = await this.create(data, {
642
767
  session,
643
768
  ...tc
644
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;
645
785
  });
646
- const payload = {
647
- memberId: member._id,
648
- externalId: member.externalId,
649
- externalType: member.externalType,
650
- points: input.points,
651
- balanceAfter: tx.balanceAfter,
652
- transactionId: tx._id,
653
- referenceType: input.referenceType,
654
- referenceId: input.referenceId
655
- };
656
- await this._dispatch(createEvent(LoyaltyEvents.POINTS_EARNED, payload, ctx, {
657
- resource: "pointTransaction",
658
- resourceId: tx._id
659
- }), ctx);
660
- this.notifications?.notify(LoyaltyEvents.POINTS_EARNED, { ...payload });
786
+ if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_EARNED, { ...payload });
661
787
  return tx;
662
788
  } catch (err) {
663
789
  if (input.idempotencyKey && (isDuplicateKey(err) || isWriteConflict(err))) {
@@ -691,6 +817,7 @@ var PointTransactionRepository = class extends Repository {
691
817
  let totalPoints = 0;
692
818
  const errors = [];
693
819
  for (const row of members) {
820
+ throwIfAborted(ctx.signal);
694
821
  try {
695
822
  validatePositivePoints(row.points);
696
823
  } catch (err) {
@@ -765,7 +892,9 @@ var PointTransactionRepository = class extends Repository {
765
892
  }
766
893
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
767
894
  if (input.points < 0 && member.balance.current < Math.abs(input.points)) throw new InsufficientPointsError(member.balance.current, Math.abs(input.points));
768
- 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;
769
898
  if (input.idempotencyKey) {
770
899
  const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
771
900
  throwOnNotFound: false,
@@ -796,24 +925,25 @@ var PointTransactionRepository = class extends Repository {
796
925
  actorId: ctx.actorId
797
926
  };
798
927
  if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
799
- return this.create(data, {
928
+ const created = await this.create(data, {
800
929
  session,
801
930
  ...tc
802
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;
803
945
  });
804
- const payload = {
805
- memberId: member._id,
806
- externalId: member.externalId,
807
- points: input.points,
808
- balanceAfter: tx.balanceAfter,
809
- transactionId: tx._id,
810
- description: input.description
811
- };
812
- await this._dispatch(createEvent(LoyaltyEvents.POINTS_ADJUSTED, payload, ctx, {
813
- resource: "pointTransaction",
814
- resourceId: tx._id
815
- }), ctx);
816
- this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
946
+ if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
817
947
  return tx;
818
948
  }
819
949
  async processExpirations(ctx) {
@@ -833,6 +963,7 @@ var PointTransactionRepository = class extends Repository {
833
963
  let transactionCount = 0;
834
964
  const affectedMemberIds = /* @__PURE__ */ new Set();
835
965
  for (const tx of expiredTxs) {
966
+ throwIfAborted(ctx.signal);
836
967
  await this.unitOfWork.withTransaction(async (session) => {
837
968
  await this.create({
838
969
  memberId: tx.memberId,
@@ -931,6 +1062,12 @@ var RedemptionRepository = class extends Repository {
931
1062
  outbox: this.outbox
932
1063
  }, event, ctx);
933
1064
  }
1065
+ get _dispatchDeps() {
1066
+ return {
1067
+ events: this.events,
1068
+ outbox: this.outbox
1069
+ };
1070
+ }
934
1071
  /**
935
1072
  * Atomic compare-and-set status transition.
936
1073
  * Returns null if the current status doesn't match `expectedStatus` (CAS mismatch).
@@ -1000,7 +1137,9 @@ var RedemptionRepository = class extends Repository {
1000
1137
  if (!validation.valid) throw new ValidationError(validation.error ?? "Redemption validation failed");
1001
1138
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
1002
1139
  const expiresAt = input.expiresAt ?? new Date(Date.now() + this.config.redemption.reservationTtlMinutes * 60 * 1e3);
1003
- 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;
1004
1143
  const updatedMember = await this.memberRepo.updateBalance(input.memberId, { current: -validation.pointsToRedeem }, {
1005
1144
  session,
1006
1145
  ...tc
@@ -1018,25 +1157,26 @@ var RedemptionRepository = class extends Repository {
1018
1157
  ownerId: input.ownerId,
1019
1158
  expiresAt
1020
1159
  };
1021
- return this.create(data, {
1160
+ const created = await this.create(data, {
1022
1161
  session,
1023
1162
  ...tc
1024
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;
1025
1178
  });
1026
- const payload = {
1027
- memberId: member._id,
1028
- externalId: member.externalId,
1029
- redemptionId: redemption._id,
1030
- points: validation.pointsToRedeem,
1031
- discountAmount: validation.discountAmount,
1032
- ownerType: input.ownerType,
1033
- ownerId: input.ownerId
1034
- };
1035
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RESERVED, payload, ctx, {
1036
- resource: "redemption",
1037
- resourceId: redemption._id
1038
- }), ctx);
1039
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
1179
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
1040
1180
  return redemption;
1041
1181
  }
1042
1182
  async confirm(redemptionId, ctx) {
@@ -1049,7 +1189,9 @@ var RedemptionRepository = class extends Repository {
1049
1189
  });
1050
1190
  if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1051
1191
  if (redemption.expiresAt && redemption.expiresAt < /* @__PURE__ */ new Date()) throw new RedemptionExpiredError(redemptionId);
1052
- 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;
1053
1195
  if (!await this.atomicStatusTransition(redemptionId, "reserved", {
1054
1196
  status: "confirmed",
1055
1197
  pointsConfirmed: redemption.pointsReserved,
@@ -1058,7 +1200,10 @@ var RedemptionRepository = class extends Repository {
1058
1200
  session,
1059
1201
  ...tc
1060
1202
  })) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
1061
- const member = await this.memberRepo.requireActiveMember(redemption.memberId, tc);
1203
+ const member = await this.memberRepo.requireActiveMember(redemption.memberId, {
1204
+ session,
1205
+ ...tc
1206
+ });
1062
1207
  const tx = await this.txRepo.create({
1063
1208
  memberId: redemption.memberId,
1064
1209
  programId: member.programId,
@@ -1084,26 +1229,22 @@ var RedemptionRepository = class extends Repository {
1084
1229
  ...tc
1085
1230
  });
1086
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);
1087
1245
  return confirmed;
1088
1246
  });
1089
- const member = await this.memberRepo.getById(updated.memberId, {
1090
- throwOnNotFound: false,
1091
- ...tc
1092
- });
1093
- const payload = {
1094
- memberId: updated.memberId,
1095
- externalId: member?.externalId ?? "",
1096
- redemptionId: updated._id,
1097
- points: updated.pointsConfirmed,
1098
- discountAmount: updated.discountAmount,
1099
- ownerType: updated.ownerType,
1100
- ownerId: updated.ownerId
1101
- };
1102
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_CONFIRMED, payload, ctx, {
1103
- resource: "redemption",
1104
- resourceId: updated._id
1105
- }), ctx);
1106
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_CONFIRMED, { ...payload });
1247
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_CONFIRMED, { ...payload });
1107
1248
  return updated;
1108
1249
  }
1109
1250
  async release(redemptionId, ctx) {
@@ -1115,7 +1256,9 @@ var RedemptionRepository = class extends Repository {
1115
1256
  ...tc
1116
1257
  });
1117
1258
  if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1118
- 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;
1119
1262
  const transitioned = await this.atomicStatusTransition(redemptionId, "reserved", {
1120
1263
  status: "released",
1121
1264
  releasedAt: /* @__PURE__ */ new Date()
@@ -1128,26 +1271,27 @@ var RedemptionRepository = class extends Repository {
1128
1271
  session,
1129
1272
  ...tc
1130
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);
1131
1292
  return transitioned;
1132
1293
  });
1133
- const member = await this.memberRepo.getById(updated.memberId, {
1134
- throwOnNotFound: false,
1135
- ...tc
1136
- });
1137
- const payload = {
1138
- memberId: updated.memberId,
1139
- externalId: member?.externalId ?? "",
1140
- redemptionId: updated._id,
1141
- points: updated.pointsReserved,
1142
- discountAmount: updated.discountAmount,
1143
- ownerType: updated.ownerType,
1144
- ownerId: updated.ownerId
1145
- };
1146
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RELEASED, payload, ctx, {
1147
- resource: "redemption",
1148
- resourceId: updated._id
1149
- }), ctx);
1150
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_RELEASED, { ...payload });
1294
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RELEASED, { ...payload });
1151
1295
  return updated;
1152
1296
  }
1153
1297
  /**
@@ -1188,7 +1332,9 @@ var RedemptionRepository = class extends Repository {
1188
1332
  usedProportional = true;
1189
1333
  } else proportionalFallback = true;
1190
1334
  }
1191
- 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;
1192
1338
  const transitioned = await this.atomicStatusTransition(redemptionId, "confirmed", {
1193
1339
  status: "reversed",
1194
1340
  reversedAt: /* @__PURE__ */ new Date(),
@@ -1198,9 +1344,12 @@ var RedemptionRepository = class extends Repository {
1198
1344
  ...tc
1199
1345
  });
1200
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
+ });
1201
1351
  let creditTxId = "";
1202
1352
  if (pointsToCredit > 0) {
1203
- const member = await this.memberRepo.requireMember(redemption.memberId, tc);
1204
1353
  const updatedMember = await this.memberRepo.updateBalance(redemption.memberId, {
1205
1354
  current: pointsToCredit,
1206
1355
  redeemed: -pointsToCredit
@@ -1233,6 +1382,7 @@ var RedemptionRepository = class extends Repository {
1233
1382
  }
1234
1383
  const finalUpdate = {};
1235
1384
  if (creditTxId) finalUpdate.reversalTransactionId = creditTxId;
1385
+ let result = transitioned;
1236
1386
  if (Object.keys(finalUpdate).length > 0) {
1237
1387
  const updated = await this.update(redemptionId, finalUpdate, {
1238
1388
  throwOnNotFound: true,
@@ -1241,29 +1391,25 @@ var RedemptionRepository = class extends Repository {
1241
1391
  ...tc
1242
1392
  });
1243
1393
  if (!updated) throw new RedemptionNotFoundError(redemptionId);
1244
- return updated;
1394
+ result = updated;
1245
1395
  }
1246
- return transitioned;
1247
- });
1248
- const member = await this.memberRepo.getById(reversed.memberId, {
1249
- throwOnNotFound: false,
1250
- ...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;
1251
1411
  });
1252
- const payload = {
1253
- memberId: reversed.memberId,
1254
- externalId: member?.externalId ?? "",
1255
- redemptionId: reversed._id,
1256
- pointsCredited: pointsToCredit,
1257
- originalPoints,
1258
- proportional: usedProportional,
1259
- transactionId: reversed.reversalTransactionId ?? ""
1260
- };
1261
- if (options?.reason) payload.reason = options.reason;
1262
- await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_REVERSED, payload, ctx, {
1263
- resource: "redemption",
1264
- resourceId: reversed._id
1265
- }), ctx);
1266
- this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
1412
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
1267
1413
  return reversed;
1268
1414
  }
1269
1415
  async cleanupExpired(ctx) {
@@ -1277,6 +1423,7 @@ var RedemptionRepository = class extends Repository {
1277
1423
  }, tc);
1278
1424
  let released = 0;
1279
1425
  for (const redemption of expired) {
1426
+ throwIfAborted(ctx.signal);
1280
1427
  await this.release(redemption._id, ctx);
1281
1428
  released++;
1282
1429
  }
@@ -1478,6 +1625,7 @@ var ReferralRepository = class extends Repository {
1478
1625
  }, tc);
1479
1626
  let count = 0;
1480
1627
  for (const referral of expired) {
1628
+ throwIfAborted(ctx.signal);
1481
1629
  await this.update(referral._id, { status: "expired" }, tc);
1482
1630
  count++;
1483
1631
  }
@@ -1652,16 +1800,19 @@ var TierDefinitionRepository = class extends Repository {
1652
1800
  let upgraded = 0;
1653
1801
  let downgraded = 0;
1654
1802
  let unchanged = 0;
1655
- for (const member of members) switch ((await this.evaluateMember(member._id, ctx)).action) {
1656
- case "upgraded":
1657
- upgraded++;
1658
- break;
1659
- case "downgraded":
1660
- downgraded++;
1661
- break;
1662
- default:
1663
- unchanged++;
1664
- 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
+ }
1665
1816
  }
1666
1817
  return {
1667
1818
  upgraded,
@@ -1740,6 +1891,7 @@ var TierDefinitionRepository = class extends Repository {
1740
1891
  }, tc);
1741
1892
  let cleared = 0;
1742
1893
  for (const member of expired) {
1894
+ throwIfAborted(ctx.signal);
1743
1895
  const previousOverride = member.tierOverride;
1744
1896
  if (!previousOverride) continue;
1745
1897
  await this.memberRepo.update(member._id, {
@@ -1924,4 +2076,4 @@ function createRepositories(input) {
1924
2076
  };
1925
2077
  }
1926
2078
  //#endregion
1927
- 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 };