@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.
- package/CHANGELOG.md +68 -0
- package/README.md +69 -35
- package/dist/{config-96Sl6oNJ.d.mts → config-BdSyAdnF.d.mts} +10 -0
- package/dist/events/index.d.mts +1 -1
- package/dist/events/index.mjs +2 -2
- package/dist/{events-BtZOdiPw.mjs → events-DN5_p6T0.mjs} +1 -1
- package/dist/{index-Cl-WGBFJ.d.mts → index-BN96EDac.d.mts} +6 -4
- package/dist/{index-DArT4zkB.d.mts → index-CnESLipu.d.mts} +1 -1
- package/dist/{index-DWarXsmw.d.mts → index-D20Cn4KB.d.mts} +9 -0
- package/dist/index.d.mts +154 -6
- package/dist/index.mjs +21 -15
- package/dist/{loyalty-events-Cyt9TwX6.mjs → loyalty-events-C38JA3cU.mjs} +4 -2
- package/dist/models/index.d.mts +497 -497
- package/dist/models/index.mjs +1 -1
- package/dist/{models-0_OQIqBy.mjs → models-Ddh62lgO.mjs} +4 -1
- package/dist/repositories/index.d.mts +1 -1
- package/dist/repositories/index.mjs +1 -1
- package/dist/{repositories-BMwidCp-.mjs → repositories-C_p8asCn.mjs} +300 -153
- package/dist/types/index.d.mts +2 -2
- package/package.json +6 -6
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import { n as createEvent, t as LoyaltyEvents } from "./loyalty-events-
|
|
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 = "
|
|
22
|
-
|
|
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 = "
|
|
29
|
-
|
|
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 = "
|
|
36
|
-
|
|
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 = "
|
|
43
|
-
|
|
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 = "
|
|
50
|
-
|
|
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 = "
|
|
57
|
-
|
|
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 = "
|
|
64
|
-
|
|
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 = "
|
|
71
|
-
|
|
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 = "
|
|
78
|
-
|
|
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 = "
|
|
85
|
-
|
|
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 = "
|
|
92
|
-
|
|
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 = "
|
|
99
|
-
|
|
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 = "
|
|
106
|
-
|
|
124
|
+
code = "loyalty.validation.invalid";
|
|
125
|
+
status = 400;
|
|
107
126
|
};
|
|
108
127
|
var TenantIsolationError = class extends LoyaltyError {
|
|
109
|
-
code = "
|
|
110
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1394
|
+
result = updated;
|
|
1250
1395
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
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)
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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,
|
|
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 };
|