@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/index.cjs
CHANGED
|
@@ -24,6 +24,8 @@ __export(src_exports, {
|
|
|
24
24
|
ApiKeyCreditsProvider: () => ApiKeyCreditsProvider,
|
|
25
25
|
AuthenticationError: () => AuthenticationError,
|
|
26
26
|
AuthorizationError: () => AuthorizationError,
|
|
27
|
+
CREDIT_AMOUNT_MAX: () => CREDIT_AMOUNT_MAX,
|
|
28
|
+
CREDIT_AMOUNT_SCALE: () => CREDIT_AMOUNT_SCALE,
|
|
27
29
|
ConsoleNotificationHandler: () => ConsoleNotificationHandler,
|
|
28
30
|
CreditError: () => CreditError,
|
|
29
31
|
CreditErrorCode: () => CreditErrorCode,
|
|
@@ -32,31 +34,57 @@ __export(src_exports, {
|
|
|
32
34
|
CreditsService: () => CreditsService,
|
|
33
35
|
InMemoryCreditRepository: () => InMemoryCreditRepository,
|
|
34
36
|
NetworkError: () => NetworkError,
|
|
37
|
+
RESERVATION_STATUSES: () => RESERVATION_STATUSES,
|
|
38
|
+
RESERVED_JOURNAL_KEY_PREFIX: () => RESERVED_JOURNAL_KEY_PREFIX,
|
|
35
39
|
SDKErrorCode: () => SDKErrorCode,
|
|
36
40
|
SDKInsufficientCreditsError: () => InsufficientCreditsError,
|
|
37
41
|
SDKReservationExpiredError: () => ReservationExpiredError,
|
|
38
42
|
SDKReservationNotFoundError: () => ReservationNotFoundError,
|
|
39
43
|
ServerError: () => ServerError,
|
|
44
|
+
TERMINAL_RESERVATION_STATUSES: () => TERMINAL_RESERVATION_STATUSES,
|
|
40
45
|
UNLIMITED_BALANCE_SENTINEL: () => UNLIMITED_BALANCE_SENTINEL,
|
|
41
46
|
ValidationError: () => ValidationError,
|
|
47
|
+
addCreditsThroughRepository: () => addCreditsThroughRepository,
|
|
48
|
+
assertDirectStatusWriteAllowed: () => assertDirectStatusWriteAllowed,
|
|
49
|
+
assertHoldPlaced: () => assertHoldPlaced,
|
|
50
|
+
assertKnownReservationStatus: () => assertKnownReservationStatus,
|
|
51
|
+
assertPublicJournalKey: () => assertPublicJournalKey,
|
|
52
|
+
assertRepresentableAmount: () => assertRepresentableAmount,
|
|
53
|
+
assertRepresentableFields: () => assertRepresentableFields,
|
|
54
|
+
assertRepresentableTierAmount: () => assertRepresentableTierAmount,
|
|
55
|
+
assertUnkeyedDirectReservation: () => assertUnkeyedDirectReservation,
|
|
56
|
+
assertUnreferencedDirectTransaction: () => assertUnreferencedDirectTransaction,
|
|
57
|
+
assertValidCreditAmount: () => assertValidCreditAmount,
|
|
58
|
+
assertValidIdempotencyKey: () => assertValidIdempotencyKey,
|
|
59
|
+
assertValidStoredAmount: () => assertValidStoredAmount,
|
|
60
|
+
assertValidStoredAmountRaw: () => assertValidStoredAmountRaw,
|
|
61
|
+
backedBalanceFloor: () => backedBalanceFloor,
|
|
42
62
|
calculateAvailableCredits: () => calculateAvailableCredits,
|
|
43
63
|
checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
|
|
64
|
+
classifyDatabaseError: () => classifyDatabaseError,
|
|
44
65
|
clearConfigProvider: () => clearConfigProvider,
|
|
45
66
|
clearCooldownState: () => clearCooldownState,
|
|
46
67
|
clearNotificationHandlers: () => clearNotificationHandlers,
|
|
47
68
|
commitReservationWithJournal: () => commitReservationWithJournal,
|
|
69
|
+
commitThroughRepository: () => commitThroughRepository,
|
|
48
70
|
createApiKeyProvider: () => createApiKeyProvider,
|
|
49
71
|
createCreditsService: () => createCreditsService,
|
|
50
72
|
createDeferredExecutor: () => createDeferredExecutor,
|
|
51
73
|
createDummyReservation: () => createDummyReservation2,
|
|
52
74
|
createGenericAdapter: () => createGenericAdapter,
|
|
75
|
+
createIdempotencyConflictError: () => createIdempotencyConflictError,
|
|
53
76
|
createInMemoryCreditRepository: () => createInMemoryCreditRepository,
|
|
54
77
|
createInsufficientCreditsError: () => createInsufficientCreditsError,
|
|
78
|
+
createInvalidAmountError: () => createInvalidAmountError,
|
|
55
79
|
createInvalidOperationTypeError: () => createInvalidOperationTypeError,
|
|
80
|
+
createPaymentRefConflictError: () => createPaymentRefConflictError,
|
|
56
81
|
createReservationAlreadyProcessedError: () => createReservationAlreadyProcessedError,
|
|
57
82
|
createReservationExpiredError: () => createReservationExpiredError,
|
|
58
83
|
createReservationNotFoundError: () => createReservationNotFoundError,
|
|
84
|
+
createTransientError: () => createTransientError,
|
|
85
|
+
createUnsupportedOperationError: () => createUnsupportedOperationError,
|
|
59
86
|
createUserNotFoundError: () => createUserNotFoundError,
|
|
87
|
+
describePaymentMismatch: () => describePaymentMismatch,
|
|
60
88
|
generateId: () => generateId,
|
|
61
89
|
generateRequestId: () => generateRequestId2,
|
|
62
90
|
genericDeferred: () => genericDeferred,
|
|
@@ -75,35 +103,63 @@ __export(src_exports, {
|
|
|
75
103
|
getOperationLabel: () => getOperationLabel,
|
|
76
104
|
getOperationLabels: () => getOperationLabels,
|
|
77
105
|
getReservationExpiryMs: () => getReservationExpiryMs,
|
|
106
|
+
getSqlState: () => getSqlState,
|
|
78
107
|
getTierConfig: () => getTierConfig,
|
|
79
108
|
getTotalCredits: () => getTotalCredits,
|
|
80
109
|
getUnlimitedSentinelBalance: () => getUnlimitedSentinelBalance,
|
|
81
110
|
getValidOperationTypes: () => getValidOperationTypes,
|
|
82
111
|
getValidTiers: () => getValidTiers,
|
|
112
|
+
hasPlacedHold: () => hasPlacedHold,
|
|
83
113
|
initializeConfig: () => initializeConfig,
|
|
84
114
|
isCreditError: () => isCreditError,
|
|
85
115
|
isCreditErrorMessage: () => isCreditErrorMessage,
|
|
116
|
+
isCreditedOutcome: () => isCreditedOutcome,
|
|
86
117
|
isFeatureEnabled: () => isFeatureEnabled,
|
|
87
118
|
isFreeTier: () => isFreeTier,
|
|
119
|
+
isIdempotencyConflictError: () => isIdempotencyConflictError,
|
|
88
120
|
isInsufficientCreditsError: () => isInsufficientCreditsError,
|
|
89
121
|
isPreviewMode: () => isPreviewMode2,
|
|
122
|
+
isRepresentableAmount: () => isRepresentableAmount,
|
|
123
|
+
isReservationStatus: () => isReservationStatus,
|
|
124
|
+
isReservedJournalKey: () => isReservedJournalKey,
|
|
125
|
+
isReservedOutcome: () => isReservedOutcome,
|
|
126
|
+
isTerminalReservationStatus: () => isTerminalReservationStatus,
|
|
127
|
+
isTransientDatabaseError: () => isTransientDatabaseError,
|
|
128
|
+
isTransientError: () => isTransientError,
|
|
129
|
+
isUniqueViolation: () => isUniqueViolation,
|
|
90
130
|
isUnlimitedTier: () => isUnlimitedTier,
|
|
131
|
+
isValidCreditAmount: () => isValidCreditAmount,
|
|
132
|
+
isValidIdempotencyKey: () => isValidIdempotencyKey,
|
|
91
133
|
isValidOperationType: () => isValidOperationType,
|
|
92
134
|
isValidTier: () => isValidTier,
|
|
135
|
+
isWinningOutcome: () => isWinningOutcome,
|
|
93
136
|
loadConfigFromEnv: () => loadConfigFromEnv,
|
|
137
|
+
monthlyResetBalance: () => monthlyResetBalance,
|
|
94
138
|
noopDeferred: () => noopDeferred,
|
|
139
|
+
normalizePaymentRef: () => normalizePaymentRef,
|
|
95
140
|
notifySubscriptionExpired: () => notifySubscriptionExpired,
|
|
96
141
|
notifySubscriptionExpiring: () => notifySubscriptionExpiring,
|
|
142
|
+
numericToCents: () => numericToCents,
|
|
97
143
|
parseApiError: () => parseApiError,
|
|
98
144
|
parseCreditError: () => parseCreditError,
|
|
99
145
|
parseTier: () => parseTier,
|
|
100
146
|
registerConfigProvider: () => registerConfigProvider,
|
|
101
147
|
registerNotificationHandler: () => registerNotificationHandler,
|
|
102
148
|
releaseReservationWithJournal: () => releaseReservationWithJournal,
|
|
149
|
+
releaseThroughRepository: () => releaseThroughRepository,
|
|
150
|
+
reservationJournalKey: () => reservationJournalKey,
|
|
103
151
|
reserveCreditsForOperation: () => reserveCreditsForOperation,
|
|
152
|
+
reserveThroughRepository: () => reserveThroughRepository,
|
|
104
153
|
resetConfig: () => resetConfig,
|
|
154
|
+
sameAmount: () => sameAmount,
|
|
155
|
+
storedMonthlyLimit: () => storedMonthlyLimit,
|
|
156
|
+
sumAmounts: () => sumAmounts,
|
|
157
|
+
supportsCreditsV2: () => supportsCreditsV2,
|
|
158
|
+
supportsIdempotentCredit: () => supportsIdempotentCredit,
|
|
105
159
|
synchronousDeferred: () => synchronousDeferred,
|
|
160
|
+
terminalStatusOf: () => terminalStatusOf,
|
|
106
161
|
tierSchema: () => tierSchema,
|
|
162
|
+
toCents: () => toCents,
|
|
107
163
|
toClientUserCredits: () => toClientUserCredits,
|
|
108
164
|
toDate: () => toDate,
|
|
109
165
|
toPortableTimestamp: () => toPortableTimestamp,
|
|
@@ -112,38 +168,6 @@ __export(src_exports, {
|
|
|
112
168
|
});
|
|
113
169
|
module.exports = __toCommonJS(src_exports);
|
|
114
170
|
|
|
115
|
-
// src/core/types.ts
|
|
116
|
-
function calculateAvailableCredits(balance, bonusCredits, reserved) {
|
|
117
|
-
return balance + bonusCredits - reserved;
|
|
118
|
-
}
|
|
119
|
-
function toPortableTimestamp(value) {
|
|
120
|
-
if (!value) {
|
|
121
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
122
|
-
}
|
|
123
|
-
if (typeof value === "string") {
|
|
124
|
-
return value;
|
|
125
|
-
}
|
|
126
|
-
if (value instanceof Date) {
|
|
127
|
-
return value.toISOString();
|
|
128
|
-
}
|
|
129
|
-
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
130
|
-
return value.toDate().toISOString();
|
|
131
|
-
}
|
|
132
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
133
|
-
}
|
|
134
|
-
function toDate(value) {
|
|
135
|
-
if (value instanceof Date) {
|
|
136
|
-
return value;
|
|
137
|
-
}
|
|
138
|
-
if (typeof value === "string") {
|
|
139
|
-
return new Date(value);
|
|
140
|
-
}
|
|
141
|
-
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
142
|
-
return value.toDate();
|
|
143
|
-
}
|
|
144
|
-
return /* @__PURE__ */ new Date();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
171
|
// src/core/errors.ts
|
|
148
172
|
var CreditErrorCode = {
|
|
149
173
|
INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
|
|
@@ -153,7 +177,32 @@ var CreditErrorCode = {
|
|
|
153
177
|
USER_NOT_FOUND: "USER_NOT_FOUND",
|
|
154
178
|
INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
|
|
155
179
|
CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
|
|
156
|
-
DATABASE_ERROR: "DATABASE_ERROR"
|
|
180
|
+
DATABASE_ERROR: "DATABASE_ERROR",
|
|
181
|
+
/** Caller reused an idempotency key with a different immutable payload. */
|
|
182
|
+
IDEMPOTENCY_CONFLICT: "IDEMPOTENCY_CONFLICT",
|
|
183
|
+
/**
|
|
184
|
+
* Retryable infrastructure failure — serialization/deadlock, lock timeout,
|
|
185
|
+
* connection loss, pool exhaustion. Distinct from `DATABASE_ERROR`, which is
|
|
186
|
+
* a deterministic failure that will fail again identically on retry.
|
|
187
|
+
*/
|
|
188
|
+
TRANSIENT_ERROR: "TRANSIENT_ERROR",
|
|
189
|
+
/** The configured repository adapter does not implement the requested V2 method. */
|
|
190
|
+
UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION",
|
|
191
|
+
/** Amount was not a finite positive number. */
|
|
192
|
+
INVALID_AMOUNT: "INVALID_AMOUNT",
|
|
193
|
+
/** Idempotency key was supplied but empty or whitespace-only. */
|
|
194
|
+
INVALID_IDEMPOTENCY_KEY: "INVALID_IDEMPOTENCY_KEY",
|
|
195
|
+
/**
|
|
196
|
+
* A persisted reservation holds a status this library does not define, so no
|
|
197
|
+
* transition can reason about it. Quarantined rather than cast; nothing moved.
|
|
198
|
+
*/
|
|
199
|
+
CORRUPT_RESERVATION_STATUS: "CORRUPT_RESERVATION_STATUS",
|
|
200
|
+
/**
|
|
201
|
+
* A persisted reservation does not record that its hold was placed
|
|
202
|
+
* atomically, so the credits it claims are not known to be reserved. Moving
|
|
203
|
+
* it would spend another hold's coverage; nothing moved.
|
|
204
|
+
*/
|
|
205
|
+
UNBACKED_RESERVATION: "UNBACKED_RESERVATION"
|
|
157
206
|
};
|
|
158
207
|
var CreditError = class _CreditError extends Error {
|
|
159
208
|
code;
|
|
@@ -195,7 +244,7 @@ function createInsufficientCreditsError(required, available) {
|
|
|
195
244
|
return new CreditError(
|
|
196
245
|
`Insufficient credits: available ${available}, required ${required}`,
|
|
197
246
|
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
198
|
-
{ required, available, shortfall: required -
|
|
247
|
+
{ required, available, shortfall: sumAmounts(required, -available) }
|
|
199
248
|
);
|
|
200
249
|
}
|
|
201
250
|
function createReservationNotFoundError(reservationId) {
|
|
@@ -233,6 +282,435 @@ function createInvalidOperationTypeError(operationType, validTypes) {
|
|
|
233
282
|
{ operationType, validTypes }
|
|
234
283
|
);
|
|
235
284
|
}
|
|
285
|
+
function createIdempotencyConflictError(idempotencyKey, details) {
|
|
286
|
+
return new CreditError(
|
|
287
|
+
`Idempotency key ${idempotencyKey} was reused with a different payload`,
|
|
288
|
+
CreditErrorCode.IDEMPOTENCY_CONFLICT,
|
|
289
|
+
{ idempotencyKey, ...details }
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
function createTransientError(message, details) {
|
|
293
|
+
return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
|
|
294
|
+
}
|
|
295
|
+
function createUnsupportedOperationError(operation, hint) {
|
|
296
|
+
return new CreditError(
|
|
297
|
+
hint ? `Repository does not support ${operation}. ${hint}` : `Repository does not support ${operation}`,
|
|
298
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
299
|
+
{ operation, ...hint ? { hint } : {} }
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
function createInvalidAmountError(amount) {
|
|
303
|
+
return new CreditError(
|
|
304
|
+
`Credit amount must be a finite positive number (got ${String(amount)})`,
|
|
305
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
306
|
+
{ amount }
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
function isIdempotencyConflictError(error) {
|
|
310
|
+
return isCreditError(error) && error.code === CreditErrorCode.IDEMPOTENCY_CONFLICT;
|
|
311
|
+
}
|
|
312
|
+
function isTransientError(error) {
|
|
313
|
+
return isCreditError(error) && error.code === CreditErrorCode.TRANSIENT_ERROR;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/core/amount.ts
|
|
317
|
+
var CREDIT_AMOUNT_SCALE = 2;
|
|
318
|
+
var CREDIT_AMOUNT_MAX = 999999999999e-2;
|
|
319
|
+
var MAX_CENTS = 999999999999;
|
|
320
|
+
function toCents(value) {
|
|
321
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
322
|
+
const cents = Math.round(value * 100);
|
|
323
|
+
if (!Number.isSafeInteger(cents)) return null;
|
|
324
|
+
return value === cents / 100 ? cents : null;
|
|
325
|
+
}
|
|
326
|
+
function sumAmounts(...values) {
|
|
327
|
+
let cents = 0;
|
|
328
|
+
for (const value of values) {
|
|
329
|
+
const valueCents = toCents(value);
|
|
330
|
+
if (valueCents === null) return floatSum(values);
|
|
331
|
+
cents += valueCents;
|
|
332
|
+
}
|
|
333
|
+
return Number.isSafeInteger(cents) ? cents / 100 : floatSum(values);
|
|
334
|
+
}
|
|
335
|
+
function floatSum(values) {
|
|
336
|
+
let total = 0;
|
|
337
|
+
for (const value of values) total += value;
|
|
338
|
+
return total;
|
|
339
|
+
}
|
|
340
|
+
function isValidCreditAmount(value) {
|
|
341
|
+
if (typeof value !== "number") return false;
|
|
342
|
+
const cents = toCents(value);
|
|
343
|
+
return cents !== null && cents > 0 && cents <= MAX_CENTS;
|
|
344
|
+
}
|
|
345
|
+
function assertValidCreditAmount(value, context) {
|
|
346
|
+
if (isValidCreditAmount(value)) return;
|
|
347
|
+
throw new CreditError(
|
|
348
|
+
creditAmountReason(value),
|
|
349
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
350
|
+
{ amount: value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
function creditAmountReason(value) {
|
|
354
|
+
if (typeof value !== "number") {
|
|
355
|
+
return `Credit amount must be a number (got ${typeof value})`;
|
|
356
|
+
}
|
|
357
|
+
if (!Number.isFinite(value)) {
|
|
358
|
+
return `Credit amount must be finite (got ${String(value)})`;
|
|
359
|
+
}
|
|
360
|
+
if (value <= 0) {
|
|
361
|
+
return `Credit amount must be greater than zero (got ${value})`;
|
|
362
|
+
}
|
|
363
|
+
if (value > CREDIT_AMOUNT_MAX) {
|
|
364
|
+
return `Credit amount ${value} exceeds the maximum of ${CREDIT_AMOUNT_MAX}`;
|
|
365
|
+
}
|
|
366
|
+
return `Credit amount ${value} is not exact to ${CREDIT_AMOUNT_SCALE} decimal places`;
|
|
367
|
+
}
|
|
368
|
+
function isRepresentableAmount(value) {
|
|
369
|
+
if (typeof value !== "number") return false;
|
|
370
|
+
const cents = toCents(value);
|
|
371
|
+
return cents !== null && Math.abs(cents) <= MAX_CENTS;
|
|
372
|
+
}
|
|
373
|
+
function assertRepresentableAmount(value, field, context) {
|
|
374
|
+
if (isRepresentableAmount(value)) return;
|
|
375
|
+
throw new CreditError(
|
|
376
|
+
`${field} must be a finite number exact to ${CREDIT_AMOUNT_SCALE} decimal places within \xB1${CREDIT_AMOUNT_MAX} (got ${String(value)})`,
|
|
377
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
378
|
+
{ field, value, max: CREDIT_AMOUNT_MAX, scale: CREDIT_AMOUNT_SCALE, ...context }
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
function assertRepresentableFields(fields, context) {
|
|
382
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
383
|
+
if (value === void 0) continue;
|
|
384
|
+
assertRepresentableAmount(value, field, context);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function backedBalanceFloor(reserved, bonusCredits) {
|
|
388
|
+
const floor = Math.max(0, sumAmounts(reserved, -bonusCredits));
|
|
389
|
+
assertRepresentableAmount(floor, "backedBalanceFloor", { reserved, bonusCredits });
|
|
390
|
+
return floor;
|
|
391
|
+
}
|
|
392
|
+
function assertRepresentableTierAmount(value, field, context) {
|
|
393
|
+
if (value === Number.POSITIVE_INFINITY) return;
|
|
394
|
+
assertRepresentableAmount(value, field, context);
|
|
395
|
+
}
|
|
396
|
+
function storedMonthlyLimit(limit) {
|
|
397
|
+
return Number.isFinite(limit) ? limit : CREDIT_AMOUNT_MAX;
|
|
398
|
+
}
|
|
399
|
+
function assertValidStoredAmountRaw(raw, context) {
|
|
400
|
+
const cents = numericToCents(raw);
|
|
401
|
+
if (cents !== null && cents > 0n && cents <= BigInt(MAX_CENTS)) {
|
|
402
|
+
return Number(cents) / 100;
|
|
403
|
+
}
|
|
404
|
+
throw new CreditError(
|
|
405
|
+
`Stored credit amount ${String(raw)} is not a valid amount, so this transition was refused before any state changed. The row must be repaired before it can be committed, released or expired.`,
|
|
406
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
407
|
+
{
|
|
408
|
+
amount: typeof raw === "string" ? raw : String(raw),
|
|
409
|
+
reason: "corrupt_stored_amount",
|
|
410
|
+
max: CREDIT_AMOUNT_MAX,
|
|
411
|
+
scale: CREDIT_AMOUNT_SCALE,
|
|
412
|
+
...context
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
function assertValidStoredAmount(value, context) {
|
|
417
|
+
if (isValidCreditAmount(value)) return;
|
|
418
|
+
throw new CreditError(
|
|
419
|
+
`Stored credit amount ${String(value)} is not a valid amount, so this transition was refused before any state changed. The row must be repaired before it can be committed, released or expired.`,
|
|
420
|
+
CreditErrorCode.INVALID_AMOUNT,
|
|
421
|
+
{ amount: value, reason: "corrupt_stored_amount", max: CREDIT_AMOUNT_MAX, ...context }
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
function numericToCents(value) {
|
|
425
|
+
if (typeof value === "number") {
|
|
426
|
+
const cents2 = toCents(value);
|
|
427
|
+
return cents2 === null ? null : BigInt(cents2);
|
|
428
|
+
}
|
|
429
|
+
if (typeof value !== "string") return null;
|
|
430
|
+
const match = /^([+-]?)(\d+)(?:\.(\d*))?$/.exec(value.trim());
|
|
431
|
+
if (!match) return null;
|
|
432
|
+
const [, sign, whole, fraction = ""] = match;
|
|
433
|
+
if (/[1-9]/.test(fraction.slice(CREDIT_AMOUNT_SCALE))) return null;
|
|
434
|
+
const cents = BigInt(whole) * 100n + BigInt(fraction.slice(0, CREDIT_AMOUNT_SCALE).padEnd(CREDIT_AMOUNT_SCALE, "0"));
|
|
435
|
+
return sign === "-" ? -cents : cents;
|
|
436
|
+
}
|
|
437
|
+
function sameAmount(a, b) {
|
|
438
|
+
const left = numericToCents(a);
|
|
439
|
+
const right = numericToCents(b);
|
|
440
|
+
return left !== null && right !== null && left === right;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/core/types.ts
|
|
444
|
+
function calculateAvailableCredits(balance, bonusCredits, reserved) {
|
|
445
|
+
return sumAmounts(balance, bonusCredits, -reserved);
|
|
446
|
+
}
|
|
447
|
+
function toPortableTimestamp(value) {
|
|
448
|
+
if (!value) {
|
|
449
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
450
|
+
}
|
|
451
|
+
if (typeof value === "string") {
|
|
452
|
+
return value;
|
|
453
|
+
}
|
|
454
|
+
if (value instanceof Date) {
|
|
455
|
+
return value.toISOString();
|
|
456
|
+
}
|
|
457
|
+
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
458
|
+
return value.toDate().toISOString();
|
|
459
|
+
}
|
|
460
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
461
|
+
}
|
|
462
|
+
function toDate(value) {
|
|
463
|
+
if (value instanceof Date) {
|
|
464
|
+
return value;
|
|
465
|
+
}
|
|
466
|
+
if (typeof value === "string") {
|
|
467
|
+
return new Date(value);
|
|
468
|
+
}
|
|
469
|
+
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
470
|
+
return value.toDate();
|
|
471
|
+
}
|
|
472
|
+
return /* @__PURE__ */ new Date();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/core/outcomes.ts
|
|
476
|
+
function isCreditedOutcome(outcome) {
|
|
477
|
+
return outcome.outcome === "created";
|
|
478
|
+
}
|
|
479
|
+
function isWinningOutcome(outcome) {
|
|
480
|
+
return outcome.outcome === "committed" || outcome.outcome === "released" || outcome.outcome === "expired";
|
|
481
|
+
}
|
|
482
|
+
function isReservedOutcome(outcome) {
|
|
483
|
+
return outcome.outcome === "created" || outcome.outcome === "replayed";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/core/reservation-integrity.ts
|
|
487
|
+
var RESERVATION_STATUSES = [
|
|
488
|
+
"reserved",
|
|
489
|
+
"committed",
|
|
490
|
+
"released",
|
|
491
|
+
"expired"
|
|
492
|
+
];
|
|
493
|
+
var TERMINAL_RESERVATION_STATUSES = [
|
|
494
|
+
"committed",
|
|
495
|
+
"released",
|
|
496
|
+
"expired"
|
|
497
|
+
];
|
|
498
|
+
function isReservationStatus(value) {
|
|
499
|
+
return RESERVATION_STATUSES.includes(value);
|
|
500
|
+
}
|
|
501
|
+
function isTerminalReservationStatus(value) {
|
|
502
|
+
return TERMINAL_RESERVATION_STATUSES.includes(value);
|
|
503
|
+
}
|
|
504
|
+
function assertKnownReservationStatus(reservation, transition) {
|
|
505
|
+
if (isReservationStatus(reservation.status)) return;
|
|
506
|
+
throw new CreditError(
|
|
507
|
+
`Reservation ${reservation.id} holds the status ${JSON.stringify(reservation.status)}, which is not one of ${RESERVATION_STATUSES.join(", ")}. The ${transition} was refused before any state changed; the row must be repaired by an operator.`,
|
|
508
|
+
CreditErrorCode.CORRUPT_RESERVATION_STATUS,
|
|
509
|
+
{
|
|
510
|
+
userId: reservation.userId,
|
|
511
|
+
reservationId: reservation.id,
|
|
512
|
+
transition,
|
|
513
|
+
status: typeof reservation.status === "string" ? reservation.status : String(reservation.status),
|
|
514
|
+
allowed: [...RESERVATION_STATUSES],
|
|
515
|
+
reason: "corrupt_reservation_status"
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
function terminalStatusOf(reservation, transition) {
|
|
520
|
+
assertKnownReservationStatus(reservation, transition);
|
|
521
|
+
if (isTerminalReservationStatus(reservation.status)) return reservation.status;
|
|
522
|
+
throw new CreditError(
|
|
523
|
+
`Reservation ${reservation.id} is ${reservation.status}, which is not a terminal status, but the ${transition} tried to report it as one.`,
|
|
524
|
+
CreditErrorCode.CORRUPT_RESERVATION_STATUS,
|
|
525
|
+
{
|
|
526
|
+
userId: reservation.userId,
|
|
527
|
+
reservationId: reservation.id,
|
|
528
|
+
transition,
|
|
529
|
+
status: reservation.status,
|
|
530
|
+
reason: "not_terminal"
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
function assertHoldPlaced(reservation, transition) {
|
|
535
|
+
if (reservation.holdPlacedAt) return;
|
|
536
|
+
throw new CreditError(
|
|
537
|
+
`Reservation ${reservation.id} does not record that its hold was placed atomically (holdPlacedAt is absent), so the credits it claims to hold may never have been reserved. The ${transition} was refused before any state changed: honouring it would spend coverage belonging to another reservation. Rows written by createReservation are not V2 reservations; place holds with reserveCredits.`,
|
|
538
|
+
CreditErrorCode.UNBACKED_RESERVATION,
|
|
539
|
+
{
|
|
540
|
+
userId: reservation.userId,
|
|
541
|
+
reservationId: reservation.id,
|
|
542
|
+
transition,
|
|
543
|
+
idempotencyKey: reservation.idempotencyKey ?? void 0,
|
|
544
|
+
reason: "hold_not_placed"
|
|
545
|
+
}
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
function assertUnkeyedDirectReservation(input) {
|
|
549
|
+
if (input.idempotencyKey === void 0 || input.idempotencyKey === null) return;
|
|
550
|
+
throw new CreditError(
|
|
551
|
+
"createReservation cannot accept an idempotencyKey: it writes the reservation row without placing the hold, so the key would name credits that were never reserved, and a later reserveCredits with the same key would adopt it as a replay. Call reserveCredits (or reserveCreditsV2) with the key instead \u2014 it claims the key and places the hold in one transaction.",
|
|
552
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
553
|
+
{
|
|
554
|
+
userId: input.userId,
|
|
555
|
+
idempotencyKey: input.idempotencyKey,
|
|
556
|
+
operation: "createReservation",
|
|
557
|
+
reason: "keyed_direct_reservation"
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
function hasPlacedHold(reservation) {
|
|
562
|
+
return Boolean(reservation.holdPlacedAt);
|
|
563
|
+
}
|
|
564
|
+
function assertDirectStatusWriteAllowed(reservation, status) {
|
|
565
|
+
if (status === "reserved") {
|
|
566
|
+
throw new CreditError(
|
|
567
|
+
`Reservation ${reservation.id} cannot have its status set back to "reserved": updateReservationStatus does not place a hold, so the reopened row would claim credits nothing reserved. Create a new reservation with reserveCredits instead.`,
|
|
568
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
569
|
+
{
|
|
570
|
+
userId: reservation.userId,
|
|
571
|
+
reservationId: reservation.id,
|
|
572
|
+
status,
|
|
573
|
+
reason: "reopen_reservation"
|
|
574
|
+
}
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
if (!reservation.holdPlacedAt) return;
|
|
578
|
+
throw new CreditError(
|
|
579
|
+
`Reservation ${reservation.id} is backed by an atomically placed hold, so its status may only be assigned by the transition that settles it. Writing "${status}" directly would change the status without moving the credits it stands for, stranding the hold. Use commitReservation / releaseReservation / expireReservation (or their V2 forms) instead.`,
|
|
580
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
581
|
+
{
|
|
582
|
+
userId: reservation.userId,
|
|
583
|
+
reservationId: reservation.id,
|
|
584
|
+
status,
|
|
585
|
+
reason: "direct_status_write_on_backed_hold"
|
|
586
|
+
}
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/core/payment-ref.ts
|
|
591
|
+
function normalizePaymentRef(ref) {
|
|
592
|
+
if (typeof ref !== "string") return void 0;
|
|
593
|
+
const trimmed = ref.trim();
|
|
594
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
595
|
+
}
|
|
596
|
+
function describePaymentMismatch(stored, expected) {
|
|
597
|
+
if (stored.userId !== expected.userId) return "userId";
|
|
598
|
+
if (!sameAmount(stored.amount, expected.amount)) return "amount";
|
|
599
|
+
if (stored.type !== expected.type) return "type";
|
|
600
|
+
if (stored.source !== void 0 && stored.source !== expected.source) return "source";
|
|
601
|
+
if (stored.referenceType !== void 0 && stored.referenceType !== expected.referenceType) {
|
|
602
|
+
return "referenceType";
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
function assertUnreferencedDirectTransaction(input) {
|
|
607
|
+
if (normalizePaymentRef(input.paymentRef) === void 0) return;
|
|
608
|
+
throw new CreditError(
|
|
609
|
+
"createTransaction cannot accept a paymentRef: it records a transaction without crediting the account, and the reference would then be treated as an already-delivered payment \u2014 a later addCredits call with the same reference reports `replayed` and credits nothing. Record unreferenced transactions here, or deliver referenced payments through addCredits (addCreditsAtomic / addCreditsV2), which claims the reference and moves the balance atomically.",
|
|
610
|
+
CreditErrorCode.UNSUPPORTED_OPERATION,
|
|
611
|
+
{
|
|
612
|
+
userId: input.userId,
|
|
613
|
+
paymentRef: input.paymentRef,
|
|
614
|
+
operation: "createTransaction",
|
|
615
|
+
reason: "referenced_direct_transaction"
|
|
616
|
+
}
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
function createPaymentRefConflictError(paymentRef, details) {
|
|
620
|
+
return new CreditError(
|
|
621
|
+
`Payment reference ${paymentRef} has already been used for a different credit event (${details.mismatch} differs). Nothing was credited. A payment reference identifies one credit event globally; reusing it for another user, amount or source is refused rather than silently ignored.`,
|
|
622
|
+
CreditErrorCode.IDEMPOTENCY_CONFLICT,
|
|
623
|
+
{ paymentRef, reason: "payment_ref_conflict", ...details }
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/core/idempotency.ts
|
|
628
|
+
function isValidIdempotencyKey(value) {
|
|
629
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
630
|
+
}
|
|
631
|
+
function assertValidIdempotencyKey(value, context) {
|
|
632
|
+
if (value === void 0 || value === null) return;
|
|
633
|
+
if (isValidIdempotencyKey(value)) return;
|
|
634
|
+
throw new CreditError(
|
|
635
|
+
typeof value === "string" ? "An idempotency key was supplied but is empty or whitespace-only. It cannot deduplicate anything, so it was rejected rather than stored as a key that never matches. Omit the key for at-least-once behaviour, or send a meaningful one." : `Idempotency key must be a string (got ${typeof value})`,
|
|
636
|
+
CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
|
|
637
|
+
{ idempotencyKey: value, ...context }
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
var RESERVED_JOURNAL_KEY_PREFIX = "reservation:";
|
|
641
|
+
function isReservedJournalKey(key) {
|
|
642
|
+
return typeof key === "string" && key.startsWith(RESERVED_JOURNAL_KEY_PREFIX);
|
|
643
|
+
}
|
|
644
|
+
function assertPublicJournalKey(key, userId) {
|
|
645
|
+
if (!isReservedJournalKey(key)) return;
|
|
646
|
+
throw new CreditError(
|
|
647
|
+
`Idempotency keys starting with "${RESERVED_JOURNAL_KEY_PREFIX}" are reserved for the V2 reservation transitions and cannot be written directly. Use a key of your own, or commit/release/expire the reservation through the V2 methods.`,
|
|
648
|
+
CreditErrorCode.INVALID_IDEMPOTENCY_KEY,
|
|
649
|
+
{ userId, idempotencyKey: key, reason: "reserved_namespace" }
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/core/error-classify.ts
|
|
654
|
+
var TRANSIENT_SQLSTATES = /* @__PURE__ */ new Set([
|
|
655
|
+
"40000",
|
|
656
|
+
"40001",
|
|
657
|
+
"40P01",
|
|
658
|
+
"55P03",
|
|
659
|
+
"57014",
|
|
660
|
+
"57P01",
|
|
661
|
+
"57P02",
|
|
662
|
+
"57P03",
|
|
663
|
+
"53200",
|
|
664
|
+
"53300",
|
|
665
|
+
"53400"
|
|
666
|
+
]);
|
|
667
|
+
var TRANSIENT_DRIVER_CODES = /* @__PURE__ */ new Set([
|
|
668
|
+
"ECONNRESET",
|
|
669
|
+
"ECONNREFUSED",
|
|
670
|
+
"EPIPE",
|
|
671
|
+
"ETIMEDOUT",
|
|
672
|
+
"ENOTFOUND",
|
|
673
|
+
"EHOSTUNREACH"
|
|
674
|
+
]);
|
|
675
|
+
var NUMERIC_OVERFLOW = "22003";
|
|
676
|
+
function getSqlState(error) {
|
|
677
|
+
const seen = /* @__PURE__ */ new Set();
|
|
678
|
+
let current = error;
|
|
679
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
680
|
+
if (typeof current !== "object" || current === null || seen.has(current)) return void 0;
|
|
681
|
+
seen.add(current);
|
|
682
|
+
const code = current.code;
|
|
683
|
+
if (typeof code === "string") return code;
|
|
684
|
+
current = current.cause;
|
|
685
|
+
}
|
|
686
|
+
return void 0;
|
|
687
|
+
}
|
|
688
|
+
function isUniqueViolation(error) {
|
|
689
|
+
return getSqlState(error) === "23505";
|
|
690
|
+
}
|
|
691
|
+
function isTransientDatabaseError(error) {
|
|
692
|
+
const code = getSqlState(error);
|
|
693
|
+
if (!code) return false;
|
|
694
|
+
if (TRANSIENT_SQLSTATES.has(code)) return true;
|
|
695
|
+
if (TRANSIENT_DRIVER_CODES.has(code)) return true;
|
|
696
|
+
return code.startsWith("08");
|
|
697
|
+
}
|
|
698
|
+
function classifyDatabaseError(error, context) {
|
|
699
|
+
if (isCreditError(error)) return error;
|
|
700
|
+
const sqlState = getSqlState(error);
|
|
701
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
702
|
+
const details = { ...context, ...sqlState ? { sqlState } : {} };
|
|
703
|
+
if (isTransientDatabaseError(error)) {
|
|
704
|
+
return new CreditError(message, CreditErrorCode.TRANSIENT_ERROR, details);
|
|
705
|
+
}
|
|
706
|
+
if (sqlState === NUMERIC_OVERFLOW) {
|
|
707
|
+
return new CreditError(message, CreditErrorCode.INVALID_AMOUNT, {
|
|
708
|
+
...details,
|
|
709
|
+
reason: "amount_out_of_range"
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return new CreditError(message, CreditErrorCode.DATABASE_ERROR, details);
|
|
713
|
+
}
|
|
236
714
|
|
|
237
715
|
// src/core/deferred.ts
|
|
238
716
|
var genericDeferred = {
|
|
@@ -553,6 +1031,12 @@ var UNLIMITED_BALANCE_SENTINEL = 999999;
|
|
|
553
1031
|
function getUnlimitedSentinelBalance() {
|
|
554
1032
|
return UNLIMITED_BALANCE_SENTINEL;
|
|
555
1033
|
}
|
|
1034
|
+
function monthlyResetBalance(tier) {
|
|
1035
|
+
if (isUnlimitedTier(tier)) {
|
|
1036
|
+
return { kind: "atLeast", value: UNLIMITED_BALANCE_SENTINEL };
|
|
1037
|
+
}
|
|
1038
|
+
return { kind: "absolute", value: getConfigMonthlyLimit(tier) };
|
|
1039
|
+
}
|
|
556
1040
|
function getConfigTierConfig(tier) {
|
|
557
1041
|
const cfg = getConfig().tierConfigs[tier];
|
|
558
1042
|
if (cfg) return cfg;
|
|
@@ -595,57 +1079,203 @@ function resetConfig() {
|
|
|
595
1079
|
}
|
|
596
1080
|
initializeConfig();
|
|
597
1081
|
|
|
598
|
-
// src/
|
|
599
|
-
|
|
1082
|
+
// src/repository/types.ts
|
|
1083
|
+
function supportsCreditsV2(repository) {
|
|
1084
|
+
return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
|
|
1085
|
+
}
|
|
1086
|
+
function supportsIdempotentCredit(repository) {
|
|
1087
|
+
return typeof repository.addCreditsV2 === "function";
|
|
1088
|
+
}
|
|
1089
|
+
function toClientUserCredits(credits) {
|
|
1090
|
+
return {
|
|
1091
|
+
userId: credits.userId,
|
|
1092
|
+
balance: credits.balance,
|
|
1093
|
+
bonusCredits: credits.bonusCredits ?? 0,
|
|
1094
|
+
reserved: credits.reserved,
|
|
1095
|
+
tier: credits.tier,
|
|
1096
|
+
monthlyLimit: credits.monthlyLimit,
|
|
1097
|
+
monthlyUsed: credits.monthlyUsed,
|
|
1098
|
+
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
1099
|
+
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
1100
|
+
createdAt: toISOString(credits.createdAt),
|
|
1101
|
+
updatedAt: toISOString(credits.updatedAt)
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
function toISOString(value) {
|
|
1105
|
+
if (!value) return (/* @__PURE__ */ new Date()).toISOString();
|
|
1106
|
+
if (typeof value === "string") return value;
|
|
1107
|
+
if (value instanceof Date) return value.toISOString();
|
|
1108
|
+
if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
|
|
1109
|
+
return value.toDate().toISOString();
|
|
1110
|
+
}
|
|
1111
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// src/repository/flow.ts
|
|
1115
|
+
async function reserveThroughRepository(repository, input) {
|
|
1116
|
+
const v2 = supportsCreditsV2(repository);
|
|
1117
|
+
if (!v2 && input.idempotencyKey !== void 0) {
|
|
1118
|
+
throw createUnsupportedOperationError(
|
|
1119
|
+
"reserveCredits with an idempotencyKey",
|
|
1120
|
+
"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."
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
1124
|
+
if (v2) {
|
|
1125
|
+
return repository.reserveCreditsV2(input);
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
const reservation = await repository.reserveCreditsAtomic(
|
|
1129
|
+
input.userId,
|
|
1130
|
+
input.amount,
|
|
1131
|
+
input.operationType,
|
|
1132
|
+
input.expiresAt
|
|
1133
|
+
);
|
|
1134
|
+
return { outcome: "created", reservation };
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
if (!isInsufficientCreditsError(error)) throw error;
|
|
1137
|
+
const credits = await repository.getUserCredits(input.userId);
|
|
1138
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
1139
|
+
return {
|
|
1140
|
+
outcome: "insufficient",
|
|
1141
|
+
available,
|
|
1142
|
+
required: input.amount,
|
|
1143
|
+
shortfall: Math.max(sumAmounts(input.amount, -available), 0)
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
async function commitThroughRepository(repository, userId, reservationId, options) {
|
|
1148
|
+
if (supportsCreditsV2(repository)) {
|
|
1149
|
+
return repository.commitReservationV2(userId, reservationId, options);
|
|
1150
|
+
}
|
|
600
1151
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
601
|
-
if (!reservation) {
|
|
602
|
-
|
|
1152
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1153
|
+
if (reservation.status !== "reserved") {
|
|
1154
|
+
return {
|
|
1155
|
+
outcome: "already_terminal",
|
|
1156
|
+
reservation,
|
|
1157
|
+
terminalStatus: reservation.status
|
|
1158
|
+
};
|
|
603
1159
|
}
|
|
604
1160
|
await repository.commitReservationAtomic(userId, reservationId);
|
|
605
1161
|
const credits = await repository.getUserCredits(userId);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1162
|
+
const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
|
|
1163
|
+
const entry = await repository.createJournalEntry({
|
|
1164
|
+
userId,
|
|
1165
|
+
entryType: "debit",
|
|
1166
|
+
amount: reservation.amount,
|
|
1167
|
+
balanceAfter,
|
|
1168
|
+
source: "operation_commit",
|
|
1169
|
+
referenceId: reservationId,
|
|
1170
|
+
referenceType: "reservation",
|
|
1171
|
+
description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1172
|
+
// Deterministic fields last: they carry the identity of the event, and a
|
|
1173
|
+
// caller who happens to send an `operationType` or an `amount` must not
|
|
1174
|
+
// relabel what actually moved.
|
|
1175
|
+
metadata: {
|
|
1176
|
+
...options?.metadata,
|
|
1177
|
+
operationType: reservation.operationType,
|
|
1178
|
+
amount: reservation.amount
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
return {
|
|
1182
|
+
outcome: "committed",
|
|
1183
|
+
reservation: { ...reservation, status: "committed" },
|
|
1184
|
+
amount: reservation.amount,
|
|
1185
|
+
balanceAfter,
|
|
1186
|
+
journalEntryId: entry.id
|
|
1187
|
+
};
|
|
621
1188
|
}
|
|
622
|
-
async function
|
|
1189
|
+
async function releaseThroughRepository(repository, userId, reservationId, options) {
|
|
1190
|
+
if (supportsCreditsV2(repository)) {
|
|
1191
|
+
return repository.releaseReservationV2(userId, reservationId, options);
|
|
1192
|
+
}
|
|
623
1193
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
1194
|
+
if (!reservation) {
|
|
1195
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
1196
|
+
return { outcome: "not_found", reservationId };
|
|
1197
|
+
}
|
|
1198
|
+
if (reservation.status !== "reserved") {
|
|
1199
|
+
await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
|
|
1200
|
+
return {
|
|
1201
|
+
outcome: "already_terminal",
|
|
1202
|
+
reservation,
|
|
1203
|
+
terminalStatus: reservation.status
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
624
1206
|
await repository.releaseReservationAtomic(userId, reservationId);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
amount: reservation.amount
|
|
641
|
-
}
|
|
642
|
-
});
|
|
1207
|
+
const credits = await repository.getUserCredits(userId);
|
|
1208
|
+
const entry = await repository.createJournalEntry({
|
|
1209
|
+
userId,
|
|
1210
|
+
entryType: "credit",
|
|
1211
|
+
// No credits changed hands — the hold just moved back into "available".
|
|
1212
|
+
amount: 0,
|
|
1213
|
+
balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
|
|
1214
|
+
source: "operation_release",
|
|
1215
|
+
referenceId: reservationId,
|
|
1216
|
+
referenceType: "reservation",
|
|
1217
|
+
description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1218
|
+
metadata: {
|
|
1219
|
+
...options?.metadata,
|
|
1220
|
+
operationType: reservation.operationType,
|
|
1221
|
+
amount: reservation.amount
|
|
643
1222
|
}
|
|
1223
|
+
});
|
|
1224
|
+
return {
|
|
1225
|
+
outcome: "released",
|
|
1226
|
+
reservation: { ...reservation, status: "released" },
|
|
1227
|
+
amount: reservation.amount,
|
|
1228
|
+
journalEntryId: entry.id
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
async function addCreditsThroughRepository(repository, input) {
|
|
1232
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
|
|
1233
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
1234
|
+
if (supportsIdempotentCredit(repository)) {
|
|
1235
|
+
return repository.addCreditsV2({ ...input, paymentRef });
|
|
1236
|
+
}
|
|
1237
|
+
await repository.addCreditsAtomic(
|
|
1238
|
+
input.userId,
|
|
1239
|
+
input.amount,
|
|
1240
|
+
input.description,
|
|
1241
|
+
paymentRef,
|
|
1242
|
+
input.options
|
|
1243
|
+
);
|
|
1244
|
+
return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/core/operations.ts
|
|
1248
|
+
async function commitReservationWithJournal(repository, userId, reservationId) {
|
|
1249
|
+
const outcome = await commitThroughRepository(repository, userId, reservationId);
|
|
1250
|
+
if (outcome.outcome === "not_found") {
|
|
1251
|
+
throw createReservationNotFoundError(reservationId);
|
|
644
1252
|
}
|
|
1253
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
1254
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
async function releaseReservationWithJournal(repository, userId, reservationId) {
|
|
1258
|
+
await releaseThroughRepository(repository, userId, reservationId);
|
|
645
1259
|
}
|
|
646
|
-
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
|
|
1260
|
+
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
|
|
647
1261
|
const expiresAt = new Date(Date.now() + expiryMs);
|
|
648
|
-
|
|
1262
|
+
const outcome = await reserveThroughRepository(repository, {
|
|
1263
|
+
userId,
|
|
1264
|
+
amount,
|
|
1265
|
+
operationType,
|
|
1266
|
+
expiresAt,
|
|
1267
|
+
idempotencyKey
|
|
1268
|
+
});
|
|
1269
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1270
|
+
return outcome.reservation;
|
|
1271
|
+
}
|
|
1272
|
+
if (outcome.outcome === "insufficient") {
|
|
1273
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
1274
|
+
}
|
|
1275
|
+
throw createReservationAlreadyProcessedError(
|
|
1276
|
+
outcome.existing.id,
|
|
1277
|
+
outcome.existing.status
|
|
1278
|
+
);
|
|
649
1279
|
}
|
|
650
1280
|
|
|
651
1281
|
// src/config/costs.ts
|
|
@@ -680,64 +1310,493 @@ function getOperationCostsWithLabels() {
|
|
|
680
1310
|
return result;
|
|
681
1311
|
}
|
|
682
1312
|
|
|
683
|
-
// src/repository/types.ts
|
|
684
|
-
function
|
|
685
|
-
return {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1313
|
+
// src/repository/v2-types.ts
|
|
1314
|
+
function reservationJournalKey(reservationId, transition) {
|
|
1315
|
+
return `${RESERVED_JOURNAL_KEY_PREFIX}${reservationId}:${transition}`;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// src/repository/utils.ts
|
|
1319
|
+
function generateId() {
|
|
1320
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
1321
|
+
}
|
|
1322
|
+
function toDate2(value) {
|
|
1323
|
+
if (value instanceof Date) return value;
|
|
1324
|
+
if (typeof value === "string") return new Date(value);
|
|
1325
|
+
if (typeof value === "object" && value !== null && "toDate" in value) {
|
|
1326
|
+
return value.toDate();
|
|
1327
|
+
}
|
|
1328
|
+
return /* @__PURE__ */ new Date();
|
|
1329
|
+
}
|
|
1330
|
+
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1331
|
+
const next = new Date(from);
|
|
1332
|
+
next.setMonth(next.getMonth() + 1);
|
|
1333
|
+
next.setDate(1);
|
|
1334
|
+
next.setHours(0, 0, 0, 0);
|
|
1335
|
+
return next;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// src/repository/memory/snapshot.ts
|
|
1339
|
+
function copyRecord(record) {
|
|
1340
|
+
if (!record) return void 0;
|
|
1341
|
+
const copy = { ...record };
|
|
1342
|
+
const metadata = record.metadata;
|
|
1343
|
+
if (metadata && typeof metadata === "object") {
|
|
1344
|
+
copy.metadata = { ...metadata };
|
|
1345
|
+
}
|
|
1346
|
+
return copy;
|
|
1347
|
+
}
|
|
1348
|
+
function copyRecords(records) {
|
|
1349
|
+
return records.map((record) => copyRecord(record));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/repository/memory/mutex.ts
|
|
1353
|
+
var KeyedMutex = class {
|
|
1354
|
+
tails = /* @__PURE__ */ new Map();
|
|
1355
|
+
/**
|
|
1356
|
+
* Run `fn` with exclusive access to `key`.
|
|
1357
|
+
*
|
|
1358
|
+
* Callers queue in arrival order. The stored tail settles either way, so one
|
|
1359
|
+
* caller throwing does not wedge the queue behind it.
|
|
1360
|
+
*/
|
|
1361
|
+
async run(key, fn) {
|
|
1362
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
1363
|
+
const result = previous.then(fn, fn);
|
|
1364
|
+
const tail = result.then(
|
|
1365
|
+
() => void 0,
|
|
1366
|
+
() => void 0
|
|
1367
|
+
);
|
|
1368
|
+
this.tails.set(key, tail);
|
|
1369
|
+
try {
|
|
1370
|
+
return await result;
|
|
1371
|
+
} finally {
|
|
1372
|
+
if (this.tails.get(key) === tail) {
|
|
1373
|
+
this.tails.delete(key);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
// src/repository/memory/store.ts
|
|
1380
|
+
var MemoryStore = class {
|
|
1381
|
+
users = /* @__PURE__ */ new Map();
|
|
1382
|
+
reservations = /* @__PURE__ */ new Map();
|
|
1383
|
+
transactions = /* @__PURE__ */ new Map();
|
|
1384
|
+
usageLogs = [];
|
|
1385
|
+
journalEntries = /* @__PURE__ */ new Map();
|
|
1386
|
+
/** Stands in for the reservations table's partial unique index. */
|
|
1387
|
+
reservationKeys = /* @__PURE__ */ new Map();
|
|
1388
|
+
/** Stands in for the journal table's partial unique index. */
|
|
1389
|
+
journalKeys = /* @__PURE__ */ new Map();
|
|
1390
|
+
/** Stands in for the database's per-user row locks. */
|
|
1391
|
+
locks = new KeyedMutex();
|
|
1392
|
+
/**
|
|
1393
|
+
* Test-only yield point, awaited between reading state and mutating it.
|
|
1394
|
+
*
|
|
1395
|
+
* Unset in production, so the critical sections stay synchronous and this
|
|
1396
|
+
* costs nothing. Tests set it to a real macrotask yield, which forces
|
|
1397
|
+
* concurrent callers to interleave at exactly the spot a database would have
|
|
1398
|
+
* them contend — turning the lock above from decoration into the thing the
|
|
1399
|
+
* concurrency tests actually depend on.
|
|
1400
|
+
*/
|
|
1401
|
+
schedulingHook;
|
|
1402
|
+
clear() {
|
|
1403
|
+
this.users.clear();
|
|
1404
|
+
this.reservations.clear();
|
|
1405
|
+
this.transactions.clear();
|
|
1406
|
+
this.usageLogs = [];
|
|
1407
|
+
this.journalEntries.clear();
|
|
1408
|
+
this.reservationKeys.clear();
|
|
1409
|
+
this.journalKeys.clear();
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
function scopedKey(userId, key) {
|
|
1413
|
+
return `${userId}
|
|
1414
|
+
${key}`;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// src/repository/memory/transition-journal.ts
|
|
1418
|
+
function describeJournalMismatch(row, input) {
|
|
1419
|
+
if (row.userId !== input.userId) return "user_id";
|
|
1420
|
+
if (row.entryType !== input.entryType) return "entry_type";
|
|
1421
|
+
if (!sameAmount(row.amount, input.amount)) return "amount";
|
|
1422
|
+
if (!sameAmount(row.balanceAfter, input.balanceAfter)) return "balance_after";
|
|
1423
|
+
if (row.source !== input.source) return "source";
|
|
1424
|
+
if (row.referenceId !== input.reservationId) return "reference_id";
|
|
1425
|
+
if (row.referenceType !== "reservation") return "reference_type";
|
|
1426
|
+
const existingMeta = row.metadata ?? {};
|
|
1427
|
+
const expectedMeta = input.metadata ?? {};
|
|
1428
|
+
if (existingMeta.operationType !== expectedMeta.operationType) {
|
|
1429
|
+
return "metadata.operationType";
|
|
1430
|
+
}
|
|
1431
|
+
const hadAmount = existingMeta.amount !== void 0;
|
|
1432
|
+
const wantsAmount = expectedMeta.amount !== void 0;
|
|
1433
|
+
if (hadAmount !== wantsAmount) return "metadata.amount";
|
|
1434
|
+
if (wantsAmount && !sameAmount(existingMeta.amount, expectedMeta.amount)) {
|
|
1435
|
+
return "metadata.amount";
|
|
1436
|
+
}
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
function findEntry(store, userId, entryId) {
|
|
1440
|
+
return store.journalEntries.get(userId)?.find((entry) => entry.id === entryId);
|
|
1441
|
+
}
|
|
1442
|
+
function planTransitionJournal(store, input) {
|
|
1443
|
+
assertRepresentableFields(
|
|
1444
|
+
{ amount: input.amount, "journal balanceAfter": input.balanceAfter },
|
|
1445
|
+
{ userId: input.userId, reservationId: input.reservationId, operation: input.operation }
|
|
1446
|
+
);
|
|
1447
|
+
const existingId = store.journalKeys.get(scopedKey(input.userId, input.idempotencyKey));
|
|
1448
|
+
if (!existingId) return void 0;
|
|
1449
|
+
const row = findEntry(store, input.userId, existingId);
|
|
1450
|
+
const mismatch = row ? describeJournalMismatch(row, input) : "the key is registered but its entry is missing";
|
|
1451
|
+
if (mismatch) {
|
|
1452
|
+
throw new CreditError(
|
|
1453
|
+
`Journal key ${input.idempotencyKey} is already used by a different entry (${mismatch}). The transition was refused rather than reported as applied.`,
|
|
1454
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1455
|
+
{
|
|
1456
|
+
idempotencyKey: input.idempotencyKey,
|
|
1457
|
+
reservationId: input.reservationId,
|
|
1458
|
+
mismatch
|
|
1459
|
+
}
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
return existingId;
|
|
1463
|
+
}
|
|
1464
|
+
function commitTransitionJournal(store, input, existingId) {
|
|
1465
|
+
if (existingId) return existingId;
|
|
1466
|
+
const entry = {
|
|
1467
|
+
id: generateId(),
|
|
1468
|
+
userId: input.userId,
|
|
1469
|
+
entryType: input.entryType,
|
|
1470
|
+
amount: input.amount,
|
|
1471
|
+
balanceAfter: input.balanceAfter,
|
|
1472
|
+
source: input.source,
|
|
1473
|
+
referenceId: input.reservationId,
|
|
1474
|
+
referenceType: "reservation",
|
|
1475
|
+
description: input.description,
|
|
1476
|
+
metadata: input.metadata,
|
|
1477
|
+
idempotencyKey: input.idempotencyKey,
|
|
1478
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
697
1479
|
};
|
|
1480
|
+
if (!store.journalEntries.has(input.userId)) {
|
|
1481
|
+
store.journalEntries.set(input.userId, []);
|
|
1482
|
+
}
|
|
1483
|
+
store.journalEntries.get(input.userId).push(entry);
|
|
1484
|
+
store.journalKeys.set(scopedKey(input.userId, input.idempotencyKey), entry.id);
|
|
1485
|
+
return entry.id;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// src/repository/memory/v2.ts
|
|
1489
|
+
async function yieldPoint(store) {
|
|
1490
|
+
if (store.schedulingHook) await store.schedulingHook();
|
|
1491
|
+
}
|
|
1492
|
+
function assertReservedCoversHold(credits, userId, reservationId, amount) {
|
|
1493
|
+
if (credits.reserved >= amount) return;
|
|
1494
|
+
throw new CreditError(
|
|
1495
|
+
`Credit balance invariant violated for user ${userId} while processing reservation ${reservationId}: reserved (${credits.reserved}) is less than the hold (${amount}). The transition was rolled back.`,
|
|
1496
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1497
|
+
{ userId, reservationId, amount, reserved: credits.reserved }
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
function assertLockedAmount(reservation, transition) {
|
|
1501
|
+
assertValidStoredAmount(reservation.amount, {
|
|
1502
|
+
userId: reservation.userId,
|
|
1503
|
+
reservationId: reservation.id,
|
|
1504
|
+
transition
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
function terminalStatusOf2(reservation, transition) {
|
|
1508
|
+
return terminalStatusOf(reservation, transition);
|
|
1509
|
+
}
|
|
1510
|
+
function assertTrustworthy(reservation, transition) {
|
|
1511
|
+
assertKnownReservationStatus(reservation, transition);
|
|
1512
|
+
assertLockedAmount(reservation, transition);
|
|
1513
|
+
assertHoldPlaced(reservation, transition);
|
|
1514
|
+
}
|
|
1515
|
+
function getReservation(store, userId, reservationId) {
|
|
1516
|
+
return store.reservations.get(userId)?.get(reservationId);
|
|
1517
|
+
}
|
|
1518
|
+
function requireUser(store, userId) {
|
|
1519
|
+
const credits = store.users.get(userId);
|
|
1520
|
+
if (!credits) {
|
|
1521
|
+
throw new CreditError(
|
|
1522
|
+
`User credits not found for user ${userId}`,
|
|
1523
|
+
CreditErrorCode.USER_NOT_FOUND,
|
|
1524
|
+
{ userId }
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
return credits;
|
|
1528
|
+
}
|
|
1529
|
+
async function reserveCreditsV2(store, input) {
|
|
1530
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
1531
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1532
|
+
return store.locks.run(input.userId, async () => {
|
|
1533
|
+
if (input.idempotencyKey) {
|
|
1534
|
+
const existingId = store.reservationKeys.get(
|
|
1535
|
+
scopedKey(input.userId, input.idempotencyKey)
|
|
1536
|
+
);
|
|
1537
|
+
if (existingId) {
|
|
1538
|
+
const existing = getReservation(store, input.userId, existingId);
|
|
1539
|
+
if (existing) {
|
|
1540
|
+
assertHoldPlaced(existing, "reserve replay");
|
|
1541
|
+
const samePayload = existing.amount === input.amount && existing.operationType === input.operationType;
|
|
1542
|
+
return samePayload ? { outcome: "replayed", reservation: copyRecord(existing) } : {
|
|
1543
|
+
outcome: "idempotency_conflict",
|
|
1544
|
+
idempotencyKey: input.idempotencyKey,
|
|
1545
|
+
existing: copyRecord(existing)
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
const credits = requireUser(store, input.userId);
|
|
1551
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
1552
|
+
await yieldPoint(store);
|
|
1553
|
+
if (available < input.amount) {
|
|
1554
|
+
return {
|
|
1555
|
+
outcome: "insufficient",
|
|
1556
|
+
available,
|
|
1557
|
+
required: input.amount,
|
|
1558
|
+
shortfall: sumAmounts(input.amount, -available)
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1562
|
+
const reservation = {
|
|
1563
|
+
id: generateId(),
|
|
1564
|
+
userId: input.userId,
|
|
1565
|
+
amount: input.amount,
|
|
1566
|
+
operationType: input.operationType,
|
|
1567
|
+
status: "reserved",
|
|
1568
|
+
createdAt: now,
|
|
1569
|
+
expiresAt: input.expiresAt.toISOString(),
|
|
1570
|
+
idempotencyKey: input.idempotencyKey,
|
|
1571
|
+
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1572
|
+
// critical section that raises `reserved` below, so the row and the hold
|
|
1573
|
+
// become visible together. `createReservation` cannot set it.
|
|
1574
|
+
holdPlacedAt: now
|
|
1575
|
+
};
|
|
1576
|
+
if (!store.reservations.has(input.userId)) {
|
|
1577
|
+
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1578
|
+
}
|
|
1579
|
+
const nextReserved = sumAmounts(credits.reserved, input.amount);
|
|
1580
|
+
assertRepresentableFields(
|
|
1581
|
+
{ reserved: nextReserved },
|
|
1582
|
+
{ userId: input.userId, operation: "reserveCredits" }
|
|
1583
|
+
);
|
|
1584
|
+
store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1585
|
+
if (input.idempotencyKey) {
|
|
1586
|
+
store.reservationKeys.set(
|
|
1587
|
+
scopedKey(input.userId, input.idempotencyKey),
|
|
1588
|
+
reservation.id
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
credits.reserved = nextReserved;
|
|
1592
|
+
credits.updatedAt = now;
|
|
1593
|
+
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1594
|
+
});
|
|
698
1595
|
}
|
|
699
|
-
function
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1596
|
+
async function commitReservationV2(store, userId, reservationId, options) {
|
|
1597
|
+
return store.locks.run(userId, async () => {
|
|
1598
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1599
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1600
|
+
assertTrustworthy(reservation, "commit");
|
|
1601
|
+
if (reservation.status !== "reserved") {
|
|
1602
|
+
return {
|
|
1603
|
+
outcome: "already_terminal",
|
|
1604
|
+
reservation: copyRecord(reservation),
|
|
1605
|
+
terminalStatus: terminalStatusOf2(reservation, "commit")
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
const credits = requireUser(store, userId);
|
|
1609
|
+
const amount = reservation.amount;
|
|
1610
|
+
await yieldPoint(store);
|
|
1611
|
+
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1612
|
+
if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
|
|
1613
|
+
throw new CreditError(
|
|
1614
|
+
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1615
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1616
|
+
{ userId, reservationId, required: amount }
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
const fromBalance = Math.min(credits.balance, amount);
|
|
1620
|
+
const nextBalance = sumAmounts(credits.balance, -fromBalance);
|
|
1621
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1622
|
+
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1623
|
+
const journal = {
|
|
1624
|
+
userId,
|
|
1625
|
+
operation: "commitReservation",
|
|
1626
|
+
entryType: "debit",
|
|
1627
|
+
amount,
|
|
1628
|
+
balanceAfter,
|
|
1629
|
+
source: "operation_commit",
|
|
1630
|
+
reservationId,
|
|
1631
|
+
description: options?.description ?? `Committed ${amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1632
|
+
// Caller metadata first: the deterministic fields carry the identity of
|
|
1633
|
+
// the event that the collision check compares on.
|
|
1634
|
+
metadata: { ...options?.metadata, operationType: reservation.operationType, amount },
|
|
1635
|
+
idempotencyKey: reservationJournalKey(reservationId, "commit")
|
|
1636
|
+
};
|
|
1637
|
+
const nextReserved = sumAmounts(credits.reserved, -amount);
|
|
1638
|
+
const nextMonthlyUsed = sumAmounts(credits.monthlyUsed, amount);
|
|
1639
|
+
assertRepresentableFields(
|
|
1640
|
+
{
|
|
1641
|
+
balance: nextBalance,
|
|
1642
|
+
bonusCredits: nextBonusCredits,
|
|
1643
|
+
reserved: nextReserved,
|
|
1644
|
+
monthlyUsed: nextMonthlyUsed
|
|
1645
|
+
},
|
|
1646
|
+
{ userId, operation: "commitReservation" }
|
|
1647
|
+
);
|
|
1648
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1649
|
+
reservation.status = "committed";
|
|
1650
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1651
|
+
credits.balance = nextBalance;
|
|
1652
|
+
credits.bonusCredits = nextBonusCredits;
|
|
1653
|
+
credits.reserved = nextReserved;
|
|
1654
|
+
credits.monthlyUsed = nextMonthlyUsed;
|
|
1655
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1656
|
+
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1657
|
+
return {
|
|
1658
|
+
outcome: "committed",
|
|
1659
|
+
reservation: copyRecord(reservation),
|
|
1660
|
+
amount,
|
|
1661
|
+
balanceAfter,
|
|
1662
|
+
journalEntryId
|
|
1663
|
+
};
|
|
1664
|
+
});
|
|
707
1665
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1666
|
+
async function releaseReservationV2(store, userId, reservationId, options) {
|
|
1667
|
+
return store.locks.run(userId, async () => {
|
|
1668
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1669
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1670
|
+
assertTrustworthy(reservation, "release");
|
|
1671
|
+
if (reservation.status !== "reserved") {
|
|
1672
|
+
return {
|
|
1673
|
+
outcome: "already_terminal",
|
|
1674
|
+
reservation: copyRecord(reservation),
|
|
1675
|
+
terminalStatus: terminalStatusOf2(reservation, "release")
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
await yieldPoint(store);
|
|
1679
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1680
|
+
status: "released",
|
|
1681
|
+
source: "operation_release",
|
|
1682
|
+
transition: "release",
|
|
1683
|
+
verb: "Released",
|
|
1684
|
+
options
|
|
1685
|
+
});
|
|
1686
|
+
return {
|
|
1687
|
+
outcome: "released",
|
|
1688
|
+
reservation: copyRecord(reservation),
|
|
1689
|
+
amount: reservation.amount,
|
|
1690
|
+
journalEntryId
|
|
1691
|
+
};
|
|
1692
|
+
});
|
|
712
1693
|
}
|
|
713
|
-
function
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1694
|
+
async function expireReservationV2(store, userId, reservationId, options) {
|
|
1695
|
+
const asOf = options?.asOf ?? /* @__PURE__ */ new Date();
|
|
1696
|
+
return store.locks.run(userId, async () => {
|
|
1697
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1698
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1699
|
+
assertTrustworthy(reservation, "expire");
|
|
1700
|
+
if (reservation.status !== "reserved") {
|
|
1701
|
+
return {
|
|
1702
|
+
outcome: "already_terminal",
|
|
1703
|
+
reservation: copyRecord(reservation),
|
|
1704
|
+
terminalStatus: terminalStatusOf2(reservation, "expire")
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
if (new Date(reservation.expiresAt).getTime() > asOf.getTime()) {
|
|
1708
|
+
return { outcome: "not_due", reservation: copyRecord(reservation) };
|
|
1709
|
+
}
|
|
1710
|
+
await yieldPoint(store);
|
|
1711
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1712
|
+
status: "expired",
|
|
1713
|
+
source: "reservation_expired",
|
|
1714
|
+
transition: "expire",
|
|
1715
|
+
verb: "Expired",
|
|
1716
|
+
options
|
|
1717
|
+
});
|
|
1718
|
+
return {
|
|
1719
|
+
outcome: "expired",
|
|
1720
|
+
reservation: copyRecord(reservation),
|
|
1721
|
+
amount: reservation.amount,
|
|
1722
|
+
journalEntryId
|
|
1723
|
+
};
|
|
1724
|
+
});
|
|
720
1725
|
}
|
|
721
|
-
function
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1726
|
+
function finishUnspent(store, reservation, transition) {
|
|
1727
|
+
const credits = requireUser(store, reservation.userId);
|
|
1728
|
+
assertReservedCoversHold(
|
|
1729
|
+
credits,
|
|
1730
|
+
reservation.userId,
|
|
1731
|
+
reservation.id,
|
|
1732
|
+
reservation.amount
|
|
1733
|
+
);
|
|
1734
|
+
const journal = {
|
|
1735
|
+
userId: reservation.userId,
|
|
1736
|
+
operation: `${transition.transition}Reservation`,
|
|
1737
|
+
entryType: "credit",
|
|
1738
|
+
amount: 0,
|
|
1739
|
+
balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1740
|
+
source: transition.source,
|
|
1741
|
+
reservationId: reservation.id,
|
|
1742
|
+
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1743
|
+
metadata: {
|
|
1744
|
+
...transition.options?.metadata,
|
|
1745
|
+
operationType: reservation.operationType,
|
|
1746
|
+
amount: reservation.amount
|
|
1747
|
+
},
|
|
1748
|
+
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1749
|
+
};
|
|
1750
|
+
const nextReserved = sumAmounts(credits.reserved, -reservation.amount);
|
|
1751
|
+
assertRepresentableFields(
|
|
1752
|
+
{ reserved: nextReserved },
|
|
1753
|
+
{ userId: reservation.userId, operation: `${transition.transition}Reservation` }
|
|
1754
|
+
);
|
|
1755
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1756
|
+
reservation.status = transition.status;
|
|
1757
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1758
|
+
credits.reserved = nextReserved;
|
|
1759
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1760
|
+
return commitTransitionJournal(store, journal, existingEntryId);
|
|
727
1761
|
}
|
|
728
1762
|
|
|
729
1763
|
// src/repository/memory/index.ts
|
|
1764
|
+
function isoOf(value) {
|
|
1765
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
1766
|
+
}
|
|
730
1767
|
var InMemoryCreditRepository = class {
|
|
731
|
-
|
|
732
|
-
reservations = /* @__PURE__ */ new Map();
|
|
733
|
-
transactions = /* @__PURE__ */ new Map();
|
|
734
|
-
usageLogs = [];
|
|
735
|
-
journalEntries = /* @__PURE__ */ new Map();
|
|
1768
|
+
store = new MemoryStore();
|
|
736
1769
|
// ==================== User Credits ====================
|
|
1770
|
+
/**
|
|
1771
|
+
* A copy, never the live record.
|
|
1772
|
+
*
|
|
1773
|
+
* Handing out the stored object made this adapter behave unlike any real
|
|
1774
|
+
* one: a caller holding a "before" snapshot saw it mutate under them. The
|
|
1775
|
+
* service's monthly reset compared `resetResult.credits.balance` against the
|
|
1776
|
+
* balance it had read moments earlier, got zero because both were the same
|
|
1777
|
+
* object, and silently skipped the reset's journal entry — while the SQL
|
|
1778
|
+
* adapter, which maps each row into a fresh object, wrote it.
|
|
1779
|
+
*/
|
|
737
1780
|
async getUserCredits(userId) {
|
|
738
|
-
|
|
1781
|
+
const credits = this.store.users.get(userId);
|
|
1782
|
+
return copyRecord(credits) ?? null;
|
|
739
1783
|
}
|
|
740
1784
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1785
|
+
return copyRecord(this.seedUser(userId, tier, initialBalance));
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* The synchronous body of `initializeUserCredits`.
|
|
1789
|
+
*
|
|
1790
|
+
* Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
|
|
1791
|
+
* its critical section, and an `await` between the payment-reference check
|
|
1792
|
+
* and the transaction write opens a gap another caller can run through — two
|
|
1793
|
+
* simultaneous first deliveries of the same reference would then both credit.
|
|
1794
|
+
*/
|
|
1795
|
+
seedUser(userId, tier, initialBalance) {
|
|
1796
|
+
assertRepresentableAmount(initialBalance, "initialBalance", { userId });
|
|
1797
|
+
const configured = getConfigMonthlyLimit(tier);
|
|
1798
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1799
|
+
const monthlyLimit = storedMonthlyLimit(configured);
|
|
741
1800
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
742
1801
|
const credits = {
|
|
743
1802
|
userId,
|
|
@@ -745,61 +1804,93 @@ var InMemoryCreditRepository = class {
|
|
|
745
1804
|
bonusCredits: 0,
|
|
746
1805
|
reserved: 0,
|
|
747
1806
|
tier,
|
|
748
|
-
monthlyLimit
|
|
1807
|
+
monthlyLimit,
|
|
749
1808
|
monthlyUsed: 0,
|
|
750
1809
|
monthlyResetAt: getNextMonthlyReset().toISOString(),
|
|
751
1810
|
subscriptionExpiresAt: null,
|
|
752
1811
|
createdAt: now,
|
|
753
1812
|
updatedAt: now
|
|
754
1813
|
};
|
|
755
|
-
this.users.set(userId, credits);
|
|
1814
|
+
this.store.users.set(userId, credits);
|
|
756
1815
|
return credits;
|
|
757
1816
|
}
|
|
758
1817
|
async updateUserCredits(userId, updates) {
|
|
759
|
-
|
|
1818
|
+
assertRepresentableFields(
|
|
1819
|
+
{
|
|
1820
|
+
balance: updates.balance,
|
|
1821
|
+
bonusCredits: updates.bonusCredits,
|
|
1822
|
+
reserved: updates.reserved,
|
|
1823
|
+
monthlyLimit: updates.monthlyLimit,
|
|
1824
|
+
monthlyUsed: updates.monthlyUsed,
|
|
1825
|
+
balanceIncrement: updates.balanceIncrement,
|
|
1826
|
+
bonusCreditsIncrement: updates.bonusCreditsIncrement,
|
|
1827
|
+
reservedIncrement: updates.reservedIncrement,
|
|
1828
|
+
monthlyUsedIncrement: updates.monthlyUsedIncrement
|
|
1829
|
+
},
|
|
1830
|
+
{ userId, operation: "updateUserCredits" }
|
|
1831
|
+
);
|
|
1832
|
+
const credits = this.store.users.get(userId);
|
|
760
1833
|
if (!credits) {
|
|
761
1834
|
throw new Error(`User ${userId} not found`);
|
|
762
1835
|
}
|
|
763
|
-
const
|
|
764
|
-
if (updates.balance !== void 0)
|
|
765
|
-
if (updates.bonusCredits !== void 0)
|
|
766
|
-
if (updates.reserved !== void 0)
|
|
767
|
-
if (updates.tier !== void 0)
|
|
768
|
-
if (updates.monthlyLimit !== void 0)
|
|
769
|
-
if (updates.monthlyUsed !== void 0)
|
|
1836
|
+
const next = { ...credits };
|
|
1837
|
+
if (updates.balance !== void 0) next.balance = updates.balance;
|
|
1838
|
+
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1839
|
+
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1840
|
+
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
1841
|
+
if (updates.monthlyLimit !== void 0) next.monthlyLimit = updates.monthlyLimit;
|
|
1842
|
+
if (updates.monthlyUsed !== void 0) next.monthlyUsed = updates.monthlyUsed;
|
|
770
1843
|
if (updates.monthlyResetAt !== void 0) {
|
|
771
|
-
|
|
1844
|
+
next.monthlyResetAt = isoOf(updates.monthlyResetAt);
|
|
772
1845
|
}
|
|
773
1846
|
if (updates.subscriptionExpiresAt !== void 0) {
|
|
774
|
-
|
|
775
|
-
credits.subscriptionExpiresAt = null;
|
|
776
|
-
} else {
|
|
777
|
-
credits.subscriptionExpiresAt = updates.subscriptionExpiresAt instanceof Date ? updates.subscriptionExpiresAt.toISOString() : updates.subscriptionExpiresAt;
|
|
778
|
-
}
|
|
1847
|
+
next.subscriptionExpiresAt = updates.subscriptionExpiresAt === null ? null : isoOf(updates.subscriptionExpiresAt);
|
|
779
1848
|
}
|
|
780
1849
|
if (updates.balanceIncrement !== void 0) {
|
|
781
|
-
|
|
1850
|
+
next.balance = sumAmounts(next.balance, updates.balanceIncrement);
|
|
782
1851
|
}
|
|
783
1852
|
if (updates.bonusCreditsIncrement !== void 0) {
|
|
784
|
-
|
|
1853
|
+
next.bonusCredits = sumAmounts(next.bonusCredits, updates.bonusCreditsIncrement);
|
|
785
1854
|
}
|
|
786
1855
|
if (updates.reservedIncrement !== void 0) {
|
|
787
|
-
|
|
1856
|
+
next.reserved = sumAmounts(next.reserved, updates.reservedIncrement);
|
|
788
1857
|
}
|
|
789
1858
|
if (updates.monthlyUsedIncrement !== void 0) {
|
|
790
|
-
|
|
1859
|
+
next.monthlyUsed = sumAmounts(next.monthlyUsed, updates.monthlyUsedIncrement);
|
|
791
1860
|
}
|
|
792
|
-
|
|
793
|
-
|
|
1861
|
+
assertRepresentableFields(
|
|
1862
|
+
{
|
|
1863
|
+
balance: next.balance,
|
|
1864
|
+
bonusCredits: next.bonusCredits,
|
|
1865
|
+
reserved: next.reserved,
|
|
1866
|
+
monthlyUsed: next.monthlyUsed
|
|
1867
|
+
},
|
|
1868
|
+
{ userId, operation: "updateUserCredits" }
|
|
1869
|
+
);
|
|
1870
|
+
assertRepresentableTierAmount(next.monthlyLimit, "monthlyLimit", {
|
|
1871
|
+
userId,
|
|
1872
|
+
operation: "updateUserCredits"
|
|
1873
|
+
});
|
|
1874
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1875
|
+
this.store.users.set(userId, next);
|
|
794
1876
|
}
|
|
795
1877
|
async updateUserTier(userId, input) {
|
|
796
|
-
|
|
1878
|
+
assertRepresentableFields(
|
|
1879
|
+
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1880
|
+
{ userId, operation: "updateUserTier" }
|
|
1881
|
+
);
|
|
1882
|
+
const credits = this.store.users.get(userId);
|
|
797
1883
|
if (!credits) {
|
|
798
1884
|
throw new Error(`User ${userId} not found`);
|
|
799
1885
|
}
|
|
800
1886
|
credits.tier = input.tier;
|
|
801
1887
|
credits.monthlyLimit = input.monthlyLimit;
|
|
802
|
-
if (input.balance !== void 0)
|
|
1888
|
+
if (input.balance !== void 0) {
|
|
1889
|
+
credits.balance = Math.max(
|
|
1890
|
+
input.balance,
|
|
1891
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
803
1894
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
804
1895
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
805
1896
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -809,10 +1900,12 @@ var InMemoryCreditRepository = class {
|
|
|
809
1900
|
}
|
|
810
1901
|
}
|
|
811
1902
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
812
|
-
this.users.set(userId, credits);
|
|
1903
|
+
this.store.users.set(userId, credits);
|
|
813
1904
|
}
|
|
814
1905
|
// ==================== Reservations ====================
|
|
815
1906
|
async createReservation(input) {
|
|
1907
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "createReservation" });
|
|
1908
|
+
assertUnkeyedDirectReservation(input);
|
|
816
1909
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
817
1910
|
const reservation = {
|
|
818
1911
|
id: generateId(),
|
|
@@ -823,19 +1916,19 @@ var InMemoryCreditRepository = class {
|
|
|
823
1916
|
createdAt: now,
|
|
824
1917
|
expiresAt: input.expiresAt.toISOString()
|
|
825
1918
|
};
|
|
826
|
-
if (!this.reservations.has(input.userId)) {
|
|
827
|
-
this.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1919
|
+
if (!this.store.reservations.has(input.userId)) {
|
|
1920
|
+
this.store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
828
1921
|
}
|
|
829
|
-
this.reservations.get(input.userId).set(reservation.id, reservation);
|
|
830
|
-
return reservation;
|
|
1922
|
+
this.store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1923
|
+
return copyRecord(reservation);
|
|
831
1924
|
}
|
|
832
1925
|
async getReservation(userId, reservationId) {
|
|
833
|
-
const userReservations = this.reservations.get(userId);
|
|
1926
|
+
const userReservations = this.store.reservations.get(userId);
|
|
834
1927
|
if (!userReservations) return null;
|
|
835
|
-
return userReservations.get(reservationId) ?? null;
|
|
1928
|
+
return copyRecord(userReservations.get(reservationId)) ?? null;
|
|
836
1929
|
}
|
|
837
1930
|
async updateReservationStatus(userId, reservationId, status, completedAt) {
|
|
838
|
-
const userReservations = this.reservations.get(userId);
|
|
1931
|
+
const userReservations = this.store.reservations.get(userId);
|
|
839
1932
|
if (!userReservations) {
|
|
840
1933
|
throw new Error(`No reservations found for user ${userId}`);
|
|
841
1934
|
}
|
|
@@ -843,119 +1936,219 @@ var InMemoryCreditRepository = class {
|
|
|
843
1936
|
if (!reservation) {
|
|
844
1937
|
throw new Error(`Reservation ${reservationId} not found`);
|
|
845
1938
|
}
|
|
1939
|
+
assertDirectStatusWriteAllowed(reservation, status);
|
|
846
1940
|
reservation.status = status;
|
|
847
1941
|
if (completedAt) reservation.completedAt = completedAt.toISOString();
|
|
848
1942
|
userReservations.set(reservationId, reservation);
|
|
849
1943
|
}
|
|
850
1944
|
// ==================== Atomic Operations ====================
|
|
1945
|
+
// ==================== V2 boundary ====================
|
|
1946
|
+
//
|
|
1947
|
+
// As in the Drizzle adapter, V2 is the real implementation and the legacy
|
|
1948
|
+
// `*Atomic` methods are adapters over it, so both APIs share one set of
|
|
1949
|
+
// guarantees.
|
|
1950
|
+
async reserveCreditsV2(input) {
|
|
1951
|
+
return reserveCreditsV2(this.store, input);
|
|
1952
|
+
}
|
|
1953
|
+
async commitReservationV2(userId, reservationId, options) {
|
|
1954
|
+
return commitReservationV2(this.store, userId, reservationId, options);
|
|
1955
|
+
}
|
|
1956
|
+
async releaseReservationV2(userId, reservationId, options) {
|
|
1957
|
+
return releaseReservationV2(this.store, userId, reservationId, options);
|
|
1958
|
+
}
|
|
1959
|
+
async expireReservationV2(userId, reservationId, options) {
|
|
1960
|
+
return expireReservationV2(this.store, userId, reservationId, options);
|
|
1961
|
+
}
|
|
1962
|
+
// ==================== Legacy atomic operations ====================
|
|
851
1963
|
async reserveCreditsAtomic(userId, amount, operationType, expiresAt) {
|
|
852
|
-
const
|
|
853
|
-
if (
|
|
854
|
-
|
|
1964
|
+
const outcome = await this.reserveCreditsV2({ userId, amount, operationType, expiresAt });
|
|
1965
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1966
|
+
return outcome.reservation;
|
|
855
1967
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
throw new Error(
|
|
859
|
-
`Insufficient credits. Available: ${available}, Required: ${amount}`
|
|
860
|
-
);
|
|
1968
|
+
if (outcome.outcome === "insufficient") {
|
|
1969
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
861
1970
|
}
|
|
862
|
-
|
|
863
|
-
userId,
|
|
864
|
-
amount,
|
|
865
|
-
operationType,
|
|
866
|
-
expiresAt
|
|
867
|
-
});
|
|
868
|
-
credits.reserved += amount;
|
|
869
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
870
|
-
this.users.set(userId, credits);
|
|
871
|
-
return reservation;
|
|
1971
|
+
throw createReservationAlreadyProcessedError(outcome.existing.id, outcome.existing.status);
|
|
872
1972
|
}
|
|
873
1973
|
async commitReservationAtomic(userId, reservationId) {
|
|
874
|
-
const
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
if (!reservation) {
|
|
880
|
-
throw new Error(`Reservation ${reservationId} not found`);
|
|
881
|
-
}
|
|
882
|
-
if (reservation.status === "committed") {
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
if (reservation.status !== "reserved") {
|
|
886
|
-
throw new Error(
|
|
887
|
-
`Cannot commit reservation in ${reservation.status} state`
|
|
888
|
-
);
|
|
889
|
-
}
|
|
890
|
-
const amount = reservation.amount;
|
|
891
|
-
credits.balance -= amount;
|
|
892
|
-
credits.reserved -= amount;
|
|
893
|
-
credits.monthlyUsed += amount;
|
|
894
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
895
|
-
this.users.set(userId, credits);
|
|
896
|
-
await this.updateReservationStatus(userId, reservationId, "committed", /* @__PURE__ */ new Date());
|
|
1974
|
+
const outcome = await this.commitReservationV2(userId, reservationId);
|
|
1975
|
+
if (outcome.outcome === "committed") return;
|
|
1976
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1977
|
+
if (outcome.terminalStatus === "committed") return;
|
|
1978
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
897
1979
|
}
|
|
898
1980
|
async releaseReservationAtomic(userId, reservationId) {
|
|
899
|
-
const
|
|
900
|
-
if (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1981
|
+
const outcome = await this.releaseReservationV2(userId, reservationId);
|
|
1982
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* The stored transaction carrying this reference, in *any* user's ledger.
|
|
1986
|
+
*
|
|
1987
|
+
* Deliberately not scoped to one user. The SQL adapter's unique index covers
|
|
1988
|
+
* `payment_ref` alone, so searching only the crediting user's own history made
|
|
1989
|
+
* the same reference credit two different accounts here while the SQL adapter
|
|
1990
|
+
* treated the second delivery as a duplicate. See `core/payment-ref.ts`.
|
|
1991
|
+
*/
|
|
1992
|
+
findByPaymentRef(paymentRef) {
|
|
1993
|
+
for (const recorded of this.store.transactions.values()) {
|
|
1994
|
+
const hit = recorded.find((transaction) => transaction.paymentRef === paymentRef);
|
|
1995
|
+
if (hit) return hit;
|
|
909
1996
|
}
|
|
910
|
-
|
|
911
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
912
|
-
this.users.set(userId, credits);
|
|
913
|
-
await this.updateReservationStatus(userId, reservationId, "released", /* @__PURE__ */ new Date());
|
|
1997
|
+
return void 0;
|
|
914
1998
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1999
|
+
/** The journal entry written alongside a stored purchase, if there is one. */
|
|
2000
|
+
journalForTransaction(transaction) {
|
|
2001
|
+
return this.store.journalEntries.get(transaction.userId)?.find((entry) => entry.referenceId === transaction.id);
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Credit an account, resolving `paymentRef` to created / replayed / conflict.
|
|
2005
|
+
*
|
|
2006
|
+
* The reference is checked and the transaction is recorded with no `await`
|
|
2007
|
+
* between them. `createTransaction` is `async` but its body is synchronous, so
|
|
2008
|
+
* under JavaScript's run-to-completion semantics no other caller can observe
|
|
2009
|
+
* the gap — which is what stands in for the SQL adapter's unique index.
|
|
2010
|
+
*
|
|
2011
|
+
* A `replayed` or `conflict` outcome writes nothing at all: the check runs
|
|
2012
|
+
* before the balance moves, not after.
|
|
2013
|
+
*/
|
|
2014
|
+
async addCreditsV2(input) {
|
|
2015
|
+
const { userId, amount, description, options } = input;
|
|
2016
|
+
assertValidCreditAmount(amount, { userId, operation: "addCredits" });
|
|
2017
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
2018
|
+
const payload = {
|
|
2019
|
+
userId,
|
|
2020
|
+
amount,
|
|
2021
|
+
type: "purchase",
|
|
2022
|
+
source: options?.source ?? "purchase",
|
|
2023
|
+
referenceType: options?.referenceType ?? "transaction"
|
|
2024
|
+
};
|
|
2025
|
+
if (paymentRef) {
|
|
2026
|
+
const existing = this.findByPaymentRef(paymentRef);
|
|
2027
|
+
if (existing) {
|
|
2028
|
+
const journal = this.journalForTransaction(existing);
|
|
2029
|
+
const mismatch = describePaymentMismatch(
|
|
2030
|
+
{
|
|
2031
|
+
userId: existing.userId,
|
|
2032
|
+
amount: existing.amount,
|
|
2033
|
+
type: existing.type,
|
|
2034
|
+
source: journal?.source,
|
|
2035
|
+
referenceType: journal?.referenceType
|
|
2036
|
+
},
|
|
2037
|
+
payload
|
|
2038
|
+
);
|
|
2039
|
+
return mismatch === null ? { outcome: "replayed", paymentRef, transaction: copyRecord(existing) } : { outcome: "conflict", paymentRef, existing: copyRecord(existing), mismatch };
|
|
2040
|
+
}
|
|
919
2041
|
}
|
|
920
|
-
|
|
921
|
-
|
|
2042
|
+
if (!this.store.users.has(userId)) {
|
|
2043
|
+
const tier = getDefaultTier();
|
|
2044
|
+
this.seedUser(userId, tier, storedMonthlyLimit(getConfigMonthlyLimit(tier)));
|
|
2045
|
+
}
|
|
2046
|
+
const credits = this.store.users.get(userId);
|
|
2047
|
+
const previousTotal = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2048
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, amount);
|
|
2049
|
+
const newTotal = sumAmounts(credits.balance, nextBonusCredits);
|
|
2050
|
+
assertRepresentableFields(
|
|
2051
|
+
{ previousBalance: previousTotal, bonusCredits: nextBonusCredits, newBalance: newTotal },
|
|
2052
|
+
{ userId, operation: "addCredits" }
|
|
2053
|
+
);
|
|
2054
|
+
credits.bonusCredits = nextBonusCredits;
|
|
922
2055
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
923
|
-
this.users.set(userId, credits);
|
|
924
|
-
|
|
2056
|
+
this.store.users.set(userId, credits);
|
|
2057
|
+
const transaction = this.recordTransaction({
|
|
925
2058
|
userId,
|
|
926
2059
|
type: "purchase",
|
|
927
2060
|
amount,
|
|
928
2061
|
description,
|
|
929
2062
|
paymentRef,
|
|
930
|
-
previousBalance,
|
|
931
|
-
newBalance:
|
|
2063
|
+
previousBalance: previousTotal,
|
|
2064
|
+
newBalance: newTotal
|
|
2065
|
+
});
|
|
2066
|
+
const journalMetadata = {
|
|
2067
|
+
...paymentRef ? { paymentRef } : {},
|
|
2068
|
+
...options?.metadata ?? {}
|
|
2069
|
+
};
|
|
2070
|
+
const entry = await this.createJournalEntry({
|
|
2071
|
+
userId,
|
|
2072
|
+
entryType: "credit",
|
|
2073
|
+
amount,
|
|
2074
|
+
balanceAfter: newTotal,
|
|
2075
|
+
source: payload.source,
|
|
2076
|
+
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
2077
|
+
referenceType: payload.referenceType,
|
|
2078
|
+
description,
|
|
2079
|
+
metadata: Object.keys(journalMetadata).length > 0 ? journalMetadata : void 0
|
|
932
2080
|
});
|
|
2081
|
+
return paymentRef ? { outcome: "created", paymentRef, transaction, journalEntryId: entry.id } : { outcome: "created", transaction, journalEntryId: entry.id };
|
|
933
2082
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
2083
|
+
/**
|
|
2084
|
+
* The legacy signature, on top of {@link addCreditsV2}.
|
|
2085
|
+
*
|
|
2086
|
+
* `Promise<void>` has nowhere to report a conflict, and silently returning is
|
|
2087
|
+
* indistinguishable from "credited" — the exact failure this round is closing
|
|
2088
|
+
* — so a conflict throws. A genuine replay still returns quietly, because
|
|
2089
|
+
* that *is* the idempotent no-op the caller asked for.
|
|
2090
|
+
*/
|
|
2091
|
+
async addCreditsAtomic(userId, amount, description, paymentRef, options) {
|
|
2092
|
+
const outcome = await this.addCreditsV2({
|
|
2093
|
+
userId,
|
|
2094
|
+
amount,
|
|
2095
|
+
description,
|
|
2096
|
+
paymentRef,
|
|
2097
|
+
options
|
|
2098
|
+
});
|
|
2099
|
+
if (outcome.outcome === "conflict") {
|
|
2100
|
+
throw createPaymentRefConflictError(outcome.paymentRef, {
|
|
2101
|
+
userId,
|
|
2102
|
+
mismatch: outcome.mismatch,
|
|
2103
|
+
existingUserId: outcome.existing.userId
|
|
2104
|
+
});
|
|
937
2105
|
}
|
|
938
|
-
|
|
2106
|
+
}
|
|
2107
|
+
async deductCreditsAtomic(userId, amount) {
|
|
2108
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
2109
|
+
const credits = this.store.users.get(userId);
|
|
939
2110
|
if (!credits) {
|
|
940
2111
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
941
2112
|
}
|
|
942
|
-
const available = credits.balance
|
|
2113
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
943
2114
|
if (available < amount) {
|
|
944
2115
|
throw new Error(
|
|
945
2116
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
946
2117
|
);
|
|
947
2118
|
}
|
|
948
2119
|
const balanceDeduction = Math.min(credits.balance, amount);
|
|
949
|
-
const bonusDeduction = amount -
|
|
950
|
-
const previousBalance = credits.balance
|
|
951
|
-
credits.balance
|
|
952
|
-
credits.bonusCredits
|
|
2120
|
+
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
2121
|
+
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2122
|
+
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
2123
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -bonusDeduction);
|
|
2124
|
+
const newBalance = sumAmounts(previousBalance, -amount);
|
|
2125
|
+
assertRepresentableFields(
|
|
2126
|
+
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
2127
|
+
{ userId, operation: "deductCredits" }
|
|
2128
|
+
);
|
|
2129
|
+
credits.balance = nextBalance;
|
|
2130
|
+
credits.bonusCredits = nextBonusCredits;
|
|
953
2131
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
954
|
-
this.users.set(userId, credits);
|
|
955
|
-
return { previousBalance, newBalance
|
|
2132
|
+
this.store.users.set(userId, credits);
|
|
2133
|
+
return { previousBalance, newBalance };
|
|
956
2134
|
}
|
|
957
2135
|
// ==================== Transactions ====================
|
|
958
2136
|
async createTransaction(input) {
|
|
2137
|
+
assertUnreferencedDirectTransaction(input);
|
|
2138
|
+
return this.recordTransaction({
|
|
2139
|
+
...input,
|
|
2140
|
+
paymentRef: normalizePaymentRef(input.paymentRef)
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
2144
|
+
recordTransaction(input) {
|
|
2145
|
+
assertRepresentableAmount(input.amount, "transaction amount", { userId: input.userId });
|
|
2146
|
+
assertRepresentableAmount(input.previousBalance, "transaction previousBalance", {
|
|
2147
|
+
userId: input.userId
|
|
2148
|
+
});
|
|
2149
|
+
assertRepresentableAmount(input.newBalance, "transaction newBalance", {
|
|
2150
|
+
userId: input.userId
|
|
2151
|
+
});
|
|
959
2152
|
const transaction = {
|
|
960
2153
|
id: generateId(),
|
|
961
2154
|
userId: input.userId,
|
|
@@ -967,23 +2160,24 @@ var InMemoryCreditRepository = class {
|
|
|
967
2160
|
newBalance: input.newBalance,
|
|
968
2161
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
969
2162
|
};
|
|
970
|
-
if (!this.transactions.has(input.userId)) {
|
|
971
|
-
this.transactions.set(input.userId, []);
|
|
2163
|
+
if (!this.store.transactions.has(input.userId)) {
|
|
2164
|
+
this.store.transactions.set(input.userId, []);
|
|
972
2165
|
}
|
|
973
|
-
this.transactions.get(input.userId).push(transaction);
|
|
974
|
-
return transaction;
|
|
2166
|
+
this.store.transactions.get(input.userId).push(transaction);
|
|
2167
|
+
return copyRecord(transaction);
|
|
975
2168
|
}
|
|
976
2169
|
async getTransactions(userId, limit = 50, offset = 0) {
|
|
977
|
-
const userTransactions = this.transactions.get(userId) ?? [];
|
|
2170
|
+
const userTransactions = this.store.transactions.get(userId) ?? [];
|
|
978
2171
|
const sorted = [...userTransactions].sort((a, b) => {
|
|
979
2172
|
const aDate = toDate2(a.createdAt).getTime();
|
|
980
2173
|
const bDate = toDate2(b.createdAt).getTime();
|
|
981
2174
|
return bDate - aDate;
|
|
982
2175
|
});
|
|
983
|
-
return sorted.slice(offset, offset + limit);
|
|
2176
|
+
return copyRecords(sorted.slice(offset, offset + limit));
|
|
984
2177
|
}
|
|
985
2178
|
// ==================== Usage Logs ====================
|
|
986
2179
|
async logUsage(input) {
|
|
2180
|
+
assertRepresentableAmount(input.creditsUsed, "creditsUsed", { userId: input.userId });
|
|
987
2181
|
const log = {
|
|
988
2182
|
id: generateId(),
|
|
989
2183
|
userId: input.userId,
|
|
@@ -995,14 +2189,14 @@ var InMemoryCreditRepository = class {
|
|
|
995
2189
|
resourceId: input.resourceId,
|
|
996
2190
|
resourceType: input.resourceType,
|
|
997
2191
|
requestId: input.requestId,
|
|
998
|
-
metadata: input.metadata,
|
|
2192
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
999
2193
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1000
2194
|
};
|
|
1001
|
-
this.usageLogs.push(log);
|
|
1002
|
-
return log;
|
|
2195
|
+
this.store.usageLogs.push(log);
|
|
2196
|
+
return copyRecord(log);
|
|
1003
2197
|
}
|
|
1004
2198
|
async getUsageLogs(query) {
|
|
1005
|
-
let results = [...this.usageLogs];
|
|
2199
|
+
let results = [...this.store.usageLogs];
|
|
1006
2200
|
if (query.userId) {
|
|
1007
2201
|
results = results.filter((log) => log.userId === query.userId);
|
|
1008
2202
|
}
|
|
@@ -1029,7 +2223,7 @@ var InMemoryCreditRepository = class {
|
|
|
1029
2223
|
});
|
|
1030
2224
|
const offset = query.offset ?? 0;
|
|
1031
2225
|
const limit = query.limit ?? 50;
|
|
1032
|
-
return results.slice(offset, offset + limit);
|
|
2226
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
1033
2227
|
}
|
|
1034
2228
|
async getUsageLogsCount(query) {
|
|
1035
2229
|
const results = await this.getUsageLogs({ ...query, limit: Infinity, offset: 0 });
|
|
@@ -1037,6 +2231,29 @@ var InMemoryCreditRepository = class {
|
|
|
1037
2231
|
}
|
|
1038
2232
|
// ==================== Journal Entries ====================
|
|
1039
2233
|
async createJournalEntry(input) {
|
|
2234
|
+
return this.recordJournalEntry(input);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
|
|
2238
|
+
* write its journal line inside the same run-to-completion step as the
|
|
2239
|
+
* balance write — an `await` between the two is exactly the gap that loses
|
|
2240
|
+
* the line when the second half fails.
|
|
2241
|
+
*/
|
|
2242
|
+
recordJournalEntry(input) {
|
|
2243
|
+
assertRepresentableAmount(input.amount, "journal amount", { userId: input.userId });
|
|
2244
|
+
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
2245
|
+
userId: input.userId
|
|
2246
|
+
});
|
|
2247
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
2248
|
+
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
2249
|
+
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
2250
|
+
if (key !== void 0 && this.store.journalKeys.has(key)) {
|
|
2251
|
+
throw new CreditError(
|
|
2252
|
+
`Journal idempotency key ${input.idempotencyKey} is already used for user ${input.userId}. The unique constraint refuses a second entry.`,
|
|
2253
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
2254
|
+
{ userId: input.userId, idempotencyKey: input.idempotencyKey }
|
|
2255
|
+
);
|
|
2256
|
+
}
|
|
1040
2257
|
const entry = {
|
|
1041
2258
|
id: generateId(),
|
|
1042
2259
|
userId: input.userId,
|
|
@@ -1047,17 +2264,19 @@ var InMemoryCreditRepository = class {
|
|
|
1047
2264
|
referenceId: input.referenceId,
|
|
1048
2265
|
referenceType: input.referenceType,
|
|
1049
2266
|
description: input.description,
|
|
1050
|
-
metadata: input.metadata,
|
|
2267
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
2268
|
+
idempotencyKey: input.idempotencyKey,
|
|
1051
2269
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1052
2270
|
};
|
|
1053
|
-
if (!this.journalEntries.has(input.userId)) {
|
|
1054
|
-
this.journalEntries.set(input.userId, []);
|
|
2271
|
+
if (!this.store.journalEntries.has(input.userId)) {
|
|
2272
|
+
this.store.journalEntries.set(input.userId, []);
|
|
1055
2273
|
}
|
|
1056
|
-
this.journalEntries.get(input.userId).push(entry);
|
|
1057
|
-
|
|
2274
|
+
this.store.journalEntries.get(input.userId).push(entry);
|
|
2275
|
+
if (key !== void 0) this.store.journalKeys.set(key, entry.id);
|
|
2276
|
+
return copyRecord(entry);
|
|
1058
2277
|
}
|
|
1059
2278
|
async getJournalEntries(query) {
|
|
1060
|
-
let results = this.journalEntries.get(query.userId) ?? [];
|
|
2279
|
+
let results = this.store.journalEntries.get(query.userId) ?? [];
|
|
1061
2280
|
if (query.source) {
|
|
1062
2281
|
results = results.filter((entry) => entry.source === query.source);
|
|
1063
2282
|
}
|
|
@@ -1081,7 +2300,7 @@ var InMemoryCreditRepository = class {
|
|
|
1081
2300
|
});
|
|
1082
2301
|
const offset = query.offset ?? 0;
|
|
1083
2302
|
const limit = query.limit ?? 50;
|
|
1084
|
-
return results.slice(offset, offset + limit);
|
|
2303
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
1085
2304
|
}
|
|
1086
2305
|
async getJournalEntriesCount(query) {
|
|
1087
2306
|
const results = await this.getJournalEntries({
|
|
@@ -1092,53 +2311,91 @@ var InMemoryCreditRepository = class {
|
|
|
1092
2311
|
return results.length;
|
|
1093
2312
|
}
|
|
1094
2313
|
// ==================== Cleanup Operations ====================
|
|
2314
|
+
/**
|
|
2315
|
+
* Expire every hold whose deadline has passed.
|
|
2316
|
+
*
|
|
2317
|
+
* Each candidate goes through the guarded {@link expireReservationV2}, which
|
|
2318
|
+
* re-checks the status and the deadline under the user's lock. The previous
|
|
2319
|
+
* implementation released the hold and then overwrote the status to
|
|
2320
|
+
* `expired`, so a commit landing in between got its credits handed back and
|
|
2321
|
+
* was then relabelled.
|
|
2322
|
+
*/
|
|
1095
2323
|
async findAndExpireReservations(_batchSize = 100, _maxIterations = 100) {
|
|
1096
|
-
const
|
|
2324
|
+
const asOf = /* @__PURE__ */ new Date();
|
|
1097
2325
|
let expiredCount = 0;
|
|
1098
2326
|
let creditsReleased = 0;
|
|
1099
2327
|
const errors = [];
|
|
1100
|
-
|
|
2328
|
+
const candidates = [];
|
|
2329
|
+
for (const [userId, userReservations] of this.store.reservations) {
|
|
1101
2330
|
for (const [reservationId, reservation] of userReservations) {
|
|
1102
|
-
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() <
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
2331
|
+
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() < asOf.getTime()) {
|
|
2332
|
+
candidates.push({ userId, reservationId });
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
for (const candidate of candidates) {
|
|
2337
|
+
try {
|
|
2338
|
+
const outcome = await this.expireReservationV2(
|
|
2339
|
+
candidate.userId,
|
|
2340
|
+
candidate.reservationId,
|
|
2341
|
+
{ asOf }
|
|
2342
|
+
);
|
|
2343
|
+
if (outcome.outcome === "expired") {
|
|
2344
|
+
expiredCount++;
|
|
2345
|
+
creditsReleased = sumAmounts(creditsReleased, outcome.amount);
|
|
1114
2346
|
}
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
errors.push(
|
|
2349
|
+
`Failed to expire reservation ${candidate.reservationId}: ${error}`
|
|
2350
|
+
);
|
|
1115
2351
|
}
|
|
1116
2352
|
}
|
|
1117
2353
|
return { expiredCount, creditsReleased, errors };
|
|
1118
2354
|
}
|
|
1119
2355
|
// ==================== Atomic Monthly Reset ====================
|
|
1120
2356
|
async atomicMonthlyReset(userId, tier, expectedResetAt) {
|
|
1121
|
-
const credits = this.users.get(userId);
|
|
2357
|
+
const credits = this.store.users.get(userId);
|
|
1122
2358
|
if (!credits) {
|
|
1123
2359
|
throw new Error(`User ${userId} not found`);
|
|
1124
2360
|
}
|
|
1125
2361
|
const currentResetAt = toDate2(credits.monthlyResetAt).getTime();
|
|
1126
2362
|
const expected = toDate2(expectedResetAt).getTime();
|
|
1127
2363
|
if (currentResetAt !== expected) {
|
|
1128
|
-
return { wasReset: false, credits };
|
|
2364
|
+
return { wasReset: false, credits: { ...credits } };
|
|
1129
2365
|
}
|
|
1130
|
-
|
|
2366
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
1131
2367
|
const nextReset = getNextMonthlyReset();
|
|
1132
|
-
|
|
2368
|
+
const target = monthlyResetBalance(tier);
|
|
2369
|
+
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2370
|
+
const previousBalance = credits.balance;
|
|
2371
|
+
const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
|
|
2372
|
+
const change = sumAmounts(newBalance, -previousBalance);
|
|
2373
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2374
|
+
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2375
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2376
|
+
credits.balance = newBalance;
|
|
1133
2377
|
credits.monthlyUsed = 0;
|
|
1134
2378
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1135
2379
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1136
|
-
this.users.set(userId, credits);
|
|
1137
|
-
|
|
2380
|
+
this.store.users.set(userId, credits);
|
|
2381
|
+
if (change !== 0) {
|
|
2382
|
+
this.recordJournalEntry({
|
|
2383
|
+
userId,
|
|
2384
|
+
entryType: change > 0 ? "credit" : "debit",
|
|
2385
|
+
amount: Math.abs(change),
|
|
2386
|
+
balanceAfter,
|
|
2387
|
+
source: "monthly_reset",
|
|
2388
|
+
referenceId: `reset-${Date.now()}`,
|
|
2389
|
+
referenceType: "reset",
|
|
2390
|
+
description: `Monthly credit reset for ${tier} tier.`,
|
|
2391
|
+
metadata: { tier, previousBalance, newBalance }
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
return { wasReset: true, credits: { ...credits }, journaled: true };
|
|
1138
2395
|
}
|
|
1139
2396
|
// ==================== Subscription Expiry ====================
|
|
1140
2397
|
async checkAndHandleSubscriptionExpiry(userId, gracePeriodDays = 3) {
|
|
1141
|
-
const credits = this.users.get(userId);
|
|
2398
|
+
const credits = this.store.users.get(userId);
|
|
1142
2399
|
if (!credits) {
|
|
1143
2400
|
throw new Error(`User ${userId} not found`);
|
|
1144
2401
|
}
|
|
@@ -1147,7 +2404,7 @@ var InMemoryCreditRepository = class {
|
|
|
1147
2404
|
wasDowngraded: false,
|
|
1148
2405
|
inGracePeriod: false,
|
|
1149
2406
|
graceDaysRemaining: 0,
|
|
1150
|
-
credits
|
|
2407
|
+
credits: { ...credits }
|
|
1151
2408
|
};
|
|
1152
2409
|
}
|
|
1153
2410
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1158,7 +2415,7 @@ var InMemoryCreditRepository = class {
|
|
|
1158
2415
|
wasDowngraded: false,
|
|
1159
2416
|
inGracePeriod: false,
|
|
1160
2417
|
graceDaysRemaining: 0,
|
|
1161
|
-
credits
|
|
2418
|
+
credits: { ...credits }
|
|
1162
2419
|
};
|
|
1163
2420
|
}
|
|
1164
2421
|
if (daysSinceExpiry <= gracePeriodDays) {
|
|
@@ -1166,22 +2423,48 @@ var InMemoryCreditRepository = class {
|
|
|
1166
2423
|
wasDowngraded: false,
|
|
1167
2424
|
inGracePeriod: true,
|
|
1168
2425
|
graceDaysRemaining: Math.ceil(gracePeriodDays - daysSinceExpiry),
|
|
1169
|
-
credits
|
|
2426
|
+
credits: { ...credits }
|
|
1170
2427
|
};
|
|
1171
2428
|
}
|
|
1172
2429
|
const defaultTier = getDefaultTier();
|
|
1173
|
-
const
|
|
2430
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2431
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2432
|
+
userId,
|
|
2433
|
+
tier: defaultTier
|
|
2434
|
+
});
|
|
2435
|
+
const limit = storedMonthlyLimit(configured);
|
|
2436
|
+
const previousTier = credits.tier;
|
|
2437
|
+
const previousBalance = credits.balance;
|
|
2438
|
+
const newBalance = Math.max(
|
|
2439
|
+
Math.min(credits.balance, limit),
|
|
2440
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
2441
|
+
);
|
|
2442
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2443
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1174
2444
|
credits.tier = defaultTier;
|
|
1175
|
-
credits.monthlyLimit =
|
|
1176
|
-
credits.balance =
|
|
2445
|
+
credits.monthlyLimit = limit;
|
|
2446
|
+
credits.balance = newBalance;
|
|
1177
2447
|
credits.subscriptionExpiresAt = null;
|
|
1178
2448
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1179
|
-
this.users.set(userId, credits);
|
|
2449
|
+
this.store.users.set(userId, credits);
|
|
2450
|
+
this.recordJournalEntry({
|
|
2451
|
+
userId,
|
|
2452
|
+
entryType: "debit",
|
|
2453
|
+
amount: 0,
|
|
2454
|
+
// No credits deducted, just tier change
|
|
2455
|
+
balanceAfter,
|
|
2456
|
+
source: "subscription_downgrade",
|
|
2457
|
+
referenceId: `downgrade-${Date.now()}`,
|
|
2458
|
+
referenceType: "subscription",
|
|
2459
|
+
description: `Subscription expired. Downgraded from ${previousTier} to ${defaultTier} tier.`,
|
|
2460
|
+
metadata: { previousTier, previousBalance, newBalance }
|
|
2461
|
+
});
|
|
1180
2462
|
return {
|
|
1181
2463
|
wasDowngraded: true,
|
|
1182
2464
|
inGracePeriod: false,
|
|
1183
2465
|
graceDaysRemaining: 0,
|
|
1184
|
-
credits
|
|
2466
|
+
credits: { ...credits },
|
|
2467
|
+
journaled: true
|
|
1185
2468
|
};
|
|
1186
2469
|
}
|
|
1187
2470
|
// ==================== Testing Utilities ====================
|
|
@@ -1189,25 +2472,33 @@ var InMemoryCreditRepository = class {
|
|
|
1189
2472
|
* Clear all data (useful for testing)
|
|
1190
2473
|
*/
|
|
1191
2474
|
clear() {
|
|
1192
|
-
this.
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
2475
|
+
this.store.clear();
|
|
2476
|
+
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Install a yield point inside every V2 critical section (testing only).
|
|
2479
|
+
*
|
|
2480
|
+
* Unset, the critical sections run start-to-finish synchronously, so
|
|
2481
|
+
* concurrent callers never interleave and a concurrency test would pass even
|
|
2482
|
+
* with the locking removed. Setting this to a real yield (a macrotask or a
|
|
2483
|
+
* barrier) makes callers genuinely overlap, so the tests exercise the lock
|
|
2484
|
+
* instead of the event loop. Never call this in production code.
|
|
2485
|
+
*/
|
|
2486
|
+
setSchedulingHook(hook) {
|
|
2487
|
+
this.store.schedulingHook = hook;
|
|
1197
2488
|
}
|
|
1198
2489
|
/**
|
|
1199
2490
|
* Get all users (useful for testing/debugging)
|
|
1200
2491
|
*/
|
|
1201
2492
|
getAllUsers() {
|
|
1202
|
-
return Array.from(this.users.values());
|
|
2493
|
+
return copyRecords(Array.from(this.store.users.values()));
|
|
1203
2494
|
}
|
|
1204
2495
|
/**
|
|
1205
2496
|
* Get all reservations for a user (useful for testing)
|
|
1206
2497
|
*/
|
|
1207
2498
|
getAllReservations(userId) {
|
|
1208
|
-
const userReservations = this.reservations.get(userId);
|
|
2499
|
+
const userReservations = this.store.reservations.get(userId);
|
|
1209
2500
|
if (!userReservations) return [];
|
|
1210
|
-
return Array.from(userReservations.values());
|
|
2501
|
+
return copyRecords(Array.from(userReservations.values()));
|
|
1211
2502
|
}
|
|
1212
2503
|
};
|
|
1213
2504
|
function createInMemoryCreditRepository() {
|
|
@@ -1318,28 +2609,38 @@ var CreditsService = class {
|
|
|
1318
2609
|
userId,
|
|
1319
2610
|
getConfig().subscriptionGracePeriodDays
|
|
1320
2611
|
);
|
|
1321
|
-
if (expiryResult.wasDowngraded) {
|
|
2612
|
+
if (expiryResult.wasDowngraded && !expiryResult.journaled) {
|
|
1322
2613
|
await this.repository.createJournalEntry({
|
|
1323
2614
|
userId,
|
|
1324
2615
|
entryType: "debit",
|
|
1325
2616
|
amount: 0,
|
|
1326
2617
|
// No credits deducted, just tier change
|
|
1327
|
-
|
|
2618
|
+
// The ledger balance, matching every other journal writer: the V2
|
|
2619
|
+
// transitions and add/deduct all record `balance + bonusCredits`, so
|
|
2620
|
+
// recording `balance` alone made the audit trail disagree with itself
|
|
2621
|
+
// for any user holding bonus credits.
|
|
2622
|
+
balanceAfter: sumAmounts(
|
|
2623
|
+
expiryResult.credits.balance,
|
|
2624
|
+
expiryResult.credits.bonusCredits
|
|
2625
|
+
),
|
|
1328
2626
|
source: "subscription_downgrade",
|
|
1329
2627
|
referenceId: `downgrade-${Date.now()}`,
|
|
1330
2628
|
referenceType: "subscription",
|
|
1331
|
-
|
|
2629
|
+
// The tier actually landed on, not a hard-coded "free": the downgrade
|
|
2630
|
+
// target is configurable, so naming `free` here could describe a
|
|
2631
|
+
// transition that never happened.
|
|
2632
|
+
description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
|
|
1332
2633
|
metadata: {
|
|
1333
2634
|
previousTier: data.tier,
|
|
1334
2635
|
previousBalance: data.balance,
|
|
1335
2636
|
newBalance: expiryResult.credits.balance
|
|
1336
2637
|
}
|
|
1337
2638
|
});
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
}
|
|
2639
|
+
}
|
|
2640
|
+
if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
|
|
2641
|
+
this.subscriptionExpiredCallback(userId, true).catch((error) => {
|
|
2642
|
+
console.error("[Credits] Failed to send subscription expired notification:", error);
|
|
2643
|
+
});
|
|
1343
2644
|
}
|
|
1344
2645
|
data = expiryResult.credits;
|
|
1345
2646
|
}
|
|
@@ -1350,14 +2651,17 @@ var CreditsService = class {
|
|
|
1350
2651
|
data.tier,
|
|
1351
2652
|
expectedResetAt
|
|
1352
2653
|
);
|
|
1353
|
-
if (resetResult.wasReset) {
|
|
1354
|
-
const balanceChange = resetResult.credits.balance -
|
|
2654
|
+
if (resetResult.wasReset && !resetResult.journaled) {
|
|
2655
|
+
const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
|
|
1355
2656
|
if (balanceChange !== 0) {
|
|
1356
2657
|
await this.repository.createJournalEntry({
|
|
1357
2658
|
userId,
|
|
1358
2659
|
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
1359
2660
|
amount: Math.abs(balanceChange),
|
|
1360
|
-
balanceAfter:
|
|
2661
|
+
balanceAfter: sumAmounts(
|
|
2662
|
+
resetResult.credits.balance,
|
|
2663
|
+
resetResult.credits.bonusCredits
|
|
2664
|
+
),
|
|
1361
2665
|
source: "monthly_reset",
|
|
1362
2666
|
referenceId: `reset-${Date.now()}`,
|
|
1363
2667
|
referenceType: "reset",
|
|
@@ -1408,14 +2712,14 @@ var CreditsService = class {
|
|
|
1408
2712
|
*/
|
|
1409
2713
|
async checkCredits(userId, requiredCredits) {
|
|
1410
2714
|
const credits = await this.getOrCreateUserCredits(userId);
|
|
1411
|
-
const totalBalance = credits.balance
|
|
1412
|
-
const available = totalBalance -
|
|
2715
|
+
const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2716
|
+
const available = sumAmounts(totalBalance, -credits.reserved);
|
|
1413
2717
|
const hasCredits = available >= requiredCredits;
|
|
1414
2718
|
return {
|
|
1415
2719
|
hasCredits,
|
|
1416
2720
|
balance: totalBalance,
|
|
1417
2721
|
required: requiredCredits,
|
|
1418
|
-
shortfall: hasCredits ? 0 : requiredCredits -
|
|
2722
|
+
shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
|
|
1419
2723
|
};
|
|
1420
2724
|
}
|
|
1421
2725
|
/**
|
|
@@ -1429,74 +2733,110 @@ var CreditsService = class {
|
|
|
1429
2733
|
* @throws Error if insufficient credits
|
|
1430
2734
|
*/
|
|
1431
2735
|
async reserveCredits(userId, amount, operationType, options) {
|
|
2736
|
+
const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
|
|
2737
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
2738
|
+
return outcome.reservation;
|
|
2739
|
+
}
|
|
2740
|
+
if (outcome.outcome === "insufficient") {
|
|
2741
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
2742
|
+
}
|
|
2743
|
+
throw createIdempotencyConflictError(outcome.idempotencyKey, {
|
|
2744
|
+
userId,
|
|
2745
|
+
requested: { amount, operationType },
|
|
2746
|
+
existing: {
|
|
2747
|
+
amount: outcome.existing.amount,
|
|
2748
|
+
operationType: outcome.existing.operationType
|
|
2749
|
+
}
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* Reserve credits and get the typed outcome instead of an exception.
|
|
2754
|
+
*
|
|
2755
|
+
* Prefer this over {@link reserveCredits} in async callers: it distinguishes
|
|
2756
|
+
* a fresh hold from an idempotent replay, and reports insufficient funds as
|
|
2757
|
+
* a value rather than as control flow.
|
|
2758
|
+
*/
|
|
2759
|
+
async reserveCreditsDetailed(userId, amount, operationType, options) {
|
|
1432
2760
|
const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
|
|
1433
2761
|
const expiresAt = new Date(Date.now() + ttlMs);
|
|
1434
|
-
return this.repository
|
|
2762
|
+
return reserveThroughRepository(this.repository, {
|
|
2763
|
+
userId,
|
|
2764
|
+
amount,
|
|
2765
|
+
operationType,
|
|
2766
|
+
expiresAt,
|
|
2767
|
+
idempotencyKey: options?.idempotencyKey
|
|
2768
|
+
});
|
|
1435
2769
|
}
|
|
1436
2770
|
/**
|
|
1437
|
-
* Commit a reservation (phase 2 of two-phase commit - success)
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
2771
|
+
* Commit a reservation (phase 2 of two-phase commit - success).
|
|
2772
|
+
*
|
|
2773
|
+
* Idempotent: re-delivering a commit for an already-committed reservation is
|
|
2774
|
+
* a no-op. Committing one that was released or expired throws, because the
|
|
2775
|
+
* credits are no longer held and pretending otherwise would hide a real bug.
|
|
1440
2776
|
*/
|
|
1441
|
-
async commitCredits(userId, reservationId) {
|
|
1442
|
-
const
|
|
1443
|
-
if (
|
|
1444
|
-
throw
|
|
2777
|
+
async commitCredits(userId, reservationId, options) {
|
|
2778
|
+
const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
|
|
2779
|
+
if (outcome.outcome === "not_found") {
|
|
2780
|
+
throw createReservationNotFoundError(reservationId);
|
|
1445
2781
|
}
|
|
1446
|
-
if (
|
|
1447
|
-
|
|
2782
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
2783
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1448
2784
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2785
|
+
}
|
|
2786
|
+
/**
|
|
2787
|
+
* Commit a reservation and get the typed outcome.
|
|
2788
|
+
*
|
|
2789
|
+
* Exactly one concurrent caller sees `committed`; that call moved the balance
|
|
2790
|
+
* and wrote the single journal entry. On a V2 repository the journal is
|
|
2791
|
+
* written inside the same transaction, so this method adds none of its own —
|
|
2792
|
+
* the duplicate entry the previous implementation wrote is gone.
|
|
2793
|
+
*
|
|
2794
|
+
* The low-balance notification fires only for the winner, and only after the
|
|
2795
|
+
* transaction has committed, so no callback runs inside a database
|
|
2796
|
+
* transaction.
|
|
2797
|
+
*/
|
|
2798
|
+
async commitCreditsDetailed(userId, reservationId, options) {
|
|
2799
|
+
const outcome = await commitThroughRepository(
|
|
2800
|
+
this.repository,
|
|
2801
|
+
userId,
|
|
2802
|
+
reservationId,
|
|
2803
|
+
options
|
|
2804
|
+
);
|
|
2805
|
+
if (outcome.outcome === "committed" && this.lowBalanceCallback) {
|
|
2806
|
+
this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
|
|
2807
|
+
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1464
2808
|
});
|
|
1465
|
-
if (this.lowBalanceCallback) {
|
|
1466
|
-
this.lowBalanceCallback(userId, credits.balance).catch((error) => {
|
|
1467
|
-
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1468
|
-
});
|
|
1469
|
-
}
|
|
1470
2809
|
}
|
|
2810
|
+
return outcome;
|
|
1471
2811
|
}
|
|
1472
2812
|
/**
|
|
1473
|
-
* Release a reservation (phase 2 of two-phase commit - failure)
|
|
1474
|
-
*
|
|
2813
|
+
* Release a reservation (phase 2 of two-phase commit - failure).
|
|
2814
|
+
*
|
|
2815
|
+
* Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
|
|
2816
|
+
* pre-V2 behaviour — a release naming a reservation that does not exist is a
|
|
2817
|
+
* caller bug, and swallowing it hides the bug while the credits stay held.
|
|
2818
|
+
*
|
|
2819
|
+
* Releasing one that is already terminal — `released`, `expired`, or
|
|
2820
|
+
* `committed` — is a no-op, which is the behaviour this method has always
|
|
2821
|
+
* had. A release that loses a race to a concurrent commit is the common
|
|
2822
|
+
* case in a retry-heavy caller, and callers written against the pre-V2
|
|
2823
|
+
* contract handle it by not handling it.
|
|
2824
|
+
*
|
|
2825
|
+
* That does mean this method cannot tell you that the credits were spent
|
|
2826
|
+
* rather than returned. Use {@link releaseCreditsDetailed}, which reports
|
|
2827
|
+
* `already_terminal` with `terminalStatus: 'committed'`, when you need to
|
|
2828
|
+
* branch on it.
|
|
1475
2829
|
*/
|
|
1476
|
-
async releaseCredits(userId, reservationId) {
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const credits = await this.repository.getUserCredits(userId);
|
|
1481
|
-
if (credits) {
|
|
1482
|
-
await this.repository.createJournalEntry({
|
|
1483
|
-
userId,
|
|
1484
|
-
entryType: "credit",
|
|
1485
|
-
amount: 0,
|
|
1486
|
-
// No actual credits returned (they were reserved, not spent)
|
|
1487
|
-
balanceAfter: credits.balance,
|
|
1488
|
-
source: "operation_release",
|
|
1489
|
-
referenceId: reservationId,
|
|
1490
|
-
referenceType: "reservation",
|
|
1491
|
-
description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1492
|
-
metadata: {
|
|
1493
|
-
operationType: reservation.operationType,
|
|
1494
|
-
amount: reservation.amount
|
|
1495
|
-
}
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
2830
|
+
async releaseCredits(userId, reservationId, options) {
|
|
2831
|
+
const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
|
|
2832
|
+
if (outcome.outcome === "not_found") {
|
|
2833
|
+
throw createReservationNotFoundError(reservationId);
|
|
1498
2834
|
}
|
|
1499
2835
|
}
|
|
2836
|
+
/** Release a reservation and get the typed outcome. */
|
|
2837
|
+
async releaseCreditsDetailed(userId, reservationId, options) {
|
|
2838
|
+
return releaseThroughRepository(this.repository, userId, reservationId, options);
|
|
2839
|
+
}
|
|
1500
2840
|
/**
|
|
1501
2841
|
* Log usage for audit trail
|
|
1502
2842
|
* @param log - Usage log data
|
|
@@ -1510,9 +2850,22 @@ var CreditsService = class {
|
|
|
1510
2850
|
* @param amount - Credits to add
|
|
1511
2851
|
* @param description - Transaction description
|
|
1512
2852
|
* @param paymentRef - Optional payment reference
|
|
2853
|
+
* @param options - Optional journal source / reference type / extra metadata
|
|
2854
|
+
* for the credit journal entry (e.g. revenue-attribution fields).
|
|
2855
|
+
* @returns Whether this delivery credited the account (`created`), repeated an
|
|
2856
|
+
* earlier one (`replayed`), or reused the reference for a different credit
|
|
2857
|
+
* event (`conflict`, which credited nothing). On a repository without
|
|
2858
|
+
* `addCreditsV2` the result is always `created` and carries no
|
|
2859
|
+
* deduplication guarantee — see {@link addCreditsThroughRepository}.
|
|
1513
2860
|
*/
|
|
1514
|
-
async addCredits(userId, amount, description, paymentRef) {
|
|
1515
|
-
return this.repository
|
|
2861
|
+
async addCredits(userId, amount, description, paymentRef, options) {
|
|
2862
|
+
return addCreditsThroughRepository(this.repository, {
|
|
2863
|
+
userId,
|
|
2864
|
+
amount,
|
|
2865
|
+
description,
|
|
2866
|
+
paymentRef,
|
|
2867
|
+
options
|
|
2868
|
+
});
|
|
1516
2869
|
}
|
|
1517
2870
|
/**
|
|
1518
2871
|
* One-shot atomic "deduct-if-sufficient" credit charge.
|
|
@@ -1536,15 +2889,14 @@ var CreditsService = class {
|
|
|
1536
2889
|
* @throws Error if amount is not a positive number
|
|
1537
2890
|
*/
|
|
1538
2891
|
async deductCredits(userId, amount, options) {
|
|
1539
|
-
|
|
1540
|
-
throw new Error(`deductCredits amount must be positive (got ${amount})`);
|
|
1541
|
-
}
|
|
2892
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1542
2893
|
let deduction;
|
|
1543
2894
|
try {
|
|
1544
2895
|
deduction = await this.repository.deductCreditsAtomic(userId, amount);
|
|
1545
2896
|
} catch (error) {
|
|
2897
|
+
if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
|
|
1546
2898
|
const credits = await this.repository.getUserCredits(userId);
|
|
1547
|
-
const available = credits ? credits.balance
|
|
2899
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
1548
2900
|
if (available >= amount) {
|
|
1549
2901
|
throw error;
|
|
1550
2902
|
}
|
|
@@ -1553,7 +2905,7 @@ var CreditsService = class {
|
|
|
1553
2905
|
reason: "insufficient",
|
|
1554
2906
|
available,
|
|
1555
2907
|
required: amount,
|
|
1556
|
-
shortfall: amount -
|
|
2908
|
+
shortfall: sumAmounts(amount, -available)
|
|
1557
2909
|
};
|
|
1558
2910
|
}
|
|
1559
2911
|
const operationType = options?.operationType;
|
|
@@ -1567,8 +2919,8 @@ var CreditsService = class {
|
|
|
1567
2919
|
referenceType: "adjustment",
|
|
1568
2920
|
description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
|
|
1569
2921
|
metadata: {
|
|
1570
|
-
...
|
|
1571
|
-
...
|
|
2922
|
+
...options?.metadata,
|
|
2923
|
+
...operationType && { operationType }
|
|
1572
2924
|
}
|
|
1573
2925
|
});
|
|
1574
2926
|
if (operationType) {
|
|
@@ -1603,8 +2955,15 @@ var CreditsService = class {
|
|
|
1603
2955
|
const free = isFreeTier(tier);
|
|
1604
2956
|
await this.repository.updateUserTier(userId, {
|
|
1605
2957
|
tier,
|
|
1606
|
-
//
|
|
1607
|
-
|
|
2958
|
+
// Through the shared contract, like every path that resolves the tier's
|
|
2959
|
+
// limit from configuration. (The repository's `updateUserCredits` and
|
|
2960
|
+
// `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
|
|
2961
|
+
// they are given a value, not a tier, so there is no sentinel to map.)
|
|
2962
|
+
// This used to store 0 for an unlimited tier under a "0 means unlimited"
|
|
2963
|
+
// convention that nothing actually implemented — no read path anywhere in
|
|
2964
|
+
// the library branches on it — so upgrading a user to unlimited gave them
|
|
2965
|
+
// an allowance of zero.
|
|
2966
|
+
monthlyLimit: storedMonthlyLimit(monthlyLimit),
|
|
1608
2967
|
// Reset balance to new tier limit if upgrading
|
|
1609
2968
|
balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
|
|
1610
2969
|
monthlyUsed: !free ? 0 : void 0,
|
|
@@ -2295,7 +3654,7 @@ var CreditsClient = class {
|
|
|
2295
3654
|
*/
|
|
2296
3655
|
async getAvailableCredits() {
|
|
2297
3656
|
const balance = await this.getBalance();
|
|
2298
|
-
return balance.balance
|
|
3657
|
+
return sumAmounts(balance.balance, balance.bonusCredits, -balance.reserved);
|
|
2299
3658
|
}
|
|
2300
3659
|
/**
|
|
2301
3660
|
* Execute an operation with automatic credit handling
|
|
@@ -2524,7 +3883,7 @@ function parseCreditError(error) {
|
|
|
2524
3883
|
return {
|
|
2525
3884
|
available,
|
|
2526
3885
|
required,
|
|
2527
|
-
shortfall: required -
|
|
3886
|
+
shortfall: sumAmounts(required, -available),
|
|
2528
3887
|
rawMessage: error
|
|
2529
3888
|
};
|
|
2530
3889
|
}
|
|
@@ -2535,7 +3894,7 @@ function isCreditErrorMessage(error) {
|
|
|
2535
3894
|
|
|
2536
3895
|
// src/utils/index.ts
|
|
2537
3896
|
function getTotalCredits(credits) {
|
|
2538
|
-
return credits.balance
|
|
3897
|
+
return sumAmounts(credits.balance, credits.bonusCredits);
|
|
2539
3898
|
}
|
|
2540
3899
|
function generateRequestId2() {
|
|
2541
3900
|
const timestamp = Date.now();
|
|
@@ -2563,6 +3922,8 @@ function createDummyReservation2(userId, operationType) {
|
|
|
2563
3922
|
ApiKeyCreditsProvider,
|
|
2564
3923
|
AuthenticationError,
|
|
2565
3924
|
AuthorizationError,
|
|
3925
|
+
CREDIT_AMOUNT_MAX,
|
|
3926
|
+
CREDIT_AMOUNT_SCALE,
|
|
2566
3927
|
ConsoleNotificationHandler,
|
|
2567
3928
|
CreditError,
|
|
2568
3929
|
CreditErrorCode,
|
|
@@ -2571,31 +3932,57 @@ function createDummyReservation2(userId, operationType) {
|
|
|
2571
3932
|
CreditsService,
|
|
2572
3933
|
InMemoryCreditRepository,
|
|
2573
3934
|
NetworkError,
|
|
3935
|
+
RESERVATION_STATUSES,
|
|
3936
|
+
RESERVED_JOURNAL_KEY_PREFIX,
|
|
2574
3937
|
SDKErrorCode,
|
|
2575
3938
|
SDKInsufficientCreditsError,
|
|
2576
3939
|
SDKReservationExpiredError,
|
|
2577
3940
|
SDKReservationNotFoundError,
|
|
2578
3941
|
ServerError,
|
|
3942
|
+
TERMINAL_RESERVATION_STATUSES,
|
|
2579
3943
|
UNLIMITED_BALANCE_SENTINEL,
|
|
2580
3944
|
ValidationError,
|
|
3945
|
+
addCreditsThroughRepository,
|
|
3946
|
+
assertDirectStatusWriteAllowed,
|
|
3947
|
+
assertHoldPlaced,
|
|
3948
|
+
assertKnownReservationStatus,
|
|
3949
|
+
assertPublicJournalKey,
|
|
3950
|
+
assertRepresentableAmount,
|
|
3951
|
+
assertRepresentableFields,
|
|
3952
|
+
assertRepresentableTierAmount,
|
|
3953
|
+
assertUnkeyedDirectReservation,
|
|
3954
|
+
assertUnreferencedDirectTransaction,
|
|
3955
|
+
assertValidCreditAmount,
|
|
3956
|
+
assertValidIdempotencyKey,
|
|
3957
|
+
assertValidStoredAmount,
|
|
3958
|
+
assertValidStoredAmountRaw,
|
|
3959
|
+
backedBalanceFloor,
|
|
2581
3960
|
calculateAvailableCredits,
|
|
2582
3961
|
checkAndNotifyLowBalance,
|
|
3962
|
+
classifyDatabaseError,
|
|
2583
3963
|
clearConfigProvider,
|
|
2584
3964
|
clearCooldownState,
|
|
2585
3965
|
clearNotificationHandlers,
|
|
2586
3966
|
commitReservationWithJournal,
|
|
3967
|
+
commitThroughRepository,
|
|
2587
3968
|
createApiKeyProvider,
|
|
2588
3969
|
createCreditsService,
|
|
2589
3970
|
createDeferredExecutor,
|
|
2590
3971
|
createDummyReservation,
|
|
2591
3972
|
createGenericAdapter,
|
|
3973
|
+
createIdempotencyConflictError,
|
|
2592
3974
|
createInMemoryCreditRepository,
|
|
2593
3975
|
createInsufficientCreditsError,
|
|
3976
|
+
createInvalidAmountError,
|
|
2594
3977
|
createInvalidOperationTypeError,
|
|
3978
|
+
createPaymentRefConflictError,
|
|
2595
3979
|
createReservationAlreadyProcessedError,
|
|
2596
3980
|
createReservationExpiredError,
|
|
2597
3981
|
createReservationNotFoundError,
|
|
3982
|
+
createTransientError,
|
|
3983
|
+
createUnsupportedOperationError,
|
|
2598
3984
|
createUserNotFoundError,
|
|
3985
|
+
describePaymentMismatch,
|
|
2599
3986
|
generateId,
|
|
2600
3987
|
generateRequestId,
|
|
2601
3988
|
genericDeferred,
|
|
@@ -2614,35 +4001,63 @@ function createDummyReservation2(userId, operationType) {
|
|
|
2614
4001
|
getOperationLabel,
|
|
2615
4002
|
getOperationLabels,
|
|
2616
4003
|
getReservationExpiryMs,
|
|
4004
|
+
getSqlState,
|
|
2617
4005
|
getTierConfig,
|
|
2618
4006
|
getTotalCredits,
|
|
2619
4007
|
getUnlimitedSentinelBalance,
|
|
2620
4008
|
getValidOperationTypes,
|
|
2621
4009
|
getValidTiers,
|
|
4010
|
+
hasPlacedHold,
|
|
2622
4011
|
initializeConfig,
|
|
2623
4012
|
isCreditError,
|
|
2624
4013
|
isCreditErrorMessage,
|
|
4014
|
+
isCreditedOutcome,
|
|
2625
4015
|
isFeatureEnabled,
|
|
2626
4016
|
isFreeTier,
|
|
4017
|
+
isIdempotencyConflictError,
|
|
2627
4018
|
isInsufficientCreditsError,
|
|
2628
4019
|
isPreviewMode,
|
|
4020
|
+
isRepresentableAmount,
|
|
4021
|
+
isReservationStatus,
|
|
4022
|
+
isReservedJournalKey,
|
|
4023
|
+
isReservedOutcome,
|
|
4024
|
+
isTerminalReservationStatus,
|
|
4025
|
+
isTransientDatabaseError,
|
|
4026
|
+
isTransientError,
|
|
4027
|
+
isUniqueViolation,
|
|
2629
4028
|
isUnlimitedTier,
|
|
4029
|
+
isValidCreditAmount,
|
|
4030
|
+
isValidIdempotencyKey,
|
|
2630
4031
|
isValidOperationType,
|
|
2631
4032
|
isValidTier,
|
|
4033
|
+
isWinningOutcome,
|
|
2632
4034
|
loadConfigFromEnv,
|
|
4035
|
+
monthlyResetBalance,
|
|
2633
4036
|
noopDeferred,
|
|
4037
|
+
normalizePaymentRef,
|
|
2634
4038
|
notifySubscriptionExpired,
|
|
2635
4039
|
notifySubscriptionExpiring,
|
|
4040
|
+
numericToCents,
|
|
2636
4041
|
parseApiError,
|
|
2637
4042
|
parseCreditError,
|
|
2638
4043
|
parseTier,
|
|
2639
4044
|
registerConfigProvider,
|
|
2640
4045
|
registerNotificationHandler,
|
|
2641
4046
|
releaseReservationWithJournal,
|
|
4047
|
+
releaseThroughRepository,
|
|
4048
|
+
reservationJournalKey,
|
|
2642
4049
|
reserveCreditsForOperation,
|
|
4050
|
+
reserveThroughRepository,
|
|
2643
4051
|
resetConfig,
|
|
4052
|
+
sameAmount,
|
|
4053
|
+
storedMonthlyLimit,
|
|
4054
|
+
sumAmounts,
|
|
4055
|
+
supportsCreditsV2,
|
|
4056
|
+
supportsIdempotentCredit,
|
|
2644
4057
|
synchronousDeferred,
|
|
4058
|
+
terminalStatusOf,
|
|
2645
4059
|
tierSchema,
|
|
4060
|
+
toCents,
|
|
2646
4061
|
toClientUserCredits,
|
|
2647
4062
|
toDate,
|
|
2648
4063
|
toPortableTimestamp,
|