@classytic/loyalty 0.2.3 → 0.4.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,20 @@
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
+ import { localTimeParts } from "@classytic/primitives/timezone";
4
+ import { addDays, isoWeekday } from "@classytic/primitives/calendar";
3
5
  import { randomBytes } from "node:crypto";
6
+ import { throwIfAborted } from "@classytic/repo-core/repository";
4
7
  //#region src/domain/errors/base.ts
8
+ /**
9
+ * Base for every loyalty throwable. Implements `HttpError` from
10
+ * `@classytic/repo-core/errors` (the org-canonical throwable contract) so arc
11
+ * catches and serializes them to `ErrorContract` via `toErrorContract()` with
12
+ * the right HTTP `status` and a stable, machine-readable `code`.
13
+ *
14
+ * Codes are hierarchical + lowercase per PACKAGE_RULES ("Errors and wire
15
+ * envelopes"): `loyalty.<entity>.<problem>`. The `status` field (not the old
16
+ * `httpStatus`) is what `HttpError` / arc read — a clean break, no alias.
17
+ */
5
18
  var LoyaltyError = class extends Error {
6
19
  constructor(message) {
7
20
  super(message);
@@ -11,107 +24,147 @@ var LoyaltyError = class extends Error {
11
24
  return {
12
25
  code: this.code,
13
26
  message: this.message,
14
- name: this.name
27
+ name: this.name,
28
+ status: this.status
15
29
  };
16
30
  }
17
31
  };
18
32
  //#endregion
19
33
  //#region src/domain/errors/domain-errors.ts
20
34
  var InsufficientPointsError = class extends LoyaltyError {
21
- code = "INSUFFICIENT_POINTS";
22
- httpStatus = 400;
35
+ code = "loyalty.points.insufficient";
36
+ status = 400;
23
37
  constructor(available, requested) {
24
38
  super(`Insufficient points. Available: ${available}, requested: ${requested}`);
25
39
  }
26
40
  };
27
41
  var MemberNotFoundError = class extends LoyaltyError {
28
- code = "MEMBER_NOT_FOUND";
29
- httpStatus = 404;
42
+ code = "loyalty.member.not_found";
43
+ status = 404;
30
44
  constructor(id) {
31
45
  super(`Member not found: ${id}`);
32
46
  }
33
47
  };
34
48
  var MemberAlreadyEnrolledError = class extends LoyaltyError {
35
- code = "MEMBER_ALREADY_ENROLLED";
36
- httpStatus = 409;
49
+ code = "loyalty.member.already_enrolled";
50
+ status = 409;
37
51
  constructor(externalId) {
38
52
  super(`Member already enrolled: ${externalId}`);
39
53
  }
40
54
  };
41
55
  var MemberInactiveError = class extends LoyaltyError {
42
- code = "MEMBER_INACTIVE";
43
- httpStatus = 422;
56
+ code = "loyalty.member.inactive";
57
+ status = 422;
44
58
  constructor(id) {
45
59
  super(`Member is not active: ${id}`);
46
60
  }
47
61
  };
62
+ var RuleNotFoundError = class extends LoyaltyError {
63
+ code = "loyalty.earning_rule.not_found";
64
+ status = 404;
65
+ constructor(id) {
66
+ super(`Earning rule not found: ${id}`);
67
+ }
68
+ };
48
69
  var TierNotFoundError = class extends LoyaltyError {
49
- code = "TIER_NOT_FOUND";
50
- httpStatus = 404;
70
+ code = "loyalty.tier.not_found";
71
+ status = 404;
51
72
  constructor(id) {
52
73
  super(`Tier not found: ${id}`);
53
74
  }
54
75
  };
55
76
  var RedemptionNotFoundError = class extends LoyaltyError {
56
- code = "REDEMPTION_NOT_FOUND";
57
- httpStatus = 404;
77
+ code = "loyalty.redemption.not_found";
78
+ status = 404;
58
79
  constructor(id) {
59
80
  super(`Redemption not found: ${id}`);
60
81
  }
61
82
  };
62
83
  var RedemptionExpiredError = class extends LoyaltyError {
63
- code = "REDEMPTION_EXPIRED";
64
- httpStatus = 410;
84
+ code = "loyalty.redemption.expired";
85
+ status = 410;
65
86
  constructor(id) {
66
87
  super(`Redemption expired: ${id}`);
67
88
  }
68
89
  };
69
90
  var ReferralNotFoundError = class extends LoyaltyError {
70
- code = "REFERRAL_NOT_FOUND";
71
- httpStatus = 404;
91
+ code = "loyalty.referral.not_found";
92
+ status = 404;
72
93
  constructor(id) {
73
94
  super(`Referral not found: ${id}`);
74
95
  }
75
96
  };
76
97
  var DuplicateReferralError = class extends LoyaltyError {
77
- code = "DUPLICATE_REFERRAL";
78
- httpStatus = 409;
98
+ code = "loyalty.referral.duplicate";
99
+ status = 409;
79
100
  constructor(refereeId) {
80
101
  super(`Referee already has a referral: ${refereeId}`);
81
102
  }
82
103
  };
83
104
  var ReferralLimitExceededError = class extends LoyaltyError {
84
- code = "REFERRAL_LIMIT_EXCEEDED";
85
- httpStatus = 429;
105
+ code = "loyalty.referral.limit_exceeded";
106
+ status = 429;
86
107
  constructor(limit, periodDays) {
87
108
  super(`Referral limit of ${limit} per ${periodDays} days exceeded`);
88
109
  }
89
110
  };
90
111
  var SelfReferralError = class extends LoyaltyError {
91
- code = "SELF_REFERRAL";
92
- httpStatus = 422;
112
+ code = "loyalty.referral.self";
113
+ status = 422;
93
114
  constructor() {
94
115
  super("Cannot refer yourself");
95
116
  }
96
117
  };
97
118
  var CircularReferralError = class extends LoyaltyError {
98
- code = "CIRCULAR_REFERRAL";
99
- httpStatus = 422;
119
+ code = "loyalty.referral.circular";
120
+ status = 422;
100
121
  constructor() {
101
122
  super("Circular referral detected");
102
123
  }
103
124
  };
104
125
  var ValidationError = class extends LoyaltyError {
105
- code = "VALIDATION_ERROR";
106
- httpStatus = 400;
126
+ code = "loyalty.validation.invalid";
127
+ status = 400;
107
128
  };
108
129
  var TenantIsolationError = class extends LoyaltyError {
109
- code = "TENANT_ISOLATION";
110
- httpStatus = 403;
130
+ code = "loyalty.tenant.isolation";
131
+ status = 403;
111
132
  constructor() {
112
133
  super("Tenant context is required but not provided");
113
134
  }
114
135
  };
136
+ /**
137
+ * A host passed `ctx.session` (it owns the outer transaction) but did NOT
138
+ * attach a `[PENDING_EVENTS]` queue. Publishing to in-process subscribers
139
+ * BEFORE the host's commit would leak ghost events on rollback, and the
140
+ * outbox save would not be coordinated with the host's unit of work.
141
+ *
142
+ * Two safe patterns: (1) let loyalty own the transaction — call the verb
143
+ * WITHOUT `ctx.session`; or (2) attach a fresh `[PENDING_EVENTS]` array via
144
+ * `{ ...ctx, [PENDING_EVENTS]: queue }` and call `flushPendingLoyaltyEvents`
145
+ * after your transaction commits. Both are exported from the package root.
146
+ */
147
+ var UnmanagedSessionError = class extends LoyaltyError {
148
+ code = "loyalty.transaction.unmanaged_session";
149
+ status = 400;
150
+ constructor() {
151
+ 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`.");
152
+ }
153
+ };
154
+ /**
155
+ * The backend doesn't declare the runtime capabilities loyalty's
156
+ * transactional verbs require (earn / adjust / reserve / confirm / release /
157
+ * reverse all run inside `withTransaction`). MongoDB must run as a replica set
158
+ * or sharded cluster. Thrown at engine creation — fail loud at boot, not with
159
+ * a cryptic driver error on the first transactional call.
160
+ */
161
+ var LoyaltyCapabilityError = class extends LoyaltyError {
162
+ code = "loyalty.engine.missing_capabilities";
163
+ status = 500;
164
+ constructor(missing) {
165
+ 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.`);
166
+ }
167
+ };
115
168
  //#endregion
116
169
  //#region src/utils/tenant-guard.ts
117
170
  /**
@@ -282,6 +335,7 @@ var EarningRuleRepository = class extends Repository {
282
335
  const transactions = [];
283
336
  let totalPoints = 0;
284
337
  for (const rule of actionRules) {
338
+ if (!this.matchesConditions(rule, member)) continue;
285
339
  const points = rule.reward.fixedPoints ?? 0;
286
340
  if (points <= 0) continue;
287
341
  const tx = await this.txRepo.earnPoints({
@@ -289,8 +343,11 @@ var EarningRuleRepository = class extends Repository {
289
343
  points,
290
344
  description: `Action: ${input.action} (${rule.name})`,
291
345
  referenceType: "action",
292
- referenceId: input.referenceId,
293
- earningRuleId: rule._id
346
+ earningRuleId: rule._id,
347
+ ...input.referenceId !== void 0 ? {
348
+ referenceId: input.referenceId,
349
+ idempotencyKey: `${input.referenceId}:${rule._id}`
350
+ } : {}
294
351
  }, ctx);
295
352
  totalPoints += points;
296
353
  breakdown.push({
@@ -306,10 +363,14 @@ var EarningRuleRepository = class extends Repository {
306
363
  transactions
307
364
  };
308
365
  }
366
+ /**
367
+ * `orderTotal` is optional — action-type evaluations have no order, so
368
+ * `minOrderAmount` only applies when a total is supplied (order paths).
369
+ */
309
370
  matchesConditions(rule, member, orderTotal) {
310
371
  const { conditions } = rule;
311
372
  const now = /* @__PURE__ */ new Date();
312
- if (conditions.minOrderAmount && orderTotal < conditions.minOrderAmount) return false;
373
+ if (conditions.minOrderAmount && orderTotal !== void 0 && orderTotal < conditions.minOrderAmount) return false;
313
374
  if (conditions.tiers && conditions.tiers.length > 0) {
314
375
  const memberTier = member.tierOverride ?? member.tier;
315
376
  if (!memberTier || !conditions.tiers.includes(memberTier)) return false;
@@ -319,10 +380,20 @@ var EarningRuleRepository = class extends Repository {
319
380
  if (conditions.dateRange.end && now > conditions.dateRange.end) return false;
320
381
  }
321
382
  if (conditions.dayOfWeek && conditions.dayOfWeek.length > 0) {
322
- if (!conditions.dayOfWeek.includes(now.getDay())) return false;
383
+ if (!conditions.dayOfWeek.includes(this.weekday(now))) return false;
323
384
  }
324
385
  return true;
325
386
  }
387
+ /**
388
+ * The wall-clock weekday of `instant` in JS `getDay()` convention
389
+ * (0=Sun … 6=Sat — what `EarningConditions.dayOfWeek` is authored in),
390
+ * evaluated in the engine's configured `timezone` (DST-exact) or UTC when
391
+ * unset. NEVER server-local `Date.getDay()`, which shifts with the deploy
392
+ * machine's `TZ` env (PACKAGE_RULES P12).
393
+ */
394
+ weekday(instant) {
395
+ return (this.config.timezone ? localTimeParts(instant, this.config.timezone).isoWeekday : isoWeekday(instant)) % 7;
396
+ }
326
397
  calculateOrderPoints(rule, orderTotal) {
327
398
  const { reward } = rule;
328
399
  let points = 0;
@@ -349,7 +420,16 @@ var EarningRuleRepository = class extends Repository {
349
420
  };
350
421
  //#endregion
351
422
  //#region src/events/dispatch.ts
423
+ /**
424
+ * Internal key under which the post-commit event-publish queue is threaded
425
+ * through the context. When set, `dispatchLoyaltyEvent` APPENDS to this queue
426
+ * instead of publishing immediately — the verb that owns the transaction
427
+ * flushes it after commit. A `Symbol.for` so it never collides with
428
+ * host-supplied context fields and never appears in the public type.
429
+ */
430
+ const PENDING_EVENTS = Symbol.for("@classytic/loyalty:pendingEvents");
352
431
  async function dispatchLoyaltyEvent(deps, event, ctx) {
432
+ const pending = ctx[PENDING_EVENTS];
353
433
  if (deps.outbox) try {
354
434
  const saveOptions = {};
355
435
  if (ctx.session !== void 0) saveOptions.session = ctx.session;
@@ -358,12 +438,64 @@ async function dispatchLoyaltyEvent(deps, event, ctx) {
358
438
  deps.logger?.error(`[loyalty] outbox.save failed for ${event.type}:`, err);
359
439
  throw err;
360
440
  }
361
- if (deps.events) try {
441
+ else if (ctx.session && !pending) throw new UnmanagedSessionError();
442
+ if (pending) {
443
+ pending.push(event);
444
+ return;
445
+ }
446
+ if (deps.events && !ctx.session) try {
362
447
  await deps.events.publish(event);
363
448
  } catch (err) {
364
449
  deps.logger?.error(`[loyalty] events.publish failed for ${event.type}:`, err);
365
450
  }
366
451
  }
452
+ /**
453
+ * Runs a multi-write domain verb inside a unit-of-work transaction with the
454
+ * P8 post-commit-publish discipline (PACKAGE_RULES §P8 phased variant):
455
+ *
456
+ * 1. A fresh `[PENDING_EVENTS]` queue is attached to the inner ctx along
457
+ * with the transaction `session`. Dispatches inside `body` save the
458
+ * outbox row in-session (atomic with the business write) and queue the
459
+ * transport publish instead of firing it.
460
+ * 2. The queue is reset at the START of each attempt — a transient-error
461
+ * retry of `body` can't leak ghost events.
462
+ * 3. After the transaction COMMITS, the queue is flushed to the transport.
463
+ *
464
+ * When the host already owns the outer transaction (`ctx.session` set), it
465
+ * MUST have attached a `[PENDING_EVENTS]` queue — `body` runs directly against
466
+ * the host's session+queue and the host flushes after its own commit. A bare
467
+ * `ctx.session` with no queue throws `UnmanagedSessionError` (raised inside
468
+ * `dispatchLoyaltyEvent` at the first emit).
469
+ */
470
+ async function runLoyaltyUnit(unitOfWork, deps, ctx, body) {
471
+ const internalCtx = ctx;
472
+ if (ctx.session) return body(internalCtx);
473
+ const pending = [];
474
+ const result = await unitOfWork.withTransaction(async (session) => {
475
+ pending.length = 0;
476
+ return body({
477
+ ...internalCtx,
478
+ session,
479
+ [PENDING_EVENTS]: pending
480
+ });
481
+ });
482
+ await flushPendingLoyaltyEvents(deps.events, pending, deps.logger);
483
+ return result;
484
+ }
485
+ /**
486
+ * Drains a queue of deferred events (the one attached under `[PENDING_EVENTS]`)
487
+ * through the transport, swallowing per-event errors. Called by transactional
488
+ * verbs AFTER `withTransaction` commits, and exported so hosts that own the
489
+ * outer transaction can flush after their own commit.
490
+ */
491
+ async function flushPendingLoyaltyEvents(events, queue, logger) {
492
+ if (!events || queue.length === 0) return;
493
+ for (const event of queue) try {
494
+ await events.publish(event);
495
+ } catch (err) {
496
+ logger?.error(`[loyalty] events.publish failed for ${event.type}:`, err);
497
+ }
498
+ }
367
499
  //#endregion
368
500
  //#region src/utils/code-generator.ts
369
501
  const CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -586,6 +718,12 @@ var PointTransactionRepository = class extends Repository {
586
718
  outbox: this.outbox
587
719
  }, event, ctx);
588
720
  }
721
+ get _dispatchDeps() {
722
+ return {
723
+ events: this.events,
724
+ outbox: this.outbox
725
+ };
726
+ }
589
727
  /**
590
728
  * Auto-inject tenant id from options into doc payload. Matches
591
729
  * `@classytic/order` / `@classytic/promo` — see MemberRepository.
@@ -613,7 +751,9 @@ var PointTransactionRepository = class extends Repository {
613
751
  }
614
752
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
615
753
  try {
616
- const tx = await this.unitOfWork.withTransaction(async (session) => {
754
+ let payload;
755
+ const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
756
+ const session = txCtx.session;
617
757
  if (input.idempotencyKey) {
618
758
  const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
619
759
  throwOnNotFound: false,
@@ -643,26 +783,27 @@ var PointTransactionRepository = class extends Repository {
643
783
  ...input.metadata ? { metadata: input.metadata } : {}
644
784
  };
645
785
  if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
646
- return this.create(data, {
786
+ const created = await this.create(data, {
647
787
  session,
648
788
  ...tc
649
789
  });
790
+ payload = {
791
+ memberId: member._id,
792
+ externalId: member.externalId,
793
+ externalType: member.externalType,
794
+ points: input.points,
795
+ balanceAfter: created.balanceAfter,
796
+ transactionId: created._id,
797
+ referenceType: input.referenceType,
798
+ referenceId: input.referenceId
799
+ };
800
+ await this._dispatch(createEvent(LoyaltyEvents.POINTS_EARNED, payload, txCtx, {
801
+ resource: "pointTransaction",
802
+ resourceId: created._id
803
+ }), txCtx);
804
+ return created;
650
805
  });
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 });
806
+ if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_EARNED, { ...payload });
666
807
  return tx;
667
808
  } catch (err) {
668
809
  if (input.idempotencyKey && (isDuplicateKey(err) || isWriteConflict(err))) {
@@ -696,6 +837,7 @@ var PointTransactionRepository = class extends Repository {
696
837
  let totalPoints = 0;
697
838
  const errors = [];
698
839
  for (const row of members) {
840
+ throwIfAborted(ctx.signal);
699
841
  try {
700
842
  validatePositivePoints(row.points);
701
843
  } catch (err) {
@@ -770,7 +912,9 @@ var PointTransactionRepository = class extends Repository {
770
912
  }
771
913
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
772
914
  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) => {
915
+ let payload;
916
+ const tx = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
917
+ const session = txCtx.session;
774
918
  if (input.idempotencyKey) {
775
919
  const dup = await this.getByQuery({ idempotencyKey: input.idempotencyKey }, {
776
920
  throwOnNotFound: false,
@@ -801,24 +945,25 @@ var PointTransactionRepository = class extends Repository {
801
945
  actorId: ctx.actorId
802
946
  };
803
947
  if (input.idempotencyKey) data.idempotencyKey = input.idempotencyKey;
804
- return this.create(data, {
948
+ const created = await this.create(data, {
805
949
  session,
806
950
  ...tc
807
951
  });
952
+ payload = {
953
+ memberId: member._id,
954
+ externalId: member.externalId,
955
+ points: input.points,
956
+ balanceAfter: created.balanceAfter,
957
+ transactionId: created._id,
958
+ description: input.description
959
+ };
960
+ await this._dispatch(createEvent(LoyaltyEvents.POINTS_ADJUSTED, payload, txCtx, {
961
+ resource: "pointTransaction",
962
+ resourceId: created._id
963
+ }), txCtx);
964
+ return created;
808
965
  });
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 });
966
+ if (payload) this.notifications?.notify(LoyaltyEvents.POINTS_ADJUSTED, { ...payload });
822
967
  return tx;
823
968
  }
824
969
  async processExpirations(ctx) {
@@ -838,6 +983,7 @@ var PointTransactionRepository = class extends Repository {
838
983
  let transactionCount = 0;
839
984
  const affectedMemberIds = /* @__PURE__ */ new Set();
840
985
  for (const tx of expiredTxs) {
986
+ throwIfAborted(ctx.signal);
841
987
  await this.unitOfWork.withTransaction(async (session) => {
842
988
  await this.create({
843
989
  memberId: tx.memberId,
@@ -936,6 +1082,12 @@ var RedemptionRepository = class extends Repository {
936
1082
  outbox: this.outbox
937
1083
  }, event, ctx);
938
1084
  }
1085
+ get _dispatchDeps() {
1086
+ return {
1087
+ events: this.events,
1088
+ outbox: this.outbox
1089
+ };
1090
+ }
939
1091
  /**
940
1092
  * Atomic compare-and-set status transition.
941
1093
  * Returns null if the current status doesn't match `expectedStatus` (CAS mismatch).
@@ -1005,7 +1157,9 @@ var RedemptionRepository = class extends Repository {
1005
1157
  if (!validation.valid) throw new ValidationError(validation.error ?? "Redemption validation failed");
1006
1158
  const member = await this.memberRepo.requireActiveMember(input.memberId, tc);
1007
1159
  const expiresAt = input.expiresAt ?? new Date(Date.now() + this.config.redemption.reservationTtlMinutes * 60 * 1e3);
1008
- const redemption = await this.unitOfWork.withTransaction(async (session) => {
1160
+ let payload;
1161
+ const redemption = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1162
+ const session = txCtx.session;
1009
1163
  const updatedMember = await this.memberRepo.updateBalance(input.memberId, { current: -validation.pointsToRedeem }, {
1010
1164
  session,
1011
1165
  ...tc
@@ -1023,25 +1177,26 @@ var RedemptionRepository = class extends Repository {
1023
1177
  ownerId: input.ownerId,
1024
1178
  expiresAt
1025
1179
  };
1026
- return this.create(data, {
1180
+ const created = await this.create(data, {
1027
1181
  session,
1028
1182
  ...tc
1029
1183
  });
1184
+ payload = {
1185
+ memberId: member._id,
1186
+ externalId: member.externalId,
1187
+ redemptionId: created._id,
1188
+ points: validation.pointsToRedeem,
1189
+ discountAmount: validation.discountAmount,
1190
+ ownerType: input.ownerType,
1191
+ ownerId: input.ownerId
1192
+ };
1193
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RESERVED, payload, txCtx, {
1194
+ resource: "redemption",
1195
+ resourceId: created._id
1196
+ }), txCtx);
1197
+ return created;
1030
1198
  });
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 });
1199
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RESERVED, { ...payload });
1045
1200
  return redemption;
1046
1201
  }
1047
1202
  async confirm(redemptionId, ctx) {
@@ -1054,7 +1209,9 @@ var RedemptionRepository = class extends Repository {
1054
1209
  });
1055
1210
  if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1056
1211
  if (redemption.expiresAt && redemption.expiresAt < /* @__PURE__ */ new Date()) throw new RedemptionExpiredError(redemptionId);
1057
- const updated = await this.unitOfWork.withTransaction(async (session) => {
1212
+ let payload;
1213
+ const updated = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1214
+ const session = txCtx.session;
1058
1215
  if (!await this.atomicStatusTransition(redemptionId, "reserved", {
1059
1216
  status: "confirmed",
1060
1217
  pointsConfirmed: redemption.pointsReserved,
@@ -1063,7 +1220,10 @@ var RedemptionRepository = class extends Repository {
1063
1220
  session,
1064
1221
  ...tc
1065
1222
  })) throw new ValidationError(`Redemption status must be 'reserved', got '${redemption.status}'`);
1066
- const member = await this.memberRepo.requireActiveMember(redemption.memberId, tc);
1223
+ const member = await this.memberRepo.requireActiveMember(redemption.memberId, {
1224
+ session,
1225
+ ...tc
1226
+ });
1067
1227
  const tx = await this.txRepo.create({
1068
1228
  memberId: redemption.memberId,
1069
1229
  programId: member.programId,
@@ -1089,26 +1249,22 @@ var RedemptionRepository = class extends Repository {
1089
1249
  ...tc
1090
1250
  });
1091
1251
  if (!confirmed) throw new RedemptionNotFoundError(redemptionId);
1252
+ payload = {
1253
+ memberId: confirmed.memberId,
1254
+ externalId: member.externalId,
1255
+ redemptionId: confirmed._id,
1256
+ points: confirmed.pointsConfirmed,
1257
+ discountAmount: confirmed.discountAmount,
1258
+ ownerType: confirmed.ownerType,
1259
+ ownerId: confirmed.ownerId
1260
+ };
1261
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_CONFIRMED, payload, txCtx, {
1262
+ resource: "redemption",
1263
+ resourceId: confirmed._id
1264
+ }), txCtx);
1092
1265
  return confirmed;
1093
1266
  });
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 });
1267
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_CONFIRMED, { ...payload });
1112
1268
  return updated;
1113
1269
  }
1114
1270
  async release(redemptionId, ctx) {
@@ -1120,7 +1276,9 @@ var RedemptionRepository = class extends Repository {
1120
1276
  ...tc
1121
1277
  });
1122
1278
  if (!redemption) throw new RedemptionNotFoundError(redemptionId);
1123
- const updated = await this.unitOfWork.withTransaction(async (session) => {
1279
+ let payload;
1280
+ const updated = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1281
+ const session = txCtx.session;
1124
1282
  const transitioned = await this.atomicStatusTransition(redemptionId, "reserved", {
1125
1283
  status: "released",
1126
1284
  releasedAt: /* @__PURE__ */ new Date()
@@ -1133,26 +1291,27 @@ var RedemptionRepository = class extends Repository {
1133
1291
  session,
1134
1292
  ...tc
1135
1293
  });
1294
+ const member = await this.memberRepo.getById(transitioned.memberId, {
1295
+ throwOnNotFound: false,
1296
+ session,
1297
+ ...tc
1298
+ });
1299
+ payload = {
1300
+ memberId: transitioned.memberId,
1301
+ externalId: member?.externalId ?? "",
1302
+ redemptionId: transitioned._id,
1303
+ points: transitioned.pointsReserved,
1304
+ discountAmount: transitioned.discountAmount,
1305
+ ownerType: transitioned.ownerType,
1306
+ ownerId: transitioned.ownerId
1307
+ };
1308
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_RELEASED, payload, txCtx, {
1309
+ resource: "redemption",
1310
+ resourceId: transitioned._id
1311
+ }), txCtx);
1136
1312
  return transitioned;
1137
1313
  });
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 });
1314
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_RELEASED, { ...payload });
1156
1315
  return updated;
1157
1316
  }
1158
1317
  /**
@@ -1193,7 +1352,9 @@ var RedemptionRepository = class extends Repository {
1193
1352
  usedProportional = true;
1194
1353
  } else proportionalFallback = true;
1195
1354
  }
1196
- const reversed = await this.unitOfWork.withTransaction(async (session) => {
1355
+ let payload;
1356
+ const reversed = await runLoyaltyUnit(this.unitOfWork, this._dispatchDeps, ctx, async (txCtx) => {
1357
+ const session = txCtx.session;
1197
1358
  const transitioned = await this.atomicStatusTransition(redemptionId, "confirmed", {
1198
1359
  status: "reversed",
1199
1360
  reversedAt: /* @__PURE__ */ new Date(),
@@ -1203,9 +1364,12 @@ var RedemptionRepository = class extends Repository {
1203
1364
  ...tc
1204
1365
  });
1205
1366
  if (!transitioned) throw new ValidationError(`Redemption status must be 'confirmed', got '${redemption.status}'`);
1367
+ const member = await this.memberRepo.requireMember(redemption.memberId, {
1368
+ session,
1369
+ ...tc
1370
+ });
1206
1371
  let creditTxId = "";
1207
1372
  if (pointsToCredit > 0) {
1208
- const member = await this.memberRepo.requireMember(redemption.memberId, tc);
1209
1373
  const updatedMember = await this.memberRepo.updateBalance(redemption.memberId, {
1210
1374
  current: pointsToCredit,
1211
1375
  redeemed: -pointsToCredit
@@ -1238,6 +1402,7 @@ var RedemptionRepository = class extends Repository {
1238
1402
  }
1239
1403
  const finalUpdate = {};
1240
1404
  if (creditTxId) finalUpdate.reversalTransactionId = creditTxId;
1405
+ let result = transitioned;
1241
1406
  if (Object.keys(finalUpdate).length > 0) {
1242
1407
  const updated = await this.update(redemptionId, finalUpdate, {
1243
1408
  throwOnNotFound: true,
@@ -1246,29 +1411,25 @@ var RedemptionRepository = class extends Repository {
1246
1411
  ...tc
1247
1412
  });
1248
1413
  if (!updated) throw new RedemptionNotFoundError(redemptionId);
1249
- return updated;
1414
+ result = updated;
1250
1415
  }
1251
- return transitioned;
1252
- });
1253
- const member = await this.memberRepo.getById(reversed.memberId, {
1254
- throwOnNotFound: false,
1255
- ...tc
1416
+ payload = {
1417
+ memberId: result.memberId,
1418
+ externalId: member.externalId,
1419
+ redemptionId: result._id,
1420
+ pointsCredited: pointsToCredit,
1421
+ originalPoints,
1422
+ proportional: usedProportional,
1423
+ transactionId: result.reversalTransactionId ?? ""
1424
+ };
1425
+ if (options?.reason) payload.reason = options.reason;
1426
+ await this._dispatch(createEvent(LoyaltyEvents.REDEMPTION_REVERSED, payload, txCtx, {
1427
+ resource: "redemption",
1428
+ resourceId: result._id
1429
+ }), txCtx);
1430
+ return result;
1256
1431
  });
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 });
1432
+ if (payload) this.notifications?.notify(LoyaltyEvents.REDEMPTION_REVERSED, { ...payload });
1272
1433
  return reversed;
1273
1434
  }
1274
1435
  async cleanupExpired(ctx) {
@@ -1282,6 +1443,7 @@ var RedemptionRepository = class extends Repository {
1282
1443
  }, tc);
1283
1444
  let released = 0;
1284
1445
  for (const redemption of expired) {
1446
+ throwIfAborted(ctx.signal);
1285
1447
  await this.release(redemption._id, ctx);
1286
1448
  released++;
1287
1449
  }
@@ -1382,8 +1544,7 @@ var ReferralRepository = class extends Repository {
1382
1544
  ...tc
1383
1545
  });
1384
1546
  if (reverseReferral && String(reverseReferral.referrerId) === String(referee._id)) throw new CircularReferralError();
1385
- const periodStart = /* @__PURE__ */ new Date();
1386
- periodStart.setDate(periodStart.getDate() - this.config.referral.periodDays);
1547
+ const periodStart = addDays(/* @__PURE__ */ new Date(), -this.config.referral.periodDays);
1387
1548
  if (await this.count({
1388
1549
  referrerId: referrer._id,
1389
1550
  createdAt: { $gte: periodStart }
@@ -1483,6 +1644,7 @@ var ReferralRepository = class extends Repository {
1483
1644
  }, tc);
1484
1645
  let count = 0;
1485
1646
  for (const referral of expired) {
1647
+ throwIfAborted(ctx.signal);
1486
1648
  await this.update(referral._id, { status: "expired" }, tc);
1487
1649
  count++;
1488
1650
  }
@@ -1657,16 +1819,19 @@ var TierDefinitionRepository = class extends Repository {
1657
1819
  let upgraded = 0;
1658
1820
  let downgraded = 0;
1659
1821
  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;
1822
+ for (const member of members) {
1823
+ throwIfAborted(ctx.signal);
1824
+ switch ((await this.evaluateMember(member._id, ctx)).action) {
1825
+ case "upgraded":
1826
+ upgraded++;
1827
+ break;
1828
+ case "downgraded":
1829
+ downgraded++;
1830
+ break;
1831
+ default:
1832
+ unchanged++;
1833
+ break;
1834
+ }
1670
1835
  }
1671
1836
  return {
1672
1837
  upgraded,
@@ -1745,6 +1910,7 @@ var TierDefinitionRepository = class extends Repository {
1745
1910
  }, tc);
1746
1911
  let cleared = 0;
1747
1912
  for (const member of expired) {
1913
+ throwIfAborted(ctx.signal);
1748
1914
  const previousOverride = member.tierOverride;
1749
1915
  if (!previousOverride) continue;
1750
1916
  await this.memberRepo.update(member._id, {
@@ -1809,8 +1975,7 @@ var TierDefinitionRepository = class extends Repository {
1809
1975
  action: "unchanged"
1810
1976
  };
1811
1977
  if (downgrade.gracePeriodDays > 0 && !member.tierExpiresAt) {
1812
- const graceExpiresAt = /* @__PURE__ */ new Date();
1813
- graceExpiresAt.setDate(graceExpiresAt.getDate() + downgrade.gracePeriodDays);
1978
+ const graceExpiresAt = addDays(/* @__PURE__ */ new Date(), downgrade.gracePeriodDays);
1814
1979
  await this.memberRepo.update(memberId, { tierExpiresAt: graceExpiresAt }, {
1815
1980
  lean: true,
1816
1981
  ...tc
@@ -1929,4 +2094,4 @@ function createRepositories(input) {
1929
2094
  };
1930
2095
  }
1931
2096
  //#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 };
2097
+ 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 };