@nehorai/credits 1.6.0 → 2.0.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/LICENSE +21 -0
- package/README.md +226 -2
- package/dist/adapters/index.cjs +298 -40
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +2 -2
- package/dist/adapters/index.d.ts +2 -2
- package/dist/adapters/index.js +298 -40
- package/dist/adapters/index.js.map +1 -1
- package/dist/core/index.cjs +696 -74
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +583 -5
- package/dist/core/index.d.ts +583 -5
- package/dist/core/index.js +648 -74
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +1779 -364
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -6
- package/dist/index.d.ts +41 -6
- package/dist/index.js +1723 -364
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +1413 -181
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +142 -16
- package/dist/repository/index.d.ts +142 -16
- package/dist/repository/index.js +1406 -181
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +23 -1
- package/dist/sdk/index.cjs.map +1 -1
- package/dist/sdk/index.d.cts +2 -2
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +23 -1
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +483 -105
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +70 -10
- package/dist/service/index.d.ts +70 -10
- package/dist/service/index.js +483 -105
- package/dist/service/index.js.map +1 -1
- package/dist/types-BmG5Omzj.d.ts +646 -0
- package/dist/types-C8hZMe8a.d.cts +646 -0
- package/dist/{types-CjLj3GW5.d.cts → types-DJ8yqaVr.d.cts} +39 -1
- package/dist/{types-CjLj3GW5.d.ts → types-DJ8yqaVr.d.ts} +39 -1
- package/package.json +9 -10
- package/dist/types-CZNv1U3e.d.cts +0 -305
- package/dist/types-G4WK0rM-.d.ts +0 -305
package/dist/service/index.cjs
CHANGED
|
@@ -25,6 +25,170 @@ __export(service_exports, {
|
|
|
25
25
|
});
|
|
26
26
|
module.exports = __toCommonJS(service_exports);
|
|
27
27
|
|
|
28
|
+
// src/core/errors.ts
|
|
29
|
+
var CreditErrorCode = {
|
|
30
|
+
INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
|
|
31
|
+
RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
|
|
32
|
+
RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
|
|
33
|
+
RESERVATION_ALREADY_PROCESSED: "RESERVATION_ALREADY_PROCESSED",
|
|
34
|
+
USER_NOT_FOUND: "USER_NOT_FOUND",
|
|
35
|
+
INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
|
|
36
|
+
CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
|
|
37
|
+
DATABASE_ERROR: "DATABASE_ERROR",
|
|
38
|
+
/** Caller reused an idempotency key with a different immutable payload. */
|
|
39
|
+
IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
|
|
40
|
+
/**
|
|
41
|
+
* Retryable infrastructure failure — serialization/deadlock, lock timeout,
|
|
42
|
+
* connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
|
|
43
|
+
* a deterministic failure that will fail again identically on retry.
|
|
44
|
+
*/
|
|
45
|
+
TRANSIENT_ERROR: "TRANSIENT_ERROR",
|
|
46
|
+
/** The configured repository adapter does not implement the requested V2 method. */
|
|
47
|
+
UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
|
|
48
|
+
/** Amount was not a finite positive number. */
|
|
49
|
+
INVALID_AMOUNT: "INVALID_AMOUNT",
|
|
50
|
+
/** Idempotency key was supplied but empty or whitespace-only. */
|
|
51
|
+
INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
|
|
52
|
+
/**
|
|
53
|
+
* A persisted reservation holds a status this library does not define, so no
|
|
54
|
+
* transition can reason about it. Quarantined rather than cast; nothing moved.
|
|
55
|
+
*/
|
|
56
|
+
CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
|
|
57
|
+
/**
|
|
58
|
+
* A persisted reservation does not record that its hold was placed
|
|
59
|
+
* atomically, so the credits it claims are not known to be reserved. Moving
|
|
60
|
+
* it would spend another hold's coverage; nothing moved.
|
|
61
|
+
*/
|
|
62
|
+
UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
|
|
63
|
+
};
|
|
64
|
+
var CreditError = class _CreditError extends Error {
|
|
65
|
+
code;
|
|
66
|
+
details;
|
|
67
|
+
constructor(message, code, details) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "CreditError";
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.details = details;
|
|
72
|
+
if (Error.captureStackTrace) {
|
|
73
|
+
Error.captureStackTrace(this, _CreditError);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Convert to JSON-serializable object
|
|
78
|
+
*/
|
|
79
|
+
toJSON() {
|
|
80
|
+
return {
|
|
81
|
+
name: this.name,
|
|
82
|
+
code: this.code,
|
|
83
|
+
message: this.message,
|
|
84
|
+
...this.details && { details: this.details }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
function isCreditError(error) {
|
|
89
|
+
return error instanceof CreditError;
|
|
90
|
+
}
|
|
91
|
+
function isInsufficientCreditsError(error) {
|
|
92
|
+
if (isCreditError(error)) {
|
|
93
|
+
return error.code === CreditErrorCode.INSUFFICIENT_CREDITS;
|
|
94
|
+
}
|
|
95
|
+
if (error instanceof Error) {
|
|
96
|
+
return error.message.toLowerCase().includes("insufficient credits");
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
function createInsufficientCreditsError(required, available) {
|
|
101
|
+
return new CreditError(
|
|
102
|
+
`Insufficient credits: available ${available}, required ${required}`,
|
|
103
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
104
|
+
{ required, available, shortfall: sumAmounts(required, -available) }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
function createReservationNotFoundError(reservationId) {
|
|
108
|
+
return new CreditError(
|
|
109
|
+
`Reservation ${reservationId} not found`,
|
|
110
|
+
CreditErrorCode.RESERVATION_NOT_FOUND,
|
|
111
|
+
{ reservationId }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
function createReservationAlreadyProcessedError(reservationId, status) {
|
|
115
|
+
return new CreditError(
|
|
116
|
+
`Reservation ${reservationId} has already been ${status}`,
|
|
117
|
+
CreditErrorCode.RESERVATION_ALREADY_PROCESSED,
|
|
118
|
+
{ reservationId, status }
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
function createIdempotencyConflictError(idempotencyKey, details) {
|
|
122
|
+
return new CreditError(
|
|
123
|
+
`Idempotency key ${idempotencyKey} was reused with a different payload`,
|
|
124
|
+
CreditErrorCode.IDEMPOTENCY_CONFLICT,
|
|
125
|
+
{ idempotencyKey, ...details }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
function createUnsupportedOperationError(operation, hint) {
|
|
129
|
+
return new CreditError(
|
|
130
|
+
hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
|
|
131
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
132
|
+
{ operation, ...hint ? { hint } : {} }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/core/amount.ts
|
|
137
|
+
var CREDIT_AMOUNT_SCALE = 2;
|
|
138
|
+
var CREDIT_AMOUNT_MAX = 999999999999e-2;
|
|
139
|
+
var MAX_CENTS = 999999999999;
|
|
140
|
+
function toCents(value) {
|
|
141
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
142
|
+
const cents = Math.round(value * 100);
|
|
143
|
+
if (!Number.isSafeInteger(cents)) return null;
|
|
144
|
+
return value === cents / 100 ? cents : null;
|
|
145
|
+
}
|
|
146
|
+
function sumAmounts(...values) {
|
|
147
|
+
let cents = 0;
|
|
148
|
+
for (const value of values) {
|
|
149
|
+
const valueCents = toCents(value);
|
|
150
|
+
if (valueCents === null) return floatSum(values);
|
|
151
|
+
cents += valueCents;
|
|
152
|
+
}
|
|
153
|
+
return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
|
|
154
|
+
}
|
|
155
|
+
function floatSum(values) {
|
|
156
|
+
let total = 0;
|
|
157
|
+
for (const value of values) total += value;
|
|
158
|
+
return total;
|
|
159
|
+
}
|
|
160
|
+
function isValidCreditAmount(value) {
|
|
161
|
+
if (typeof value !== "number") return false;
|
|
162
|
+
const cents = toCents(value);
|
|
163
|
+
return cents !== null && cents > 0 && cents <= MAX_CENTS;
|
|
164
|
+
}
|
|
165
|
+
function assertValidCreditAmount(value, context) {
|
|
166
|
+
if (isValidCreditAmount(value)) return;
|
|
167
|
+
throw new CreditError(
|
|
168
|
+
creditAmountReason(value),
|
|
169
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
170
|
+
{ amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
function creditAmountReason(value) {
|
|
174
|
+
if (typeof value !== "number") {
|
|
175
|
+
return `Credit amount must be a number (got ${typeof value})`;
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isFinite(value)) {
|
|
178
|
+
return `Credit amount must be finite (got ${String(value)})`;
|
|
179
|
+
}
|
|
180
|
+
if (value <= 0) {
|
|
181
|
+
return `Credit amount must be greater than zero (got ${value})`;
|
|
182
|
+
}
|
|
183
|
+
if (value > CREDIT_AMOUNT_MAX) {
|
|
184
|
+
return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
|
|
185
|
+
}
|
|
186
|
+
return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
|
|
187
|
+
}
|
|
188
|
+
function storedMonthlyLimit(limit) {
|
|
189
|
+
return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
|
|
190
|
+
}
|
|
191
|
+
|
|
28
192
|
// src/core/types.ts
|
|
29
193
|
function toDate(value) {
|
|
30
194
|
if (value instanceof Date) {
|
|
@@ -39,32 +203,6 @@ function toDate(value) {
|
|
|
39
203
|
return /* @__PURE__ */ new Date();
|
|
40
204
|
}
|
|
41
205
|
|
|
42
|
-
// src/repository/types.ts
|
|
43
|
-
function toClientUserCredits(credits) {
|
|
44
|
-
return {
|
|
45
|
-
userId: credits.userId,
|
|
46
|
-
balance: credits.balance,
|
|
47
|
-
bonusCredits: credits.bonusCredits ?? 0,
|
|
48
|
-
reserved: credits.reserved,
|
|
49
|
-
tier: credits.tier,
|
|
50
|
-
monthlyLimit: credits.monthlyLimit,
|
|
51
|
-
monthlyUsed: credits.monthlyUsed,
|
|
52
|
-
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
53
|
-
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
54
|
-
createdAt: toISOString(credits.createdAt),
|
|
55
|
-
updatedAt: toISOString(credits.updatedAt)
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
function toISOString(value) {
|
|
59
|
-
if (!value) return (/* @__PURE__ */ new Date()).toISOString();
|
|
60
|
-
if (typeof value === "string") return value;
|
|
61
|
-
if (value instanceof Date) return value.toISOString();
|
|
62
|
-
if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
|
|
63
|
-
return value.toDate().toISOString();
|
|
64
|
-
}
|
|
65
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
66
|
-
}
|
|
67
|
-
|
|
68
206
|
// src/config/index.ts
|
|
69
207
|
var import_zod = require("zod");
|
|
70
208
|
|
|
@@ -360,6 +498,178 @@ function getOperationLabel(operationType) {
|
|
|
360
498
|
}
|
|
361
499
|
initializeConfig();
|
|
362
500
|
|
|
501
|
+
// src/core/payment-ref.ts
|
|
502
|
+
function normalizePaymentRef(ref) {
|
|
503
|
+
if (typeof ref !== "string") return void 0;
|
|
504
|
+
const trimmed = ref.trim();
|
|
505
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/repository/types.ts
|
|
509
|
+
function supportsCreditsV2(repository) {
|
|
510
|
+
return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
|
|
511
|
+
}
|
|
512
|
+
function supportsIdempotentCredit(repository) {
|
|
513
|
+
return typeof repository.addCreditsV2 === "function";
|
|
514
|
+
}
|
|
515
|
+
function toClientUserCredits(credits) {
|
|
516
|
+
return {
|
|
517
|
+
userId: credits.userId,
|
|
518
|
+
balance: credits.balance,
|
|
519
|
+
bonusCredits: credits.bonusCredits ?? 0,
|
|
520
|
+
reserved: credits.reserved,
|
|
521
|
+
tier: credits.tier,
|
|
522
|
+
monthlyLimit: credits.monthlyLimit,
|
|
523
|
+
monthlyUsed: credits.monthlyUsed,
|
|
524
|
+
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
525
|
+
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
526
|
+
createdAt: toISOString(credits.createdAt),
|
|
527
|
+
updatedAt: toISOString(credits.updatedAt)
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function toISOString(value) {
|
|
531
|
+
if (!value) return (/* @__PURE__ */ new Date()).toISOString();
|
|
532
|
+
if (typeof value === "string") return value;
|
|
533
|
+
if (value instanceof Date) return value.toISOString();
|
|
534
|
+
if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
|
|
535
|
+
return value.toDate().toISOString();
|
|
536
|
+
}
|
|
537
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/repository/flow.ts
|
|
541
|
+
async function reserveThroughRepository(repository, input) {
|
|
542
|
+
const v2 = supportsCreditsV2(repository);
|
|
543
|
+
if (!v2 && input.idempotencyKey !== void 0) {
|
|
544
|
+
throw createUnsupportedOperationError(
|
|
545
|
+
"reserveCredits with an idempotencyKey",
|
|
546
|
+
"This repository does not implement the V2 boundary, so it cannot enforce idempotency keys. Use a V2 repository (check with supportsCreditsV2) or omit the key and accept at-least-once holds."
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
550
|
+
if (v2) {
|
|
551
|
+
return repository.reserveCreditsV2(input);
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const reservation = await repository.reserveCreditsAtomic(
|
|
555
|
+
input.userId,
|
|
556
|
+
input.amount,
|
|
557
|
+
input.operationType,
|
|
558
|
+
input.expiresAt
|
|
559
|
+
);
|
|
560
|
+
return { outcome: "created", reservation };
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (!isInsufficientCreditsError(error)) throw error;
|
|
563
|
+
const credits = await repository.getUserCredits(input.userId);
|
|
564
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
565
|
+
return {
|
|
566
|
+
outcome: "insufficient",
|
|
567
|
+
available,
|
|
568
|
+
required: input.amount,
|
|
569
|
+
shortfall: Math.max(sumAmounts(input.amount, -available), 0)
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function commitThroughRepository(repository, userId, reservationId, options) {
|
|
574
|
+
if (supportsCreditsV2(repository)) {
|
|
575
|
+
return repository.commitReservationV2(userId, reservationId, options);
|
|
576
|
+
}
|
|
577
|
+
const reservation = await repository.getReservation(userId, reservationId);
|
|
578
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
579
|
+
if (reservation.status !== "reserved") {
|
|
580
|
+
return {
|
|
581
|
+
outcome: "already_terminal",
|
|
582
|
+
reservation,
|
|
583
|
+
terminalStatus: reservation.status
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
await repository.commitReservationAtomic(userId, reservationId);
|
|
587
|
+
const credits = await repository.getUserCredits(userId);
|
|
588
|
+
const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
|
|
589
|
+
const entry = await repository.createJournalEntry({
|
|
590
|
+
userId,
|
|
591
|
+
entryType: "debit",
|
|
592
|
+
amount: reservation.amount,
|
|
593
|
+
balanceAfter,
|
|
594
|
+
source: "operation_commit",
|
|
595
|
+
referenceId: reservationId,
|
|
596
|
+
referenceType: "reservation",
|
|
597
|
+
description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
598
|
+
// Deterministic fields last: they carry the identity of the event, and a
|
|
599
|
+
// caller who happens to send an `operationType` or an `amount` must not
|
|
600
|
+
// relabel what actually moved.
|
|
601
|
+
metadata: {
|
|
602
|
+
...options?.metadata,
|
|
603
|
+
operationType: reservation.operationType,
|
|
604
|
+
amount: reservation.amount
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
return {
|
|
608
|
+
outcome: "committed",
|
|
609
|
+
reservation: { ...reservation, status: "committed" },
|
|
610
|
+
amount: reservation.amount,
|
|
611
|
+
balanceAfter,
|
|
612
|
+
journalEntryId: entry.id
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
async function releaseThroughRepository(repository, userId, reservationId, options) {
|
|
616
|
+
if (supportsCreditsV2(repository)) {
|
|
617
|
+
return repository.releaseReservationV2(userId, reservationId, options);
|
|
618
|
+
}
|
|
619
|
+
const reservation = await repository.getReservation(userId, reservationId);
|
|
620
|
+
if (!reservation) {
|
|
621
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
622
|
+
return { outcome: "not_found", reservationId };
|
|
623
|
+
}
|
|
624
|
+
if (reservation.status !== "reserved") {
|
|
625
|
+
await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
|
|
626
|
+
return {
|
|
627
|
+
outcome: "already_terminal",
|
|
628
|
+
reservation,
|
|
629
|
+
terminalStatus: reservation.status
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
633
|
+
const credits = await repository.getUserCredits(userId);
|
|
634
|
+
const entry = await repository.createJournalEntry({
|
|
635
|
+
userId,
|
|
636
|
+
entryType: "credit",
|
|
637
|
+
// No credits changed hands — the hold just moved back into "available".
|
|
638
|
+
amount: 0,
|
|
639
|
+
balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
|
|
640
|
+
source: "operation_release",
|
|
641
|
+
referenceId: reservationId,
|
|
642
|
+
referenceType: "reservation",
|
|
643
|
+
description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
644
|
+
metadata: {
|
|
645
|
+
...options?.metadata,
|
|
646
|
+
operationType: reservation.operationType,
|
|
647
|
+
amount: reservation.amount
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
return {
|
|
651
|
+
outcome: "released",
|
|
652
|
+
reservation: { ...reservation, status: "released" },
|
|
653
|
+
amount: reservation.amount,
|
|
654
|
+
journalEntryId: entry.id
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
async function addCreditsThroughRepository(repository, input) {
|
|
658
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
|
|
659
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
660
|
+
if (supportsIdempotentCredit(repository)) {
|
|
661
|
+
return repository.addCreditsV2({ ...input, paymentRef });
|
|
662
|
+
}
|
|
663
|
+
await repository.addCreditsAtomic(
|
|
664
|
+
input.userId,
|
|
665
|
+
input.amount,
|
|
666
|
+
input.description,
|
|
667
|
+
paymentRef,
|
|
668
|
+
input.options
|
|
669
|
+
);
|
|
670
|
+
return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
|
|
671
|
+
}
|
|
672
|
+
|
|
363
673
|
// src/service/credits-service.ts
|
|
364
674
|
function isPastMonthlyReset(resetAt) {
|
|
365
675
|
if (!resetAt) return false;
|
|
@@ -404,28 +714,38 @@ var CreditsService = class {
|
|
|
404
714
|
userId,
|
|
405
715
|
getConfig().subscriptionGracePeriodDays
|
|
406
716
|
);
|
|
407
|
-
if (expiryResult.wasDowngraded) {
|
|
717
|
+
if (expiryResult.wasDowngraded && !expiryResult.journaled) {
|
|
408
718
|
await this.repository.createJournalEntry({
|
|
409
719
|
userId,
|
|
410
720
|
entryType: "debit",
|
|
411
721
|
amount: 0,
|
|
412
722
|
// No credits deducted, just tier change
|
|
413
|
-
|
|
723
|
+
// The ledger balance, matching every other journal writer: the V2
|
|
724
|
+
// transitions and add/deduct all record `balance + bonusCredits`, so
|
|
725
|
+
// recording `balance` alone made the audit trail disagree with itself
|
|
726
|
+
// for any user holding bonus credits.
|
|
727
|
+
balanceAfter: sumAmounts(
|
|
728
|
+
expiryResult.credits.balance,
|
|
729
|
+
expiryResult.credits.bonusCredits
|
|
730
|
+
),
|
|
414
731
|
source: "subscription_downgrade",
|
|
415
732
|
referenceId: `downgrade-${Date.now()}`,
|
|
416
733
|
referenceType: "subscription",
|
|
417
|
-
|
|
734
|
+
// The tier actually landed on, not a hard-coded "free": the downgrade
|
|
735
|
+
// target is configurable, so naming `free` here could describe a
|
|
736
|
+
// transition that never happened.
|
|
737
|
+
description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
|
|
418
738
|
metadata: {
|
|
419
739
|
previousTier: data.tier,
|
|
420
740
|
previousBalance: data.balance,
|
|
421
741
|
newBalance: expiryResult.credits.balance
|
|
422
742
|
}
|
|
423
743
|
});
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
744
|
+
}
|
|
745
|
+
if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
|
|
746
|
+
this.subscriptionExpiredCallback(userId, true).catch((error) => {
|
|
747
|
+
console.error("[Credits] Failed to send subscription expired notification:", error);
|
|
748
|
+
});
|
|
429
749
|
}
|
|
430
750
|
data = expiryResult.credits;
|
|
431
751
|
}
|
|
@@ -436,14 +756,17 @@ var CreditsService = class {
|
|
|
436
756
|
data.tier,
|
|
437
757
|
expectedResetAt
|
|
438
758
|
);
|
|
439
|
-
if (resetResult.wasReset) {
|
|
440
|
-
const balanceChange = resetResult.credits.balance -
|
|
759
|
+
if (resetResult.wasReset && !resetResult.journaled) {
|
|
760
|
+
const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
|
|
441
761
|
if (balanceChange !== 0) {
|
|
442
762
|
await this.repository.createJournalEntry({
|
|
443
763
|
userId,
|
|
444
764
|
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
445
765
|
amount: Math.abs(balanceChange),
|
|
446
|
-
balanceAfter:
|
|
766
|
+
balanceAfter: sumAmounts(
|
|
767
|
+
resetResult.credits.balance,
|
|
768
|
+
resetResult.credits.bonusCredits
|
|
769
|
+
),
|
|
447
770
|
source: "monthly_reset",
|
|
448
771
|
referenceId: `reset-${Date.now()}`,
|
|
449
772
|
referenceType: "reset",
|
|
@@ -494,14 +817,14 @@ var CreditsService = class {
|
|
|
494
817
|
*/
|
|
495
818
|
async checkCredits(userId, requiredCredits) {
|
|
496
819
|
const credits = await this.getOrCreateUserCredits(userId);
|
|
497
|
-
const totalBalance = credits.balance
|
|
498
|
-
const available = totalBalance -
|
|
820
|
+
const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
821
|
+
const available = sumAmounts(totalBalance, -credits.reserved);
|
|
499
822
|
const hasCredits = available >= requiredCredits;
|
|
500
823
|
return {
|
|
501
824
|
hasCredits,
|
|
502
825
|
balance: totalBalance,
|
|
503
826
|
required: requiredCredits,
|
|
504
|
-
shortfall: hasCredits ? 0 : requiredCredits -
|
|
827
|
+
shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
|
|
505
828
|
};
|
|
506
829
|
}
|
|
507
830
|
/**
|
|
@@ -515,74 +838,110 @@ var CreditsService = class {
|
|
|
515
838
|
* @throws Error if insufficient credits
|
|
516
839
|
*/
|
|
517
840
|
async reserveCredits(userId, amount, operationType, options) {
|
|
841
|
+
const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
|
|
842
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
843
|
+
return outcome.reservation;
|
|
844
|
+
}
|
|
845
|
+
if (outcome.outcome === "insufficient") {
|
|
846
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
847
|
+
}
|
|
848
|
+
throw createIdempotencyConflictError(outcome.idempotencyKey, {
|
|
849
|
+
userId,
|
|
850
|
+
requested: { amount, operationType },
|
|
851
|
+
existing: {
|
|
852
|
+
amount: outcome.existing.amount,
|
|
853
|
+
operationType: outcome.existing.operationType
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Reserve credits and get the typed outcome instead of an exception.
|
|
859
|
+
*
|
|
860
|
+
* Prefer this over {@link reserveCredits} in async callers: it distinguishes
|
|
861
|
+
* a fresh hold from an idempotent replay, and reports insufficient funds as
|
|
862
|
+
* a value rather than as control flow.
|
|
863
|
+
*/
|
|
864
|
+
async reserveCreditsDetailed(userId, amount, operationType, options) {
|
|
518
865
|
const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
|
|
519
866
|
const expiresAt = new Date(Date.now() + ttlMs);
|
|
520
|
-
return this.repository
|
|
867
|
+
return reserveThroughRepository(this.repository, {
|
|
868
|
+
userId,
|
|
869
|
+
amount,
|
|
870
|
+
operationType,
|
|
871
|
+
expiresAt,
|
|
872
|
+
idempotencyKey: options?.idempotencyKey
|
|
873
|
+
});
|
|
521
874
|
}
|
|
522
875
|
/**
|
|
523
|
-
* Commit a reservation (phase 2 of two-phase commit - success)
|
|
524
|
-
*
|
|
525
|
-
*
|
|
876
|
+
* Commit a reservation (phase 2 of two-phase commit - success).
|
|
877
|
+
*
|
|
878
|
+
* Idempotent: re-delivering a commit for an already-committed reservation is
|
|
879
|
+
* a no-op. Committing one that was released or expired throws, because the
|
|
880
|
+
* credits are no longer held and pretending otherwise would hide a real bug.
|
|
526
881
|
*/
|
|
527
|
-
async commitCredits(userId, reservationId) {
|
|
528
|
-
const
|
|
529
|
-
if (
|
|
530
|
-
throw
|
|
882
|
+
async commitCredits(userId, reservationId, options) {
|
|
883
|
+
const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
|
|
884
|
+
if (outcome.outcome === "not_found") {
|
|
885
|
+
throw createReservationNotFoundError(reservationId);
|
|
531
886
|
}
|
|
532
|
-
if (
|
|
533
|
-
|
|
887
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
888
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
534
889
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Commit a reservation and get the typed outcome.
|
|
893
|
+
*
|
|
894
|
+
* Exactly one concurrent caller sees `committed`; that call moved the balance
|
|
895
|
+
* and wrote the single journal entry. On a V2 repository the journal is
|
|
896
|
+
* written inside the same transaction, so this method adds none of its own —
|
|
897
|
+
* the duplicate entry the previous implementation wrote is gone.
|
|
898
|
+
*
|
|
899
|
+
* The low-balance notification fires only for the winner, and only after the
|
|
900
|
+
* transaction has committed, so no callback runs inside a database
|
|
901
|
+
* transaction.
|
|
902
|
+
*/
|
|
903
|
+
async commitCreditsDetailed(userId, reservationId, options) {
|
|
904
|
+
const outcome = await commitThroughRepository(
|
|
905
|
+
this.repository,
|
|
906
|
+
userId,
|
|
907
|
+
reservationId,
|
|
908
|
+
options
|
|
909
|
+
);
|
|
910
|
+
if (outcome.outcome === "committed" && this.lowBalanceCallback) {
|
|
911
|
+
this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
|
|
912
|
+
console.error("[Credits] Failed to send low balance notification:", error);
|
|
550
913
|
});
|
|
551
|
-
if (this.lowBalanceCallback) {
|
|
552
|
-
this.lowBalanceCallback(userId, credits.balance).catch((error) => {
|
|
553
|
-
console.error("[Credits] Failed to send low balance notification:", error);
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
914
|
}
|
|
915
|
+
return outcome;
|
|
557
916
|
}
|
|
558
917
|
/**
|
|
559
|
-
* Release a reservation (phase 2 of two-phase commit - failure)
|
|
560
|
-
*
|
|
918
|
+
* Release a reservation (phase 2 of two-phase commit - failure).
|
|
919
|
+
*
|
|
920
|
+
* Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
|
|
921
|
+
* pre-V2 behaviour — a release naming a reservation that does not exist is a
|
|
922
|
+
* caller bug, and swallowing it hides the bug while the credits stay held.
|
|
923
|
+
*
|
|
924
|
+
* Releasing one that is already terminal — `released`, `expired`, or
|
|
925
|
+
* `committed` — is a no-op, which is the behaviour this method has always
|
|
926
|
+
* had. A release that loses a race to a concurrent commit is the common
|
|
927
|
+
* case in a retry-heavy caller, and callers written against the pre-V2
|
|
928
|
+
* contract handle it by not handling it.
|
|
929
|
+
*
|
|
930
|
+
* That does mean this method cannot tell you that the credits were spent
|
|
931
|
+
* rather than returned. Use {@link releaseCreditsDetailed}, which reports
|
|
932
|
+
* `already_terminal` with `terminalStatus: 'committed'`, when you need to
|
|
933
|
+
* branch on it.
|
|
561
934
|
*/
|
|
562
|
-
async releaseCredits(userId, reservationId) {
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const credits = await this.repository.getUserCredits(userId);
|
|
567
|
-
if (credits) {
|
|
568
|
-
await this.repository.createJournalEntry({
|
|
569
|
-
userId,
|
|
570
|
-
entryType: "credit",
|
|
571
|
-
amount: 0,
|
|
572
|
-
// No actual credits returned (they were reserved, not spent)
|
|
573
|
-
balanceAfter: credits.balance,
|
|
574
|
-
source: "operation_release",
|
|
575
|
-
referenceId: reservationId,
|
|
576
|
-
referenceType: "reservation",
|
|
577
|
-
description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
578
|
-
metadata: {
|
|
579
|
-
operationType: reservation.operationType,
|
|
580
|
-
amount: reservation.amount
|
|
581
|
-
}
|
|
582
|
-
});
|
|
583
|
-
}
|
|
935
|
+
async releaseCredits(userId, reservationId, options) {
|
|
936
|
+
const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
|
|
937
|
+
if (outcome.outcome === "not_found") {
|
|
938
|
+
throw createReservationNotFoundError(reservationId);
|
|
584
939
|
}
|
|
585
940
|
}
|
|
941
|
+
/** Release a reservation and get the typed outcome. */
|
|
942
|
+
async releaseCreditsDetailed(userId, reservationId, options) {
|
|
943
|
+
return releaseThroughRepository(this.repository, userId, reservationId, options);
|
|
944
|
+
}
|
|
586
945
|
/**
|
|
587
946
|
* Log usage for audit trail
|
|
588
947
|
* @param log - Usage log data
|
|
@@ -596,9 +955,22 @@ var CreditsService = class {
|
|
|
596
955
|
* @param amount - Credits to add
|
|
597
956
|
* @param description - Transaction description
|
|
598
957
|
* @param paymentRef - Optional payment reference
|
|
958
|
+
* @param options - Optional journal source / reference type / extra metadata
|
|
959
|
+
* for the credit journal entry (e.g. revenue-attribution fields).
|
|
960
|
+
* @returns Whether this delivery credited the account (`created`), repeated an
|
|
961
|
+
* earlier one (`replayed`), or reused the reference for a different credit
|
|
962
|
+
* event (`conflict`, which credited nothing). On a repository without
|
|
963
|
+
* `addCreditsV2` the result is always `created` and carries no
|
|
964
|
+
* deduplication guarantee — see {@link addCreditsThroughRepository}.
|
|
599
965
|
*/
|
|
600
|
-
async addCredits(userId, amount, description, paymentRef) {
|
|
601
|
-
return this.repository
|
|
966
|
+
async addCredits(userId, amount, description, paymentRef, options) {
|
|
967
|
+
return addCreditsThroughRepository(this.repository, {
|
|
968
|
+
userId,
|
|
969
|
+
amount,
|
|
970
|
+
description,
|
|
971
|
+
paymentRef,
|
|
972
|
+
options
|
|
973
|
+
});
|
|
602
974
|
}
|
|
603
975
|
/**
|
|
604
976
|
* One-shot atomic "deduct-if-sufficient" credit charge.
|
|
@@ -622,15 +994,14 @@ var CreditsService = class {
|
|
|
622
994
|
* @throws Error if amount is not a positive number
|
|
623
995
|
*/
|
|
624
996
|
async deductCredits(userId, amount, options) {
|
|
625
|
-
|
|
626
|
-
throw new Error(`deductCredits amount must be positive (got ${amount})`);
|
|
627
|
-
}
|
|
997
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
628
998
|
let deduction;
|
|
629
999
|
try {
|
|
630
1000
|
deduction = await this.repository.deductCreditsAtomic(userId, amount);
|
|
631
1001
|
} catch (error) {
|
|
1002
|
+
if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
|
|
632
1003
|
const credits = await this.repository.getUserCredits(userId);
|
|
633
|
-
const available = credits ? credits.balance
|
|
1004
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
634
1005
|
if (available >= amount) {
|
|
635
1006
|
throw error;
|
|
636
1007
|
}
|
|
@@ -639,7 +1010,7 @@ var CreditsService = class {
|
|
|
639
1010
|
reason: "insufficient",
|
|
640
1011
|
available,
|
|
641
1012
|
required: amount,
|
|
642
|
-
shortfall: amount -
|
|
1013
|
+
shortfall: sumAmounts(amount, -available)
|
|
643
1014
|
};
|
|
644
1015
|
}
|
|
645
1016
|
const operationType = options?.operationType;
|
|
@@ -653,8 +1024,8 @@ var CreditsService = class {
|
|
|
653
1024
|
referenceType: "adjustment",
|
|
654
1025
|
description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
|
|
655
1026
|
metadata: {
|
|
656
|
-
...
|
|
657
|
-
...
|
|
1027
|
+
...options?.metadata,
|
|
1028
|
+
...operationType && { operationType }
|
|
658
1029
|
}
|
|
659
1030
|
});
|
|
660
1031
|
if (operationType) {
|
|
@@ -689,8 +1060,15 @@ var CreditsService = class {
|
|
|
689
1060
|
const free = isFreeTier(tier);
|
|
690
1061
|
await this.repository.updateUserTier(userId, {
|
|
691
1062
|
tier,
|
|
692
|
-
//
|
|
693
|
-
|
|
1063
|
+
// Through the shared contract, like every path that resolves the tier's
|
|
1064
|
+
// limit from configuration. (The repository's `updateUserCredits` and
|
|
1065
|
+
// `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
|
|
1066
|
+
// they are given a value, not a tier, so there is no sentinel to map.)
|
|
1067
|
+
// This used to store 0 for an unlimited tier under a "0 means unlimited"
|
|
1068
|
+
// convention that nothing actually implemented — no read path anywhere in
|
|
1069
|
+
// the library branches on it — so upgrading a user to unlimited gave them
|
|
1070
|
+
// an allowance of zero.
|
|
1071
|
+
monthlyLimit: storedMonthlyLimit(monthlyLimit),
|
|
694
1072
|
// Reset balance to new tier limit if upgrading
|
|
695
1073
|
balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
|
|
696
1074
|
monthlyUsed: !free ? 0 : void 0,
|