@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.
- package/CHANGELOG.md +101 -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-CMSNfGos.d.mts → index-BN96EDac.d.mts} +19 -4
- package/dist/{index-xLLJ7jLy.d.mts → index-CnESLipu.d.mts} +1 -1
- package/dist/{index-Bo5YQ_fu.d.mts → index-D20Cn4KB.d.mts} +23 -0
- package/dist/index.d.mts +154 -6
- package/dist/index.mjs +26 -18
- package/dist/{loyalty-events-Cyt9TwX6.mjs → loyalty-events-C38JA3cU.mjs} +4 -2
- package/dist/models/index.d.mts +513 -465
- package/dist/models/index.mjs +1 -1
- package/dist/{models-uhh9c-7k.mjs → models-Ddh62lgO.mjs} +29 -2
- package/dist/repositories/index.d.mts +1 -1
- package/dist/repositories/index.mjs +1 -1
- package/dist/{repositories-CZlhTYVp.mjs → repositories-C_p8asCn.mjs} +307 -155
- 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,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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1394
|
+
result = updated;
|
|
1245
1395
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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)
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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,
|
|
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 };
|