@nehorai/credits 1.7.0 → 2.1.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/README.md +384 -153
- package/dist/adapters/index.cjs +308 -46
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.d.cts +3 -2
- package/dist/adapters/index.d.ts +3 -2
- package/dist/adapters/index.js +308 -46
- package/dist/adapters/index.js.map +1 -1
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.cjs +700 -76
- 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 +652 -76
- package/dist/core/index.js.map +1 -1
- package/dist/index.cjs +1772 -371
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -7
- package/dist/index.d.ts +44 -7
- package/dist/index.js +1716 -371
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +1403 -185
- package/dist/repository/index.cjs.map +1 -1
- package/dist/repository/index.d.cts +141 -15
- package/dist/repository/index.d.ts +141 -15
- package/dist/repository/index.js +1396 -185
- 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 +484 -106
- package/dist/service/index.cjs.map +1 -1
- package/dist/service/index.d.cts +68 -10
- package/dist/service/index.d.ts +68 -10
- package/dist/service/index.js +484 -106
- package/dist/service/index.js.map +1 -1
- package/dist/types-BhCdep_t.d.ts +646 -0
- package/dist/{types-CjLj3GW5.d.cts → types-Dzc443vy.d.cts} +44 -2
- package/dist/{types-CjLj3GW5.d.ts → types-Dzc443vy.d.ts} +44 -2
- package/dist/types-wtksMa4n.d.cts +646 -0
- package/package.json +71 -70
- package/LICENSE +0 -21
- package/dist/types-DFe5BoJY.d.cts +0 -329
- package/dist/types-_bSybQnb.d.ts +0 -329
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 = {
|
|
@@ -291,11 +769,13 @@ var creditSystemConfigSchema = import_zod.z.object({
|
|
|
291
769
|
/**
|
|
292
770
|
* Operation costs in credits
|
|
293
771
|
* Keys are operation type names (any non-empty string)
|
|
294
|
-
* Values are
|
|
772
|
+
* Values are non-negative numbers representing credit cost. 0 marks a free
|
|
773
|
+
* operation: `withCredits` runs it without touching the ledger. Amounts that
|
|
774
|
+
* move credits stay strictly positive, so `reserveCredits` refuses a 0.
|
|
295
775
|
*/
|
|
296
776
|
operationCosts: import_zod.z.record(
|
|
297
777
|
import_zod.z.string().min(1),
|
|
298
|
-
import_zod.z.number().
|
|
778
|
+
import_zod.z.number().nonnegative()
|
|
299
779
|
),
|
|
300
780
|
/**
|
|
301
781
|
* Display labels for operation types (English canonical labels).
|
|
@@ -553,6 +1033,12 @@ var UNLIMITED_BALANCE_SENTINEL = 999999;
|
|
|
553
1033
|
function getUnlimitedSentinelBalance() {
|
|
554
1034
|
return UNLIMITED_BALANCE_SENTINEL;
|
|
555
1035
|
}
|
|
1036
|
+
function monthlyResetBalance(tier) {
|
|
1037
|
+
if (isUnlimitedTier(tier)) {
|
|
1038
|
+
return { kind: "atLeast", value: UNLIMITED_BALANCE_SENTINEL };
|
|
1039
|
+
}
|
|
1040
|
+
return { kind: "absolute", value: getConfigMonthlyLimit(tier) };
|
|
1041
|
+
}
|
|
556
1042
|
function getConfigTierConfig(tier) {
|
|
557
1043
|
const cfg = getConfig().tierConfigs[tier];
|
|
558
1044
|
if (cfg) return cfg;
|
|
@@ -595,57 +1081,203 @@ function resetConfig() {
|
|
|
595
1081
|
}
|
|
596
1082
|
initializeConfig();
|
|
597
1083
|
|
|
598
|
-
// src/
|
|
599
|
-
|
|
1084
|
+
// src/repository/types.ts
|
|
1085
|
+
function supportsCreditsV2(repository) {
|
|
1086
|
+
return typeof repository.reserveCreditsV2 === "function" && typeof repository.commitReservationV2 === "function" && typeof repository.releaseReservationV2 === "function" && typeof repository.expireReservationV2 === "function";
|
|
1087
|
+
}
|
|
1088
|
+
function supportsIdempotentCredit(repository) {
|
|
1089
|
+
return typeof repository.addCreditsV2 === "function";
|
|
1090
|
+
}
|
|
1091
|
+
function toClientUserCredits(credits) {
|
|
1092
|
+
return {
|
|
1093
|
+
userId: credits.userId,
|
|
1094
|
+
balance: credits.balance,
|
|
1095
|
+
bonusCredits: credits.bonusCredits ?? 0,
|
|
1096
|
+
reserved: credits.reserved,
|
|
1097
|
+
tier: credits.tier,
|
|
1098
|
+
monthlyLimit: credits.monthlyLimit,
|
|
1099
|
+
monthlyUsed: credits.monthlyUsed,
|
|
1100
|
+
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
1101
|
+
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
1102
|
+
createdAt: toISOString(credits.createdAt),
|
|
1103
|
+
updatedAt: toISOString(credits.updatedAt)
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
function toISOString(value) {
|
|
1107
|
+
if (!value) return (/* @__PURE__ */ new Date()).toISOString();
|
|
1108
|
+
if (typeof value === "string") return value;
|
|
1109
|
+
if (value instanceof Date) return value.toISOString();
|
|
1110
|
+
if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
|
|
1111
|
+
return value.toDate().toISOString();
|
|
1112
|
+
}
|
|
1113
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// src/repository/flow.ts
|
|
1117
|
+
async function reserveThroughRepository(repository, input) {
|
|
1118
|
+
const v2 = supportsCreditsV2(repository);
|
|
1119
|
+
if (!v2 && input.idempotencyKey !== void 0) {
|
|
1120
|
+
throw createUnsupportedOperationError(
|
|
1121
|
+
"reserveCredits with an idempotencyKey",
|
|
1122
|
+
"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."
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
1126
|
+
if (v2) {
|
|
1127
|
+
return repository.reserveCreditsV2(input);
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
const reservation = await repository.reserveCreditsAtomic(
|
|
1131
|
+
input.userId,
|
|
1132
|
+
input.amount,
|
|
1133
|
+
input.operationType,
|
|
1134
|
+
input.expiresAt
|
|
1135
|
+
);
|
|
1136
|
+
return { outcome: "created", reservation };
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
if (!isInsufficientCreditsError(error)) throw error;
|
|
1139
|
+
const credits = await repository.getUserCredits(input.userId);
|
|
1140
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
1141
|
+
return {
|
|
1142
|
+
outcome: "insufficient",
|
|
1143
|
+
available,
|
|
1144
|
+
required: input.amount,
|
|
1145
|
+
shortfall: Math.max(sumAmounts(input.amount, -available), 0)
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
async function commitThroughRepository(repository, userId, reservationId, options) {
|
|
1150
|
+
if (supportsCreditsV2(repository)) {
|
|
1151
|
+
return repository.commitReservationV2(userId, reservationId, options);
|
|
1152
|
+
}
|
|
600
1153
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
601
|
-
if (!reservation) {
|
|
602
|
-
|
|
1154
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1155
|
+
if (reservation.status !== "reserved") {
|
|
1156
|
+
return {
|
|
1157
|
+
outcome: "already_terminal",
|
|
1158
|
+
reservation,
|
|
1159
|
+
terminalStatus: reservation.status
|
|
1160
|
+
};
|
|
603
1161
|
}
|
|
604
1162
|
await repository.commitReservationAtomic(userId, reservationId);
|
|
605
1163
|
const credits = await repository.getUserCredits(userId);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1164
|
+
const balanceAfter = credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0;
|
|
1165
|
+
const entry = await repository.createJournalEntry({
|
|
1166
|
+
userId,
|
|
1167
|
+
entryType: "debit",
|
|
1168
|
+
amount: reservation.amount,
|
|
1169
|
+
balanceAfter,
|
|
1170
|
+
source: "operation_commit",
|
|
1171
|
+
referenceId: reservationId,
|
|
1172
|
+
referenceType: "reservation",
|
|
1173
|
+
description: options?.description ?? `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1174
|
+
// Deterministic fields last: they carry the identity of the event, and a
|
|
1175
|
+
// caller who happens to send an `operationType` or an `amount` must not
|
|
1176
|
+
// relabel what actually moved.
|
|
1177
|
+
metadata: {
|
|
1178
|
+
...options?.metadata,
|
|
1179
|
+
operationType: reservation.operationType,
|
|
1180
|
+
amount: reservation.amount
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
return {
|
|
1184
|
+
outcome: "committed",
|
|
1185
|
+
reservation: { ...reservation, status: "committed" },
|
|
1186
|
+
amount: reservation.amount,
|
|
1187
|
+
balanceAfter,
|
|
1188
|
+
journalEntryId: entry.id
|
|
1189
|
+
};
|
|
621
1190
|
}
|
|
622
|
-
async function
|
|
1191
|
+
async function releaseThroughRepository(repository, userId, reservationId, options) {
|
|
1192
|
+
if (supportsCreditsV2(repository)) {
|
|
1193
|
+
return repository.releaseReservationV2(userId, reservationId, options);
|
|
1194
|
+
}
|
|
623
1195
|
const reservation = await repository.getReservation(userId, reservationId);
|
|
1196
|
+
if (!reservation) {
|
|
1197
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
1198
|
+
return { outcome: "not_found", reservationId };
|
|
1199
|
+
}
|
|
1200
|
+
if (reservation.status !== "reserved") {
|
|
1201
|
+
await repository.releaseReservationAtomic(userId, reservationId).catch(() => void 0);
|
|
1202
|
+
return {
|
|
1203
|
+
outcome: "already_terminal",
|
|
1204
|
+
reservation,
|
|
1205
|
+
terminalStatus: reservation.status
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
624
1208
|
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
|
-
});
|
|
1209
|
+
const credits = await repository.getUserCredits(userId);
|
|
1210
|
+
const entry = await repository.createJournalEntry({
|
|
1211
|
+
userId,
|
|
1212
|
+
entryType: "credit",
|
|
1213
|
+
// No credits changed hands — the hold just moved back into "available".
|
|
1214
|
+
amount: 0,
|
|
1215
|
+
balanceAfter: credits ? sumAmounts(credits.balance, credits.bonusCredits) : 0,
|
|
1216
|
+
source: "operation_release",
|
|
1217
|
+
referenceId: reservationId,
|
|
1218
|
+
referenceType: "reservation",
|
|
1219
|
+
description: options?.description ?? `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1220
|
+
metadata: {
|
|
1221
|
+
...options?.metadata,
|
|
1222
|
+
operationType: reservation.operationType,
|
|
1223
|
+
amount: reservation.amount
|
|
643
1224
|
}
|
|
1225
|
+
});
|
|
1226
|
+
return {
|
|
1227
|
+
outcome: "released",
|
|
1228
|
+
reservation: { ...reservation, status: "released" },
|
|
1229
|
+
amount: reservation.amount,
|
|
1230
|
+
journalEntryId: entry.id
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
async function addCreditsThroughRepository(repository, input) {
|
|
1234
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "addCredits" });
|
|
1235
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
1236
|
+
if (supportsIdempotentCredit(repository)) {
|
|
1237
|
+
return repository.addCreditsV2({ ...input, paymentRef });
|
|
1238
|
+
}
|
|
1239
|
+
await repository.addCreditsAtomic(
|
|
1240
|
+
input.userId,
|
|
1241
|
+
input.amount,
|
|
1242
|
+
input.description,
|
|
1243
|
+
paymentRef,
|
|
1244
|
+
input.options
|
|
1245
|
+
);
|
|
1246
|
+
return paymentRef ? { outcome: "created", paymentRef } : { outcome: "created" };
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// src/core/operations.ts
|
|
1250
|
+
async function commitReservationWithJournal(repository, userId, reservationId) {
|
|
1251
|
+
const outcome = await commitThroughRepository(repository, userId, reservationId);
|
|
1252
|
+
if (outcome.outcome === "not_found") {
|
|
1253
|
+
throw createReservationNotFoundError(reservationId);
|
|
644
1254
|
}
|
|
1255
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
1256
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function releaseReservationWithJournal(repository, userId, reservationId) {
|
|
1260
|
+
await releaseThroughRepository(repository, userId, reservationId);
|
|
645
1261
|
}
|
|
646
|
-
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
|
|
1262
|
+
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3, idempotencyKey) {
|
|
647
1263
|
const expiresAt = new Date(Date.now() + expiryMs);
|
|
648
|
-
|
|
1264
|
+
const outcome = await reserveThroughRepository(repository, {
|
|
1265
|
+
userId,
|
|
1266
|
+
amount,
|
|
1267
|
+
operationType,
|
|
1268
|
+
expiresAt,
|
|
1269
|
+
idempotencyKey
|
|
1270
|
+
});
|
|
1271
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1272
|
+
return outcome.reservation;
|
|
1273
|
+
}
|
|
1274
|
+
if (outcome.outcome === "insufficient") {
|
|
1275
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
1276
|
+
}
|
|
1277
|
+
throw createReservationAlreadyProcessedError(
|
|
1278
|
+
outcome.existing.id,
|
|
1279
|
+
outcome.existing.status
|
|
1280
|
+
);
|
|
649
1281
|
}
|
|
650
1282
|
|
|
651
1283
|
// src/config/costs.ts
|
|
@@ -680,64 +1312,493 @@ function getOperationCostsWithLabels() {
|
|
|
680
1312
|
return result;
|
|
681
1313
|
}
|
|
682
1314
|
|
|
683
|
-
// src/repository/types.ts
|
|
684
|
-
function
|
|
685
|
-
return {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1315
|
+
// src/repository/v2-types.ts
|
|
1316
|
+
function reservationJournalKey(reservationId, transition) {
|
|
1317
|
+
return `${RESERVED_JOURNAL_KEY_PREFIX}${reservationId}:${transition}`;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// src/repository/utils.ts
|
|
1321
|
+
function generateId() {
|
|
1322
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
1323
|
+
}
|
|
1324
|
+
function toDate2(value) {
|
|
1325
|
+
if (value instanceof Date) return value;
|
|
1326
|
+
if (typeof value === "string") return new Date(value);
|
|
1327
|
+
if (typeof value === "object" && value !== null && "toDate" in value) {
|
|
1328
|
+
return value.toDate();
|
|
1329
|
+
}
|
|
1330
|
+
return /* @__PURE__ */ new Date();
|
|
1331
|
+
}
|
|
1332
|
+
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
1333
|
+
const next = new Date(from);
|
|
1334
|
+
next.setMonth(next.getMonth() + 1);
|
|
1335
|
+
next.setDate(1);
|
|
1336
|
+
next.setHours(0, 0, 0, 0);
|
|
1337
|
+
return next;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// src/repository/memory/snapshot.ts
|
|
1341
|
+
function copyRecord(record) {
|
|
1342
|
+
if (!record) return void 0;
|
|
1343
|
+
const copy = { ...record };
|
|
1344
|
+
const metadata = record.metadata;
|
|
1345
|
+
if (metadata && typeof metadata === "object") {
|
|
1346
|
+
copy.metadata = { ...metadata };
|
|
1347
|
+
}
|
|
1348
|
+
return copy;
|
|
1349
|
+
}
|
|
1350
|
+
function copyRecords(records) {
|
|
1351
|
+
return records.map((record) => copyRecord(record));
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// src/repository/memory/mutex.ts
|
|
1355
|
+
var KeyedMutex = class {
|
|
1356
|
+
tails = /* @__PURE__ */ new Map();
|
|
1357
|
+
/**
|
|
1358
|
+
* Run `fn` with exclusive access to `key`.
|
|
1359
|
+
*
|
|
1360
|
+
* Callers queue in arrival order. The stored tail settles either way, so one
|
|
1361
|
+
* caller throwing does not wedge the queue behind it.
|
|
1362
|
+
*/
|
|
1363
|
+
async run(key, fn) {
|
|
1364
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
1365
|
+
const result = previous.then(fn, fn);
|
|
1366
|
+
const tail = result.then(
|
|
1367
|
+
() => void 0,
|
|
1368
|
+
() => void 0
|
|
1369
|
+
);
|
|
1370
|
+
this.tails.set(key, tail);
|
|
1371
|
+
try {
|
|
1372
|
+
return await result;
|
|
1373
|
+
} finally {
|
|
1374
|
+
if (this.tails.get(key) === tail) {
|
|
1375
|
+
this.tails.delete(key);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// src/repository/memory/store.ts
|
|
1382
|
+
var MemoryStore = class {
|
|
1383
|
+
users = /* @__PURE__ */ new Map();
|
|
1384
|
+
reservations = /* @__PURE__ */ new Map();
|
|
1385
|
+
transactions = /* @__PURE__ */ new Map();
|
|
1386
|
+
usageLogs = [];
|
|
1387
|
+
journalEntries = /* @__PURE__ */ new Map();
|
|
1388
|
+
/** Stands in for the reservations table's partial unique index. */
|
|
1389
|
+
reservationKeys = /* @__PURE__ */ new Map();
|
|
1390
|
+
/** Stands in for the journal table's partial unique index. */
|
|
1391
|
+
journalKeys = /* @__PURE__ */ new Map();
|
|
1392
|
+
/** Stands in for the database's per-user row locks. */
|
|
1393
|
+
locks = new KeyedMutex();
|
|
1394
|
+
/**
|
|
1395
|
+
* Test-only yield point, awaited between reading state and mutating it.
|
|
1396
|
+
*
|
|
1397
|
+
* Unset in production, so the critical sections stay synchronous and this
|
|
1398
|
+
* costs nothing. Tests set it to a real macrotask yield, which forces
|
|
1399
|
+
* concurrent callers to interleave at exactly the spot a database would have
|
|
1400
|
+
* them contend — turning the lock above from decoration into the thing the
|
|
1401
|
+
* concurrency tests actually depend on.
|
|
1402
|
+
*/
|
|
1403
|
+
schedulingHook;
|
|
1404
|
+
clear() {
|
|
1405
|
+
this.users.clear();
|
|
1406
|
+
this.reservations.clear();
|
|
1407
|
+
this.transactions.clear();
|
|
1408
|
+
this.usageLogs = [];
|
|
1409
|
+
this.journalEntries.clear();
|
|
1410
|
+
this.reservationKeys.clear();
|
|
1411
|
+
this.journalKeys.clear();
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
function scopedKey(userId, key) {
|
|
1415
|
+
return `${userId}
|
|
1416
|
+
${key}`;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// src/repository/memory/transition-journal.ts
|
|
1420
|
+
function describeJournalMismatch(row, input) {
|
|
1421
|
+
if (row.userId !== input.userId) return "user_id";
|
|
1422
|
+
if (row.entryType !== input.entryType) return "entry_type";
|
|
1423
|
+
if (!sameAmount(row.amount, input.amount)) return "amount";
|
|
1424
|
+
if (!sameAmount(row.balanceAfter, input.balanceAfter)) return "balance_after";
|
|
1425
|
+
if (row.source !== input.source) return "source";
|
|
1426
|
+
if (row.referenceId !== input.reservationId) return "reference_id";
|
|
1427
|
+
if (row.referenceType !== "reservation") return "reference_type";
|
|
1428
|
+
const existingMeta = row.metadata ?? {};
|
|
1429
|
+
const expectedMeta = input.metadata ?? {};
|
|
1430
|
+
if (existingMeta.operationType !== expectedMeta.operationType) {
|
|
1431
|
+
return "metadata.operationType";
|
|
1432
|
+
}
|
|
1433
|
+
const hadAmount = existingMeta.amount !== void 0;
|
|
1434
|
+
const wantsAmount = expectedMeta.amount !== void 0;
|
|
1435
|
+
if (hadAmount !== wantsAmount) return "metadata.amount";
|
|
1436
|
+
if (wantsAmount && !sameAmount(existingMeta.amount, expectedMeta.amount)) {
|
|
1437
|
+
return "metadata.amount";
|
|
1438
|
+
}
|
|
1439
|
+
return null;
|
|
1440
|
+
}
|
|
1441
|
+
function findEntry(store, userId, entryId) {
|
|
1442
|
+
return store.journalEntries.get(userId)?.find((entry) => entry.id === entryId);
|
|
1443
|
+
}
|
|
1444
|
+
function planTransitionJournal(store, input) {
|
|
1445
|
+
assertRepresentableFields(
|
|
1446
|
+
{ amount: input.amount, "journal balanceAfter": input.balanceAfter },
|
|
1447
|
+
{ userId: input.userId, reservationId: input.reservationId, operation: input.operation }
|
|
1448
|
+
);
|
|
1449
|
+
const existingId = store.journalKeys.get(scopedKey(input.userId, input.idempotencyKey));
|
|
1450
|
+
if (!existingId) return void 0;
|
|
1451
|
+
const row = findEntry(store, input.userId, existingId);
|
|
1452
|
+
const mismatch = row ? describeJournalMismatch(row, input) : "the key is registered but its entry is missing";
|
|
1453
|
+
if (mismatch) {
|
|
1454
|
+
throw new CreditError(
|
|
1455
|
+
`Journal key ${input.idempotencyKey} is already used by a different entry (${mismatch}). The transition was refused rather than reported as applied.`,
|
|
1456
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1457
|
+
{
|
|
1458
|
+
idempotencyKey: input.idempotencyKey,
|
|
1459
|
+
reservationId: input.reservationId,
|
|
1460
|
+
mismatch
|
|
1461
|
+
}
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
return existingId;
|
|
1465
|
+
}
|
|
1466
|
+
function commitTransitionJournal(store, input, existingId) {
|
|
1467
|
+
if (existingId) return existingId;
|
|
1468
|
+
const entry = {
|
|
1469
|
+
id: generateId(),
|
|
1470
|
+
userId: input.userId,
|
|
1471
|
+
entryType: input.entryType,
|
|
1472
|
+
amount: input.amount,
|
|
1473
|
+
balanceAfter: input.balanceAfter,
|
|
1474
|
+
source: input.source,
|
|
1475
|
+
referenceId: input.reservationId,
|
|
1476
|
+
referenceType: "reservation",
|
|
1477
|
+
description: input.description,
|
|
1478
|
+
metadata: input.metadata,
|
|
1479
|
+
idempotencyKey: input.idempotencyKey,
|
|
1480
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
697
1481
|
};
|
|
1482
|
+
if (!store.journalEntries.has(input.userId)) {
|
|
1483
|
+
store.journalEntries.set(input.userId, []);
|
|
1484
|
+
}
|
|
1485
|
+
store.journalEntries.get(input.userId).push(entry);
|
|
1486
|
+
store.journalKeys.set(scopedKey(input.userId, input.idempotencyKey), entry.id);
|
|
1487
|
+
return entry.id;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// src/repository/memory/v2.ts
|
|
1491
|
+
async function yieldPoint(store) {
|
|
1492
|
+
if (store.schedulingHook) await store.schedulingHook();
|
|
1493
|
+
}
|
|
1494
|
+
function assertReservedCoversHold(credits, userId, reservationId, amount) {
|
|
1495
|
+
if (credits.reserved >= amount) return;
|
|
1496
|
+
throw new CreditError(
|
|
1497
|
+
`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.`,
|
|
1498
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
1499
|
+
{ userId, reservationId, amount, reserved: credits.reserved }
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
function assertLockedAmount(reservation, transition) {
|
|
1503
|
+
assertValidStoredAmount(reservation.amount, {
|
|
1504
|
+
userId: reservation.userId,
|
|
1505
|
+
reservationId: reservation.id,
|
|
1506
|
+
transition
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
function terminalStatusOf2(reservation, transition) {
|
|
1510
|
+
return terminalStatusOf(reservation, transition);
|
|
1511
|
+
}
|
|
1512
|
+
function assertTrustworthy(reservation, transition) {
|
|
1513
|
+
assertKnownReservationStatus(reservation, transition);
|
|
1514
|
+
assertLockedAmount(reservation, transition);
|
|
1515
|
+
assertHoldPlaced(reservation, transition);
|
|
1516
|
+
}
|
|
1517
|
+
function getReservation(store, userId, reservationId) {
|
|
1518
|
+
return store.reservations.get(userId)?.get(reservationId);
|
|
1519
|
+
}
|
|
1520
|
+
function requireUser(store, userId) {
|
|
1521
|
+
const credits = store.users.get(userId);
|
|
1522
|
+
if (!credits) {
|
|
1523
|
+
throw new CreditError(
|
|
1524
|
+
`User credits not found for user ${userId}`,
|
|
1525
|
+
CreditErrorCode.USER_NOT_FOUND,
|
|
1526
|
+
{ userId }
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
return credits;
|
|
1530
|
+
}
|
|
1531
|
+
async function reserveCreditsV2(store, input) {
|
|
1532
|
+
assertValidCreditAmount(input.amount, { userId: input.userId });
|
|
1533
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
1534
|
+
return store.locks.run(input.userId, async () => {
|
|
1535
|
+
if (input.idempotencyKey) {
|
|
1536
|
+
const existingId = store.reservationKeys.get(
|
|
1537
|
+
scopedKey(input.userId, input.idempotencyKey)
|
|
1538
|
+
);
|
|
1539
|
+
if (existingId) {
|
|
1540
|
+
const existing = getReservation(store, input.userId, existingId);
|
|
1541
|
+
if (existing) {
|
|
1542
|
+
assertHoldPlaced(existing, "reserve replay");
|
|
1543
|
+
const samePayload = existing.amount === input.amount && existing.operationType === input.operationType;
|
|
1544
|
+
return samePayload ? { outcome: "replayed", reservation: copyRecord(existing) } : {
|
|
1545
|
+
outcome: "idempotency_conflict",
|
|
1546
|
+
idempotencyKey: input.idempotencyKey,
|
|
1547
|
+
existing: copyRecord(existing)
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
const credits = requireUser(store, input.userId);
|
|
1553
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
1554
|
+
await yieldPoint(store);
|
|
1555
|
+
if (available < input.amount) {
|
|
1556
|
+
return {
|
|
1557
|
+
outcome: "insufficient",
|
|
1558
|
+
available,
|
|
1559
|
+
required: input.amount,
|
|
1560
|
+
shortfall: sumAmounts(input.amount, -available)
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1564
|
+
const reservation = {
|
|
1565
|
+
id: generateId(),
|
|
1566
|
+
userId: input.userId,
|
|
1567
|
+
amount: input.amount,
|
|
1568
|
+
operationType: input.operationType,
|
|
1569
|
+
status: "reserved",
|
|
1570
|
+
createdAt: now,
|
|
1571
|
+
expiresAt: input.expiresAt.toISOString(),
|
|
1572
|
+
idempotencyKey: input.idempotencyKey,
|
|
1573
|
+
// The hold-origin fact. Written here and nowhere else, in the same
|
|
1574
|
+
// critical section that raises `reserved` below, so the row and the hold
|
|
1575
|
+
// become visible together. `createReservation` cannot set it.
|
|
1576
|
+
holdPlacedAt: now
|
|
1577
|
+
};
|
|
1578
|
+
if (!store.reservations.has(input.userId)) {
|
|
1579
|
+
store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1580
|
+
}
|
|
1581
|
+
const nextReserved = sumAmounts(credits.reserved, input.amount);
|
|
1582
|
+
assertRepresentableFields(
|
|
1583
|
+
{ reserved: nextReserved },
|
|
1584
|
+
{ userId: input.userId, operation: "reserveCredits" }
|
|
1585
|
+
);
|
|
1586
|
+
store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1587
|
+
if (input.idempotencyKey) {
|
|
1588
|
+
store.reservationKeys.set(
|
|
1589
|
+
scopedKey(input.userId, input.idempotencyKey),
|
|
1590
|
+
reservation.id
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
credits.reserved = nextReserved;
|
|
1594
|
+
credits.updatedAt = now;
|
|
1595
|
+
return { outcome: "created", reservation: copyRecord(reservation) };
|
|
1596
|
+
});
|
|
698
1597
|
}
|
|
699
|
-
function
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1598
|
+
async function commitReservationV2(store, userId, reservationId, options) {
|
|
1599
|
+
return store.locks.run(userId, async () => {
|
|
1600
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1601
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1602
|
+
assertTrustworthy(reservation, "commit");
|
|
1603
|
+
if (reservation.status !== "reserved") {
|
|
1604
|
+
return {
|
|
1605
|
+
outcome: "already_terminal",
|
|
1606
|
+
reservation: copyRecord(reservation),
|
|
1607
|
+
terminalStatus: terminalStatusOf2(reservation, "commit")
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
const credits = requireUser(store, userId);
|
|
1611
|
+
const amount = reservation.amount;
|
|
1612
|
+
await yieldPoint(store);
|
|
1613
|
+
assertReservedCoversHold(credits, userId, reservationId, amount);
|
|
1614
|
+
if (sumAmounts(credits.balance, credits.bonusCredits) < amount) {
|
|
1615
|
+
throw new CreditError(
|
|
1616
|
+
`Insufficient credits to commit reservation ${reservationId}`,
|
|
1617
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
1618
|
+
{ userId, reservationId, required: amount }
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
const fromBalance = Math.min(credits.balance, amount);
|
|
1622
|
+
const nextBalance = sumAmounts(credits.balance, -fromBalance);
|
|
1623
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -sumAmounts(amount, -fromBalance));
|
|
1624
|
+
const balanceAfter = sumAmounts(nextBalance, nextBonusCredits);
|
|
1625
|
+
const journal = {
|
|
1626
|
+
userId,
|
|
1627
|
+
operation: "commitReservation",
|
|
1628
|
+
entryType: "debit",
|
|
1629
|
+
amount,
|
|
1630
|
+
balanceAfter,
|
|
1631
|
+
source: "operation_commit",
|
|
1632
|
+
reservationId,
|
|
1633
|
+
description: options?.description ?? `Committed ${amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1634
|
+
// Caller metadata first: the deterministic fields carry the identity of
|
|
1635
|
+
// the event that the collision check compares on.
|
|
1636
|
+
metadata: { ...options?.metadata, operationType: reservation.operationType, amount },
|
|
1637
|
+
idempotencyKey: reservationJournalKey(reservationId, "commit")
|
|
1638
|
+
};
|
|
1639
|
+
const nextReserved = sumAmounts(credits.reserved, -amount);
|
|
1640
|
+
const nextMonthlyUsed = sumAmounts(credits.monthlyUsed, amount);
|
|
1641
|
+
assertRepresentableFields(
|
|
1642
|
+
{
|
|
1643
|
+
balance: nextBalance,
|
|
1644
|
+
bonusCredits: nextBonusCredits,
|
|
1645
|
+
reserved: nextReserved,
|
|
1646
|
+
monthlyUsed: nextMonthlyUsed
|
|
1647
|
+
},
|
|
1648
|
+
{ userId, operation: "commitReservation" }
|
|
1649
|
+
);
|
|
1650
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1651
|
+
reservation.status = "committed";
|
|
1652
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1653
|
+
credits.balance = nextBalance;
|
|
1654
|
+
credits.bonusCredits = nextBonusCredits;
|
|
1655
|
+
credits.reserved = nextReserved;
|
|
1656
|
+
credits.monthlyUsed = nextMonthlyUsed;
|
|
1657
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1658
|
+
const journalEntryId = commitTransitionJournal(store, journal, existingEntryId);
|
|
1659
|
+
return {
|
|
1660
|
+
outcome: "committed",
|
|
1661
|
+
reservation: copyRecord(reservation),
|
|
1662
|
+
amount,
|
|
1663
|
+
balanceAfter,
|
|
1664
|
+
journalEntryId
|
|
1665
|
+
};
|
|
1666
|
+
});
|
|
707
1667
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1668
|
+
async function releaseReservationV2(store, userId, reservationId, options) {
|
|
1669
|
+
return store.locks.run(userId, async () => {
|
|
1670
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1671
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1672
|
+
assertTrustworthy(reservation, "release");
|
|
1673
|
+
if (reservation.status !== "reserved") {
|
|
1674
|
+
return {
|
|
1675
|
+
outcome: "already_terminal",
|
|
1676
|
+
reservation: copyRecord(reservation),
|
|
1677
|
+
terminalStatus: terminalStatusOf2(reservation, "release")
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
await yieldPoint(store);
|
|
1681
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1682
|
+
status: "released",
|
|
1683
|
+
source: "operation_release",
|
|
1684
|
+
transition: "release",
|
|
1685
|
+
verb: "Released",
|
|
1686
|
+
options
|
|
1687
|
+
});
|
|
1688
|
+
return {
|
|
1689
|
+
outcome: "released",
|
|
1690
|
+
reservation: copyRecord(reservation),
|
|
1691
|
+
amount: reservation.amount,
|
|
1692
|
+
journalEntryId
|
|
1693
|
+
};
|
|
1694
|
+
});
|
|
712
1695
|
}
|
|
713
|
-
function
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1696
|
+
async function expireReservationV2(store, userId, reservationId, options) {
|
|
1697
|
+
const asOf = options?.asOf ?? /* @__PURE__ */ new Date();
|
|
1698
|
+
return store.locks.run(userId, async () => {
|
|
1699
|
+
const reservation = getReservation(store, userId, reservationId);
|
|
1700
|
+
if (!reservation) return { outcome: "not_found", reservationId };
|
|
1701
|
+
assertTrustworthy(reservation, "expire");
|
|
1702
|
+
if (reservation.status !== "reserved") {
|
|
1703
|
+
return {
|
|
1704
|
+
outcome: "already_terminal",
|
|
1705
|
+
reservation: copyRecord(reservation),
|
|
1706
|
+
terminalStatus: terminalStatusOf2(reservation, "expire")
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
if (new Date(reservation.expiresAt).getTime() > asOf.getTime()) {
|
|
1710
|
+
return { outcome: "not_due", reservation: copyRecord(reservation) };
|
|
1711
|
+
}
|
|
1712
|
+
await yieldPoint(store);
|
|
1713
|
+
const journalEntryId = finishUnspent(store, reservation, {
|
|
1714
|
+
status: "expired",
|
|
1715
|
+
source: "reservation_expired",
|
|
1716
|
+
transition: "expire",
|
|
1717
|
+
verb: "Expired",
|
|
1718
|
+
options
|
|
1719
|
+
});
|
|
1720
|
+
return {
|
|
1721
|
+
outcome: "expired",
|
|
1722
|
+
reservation: copyRecord(reservation),
|
|
1723
|
+
amount: reservation.amount,
|
|
1724
|
+
journalEntryId
|
|
1725
|
+
};
|
|
1726
|
+
});
|
|
720
1727
|
}
|
|
721
|
-
function
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1728
|
+
function finishUnspent(store, reservation, transition) {
|
|
1729
|
+
const credits = requireUser(store, reservation.userId);
|
|
1730
|
+
assertReservedCoversHold(
|
|
1731
|
+
credits,
|
|
1732
|
+
reservation.userId,
|
|
1733
|
+
reservation.id,
|
|
1734
|
+
reservation.amount
|
|
1735
|
+
);
|
|
1736
|
+
const journal = {
|
|
1737
|
+
userId: reservation.userId,
|
|
1738
|
+
operation: `${transition.transition}Reservation`,
|
|
1739
|
+
entryType: "credit",
|
|
1740
|
+
amount: 0,
|
|
1741
|
+
balanceAfter: sumAmounts(credits.balance, credits.bonusCredits),
|
|
1742
|
+
source: transition.source,
|
|
1743
|
+
reservationId: reservation.id,
|
|
1744
|
+
description: transition.options?.description ?? `${transition.verb} ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1745
|
+
metadata: {
|
|
1746
|
+
...transition.options?.metadata,
|
|
1747
|
+
operationType: reservation.operationType,
|
|
1748
|
+
amount: reservation.amount
|
|
1749
|
+
},
|
|
1750
|
+
idempotencyKey: reservationJournalKey(reservation.id, transition.transition)
|
|
1751
|
+
};
|
|
1752
|
+
const nextReserved = sumAmounts(credits.reserved, -reservation.amount);
|
|
1753
|
+
assertRepresentableFields(
|
|
1754
|
+
{ reserved: nextReserved },
|
|
1755
|
+
{ userId: reservation.userId, operation: `${transition.transition}Reservation` }
|
|
1756
|
+
);
|
|
1757
|
+
const existingEntryId = planTransitionJournal(store, journal);
|
|
1758
|
+
reservation.status = transition.status;
|
|
1759
|
+
reservation.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1760
|
+
credits.reserved = nextReserved;
|
|
1761
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1762
|
+
return commitTransitionJournal(store, journal, existingEntryId);
|
|
727
1763
|
}
|
|
728
1764
|
|
|
729
1765
|
// src/repository/memory/index.ts
|
|
1766
|
+
function isoOf(value) {
|
|
1767
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
1768
|
+
}
|
|
730
1769
|
var InMemoryCreditRepository = class {
|
|
731
|
-
|
|
732
|
-
reservations = /* @__PURE__ */ new Map();
|
|
733
|
-
transactions = /* @__PURE__ */ new Map();
|
|
734
|
-
usageLogs = [];
|
|
735
|
-
journalEntries = /* @__PURE__ */ new Map();
|
|
1770
|
+
store = new MemoryStore();
|
|
736
1771
|
// ==================== User Credits ====================
|
|
1772
|
+
/**
|
|
1773
|
+
* A copy, never the live record.
|
|
1774
|
+
*
|
|
1775
|
+
* Handing out the stored object made this adapter behave unlike any real
|
|
1776
|
+
* one: a caller holding a "before" snapshot saw it mutate under them. The
|
|
1777
|
+
* service's monthly reset compared `resetResult.credits.balance` against the
|
|
1778
|
+
* balance it had read moments earlier, got zero because both were the same
|
|
1779
|
+
* object, and silently skipped the reset's journal entry — while the SQL
|
|
1780
|
+
* adapter, which maps each row into a fresh object, wrote it.
|
|
1781
|
+
*/
|
|
737
1782
|
async getUserCredits(userId) {
|
|
738
|
-
|
|
1783
|
+
const credits = this.store.users.get(userId);
|
|
1784
|
+
return copyRecord(credits) ?? null;
|
|
739
1785
|
}
|
|
740
1786
|
async initializeUserCredits(userId, tier, initialBalance) {
|
|
1787
|
+
return copyRecord(this.seedUser(userId, tier, initialBalance));
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* The synchronous body of `initializeUserCredits`.
|
|
1791
|
+
*
|
|
1792
|
+
* Kept `await`-free on purpose: `addCreditsV2` seeds a missing user *inside*
|
|
1793
|
+
* its critical section, and an `await` between the payment-reference check
|
|
1794
|
+
* and the transaction write opens a gap another caller can run through — two
|
|
1795
|
+
* simultaneous first deliveries of the same reference would then both credit.
|
|
1796
|
+
*/
|
|
1797
|
+
seedUser(userId, tier, initialBalance) {
|
|
1798
|
+
assertRepresentableAmount(initialBalance, "initialBalance", { userId });
|
|
1799
|
+
const configured = getConfigMonthlyLimit(tier);
|
|
1800
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", { userId, tier });
|
|
1801
|
+
const monthlyLimit = storedMonthlyLimit(configured);
|
|
741
1802
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
742
1803
|
const credits = {
|
|
743
1804
|
userId,
|
|
@@ -745,61 +1806,93 @@ var InMemoryCreditRepository = class {
|
|
|
745
1806
|
bonusCredits: 0,
|
|
746
1807
|
reserved: 0,
|
|
747
1808
|
tier,
|
|
748
|
-
monthlyLimit
|
|
1809
|
+
monthlyLimit,
|
|
749
1810
|
monthlyUsed: 0,
|
|
750
1811
|
monthlyResetAt: getNextMonthlyReset().toISOString(),
|
|
751
1812
|
subscriptionExpiresAt: null,
|
|
752
1813
|
createdAt: now,
|
|
753
1814
|
updatedAt: now
|
|
754
1815
|
};
|
|
755
|
-
this.users.set(userId, credits);
|
|
1816
|
+
this.store.users.set(userId, credits);
|
|
756
1817
|
return credits;
|
|
757
1818
|
}
|
|
758
1819
|
async updateUserCredits(userId, updates) {
|
|
759
|
-
|
|
1820
|
+
assertRepresentableFields(
|
|
1821
|
+
{
|
|
1822
|
+
balance: updates.balance,
|
|
1823
|
+
bonusCredits: updates.bonusCredits,
|
|
1824
|
+
reserved: updates.reserved,
|
|
1825
|
+
monthlyLimit: updates.monthlyLimit,
|
|
1826
|
+
monthlyUsed: updates.monthlyUsed,
|
|
1827
|
+
balanceIncrement: updates.balanceIncrement,
|
|
1828
|
+
bonusCreditsIncrement: updates.bonusCreditsIncrement,
|
|
1829
|
+
reservedIncrement: updates.reservedIncrement,
|
|
1830
|
+
monthlyUsedIncrement: updates.monthlyUsedIncrement
|
|
1831
|
+
},
|
|
1832
|
+
{ userId, operation: "updateUserCredits" }
|
|
1833
|
+
);
|
|
1834
|
+
const credits = this.store.users.get(userId);
|
|
760
1835
|
if (!credits) {
|
|
761
1836
|
throw new Error(`User ${userId} not found`);
|
|
762
1837
|
}
|
|
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)
|
|
1838
|
+
const next = { ...credits };
|
|
1839
|
+
if (updates.balance !== void 0) next.balance = updates.balance;
|
|
1840
|
+
if (updates.bonusCredits !== void 0) next.bonusCredits = updates.bonusCredits;
|
|
1841
|
+
if (updates.reserved !== void 0) next.reserved = updates.reserved;
|
|
1842
|
+
if (updates.tier !== void 0) next.tier = updates.tier;
|
|
1843
|
+
if (updates.monthlyLimit !== void 0) next.monthlyLimit = updates.monthlyLimit;
|
|
1844
|
+
if (updates.monthlyUsed !== void 0) next.monthlyUsed = updates.monthlyUsed;
|
|
770
1845
|
if (updates.monthlyResetAt !== void 0) {
|
|
771
|
-
|
|
1846
|
+
next.monthlyResetAt = isoOf(updates.monthlyResetAt);
|
|
772
1847
|
}
|
|
773
1848
|
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
|
-
}
|
|
1849
|
+
next.subscriptionExpiresAt = updates.subscriptionExpiresAt === null ? null : isoOf(updates.subscriptionExpiresAt);
|
|
779
1850
|
}
|
|
780
1851
|
if (updates.balanceIncrement !== void 0) {
|
|
781
|
-
|
|
1852
|
+
next.balance = sumAmounts(next.balance, updates.balanceIncrement);
|
|
782
1853
|
}
|
|
783
1854
|
if (updates.bonusCreditsIncrement !== void 0) {
|
|
784
|
-
|
|
1855
|
+
next.bonusCredits = sumAmounts(next.bonusCredits, updates.bonusCreditsIncrement);
|
|
785
1856
|
}
|
|
786
1857
|
if (updates.reservedIncrement !== void 0) {
|
|
787
|
-
|
|
1858
|
+
next.reserved = sumAmounts(next.reserved, updates.reservedIncrement);
|
|
788
1859
|
}
|
|
789
1860
|
if (updates.monthlyUsedIncrement !== void 0) {
|
|
790
|
-
|
|
1861
|
+
next.monthlyUsed = sumAmounts(next.monthlyUsed, updates.monthlyUsedIncrement);
|
|
791
1862
|
}
|
|
792
|
-
|
|
793
|
-
|
|
1863
|
+
assertRepresentableFields(
|
|
1864
|
+
{
|
|
1865
|
+
balance: next.balance,
|
|
1866
|
+
bonusCredits: next.bonusCredits,
|
|
1867
|
+
reserved: next.reserved,
|
|
1868
|
+
monthlyUsed: next.monthlyUsed
|
|
1869
|
+
},
|
|
1870
|
+
{ userId, operation: "updateUserCredits" }
|
|
1871
|
+
);
|
|
1872
|
+
assertRepresentableTierAmount(next.monthlyLimit, "monthlyLimit", {
|
|
1873
|
+
userId,
|
|
1874
|
+
operation: "updateUserCredits"
|
|
1875
|
+
});
|
|
1876
|
+
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1877
|
+
this.store.users.set(userId, next);
|
|
794
1878
|
}
|
|
795
1879
|
async updateUserTier(userId, input) {
|
|
796
|
-
|
|
1880
|
+
assertRepresentableFields(
|
|
1881
|
+
{ monthlyLimit: input.monthlyLimit, balance: input.balance, monthlyUsed: input.monthlyUsed },
|
|
1882
|
+
{ userId, operation: "updateUserTier" }
|
|
1883
|
+
);
|
|
1884
|
+
const credits = this.store.users.get(userId);
|
|
797
1885
|
if (!credits) {
|
|
798
1886
|
throw new Error(`User ${userId} not found`);
|
|
799
1887
|
}
|
|
800
1888
|
credits.tier = input.tier;
|
|
801
1889
|
credits.monthlyLimit = input.monthlyLimit;
|
|
802
|
-
if (input.balance !== void 0)
|
|
1890
|
+
if (input.balance !== void 0) {
|
|
1891
|
+
credits.balance = Math.max(
|
|
1892
|
+
input.balance,
|
|
1893
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
803
1896
|
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
804
1897
|
if (input.subscriptionExpiresAt !== void 0) {
|
|
805
1898
|
if (input.subscriptionExpiresAt === null) {
|
|
@@ -809,10 +1902,12 @@ var InMemoryCreditRepository = class {
|
|
|
809
1902
|
}
|
|
810
1903
|
}
|
|
811
1904
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
812
|
-
this.users.set(userId, credits);
|
|
1905
|
+
this.store.users.set(userId, credits);
|
|
813
1906
|
}
|
|
814
1907
|
// ==================== Reservations ====================
|
|
815
1908
|
async createReservation(input) {
|
|
1909
|
+
assertValidCreditAmount(input.amount, { userId: input.userId, operation: "createReservation" });
|
|
1910
|
+
assertUnkeyedDirectReservation(input);
|
|
816
1911
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
817
1912
|
const reservation = {
|
|
818
1913
|
id: generateId(),
|
|
@@ -823,19 +1918,19 @@ var InMemoryCreditRepository = class {
|
|
|
823
1918
|
createdAt: now,
|
|
824
1919
|
expiresAt: input.expiresAt.toISOString()
|
|
825
1920
|
};
|
|
826
|
-
if (!this.reservations.has(input.userId)) {
|
|
827
|
-
this.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
1921
|
+
if (!this.store.reservations.has(input.userId)) {
|
|
1922
|
+
this.store.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
828
1923
|
}
|
|
829
|
-
this.reservations.get(input.userId).set(reservation.id, reservation);
|
|
830
|
-
return reservation;
|
|
1924
|
+
this.store.reservations.get(input.userId).set(reservation.id, reservation);
|
|
1925
|
+
return copyRecord(reservation);
|
|
831
1926
|
}
|
|
832
1927
|
async getReservation(userId, reservationId) {
|
|
833
|
-
const userReservations = this.reservations.get(userId);
|
|
1928
|
+
const userReservations = this.store.reservations.get(userId);
|
|
834
1929
|
if (!userReservations) return null;
|
|
835
|
-
return userReservations.get(reservationId) ?? null;
|
|
1930
|
+
return copyRecord(userReservations.get(reservationId)) ?? null;
|
|
836
1931
|
}
|
|
837
1932
|
async updateReservationStatus(userId, reservationId, status, completedAt) {
|
|
838
|
-
const userReservations = this.reservations.get(userId);
|
|
1933
|
+
const userReservations = this.store.reservations.get(userId);
|
|
839
1934
|
if (!userReservations) {
|
|
840
1935
|
throw new Error(`No reservations found for user ${userId}`);
|
|
841
1936
|
}
|
|
@@ -843,86 +1938,125 @@ var InMemoryCreditRepository = class {
|
|
|
843
1938
|
if (!reservation) {
|
|
844
1939
|
throw new Error(`Reservation ${reservationId} not found`);
|
|
845
1940
|
}
|
|
1941
|
+
assertDirectStatusWriteAllowed(reservation, status);
|
|
846
1942
|
reservation.status = status;
|
|
847
1943
|
if (completedAt) reservation.completedAt = completedAt.toISOString();
|
|
848
1944
|
userReservations.set(reservationId, reservation);
|
|
849
1945
|
}
|
|
850
1946
|
// ==================== Atomic Operations ====================
|
|
1947
|
+
// ==================== V2 boundary ====================
|
|
1948
|
+
//
|
|
1949
|
+
// As in the Drizzle adapter, V2 is the real implementation and the legacy
|
|
1950
|
+
// `*Atomic` methods are adapters over it, so both APIs share one set of
|
|
1951
|
+
// guarantees.
|
|
1952
|
+
async reserveCreditsV2(input) {
|
|
1953
|
+
return reserveCreditsV2(this.store, input);
|
|
1954
|
+
}
|
|
1955
|
+
async commitReservationV2(userId, reservationId, options) {
|
|
1956
|
+
return commitReservationV2(this.store, userId, reservationId, options);
|
|
1957
|
+
}
|
|
1958
|
+
async releaseReservationV2(userId, reservationId, options) {
|
|
1959
|
+
return releaseReservationV2(this.store, userId, reservationId, options);
|
|
1960
|
+
}
|
|
1961
|
+
async expireReservationV2(userId, reservationId, options) {
|
|
1962
|
+
return expireReservationV2(this.store, userId, reservationId, options);
|
|
1963
|
+
}
|
|
1964
|
+
// ==================== Legacy atomic operations ====================
|
|
851
1965
|
async reserveCreditsAtomic(userId, amount, operationType, expiresAt) {
|
|
852
|
-
const
|
|
853
|
-
if (
|
|
854
|
-
|
|
1966
|
+
const outcome = await this.reserveCreditsV2({ userId, amount, operationType, expiresAt });
|
|
1967
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
1968
|
+
return outcome.reservation;
|
|
855
1969
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
throw new Error(
|
|
859
|
-
`Insufficient credits. Available: ${available}, Required: ${amount}`
|
|
860
|
-
);
|
|
1970
|
+
if (outcome.outcome === "insufficient") {
|
|
1971
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
861
1972
|
}
|
|
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;
|
|
1973
|
+
throw createReservationAlreadyProcessedError(outcome.existing.id, outcome.existing.status);
|
|
872
1974
|
}
|
|
873
1975
|
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());
|
|
1976
|
+
const outcome = await this.commitReservationV2(userId, reservationId);
|
|
1977
|
+
if (outcome.outcome === "committed") return;
|
|
1978
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1979
|
+
if (outcome.terminalStatus === "committed") return;
|
|
1980
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
897
1981
|
}
|
|
898
1982
|
async releaseReservationAtomic(userId, reservationId) {
|
|
899
|
-
const
|
|
900
|
-
if (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1983
|
+
const outcome = await this.releaseReservationV2(userId, reservationId);
|
|
1984
|
+
if (outcome.outcome === "not_found") throw createReservationNotFoundError(reservationId);
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* The stored transaction carrying this reference, in *any* user's ledger.
|
|
1988
|
+
*
|
|
1989
|
+
* Deliberately not scoped to one user. The SQL adapter's unique index covers
|
|
1990
|
+
* `payment_ref` alone, so searching only the crediting user's own history made
|
|
1991
|
+
* the same reference credit two different accounts here while the SQL adapter
|
|
1992
|
+
* treated the second delivery as a duplicate. See `core/payment-ref.ts`.
|
|
1993
|
+
*/
|
|
1994
|
+
findByPaymentRef(paymentRef) {
|
|
1995
|
+
for (const recorded of this.store.transactions.values()) {
|
|
1996
|
+
const hit = recorded.find((transaction) => transaction.paymentRef === paymentRef);
|
|
1997
|
+
if (hit) return hit;
|
|
909
1998
|
}
|
|
910
|
-
|
|
911
|
-
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
912
|
-
this.users.set(userId, credits);
|
|
913
|
-
await this.updateReservationStatus(userId, reservationId, "released", /* @__PURE__ */ new Date());
|
|
1999
|
+
return void 0;
|
|
914
2000
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
2001
|
+
/** The journal entry written alongside a stored purchase, if there is one. */
|
|
2002
|
+
journalForTransaction(transaction) {
|
|
2003
|
+
return this.store.journalEntries.get(transaction.userId)?.find((entry) => entry.referenceId === transaction.id);
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Credit an account, resolving `paymentRef` to created / replayed / conflict.
|
|
2007
|
+
*
|
|
2008
|
+
* The reference is checked and the transaction is recorded with no `await`
|
|
2009
|
+
* between them. `createTransaction` is `async` but its body is synchronous, so
|
|
2010
|
+
* under JavaScript's run-to-completion semantics no other caller can observe
|
|
2011
|
+
* the gap — which is what stands in for the SQL adapter's unique index.
|
|
2012
|
+
*
|
|
2013
|
+
* A `replayed` or `conflict` outcome writes nothing at all: the check runs
|
|
2014
|
+
* before the balance moves, not after.
|
|
2015
|
+
*/
|
|
2016
|
+
async addCreditsV2(input) {
|
|
2017
|
+
const { userId, amount, description, options } = input;
|
|
2018
|
+
assertValidCreditAmount(amount, { userId, operation: "addCredits" });
|
|
2019
|
+
const paymentRef = normalizePaymentRef(input.paymentRef);
|
|
2020
|
+
const payload = {
|
|
2021
|
+
userId,
|
|
2022
|
+
amount,
|
|
2023
|
+
type: "purchase",
|
|
2024
|
+
source: options?.source ?? "purchase",
|
|
2025
|
+
referenceType: options?.referenceType ?? "transaction"
|
|
2026
|
+
};
|
|
2027
|
+
if (paymentRef) {
|
|
2028
|
+
const existing = this.findByPaymentRef(paymentRef);
|
|
2029
|
+
if (existing) {
|
|
2030
|
+
const journal = this.journalForTransaction(existing);
|
|
2031
|
+
const mismatch = describePaymentMismatch(
|
|
2032
|
+
{
|
|
2033
|
+
userId: existing.userId,
|
|
2034
|
+
amount: existing.amount,
|
|
2035
|
+
type: existing.type,
|
|
2036
|
+
source: journal?.source,
|
|
2037
|
+
referenceType: journal?.referenceType
|
|
2038
|
+
},
|
|
2039
|
+
payload
|
|
2040
|
+
);
|
|
2041
|
+
return mismatch === null ? { outcome: "replayed", paymentRef, transaction: copyRecord(existing) } : { outcome: "conflict", paymentRef, existing: copyRecord(existing), mismatch };
|
|
2042
|
+
}
|
|
919
2043
|
}
|
|
920
|
-
|
|
921
|
-
|
|
2044
|
+
if (!this.store.users.has(userId)) {
|
|
2045
|
+
const tier = getDefaultTier();
|
|
2046
|
+
this.seedUser(userId, tier, storedMonthlyLimit(getConfigMonthlyLimit(tier)));
|
|
2047
|
+
}
|
|
2048
|
+
const credits = this.store.users.get(userId);
|
|
2049
|
+
const previousTotal = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2050
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, amount);
|
|
2051
|
+
const newTotal = sumAmounts(credits.balance, nextBonusCredits);
|
|
2052
|
+
assertRepresentableFields(
|
|
2053
|
+
{ previousBalance: previousTotal, bonusCredits: nextBonusCredits, newBalance: newTotal },
|
|
2054
|
+
{ userId, operation: "addCredits" }
|
|
2055
|
+
);
|
|
2056
|
+
credits.bonusCredits = nextBonusCredits;
|
|
922
2057
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
923
|
-
this.users.set(userId, credits);
|
|
924
|
-
const
|
|
925
|
-
const transaction = await this.createTransaction({
|
|
2058
|
+
this.store.users.set(userId, credits);
|
|
2059
|
+
const transaction = this.recordTransaction({
|
|
926
2060
|
userId,
|
|
927
2061
|
type: "purchase",
|
|
928
2062
|
amount,
|
|
@@ -935,43 +2069,88 @@ var InMemoryCreditRepository = class {
|
|
|
935
2069
|
...paymentRef ? { paymentRef } : {},
|
|
936
2070
|
...options?.metadata ?? {}
|
|
937
2071
|
};
|
|
938
|
-
await this.createJournalEntry({
|
|
2072
|
+
const entry = await this.createJournalEntry({
|
|
939
2073
|
userId,
|
|
940
2074
|
entryType: "credit",
|
|
941
2075
|
amount,
|
|
942
2076
|
balanceAfter: newTotal,
|
|
943
|
-
source:
|
|
2077
|
+
source: payload.source,
|
|
944
2078
|
referenceId: transaction.id ?? paymentRef ?? "unknown",
|
|
945
|
-
referenceType:
|
|
2079
|
+
referenceType: payload.referenceType,
|
|
946
2080
|
description,
|
|
947
2081
|
metadata: Object.keys(journalMetadata).length > 0 ? journalMetadata : void 0
|
|
948
2082
|
});
|
|
2083
|
+
return paymentRef ? { outcome: "created", paymentRef, transaction, journalEntryId: entry.id } : { outcome: "created", transaction, journalEntryId: entry.id };
|
|
949
2084
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
2085
|
+
/**
|
|
2086
|
+
* The legacy signature, on top of {@link addCreditsV2}.
|
|
2087
|
+
*
|
|
2088
|
+
* `Promise<void>` has nowhere to report a conflict, and silently returning is
|
|
2089
|
+
* indistinguishable from "credited" — the exact failure this round is closing
|
|
2090
|
+
* — so a conflict throws. A genuine replay still returns quietly, because
|
|
2091
|
+
* that *is* the idempotent no-op the caller asked for.
|
|
2092
|
+
*/
|
|
2093
|
+
async addCreditsAtomic(userId, amount, description, paymentRef, options) {
|
|
2094
|
+
const outcome = await this.addCreditsV2({
|
|
2095
|
+
userId,
|
|
2096
|
+
amount,
|
|
2097
|
+
description,
|
|
2098
|
+
paymentRef,
|
|
2099
|
+
options
|
|
2100
|
+
});
|
|
2101
|
+
if (outcome.outcome === "conflict") {
|
|
2102
|
+
throw createPaymentRefConflictError(outcome.paymentRef, {
|
|
2103
|
+
userId,
|
|
2104
|
+
mismatch: outcome.mismatch,
|
|
2105
|
+
existingUserId: outcome.existing.userId
|
|
2106
|
+
});
|
|
953
2107
|
}
|
|
954
|
-
|
|
2108
|
+
}
|
|
2109
|
+
async deductCreditsAtomic(userId, amount) {
|
|
2110
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
2111
|
+
const credits = this.store.users.get(userId);
|
|
955
2112
|
if (!credits) {
|
|
956
2113
|
throw new Error(`User credits not found for userId: ${userId}`);
|
|
957
2114
|
}
|
|
958
|
-
const available = credits.balance
|
|
2115
|
+
const available = sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved);
|
|
959
2116
|
if (available < amount) {
|
|
960
2117
|
throw new Error(
|
|
961
2118
|
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
962
2119
|
);
|
|
963
2120
|
}
|
|
964
2121
|
const balanceDeduction = Math.min(credits.balance, amount);
|
|
965
|
-
const bonusDeduction = amount -
|
|
966
|
-
const previousBalance = credits.balance
|
|
967
|
-
credits.balance
|
|
968
|
-
credits.bonusCredits
|
|
2122
|
+
const bonusDeduction = sumAmounts(amount, -balanceDeduction);
|
|
2123
|
+
const previousBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2124
|
+
const nextBalance = sumAmounts(credits.balance, -balanceDeduction);
|
|
2125
|
+
const nextBonusCredits = sumAmounts(credits.bonusCredits, -bonusDeduction);
|
|
2126
|
+
const newBalance = sumAmounts(previousBalance, -amount);
|
|
2127
|
+
assertRepresentableFields(
|
|
2128
|
+
{ previousBalance, balance: nextBalance, bonusCredits: nextBonusCredits, newBalance },
|
|
2129
|
+
{ userId, operation: "deductCredits" }
|
|
2130
|
+
);
|
|
2131
|
+
credits.balance = nextBalance;
|
|
2132
|
+
credits.bonusCredits = nextBonusCredits;
|
|
969
2133
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
970
|
-
this.users.set(userId, credits);
|
|
971
|
-
return { previousBalance, newBalance
|
|
2134
|
+
this.store.users.set(userId, credits);
|
|
2135
|
+
return { previousBalance, newBalance };
|
|
972
2136
|
}
|
|
973
2137
|
// ==================== Transactions ====================
|
|
974
2138
|
async createTransaction(input) {
|
|
2139
|
+
assertUnreferencedDirectTransaction(input);
|
|
2140
|
+
return this.recordTransaction({
|
|
2141
|
+
...input,
|
|
2142
|
+
paymentRef: normalizePaymentRef(input.paymentRef)
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
/** The unguarded writer, for `addCreditsV2` — which has moved the balance. */
|
|
2146
|
+
recordTransaction(input) {
|
|
2147
|
+
assertRepresentableAmount(input.amount, "transaction amount", { userId: input.userId });
|
|
2148
|
+
assertRepresentableAmount(input.previousBalance, "transaction previousBalance", {
|
|
2149
|
+
userId: input.userId
|
|
2150
|
+
});
|
|
2151
|
+
assertRepresentableAmount(input.newBalance, "transaction newBalance", {
|
|
2152
|
+
userId: input.userId
|
|
2153
|
+
});
|
|
975
2154
|
const transaction = {
|
|
976
2155
|
id: generateId(),
|
|
977
2156
|
userId: input.userId,
|
|
@@ -983,23 +2162,24 @@ var InMemoryCreditRepository = class {
|
|
|
983
2162
|
newBalance: input.newBalance,
|
|
984
2163
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
985
2164
|
};
|
|
986
|
-
if (!this.transactions.has(input.userId)) {
|
|
987
|
-
this.transactions.set(input.userId, []);
|
|
2165
|
+
if (!this.store.transactions.has(input.userId)) {
|
|
2166
|
+
this.store.transactions.set(input.userId, []);
|
|
988
2167
|
}
|
|
989
|
-
this.transactions.get(input.userId).push(transaction);
|
|
990
|
-
return transaction;
|
|
2168
|
+
this.store.transactions.get(input.userId).push(transaction);
|
|
2169
|
+
return copyRecord(transaction);
|
|
991
2170
|
}
|
|
992
2171
|
async getTransactions(userId, limit = 50, offset = 0) {
|
|
993
|
-
const userTransactions = this.transactions.get(userId) ?? [];
|
|
2172
|
+
const userTransactions = this.store.transactions.get(userId) ?? [];
|
|
994
2173
|
const sorted = [...userTransactions].sort((a, b) => {
|
|
995
2174
|
const aDate = toDate2(a.createdAt).getTime();
|
|
996
2175
|
const bDate = toDate2(b.createdAt).getTime();
|
|
997
2176
|
return bDate - aDate;
|
|
998
2177
|
});
|
|
999
|
-
return sorted.slice(offset, offset + limit);
|
|
2178
|
+
return copyRecords(sorted.slice(offset, offset + limit));
|
|
1000
2179
|
}
|
|
1001
2180
|
// ==================== Usage Logs ====================
|
|
1002
2181
|
async logUsage(input) {
|
|
2182
|
+
assertRepresentableAmount(input.creditsUsed, "creditsUsed", { userId: input.userId });
|
|
1003
2183
|
const log = {
|
|
1004
2184
|
id: generateId(),
|
|
1005
2185
|
userId: input.userId,
|
|
@@ -1011,14 +2191,14 @@ var InMemoryCreditRepository = class {
|
|
|
1011
2191
|
resourceId: input.resourceId,
|
|
1012
2192
|
resourceType: input.resourceType,
|
|
1013
2193
|
requestId: input.requestId,
|
|
1014
|
-
metadata: input.metadata,
|
|
2194
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
1015
2195
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1016
2196
|
};
|
|
1017
|
-
this.usageLogs.push(log);
|
|
1018
|
-
return log;
|
|
2197
|
+
this.store.usageLogs.push(log);
|
|
2198
|
+
return copyRecord(log);
|
|
1019
2199
|
}
|
|
1020
2200
|
async getUsageLogs(query) {
|
|
1021
|
-
let results = [...this.usageLogs];
|
|
2201
|
+
let results = [...this.store.usageLogs];
|
|
1022
2202
|
if (query.userId) {
|
|
1023
2203
|
results = results.filter((log) => log.userId === query.userId);
|
|
1024
2204
|
}
|
|
@@ -1045,7 +2225,7 @@ var InMemoryCreditRepository = class {
|
|
|
1045
2225
|
});
|
|
1046
2226
|
const offset = query.offset ?? 0;
|
|
1047
2227
|
const limit = query.limit ?? 50;
|
|
1048
|
-
return results.slice(offset, offset + limit);
|
|
2228
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
1049
2229
|
}
|
|
1050
2230
|
async getUsageLogsCount(query) {
|
|
1051
2231
|
const results = await this.getUsageLogs({ ...query, limit: Infinity, offset: 0 });
|
|
@@ -1053,6 +2233,29 @@ var InMemoryCreditRepository = class {
|
|
|
1053
2233
|
}
|
|
1054
2234
|
// ==================== Journal Entries ====================
|
|
1055
2235
|
async createJournalEntry(input) {
|
|
2236
|
+
return this.recordJournalEntry(input);
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* The synchronous body of `createJournalEntry`, so `atomicMonthlyReset` can
|
|
2240
|
+
* write its journal line inside the same run-to-completion step as the
|
|
2241
|
+
* balance write — an `await` between the two is exactly the gap that loses
|
|
2242
|
+
* the line when the second half fails.
|
|
2243
|
+
*/
|
|
2244
|
+
recordJournalEntry(input) {
|
|
2245
|
+
assertRepresentableAmount(input.amount, "journal amount", { userId: input.userId });
|
|
2246
|
+
assertRepresentableAmount(input.balanceAfter, "journal balanceAfter", {
|
|
2247
|
+
userId: input.userId
|
|
2248
|
+
});
|
|
2249
|
+
assertValidIdempotencyKey(input.idempotencyKey, { userId: input.userId });
|
|
2250
|
+
assertPublicJournalKey(input.idempotencyKey, input.userId);
|
|
2251
|
+
const key = input.idempotencyKey !== void 0 ? scopedKey(input.userId, input.idempotencyKey) : void 0;
|
|
2252
|
+
if (key !== void 0 && this.store.journalKeys.has(key)) {
|
|
2253
|
+
throw new CreditError(
|
|
2254
|
+
`Journal idempotency key ${input.idempotencyKey} is already used for user ${input.userId}. The unique constraint refuses a second entry.`,
|
|
2255
|
+
CreditErrorCode.DATABASE_ERROR,
|
|
2256
|
+
{ userId: input.userId, idempotencyKey: input.idempotencyKey }
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
1056
2259
|
const entry = {
|
|
1057
2260
|
id: generateId(),
|
|
1058
2261
|
userId: input.userId,
|
|
@@ -1063,17 +2266,19 @@ var InMemoryCreditRepository = class {
|
|
|
1063
2266
|
referenceId: input.referenceId,
|
|
1064
2267
|
referenceType: input.referenceType,
|
|
1065
2268
|
description: input.description,
|
|
1066
|
-
metadata: input.metadata,
|
|
2269
|
+
metadata: input.metadata ? { ...input.metadata } : input.metadata,
|
|
2270
|
+
idempotencyKey: input.idempotencyKey,
|
|
1067
2271
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1068
2272
|
};
|
|
1069
|
-
if (!this.journalEntries.has(input.userId)) {
|
|
1070
|
-
this.journalEntries.set(input.userId, []);
|
|
2273
|
+
if (!this.store.journalEntries.has(input.userId)) {
|
|
2274
|
+
this.store.journalEntries.set(input.userId, []);
|
|
1071
2275
|
}
|
|
1072
|
-
this.journalEntries.get(input.userId).push(entry);
|
|
1073
|
-
|
|
2276
|
+
this.store.journalEntries.get(input.userId).push(entry);
|
|
2277
|
+
if (key !== void 0) this.store.journalKeys.set(key, entry.id);
|
|
2278
|
+
return copyRecord(entry);
|
|
1074
2279
|
}
|
|
1075
2280
|
async getJournalEntries(query) {
|
|
1076
|
-
let results = this.journalEntries.get(query.userId) ?? [];
|
|
2281
|
+
let results = this.store.journalEntries.get(query.userId) ?? [];
|
|
1077
2282
|
if (query.source) {
|
|
1078
2283
|
results = results.filter((entry) => entry.source === query.source);
|
|
1079
2284
|
}
|
|
@@ -1097,7 +2302,7 @@ var InMemoryCreditRepository = class {
|
|
|
1097
2302
|
});
|
|
1098
2303
|
const offset = query.offset ?? 0;
|
|
1099
2304
|
const limit = query.limit ?? 50;
|
|
1100
|
-
return results.slice(offset, offset + limit);
|
|
2305
|
+
return copyRecords(results.slice(offset, offset + limit));
|
|
1101
2306
|
}
|
|
1102
2307
|
async getJournalEntriesCount(query) {
|
|
1103
2308
|
const results = await this.getJournalEntries({
|
|
@@ -1108,53 +2313,91 @@ var InMemoryCreditRepository = class {
|
|
|
1108
2313
|
return results.length;
|
|
1109
2314
|
}
|
|
1110
2315
|
// ==================== Cleanup Operations ====================
|
|
2316
|
+
/**
|
|
2317
|
+
* Expire every hold whose deadline has passed.
|
|
2318
|
+
*
|
|
2319
|
+
* Each candidate goes through the guarded {@link expireReservationV2}, which
|
|
2320
|
+
* re-checks the status and the deadline under the user's lock. The previous
|
|
2321
|
+
* implementation released the hold and then overwrote the status to
|
|
2322
|
+
* `expired`, so a commit landing in between got its credits handed back and
|
|
2323
|
+
* was then relabelled.
|
|
2324
|
+
*/
|
|
1111
2325
|
async findAndExpireReservations(_batchSize = 100, _maxIterations = 100) {
|
|
1112
|
-
const
|
|
2326
|
+
const asOf = /* @__PURE__ */ new Date();
|
|
1113
2327
|
let expiredCount = 0;
|
|
1114
2328
|
let creditsReleased = 0;
|
|
1115
2329
|
const errors = [];
|
|
1116
|
-
|
|
2330
|
+
const candidates = [];
|
|
2331
|
+
for (const [userId, userReservations] of this.store.reservations) {
|
|
1117
2332
|
for (const [reservationId, reservation] of userReservations) {
|
|
1118
|
-
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() <
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
2333
|
+
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() < asOf.getTime()) {
|
|
2334
|
+
candidates.push({ userId, reservationId });
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
for (const candidate of candidates) {
|
|
2339
|
+
try {
|
|
2340
|
+
const outcome = await this.expireReservationV2(
|
|
2341
|
+
candidate.userId,
|
|
2342
|
+
candidate.reservationId,
|
|
2343
|
+
{ asOf }
|
|
2344
|
+
);
|
|
2345
|
+
if (outcome.outcome === "expired") {
|
|
2346
|
+
expiredCount++;
|
|
2347
|
+
creditsReleased = sumAmounts(creditsReleased, outcome.amount);
|
|
1130
2348
|
}
|
|
2349
|
+
} catch (error) {
|
|
2350
|
+
errors.push(
|
|
2351
|
+
`Failed to expire reservation ${candidate.reservationId}: ${error}`
|
|
2352
|
+
);
|
|
1131
2353
|
}
|
|
1132
2354
|
}
|
|
1133
2355
|
return { expiredCount, creditsReleased, errors };
|
|
1134
2356
|
}
|
|
1135
2357
|
// ==================== Atomic Monthly Reset ====================
|
|
1136
2358
|
async atomicMonthlyReset(userId, tier, expectedResetAt) {
|
|
1137
|
-
const credits = this.users.get(userId);
|
|
2359
|
+
const credits = this.store.users.get(userId);
|
|
1138
2360
|
if (!credits) {
|
|
1139
2361
|
throw new Error(`User ${userId} not found`);
|
|
1140
2362
|
}
|
|
1141
2363
|
const currentResetAt = toDate2(credits.monthlyResetAt).getTime();
|
|
1142
2364
|
const expected = toDate2(expectedResetAt).getTime();
|
|
1143
2365
|
if (currentResetAt !== expected) {
|
|
1144
|
-
return { wasReset: false, credits };
|
|
2366
|
+
return { wasReset: false, credits: { ...credits } };
|
|
1145
2367
|
}
|
|
1146
|
-
|
|
2368
|
+
assertRepresentableTierAmount(getConfigMonthlyLimit(tier), "monthlyLimit", { userId, tier });
|
|
1147
2369
|
const nextReset = getNextMonthlyReset();
|
|
1148
|
-
|
|
2370
|
+
const target = monthlyResetBalance(tier);
|
|
2371
|
+
const floor = backedBalanceFloor(credits.reserved, credits.bonusCredits);
|
|
2372
|
+
const previousBalance = credits.balance;
|
|
2373
|
+
const newBalance = target.kind === "atLeast" ? Math.max(credits.balance, target.value, floor) : Math.max(target.value, floor);
|
|
2374
|
+
const change = sumAmounts(newBalance, -previousBalance);
|
|
2375
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2376
|
+
assertRepresentableAmount(Math.abs(change), "journal amount", { userId });
|
|
2377
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
2378
|
+
credits.balance = newBalance;
|
|
1149
2379
|
credits.monthlyUsed = 0;
|
|
1150
2380
|
credits.monthlyResetAt = nextReset.toISOString();
|
|
1151
2381
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1152
|
-
this.users.set(userId, credits);
|
|
1153
|
-
|
|
2382
|
+
this.store.users.set(userId, credits);
|
|
2383
|
+
if (change !== 0) {
|
|
2384
|
+
this.recordJournalEntry({
|
|
2385
|
+
userId,
|
|
2386
|
+
entryType: change > 0 ? "credit" : "debit",
|
|
2387
|
+
amount: Math.abs(change),
|
|
2388
|
+
balanceAfter,
|
|
2389
|
+
source: "monthly_reset",
|
|
2390
|
+
referenceId: `reset-${Date.now()}`,
|
|
2391
|
+
referenceType: "reset",
|
|
2392
|
+
description: `Monthly credit reset for ${tier} tier.`,
|
|
2393
|
+
metadata: { tier, previousBalance, newBalance }
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
return { wasReset: true, credits: { ...credits }, journaled: true };
|
|
1154
2397
|
}
|
|
1155
2398
|
// ==================== Subscription Expiry ====================
|
|
1156
2399
|
async checkAndHandleSubscriptionExpiry(userId, gracePeriodDays = 3) {
|
|
1157
|
-
const credits = this.users.get(userId);
|
|
2400
|
+
const credits = this.store.users.get(userId);
|
|
1158
2401
|
if (!credits) {
|
|
1159
2402
|
throw new Error(`User ${userId} not found`);
|
|
1160
2403
|
}
|
|
@@ -1163,7 +2406,7 @@ var InMemoryCreditRepository = class {
|
|
|
1163
2406
|
wasDowngraded: false,
|
|
1164
2407
|
inGracePeriod: false,
|
|
1165
2408
|
graceDaysRemaining: 0,
|
|
1166
|
-
credits
|
|
2409
|
+
credits: { ...credits }
|
|
1167
2410
|
};
|
|
1168
2411
|
}
|
|
1169
2412
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1174,7 +2417,7 @@ var InMemoryCreditRepository = class {
|
|
|
1174
2417
|
wasDowngraded: false,
|
|
1175
2418
|
inGracePeriod: false,
|
|
1176
2419
|
graceDaysRemaining: 0,
|
|
1177
|
-
credits
|
|
2420
|
+
credits: { ...credits }
|
|
1178
2421
|
};
|
|
1179
2422
|
}
|
|
1180
2423
|
if (daysSinceExpiry <= gracePeriodDays) {
|
|
@@ -1182,22 +2425,48 @@ var InMemoryCreditRepository = class {
|
|
|
1182
2425
|
wasDowngraded: false,
|
|
1183
2426
|
inGracePeriod: true,
|
|
1184
2427
|
graceDaysRemaining: Math.ceil(gracePeriodDays - daysSinceExpiry),
|
|
1185
|
-
credits
|
|
2428
|
+
credits: { ...credits }
|
|
1186
2429
|
};
|
|
1187
2430
|
}
|
|
1188
2431
|
const defaultTier = getDefaultTier();
|
|
1189
|
-
const
|
|
2432
|
+
const configured = getConfigMonthlyLimit(defaultTier);
|
|
2433
|
+
assertRepresentableTierAmount(configured, "monthlyLimit", {
|
|
2434
|
+
userId,
|
|
2435
|
+
tier: defaultTier
|
|
2436
|
+
});
|
|
2437
|
+
const limit = storedMonthlyLimit(configured);
|
|
2438
|
+
const previousTier = credits.tier;
|
|
2439
|
+
const previousBalance = credits.balance;
|
|
2440
|
+
const newBalance = Math.max(
|
|
2441
|
+
Math.min(credits.balance, limit),
|
|
2442
|
+
backedBalanceFloor(credits.reserved, credits.bonusCredits)
|
|
2443
|
+
);
|
|
2444
|
+
const balanceAfter = sumAmounts(newBalance, credits.bonusCredits);
|
|
2445
|
+
assertRepresentableAmount(balanceAfter, "journal balanceAfter", { userId });
|
|
1190
2446
|
credits.tier = defaultTier;
|
|
1191
|
-
credits.monthlyLimit =
|
|
1192
|
-
credits.balance =
|
|
2447
|
+
credits.monthlyLimit = limit;
|
|
2448
|
+
credits.balance = newBalance;
|
|
1193
2449
|
credits.subscriptionExpiresAt = null;
|
|
1194
2450
|
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1195
|
-
this.users.set(userId, credits);
|
|
2451
|
+
this.store.users.set(userId, credits);
|
|
2452
|
+
this.recordJournalEntry({
|
|
2453
|
+
userId,
|
|
2454
|
+
entryType: "debit",
|
|
2455
|
+
amount: 0,
|
|
2456
|
+
// No credits deducted, just tier change
|
|
2457
|
+
balanceAfter,
|
|
2458
|
+
source: "subscription_downgrade",
|
|
2459
|
+
referenceId: `downgrade-${Date.now()}`,
|
|
2460
|
+
referenceType: "subscription",
|
|
2461
|
+
description: `Subscription expired. Downgraded from ${previousTier} to ${defaultTier} tier.`,
|
|
2462
|
+
metadata: { previousTier, previousBalance, newBalance }
|
|
2463
|
+
});
|
|
1196
2464
|
return {
|
|
1197
2465
|
wasDowngraded: true,
|
|
1198
2466
|
inGracePeriod: false,
|
|
1199
2467
|
graceDaysRemaining: 0,
|
|
1200
|
-
credits
|
|
2468
|
+
credits: { ...credits },
|
|
2469
|
+
journaled: true
|
|
1201
2470
|
};
|
|
1202
2471
|
}
|
|
1203
2472
|
// ==================== Testing Utilities ====================
|
|
@@ -1205,25 +2474,33 @@ var InMemoryCreditRepository = class {
|
|
|
1205
2474
|
* Clear all data (useful for testing)
|
|
1206
2475
|
*/
|
|
1207
2476
|
clear() {
|
|
1208
|
-
this.
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
2477
|
+
this.store.clear();
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Install a yield point inside every V2 critical section (testing only).
|
|
2481
|
+
*
|
|
2482
|
+
* Unset, the critical sections run start-to-finish synchronously, so
|
|
2483
|
+
* concurrent callers never interleave and a concurrency test would pass even
|
|
2484
|
+
* with the locking removed. Setting this to a real yield (a macrotask or a
|
|
2485
|
+
* barrier) makes callers genuinely overlap, so the tests exercise the lock
|
|
2486
|
+
* instead of the event loop. Never call this in production code.
|
|
2487
|
+
*/
|
|
2488
|
+
setSchedulingHook(hook) {
|
|
2489
|
+
this.store.schedulingHook = hook;
|
|
1213
2490
|
}
|
|
1214
2491
|
/**
|
|
1215
2492
|
* Get all users (useful for testing/debugging)
|
|
1216
2493
|
*/
|
|
1217
2494
|
getAllUsers() {
|
|
1218
|
-
return Array.from(this.users.values());
|
|
2495
|
+
return copyRecords(Array.from(this.store.users.values()));
|
|
1219
2496
|
}
|
|
1220
2497
|
/**
|
|
1221
2498
|
* Get all reservations for a user (useful for testing)
|
|
1222
2499
|
*/
|
|
1223
2500
|
getAllReservations(userId) {
|
|
1224
|
-
const userReservations = this.reservations.get(userId);
|
|
2501
|
+
const userReservations = this.store.reservations.get(userId);
|
|
1225
2502
|
if (!userReservations) return [];
|
|
1226
|
-
return Array.from(userReservations.values());
|
|
2503
|
+
return copyRecords(Array.from(userReservations.values()));
|
|
1227
2504
|
}
|
|
1228
2505
|
};
|
|
1229
2506
|
function createInMemoryCreditRepository() {
|
|
@@ -1334,28 +2611,38 @@ var CreditsService = class {
|
|
|
1334
2611
|
userId,
|
|
1335
2612
|
getConfig().subscriptionGracePeriodDays
|
|
1336
2613
|
);
|
|
1337
|
-
if (expiryResult.wasDowngraded) {
|
|
2614
|
+
if (expiryResult.wasDowngraded && !expiryResult.journaled) {
|
|
1338
2615
|
await this.repository.createJournalEntry({
|
|
1339
2616
|
userId,
|
|
1340
2617
|
entryType: "debit",
|
|
1341
2618
|
amount: 0,
|
|
1342
2619
|
// No credits deducted, just tier change
|
|
1343
|
-
|
|
2620
|
+
// The ledger balance, matching every other journal writer: the V2
|
|
2621
|
+
// transitions and add/deduct all record `balance + bonusCredits`, so
|
|
2622
|
+
// recording `balance` alone made the audit trail disagree with itself
|
|
2623
|
+
// for any user holding bonus credits.
|
|
2624
|
+
balanceAfter: sumAmounts(
|
|
2625
|
+
expiryResult.credits.balance,
|
|
2626
|
+
expiryResult.credits.bonusCredits
|
|
2627
|
+
),
|
|
1344
2628
|
source: "subscription_downgrade",
|
|
1345
2629
|
referenceId: `downgrade-${Date.now()}`,
|
|
1346
2630
|
referenceType: "subscription",
|
|
1347
|
-
|
|
2631
|
+
// The tier actually landed on, not a hard-coded "free": the downgrade
|
|
2632
|
+
// target is configurable, so naming `free` here could describe a
|
|
2633
|
+
// transition that never happened.
|
|
2634
|
+
description: `Subscription expired. Downgraded from ${data.tier} to ${expiryResult.credits.tier} tier.`,
|
|
1348
2635
|
metadata: {
|
|
1349
2636
|
previousTier: data.tier,
|
|
1350
2637
|
previousBalance: data.balance,
|
|
1351
2638
|
newBalance: expiryResult.credits.balance
|
|
1352
2639
|
}
|
|
1353
2640
|
});
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
}
|
|
2641
|
+
}
|
|
2642
|
+
if (expiryResult.wasDowngraded && this.subscriptionExpiredCallback) {
|
|
2643
|
+
this.subscriptionExpiredCallback(userId, true).catch((error) => {
|
|
2644
|
+
console.error("[Credits] Failed to send subscription expired notification:", error);
|
|
2645
|
+
});
|
|
1359
2646
|
}
|
|
1360
2647
|
data = expiryResult.credits;
|
|
1361
2648
|
}
|
|
@@ -1366,14 +2653,17 @@ var CreditsService = class {
|
|
|
1366
2653
|
data.tier,
|
|
1367
2654
|
expectedResetAt
|
|
1368
2655
|
);
|
|
1369
|
-
if (resetResult.wasReset) {
|
|
1370
|
-
const balanceChange = resetResult.credits.balance -
|
|
2656
|
+
if (resetResult.wasReset && !resetResult.journaled) {
|
|
2657
|
+
const balanceChange = sumAmounts(resetResult.credits.balance, -data.balance);
|
|
1371
2658
|
if (balanceChange !== 0) {
|
|
1372
2659
|
await this.repository.createJournalEntry({
|
|
1373
2660
|
userId,
|
|
1374
2661
|
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
1375
2662
|
amount: Math.abs(balanceChange),
|
|
1376
|
-
balanceAfter:
|
|
2663
|
+
balanceAfter: sumAmounts(
|
|
2664
|
+
resetResult.credits.balance,
|
|
2665
|
+
resetResult.credits.bonusCredits
|
|
2666
|
+
),
|
|
1377
2667
|
source: "monthly_reset",
|
|
1378
2668
|
referenceId: `reset-${Date.now()}`,
|
|
1379
2669
|
referenceType: "reset",
|
|
@@ -1424,14 +2714,14 @@ var CreditsService = class {
|
|
|
1424
2714
|
*/
|
|
1425
2715
|
async checkCredits(userId, requiredCredits) {
|
|
1426
2716
|
const credits = await this.getOrCreateUserCredits(userId);
|
|
1427
|
-
const totalBalance = credits.balance
|
|
1428
|
-
const available = totalBalance -
|
|
2717
|
+
const totalBalance = sumAmounts(credits.balance, credits.bonusCredits);
|
|
2718
|
+
const available = sumAmounts(totalBalance, -credits.reserved);
|
|
1429
2719
|
const hasCredits = available >= requiredCredits;
|
|
1430
2720
|
return {
|
|
1431
2721
|
hasCredits,
|
|
1432
2722
|
balance: totalBalance,
|
|
1433
2723
|
required: requiredCredits,
|
|
1434
|
-
shortfall: hasCredits ? 0 : requiredCredits -
|
|
2724
|
+
shortfall: hasCredits ? 0 : sumAmounts(requiredCredits, -available)
|
|
1435
2725
|
};
|
|
1436
2726
|
}
|
|
1437
2727
|
/**
|
|
@@ -1445,74 +2735,110 @@ var CreditsService = class {
|
|
|
1445
2735
|
* @throws Error if insufficient credits
|
|
1446
2736
|
*/
|
|
1447
2737
|
async reserveCredits(userId, amount, operationType, options) {
|
|
2738
|
+
const outcome = await this.reserveCreditsDetailed(userId, amount, operationType, options);
|
|
2739
|
+
if (outcome.outcome === "created" || outcome.outcome === "replayed") {
|
|
2740
|
+
return outcome.reservation;
|
|
2741
|
+
}
|
|
2742
|
+
if (outcome.outcome === "insufficient") {
|
|
2743
|
+
throw createInsufficientCreditsError(outcome.required, outcome.available);
|
|
2744
|
+
}
|
|
2745
|
+
throw createIdempotencyConflictError(outcome.idempotencyKey, {
|
|
2746
|
+
userId,
|
|
2747
|
+
requested: { amount, operationType },
|
|
2748
|
+
existing: {
|
|
2749
|
+
amount: outcome.existing.amount,
|
|
2750
|
+
operationType: outcome.existing.operationType
|
|
2751
|
+
}
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
/**
|
|
2755
|
+
* Reserve credits and get the typed outcome instead of an exception.
|
|
2756
|
+
*
|
|
2757
|
+
* Prefer this over {@link reserveCredits} in async callers: it distinguishes
|
|
2758
|
+
* a fresh hold from an idempotent replay, and reports insufficient funds as
|
|
2759
|
+
* a value rather than as control flow.
|
|
2760
|
+
*/
|
|
2761
|
+
async reserveCreditsDetailed(userId, amount, operationType, options) {
|
|
1448
2762
|
const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
|
|
1449
2763
|
const expiresAt = new Date(Date.now() + ttlMs);
|
|
1450
|
-
return this.repository
|
|
2764
|
+
return reserveThroughRepository(this.repository, {
|
|
2765
|
+
userId,
|
|
2766
|
+
amount,
|
|
2767
|
+
operationType,
|
|
2768
|
+
expiresAt,
|
|
2769
|
+
idempotencyKey: options?.idempotencyKey
|
|
2770
|
+
});
|
|
1451
2771
|
}
|
|
1452
2772
|
/**
|
|
1453
|
-
* Commit a reservation (phase 2 of two-phase commit - success)
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
2773
|
+
* Commit a reservation (phase 2 of two-phase commit - success).
|
|
2774
|
+
*
|
|
2775
|
+
* Idempotent: re-delivering a commit for an already-committed reservation is
|
|
2776
|
+
* a no-op. Committing one that was released or expired throws, because the
|
|
2777
|
+
* credits are no longer held and pretending otherwise would hide a real bug.
|
|
1456
2778
|
*/
|
|
1457
|
-
async commitCredits(userId, reservationId) {
|
|
1458
|
-
const
|
|
1459
|
-
if (
|
|
1460
|
-
throw
|
|
2779
|
+
async commitCredits(userId, reservationId, options) {
|
|
2780
|
+
const outcome = await this.commitCreditsDetailed(userId, reservationId, options);
|
|
2781
|
+
if (outcome.outcome === "not_found") {
|
|
2782
|
+
throw createReservationNotFoundError(reservationId);
|
|
1461
2783
|
}
|
|
1462
|
-
if (
|
|
1463
|
-
|
|
2784
|
+
if (outcome.outcome === "already_terminal" && outcome.terminalStatus !== "committed") {
|
|
2785
|
+
throw createReservationAlreadyProcessedError(reservationId, outcome.terminalStatus);
|
|
1464
2786
|
}
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Commit a reservation and get the typed outcome.
|
|
2790
|
+
*
|
|
2791
|
+
* Exactly one concurrent caller sees `committed`; that call moved the balance
|
|
2792
|
+
* and wrote the single journal entry. On a V2 repository the journal is
|
|
2793
|
+
* written inside the same transaction, so this method adds none of its own —
|
|
2794
|
+
* the duplicate entry the previous implementation wrote is gone.
|
|
2795
|
+
*
|
|
2796
|
+
* The low-balance notification fires only for the winner, and only after the
|
|
2797
|
+
* transaction has committed, so no callback runs inside a database
|
|
2798
|
+
* transaction.
|
|
2799
|
+
*/
|
|
2800
|
+
async commitCreditsDetailed(userId, reservationId, options) {
|
|
2801
|
+
const outcome = await commitThroughRepository(
|
|
2802
|
+
this.repository,
|
|
2803
|
+
userId,
|
|
2804
|
+
reservationId,
|
|
2805
|
+
options
|
|
2806
|
+
);
|
|
2807
|
+
if (outcome.outcome === "committed" && this.lowBalanceCallback) {
|
|
2808
|
+
this.lowBalanceCallback(userId, outcome.balanceAfter).catch((error) => {
|
|
2809
|
+
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1480
2810
|
});
|
|
1481
|
-
if (this.lowBalanceCallback) {
|
|
1482
|
-
this.lowBalanceCallback(userId, credits.balance).catch((error) => {
|
|
1483
|
-
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1486
2811
|
}
|
|
2812
|
+
return outcome;
|
|
1487
2813
|
}
|
|
1488
2814
|
/**
|
|
1489
|
-
* Release a reservation (phase 2 of two-phase commit - failure)
|
|
1490
|
-
*
|
|
2815
|
+
* Release a reservation (phase 2 of two-phase commit - failure).
|
|
2816
|
+
*
|
|
2817
|
+
* Throws `RESERVATION_NOT_FOUND` for an unknown reservation, matching the
|
|
2818
|
+
* pre-V2 behaviour — a release naming a reservation that does not exist is a
|
|
2819
|
+
* caller bug, and swallowing it hides the bug while the credits stay held.
|
|
2820
|
+
*
|
|
2821
|
+
* Releasing one that is already terminal — `released`, `expired`, or
|
|
2822
|
+
* `committed` — is a no-op, which is the behaviour this method has always
|
|
2823
|
+
* had. A release that loses a race to a concurrent commit is the common
|
|
2824
|
+
* case in a retry-heavy caller, and callers written against the pre-V2
|
|
2825
|
+
* contract handle it by not handling it.
|
|
2826
|
+
*
|
|
2827
|
+
* That does mean this method cannot tell you that the credits were spent
|
|
2828
|
+
* rather than returned. Use {@link releaseCreditsDetailed}, which reports
|
|
2829
|
+
* `already_terminal` with `terminalStatus: 'committed'`, when you need to
|
|
2830
|
+
* branch on it.
|
|
1491
2831
|
*/
|
|
1492
|
-
async releaseCredits(userId, reservationId) {
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
const credits = await this.repository.getUserCredits(userId);
|
|
1497
|
-
if (credits) {
|
|
1498
|
-
await this.repository.createJournalEntry({
|
|
1499
|
-
userId,
|
|
1500
|
-
entryType: "credit",
|
|
1501
|
-
amount: 0,
|
|
1502
|
-
// No actual credits returned (they were reserved, not spent)
|
|
1503
|
-
balanceAfter: credits.balance,
|
|
1504
|
-
source: "operation_release",
|
|
1505
|
-
referenceId: reservationId,
|
|
1506
|
-
referenceType: "reservation",
|
|
1507
|
-
description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1508
|
-
metadata: {
|
|
1509
|
-
operationType: reservation.operationType,
|
|
1510
|
-
amount: reservation.amount
|
|
1511
|
-
}
|
|
1512
|
-
});
|
|
1513
|
-
}
|
|
2832
|
+
async releaseCredits(userId, reservationId, options) {
|
|
2833
|
+
const outcome = await this.releaseCreditsDetailed(userId, reservationId, options);
|
|
2834
|
+
if (outcome.outcome === "not_found") {
|
|
2835
|
+
throw createReservationNotFoundError(reservationId);
|
|
1514
2836
|
}
|
|
1515
2837
|
}
|
|
2838
|
+
/** Release a reservation and get the typed outcome. */
|
|
2839
|
+
async releaseCreditsDetailed(userId, reservationId, options) {
|
|
2840
|
+
return releaseThroughRepository(this.repository, userId, reservationId, options);
|
|
2841
|
+
}
|
|
1516
2842
|
/**
|
|
1517
2843
|
* Log usage for audit trail
|
|
1518
2844
|
* @param log - Usage log data
|
|
@@ -1528,9 +2854,20 @@ var CreditsService = class {
|
|
|
1528
2854
|
* @param paymentRef - Optional payment reference
|
|
1529
2855
|
* @param options - Optional journal source / reference type / extra metadata
|
|
1530
2856
|
* for the credit journal entry (e.g. revenue-attribution fields).
|
|
2857
|
+
* @returns Whether this delivery credited the account (`created`), repeated an
|
|
2858
|
+
* earlier one (`replayed`), or reused the reference for a different credit
|
|
2859
|
+
* event (`conflict`, which credited nothing). On a repository without
|
|
2860
|
+
* `addCreditsV2` the result is always `created` and carries no
|
|
2861
|
+
* deduplication guarantee — see {@link addCreditsThroughRepository}.
|
|
1531
2862
|
*/
|
|
1532
2863
|
async addCredits(userId, amount, description, paymentRef, options) {
|
|
1533
|
-
return this.repository
|
|
2864
|
+
return addCreditsThroughRepository(this.repository, {
|
|
2865
|
+
userId,
|
|
2866
|
+
amount,
|
|
2867
|
+
description,
|
|
2868
|
+
paymentRef,
|
|
2869
|
+
options
|
|
2870
|
+
});
|
|
1534
2871
|
}
|
|
1535
2872
|
/**
|
|
1536
2873
|
* One-shot atomic "deduct-if-sufficient" credit charge.
|
|
@@ -1554,15 +2891,14 @@ var CreditsService = class {
|
|
|
1554
2891
|
* @throws Error if amount is not a positive number
|
|
1555
2892
|
*/
|
|
1556
2893
|
async deductCredits(userId, amount, options) {
|
|
1557
|
-
|
|
1558
|
-
throw new Error(`deductCredits amount must be positive (got ${amount})`);
|
|
1559
|
-
}
|
|
2894
|
+
assertValidCreditAmount(amount, { userId, operation: "deductCredits" });
|
|
1560
2895
|
let deduction;
|
|
1561
2896
|
try {
|
|
1562
2897
|
deduction = await this.repository.deductCreditsAtomic(userId, amount);
|
|
1563
2898
|
} catch (error) {
|
|
2899
|
+
if (isCreditError(error) && error.code === CreditErrorCode.INVALID_AMOUNT) throw error;
|
|
1564
2900
|
const credits = await this.repository.getUserCredits(userId);
|
|
1565
|
-
const available = credits ? credits.balance
|
|
2901
|
+
const available = credits ? sumAmounts(credits.balance, credits.bonusCredits, -credits.reserved) : 0;
|
|
1566
2902
|
if (available >= amount) {
|
|
1567
2903
|
throw error;
|
|
1568
2904
|
}
|
|
@@ -1571,7 +2907,7 @@ var CreditsService = class {
|
|
|
1571
2907
|
reason: "insufficient",
|
|
1572
2908
|
available,
|
|
1573
2909
|
required: amount,
|
|
1574
|
-
shortfall: amount -
|
|
2910
|
+
shortfall: sumAmounts(amount, -available)
|
|
1575
2911
|
};
|
|
1576
2912
|
}
|
|
1577
2913
|
const operationType = options?.operationType;
|
|
@@ -1585,8 +2921,8 @@ var CreditsService = class {
|
|
|
1585
2921
|
referenceType: "adjustment",
|
|
1586
2922
|
description: operationType ? `Deducted ${amount} credits for ${getOperationLabel(operationType)}` : `Deducted ${amount} credits`,
|
|
1587
2923
|
metadata: {
|
|
1588
|
-
...
|
|
1589
|
-
...
|
|
2924
|
+
...options?.metadata,
|
|
2925
|
+
...operationType && { operationType }
|
|
1590
2926
|
}
|
|
1591
2927
|
});
|
|
1592
2928
|
if (operationType) {
|
|
@@ -1621,8 +2957,15 @@ var CreditsService = class {
|
|
|
1621
2957
|
const free = isFreeTier(tier);
|
|
1622
2958
|
await this.repository.updateUserTier(userId, {
|
|
1623
2959
|
tier,
|
|
1624
|
-
//
|
|
1625
|
-
|
|
2960
|
+
// Through the shared contract, like every path that resolves the tier's
|
|
2961
|
+
// limit from configuration. (The repository's `updateUserCredits` and
|
|
2962
|
+
// `updateUserTier` still store a caller-supplied `monthlyLimit` verbatim;
|
|
2963
|
+
// they are given a value, not a tier, so there is no sentinel to map.)
|
|
2964
|
+
// This used to store 0 for an unlimited tier under a "0 means unlimited"
|
|
2965
|
+
// convention that nothing actually implemented — no read path anywhere in
|
|
2966
|
+
// the library branches on it — so upgrading a user to unlimited gave them
|
|
2967
|
+
// an allowance of zero.
|
|
2968
|
+
monthlyLimit: storedMonthlyLimit(monthlyLimit),
|
|
1626
2969
|
// Reset balance to new tier limit if upgrading
|
|
1627
2970
|
balance: !free ? unlimited ? getUnlimitedSentinelBalance() : monthlyLimit : void 0,
|
|
1628
2971
|
monthlyUsed: !free ? 0 : void 0,
|
|
@@ -1765,9 +3108,11 @@ function createGenericAdapter(config) {
|
|
|
1765
3108
|
return handler(user, data, dummyReservation);
|
|
1766
3109
|
}
|
|
1767
3110
|
let reservation;
|
|
3111
|
+
let isFree = false;
|
|
1768
3112
|
try {
|
|
1769
3113
|
const cost = options.customCost ?? getOperationCost2(options.operationType);
|
|
1770
|
-
|
|
3114
|
+
isFree = options.customCost === void 0 && cost === 0;
|
|
3115
|
+
reservation = isFree ? { ...createDummyReservation(user.id, options.operationType), id: "free-operation" } : await reserveCreditsForOperation(
|
|
1771
3116
|
repository,
|
|
1772
3117
|
user.id,
|
|
1773
3118
|
cost,
|
|
@@ -1790,7 +3135,7 @@ function createGenericAdapter(config) {
|
|
|
1790
3135
|
try {
|
|
1791
3136
|
const result = await handler(user, data, reservation);
|
|
1792
3137
|
if (result.success) {
|
|
1793
|
-
await commitReservationWithJournal(repository, user.id, reservation.id);
|
|
3138
|
+
if (!isFree) await commitReservationWithJournal(repository, user.id, reservation.id);
|
|
1794
3139
|
logUsage({
|
|
1795
3140
|
userId: user.id,
|
|
1796
3141
|
operationType: options.operationType,
|
|
@@ -1801,7 +3146,7 @@ function createGenericAdapter(config) {
|
|
|
1801
3146
|
requestId
|
|
1802
3147
|
});
|
|
1803
3148
|
} else {
|
|
1804
|
-
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
3149
|
+
if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
1805
3150
|
logUsage({
|
|
1806
3151
|
userId: user.id,
|
|
1807
3152
|
operationType: options.operationType,
|
|
@@ -1816,7 +3161,7 @@ function createGenericAdapter(config) {
|
|
|
1816
3161
|
return result;
|
|
1817
3162
|
} catch (error) {
|
|
1818
3163
|
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
1819
|
-
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
3164
|
+
if (!isFree) await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
1820
3165
|
logUsage({
|
|
1821
3166
|
userId: user.id,
|
|
1822
3167
|
operationType: options.operationType,
|
|
@@ -2313,7 +3658,7 @@ var CreditsClient = class {
|
|
|
2313
3658
|
*/
|
|
2314
3659
|
async getAvailableCredits() {
|
|
2315
3660
|
const balance = await this.getBalance();
|
|
2316
|
-
return balance.balance
|
|
3661
|
+
return sumAmounts(balance.balance, balance.bonusCredits, -balance.reserved);
|
|
2317
3662
|
}
|
|
2318
3663
|
/**
|
|
2319
3664
|
* Execute an operation with automatic credit handling
|
|
@@ -2542,7 +3887,7 @@ function parseCreditError(error) {
|
|
|
2542
3887
|
return {
|
|
2543
3888
|
available,
|
|
2544
3889
|
required,
|
|
2545
|
-
shortfall: required -
|
|
3890
|
+
shortfall: sumAmounts(required, -available),
|
|
2546
3891
|
rawMessage: error
|
|
2547
3892
|
};
|
|
2548
3893
|
}
|
|
@@ -2553,7 +3898,7 @@ function isCreditErrorMessage(error) {
|
|
|
2553
3898
|
|
|
2554
3899
|
// src/utils/index.ts
|
|
2555
3900
|
function getTotalCredits(credits) {
|
|
2556
|
-
return credits.balance
|
|
3901
|
+
return sumAmounts(credits.balance, credits.bonusCredits);
|
|
2557
3902
|
}
|
|
2558
3903
|
function generateRequestId2() {
|
|
2559
3904
|
const timestamp = Date.now();
|
|
@@ -2581,6 +3926,8 @@ function createDummyReservation2(userId, operationType) {
|
|
|
2581
3926
|
ApiKeyCreditsProvider,
|
|
2582
3927
|
AuthenticationError,
|
|
2583
3928
|
AuthorizationError,
|
|
3929
|
+
CREDIT_AMOUNT_MAX,
|
|
3930
|
+
CREDIT_AMOUNT_SCALE,
|
|
2584
3931
|
ConsoleNotificationHandler,
|
|
2585
3932
|
CreditError,
|
|
2586
3933
|
CreditErrorCode,
|
|
@@ -2589,31 +3936,57 @@ function createDummyReservation2(userId, operationType) {
|
|
|
2589
3936
|
CreditsService,
|
|
2590
3937
|
InMemoryCreditRepository,
|
|
2591
3938
|
NetworkError,
|
|
3939
|
+
RESERVATION_STATUSES,
|
|
3940
|
+
RESERVED_JOURNAL_KEY_PREFIX,
|
|
2592
3941
|
SDKErrorCode,
|
|
2593
3942
|
SDKInsufficientCreditsError,
|
|
2594
3943
|
SDKReservationExpiredError,
|
|
2595
3944
|
SDKReservationNotFoundError,
|
|
2596
3945
|
ServerError,
|
|
3946
|
+
TERMINAL_RESERVATION_STATUSES,
|
|
2597
3947
|
UNLIMITED_BALANCE_SENTINEL,
|
|
2598
3948
|
ValidationError,
|
|
3949
|
+
addCreditsThroughRepository,
|
|
3950
|
+
assertDirectStatusWriteAllowed,
|
|
3951
|
+
assertHoldPlaced,
|
|
3952
|
+
assertKnownReservationStatus,
|
|
3953
|
+
assertPublicJournalKey,
|
|
3954
|
+
assertRepresentableAmount,
|
|
3955
|
+
assertRepresentableFields,
|
|
3956
|
+
assertRepresentableTierAmount,
|
|
3957
|
+
assertUnkeyedDirectReservation,
|
|
3958
|
+
assertUnreferencedDirectTransaction,
|
|
3959
|
+
assertValidCreditAmount,
|
|
3960
|
+
assertValidIdempotencyKey,
|
|
3961
|
+
assertValidStoredAmount,
|
|
3962
|
+
assertValidStoredAmountRaw,
|
|
3963
|
+
backedBalanceFloor,
|
|
2599
3964
|
calculateAvailableCredits,
|
|
2600
3965
|
checkAndNotifyLowBalance,
|
|
3966
|
+
classifyDatabaseError,
|
|
2601
3967
|
clearConfigProvider,
|
|
2602
3968
|
clearCooldownState,
|
|
2603
3969
|
clearNotificationHandlers,
|
|
2604
3970
|
commitReservationWithJournal,
|
|
3971
|
+
commitThroughRepository,
|
|
2605
3972
|
createApiKeyProvider,
|
|
2606
3973
|
createCreditsService,
|
|
2607
3974
|
createDeferredExecutor,
|
|
2608
3975
|
createDummyReservation,
|
|
2609
3976
|
createGenericAdapter,
|
|
3977
|
+
createIdempotencyConflictError,
|
|
2610
3978
|
createInMemoryCreditRepository,
|
|
2611
3979
|
createInsufficientCreditsError,
|
|
3980
|
+
createInvalidAmountError,
|
|
2612
3981
|
createInvalidOperationTypeError,
|
|
3982
|
+
createPaymentRefConflictError,
|
|
2613
3983
|
createReservationAlreadyProcessedError,
|
|
2614
3984
|
createReservationExpiredError,
|
|
2615
3985
|
createReservationNotFoundError,
|
|
3986
|
+
createTransientError,
|
|
3987
|
+
createUnsupportedOperationError,
|
|
2616
3988
|
createUserNotFoundError,
|
|
3989
|
+
describePaymentMismatch,
|
|
2617
3990
|
generateId,
|
|
2618
3991
|
generateRequestId,
|
|
2619
3992
|
genericDeferred,
|
|
@@ -2632,35 +4005,63 @@ function createDummyReservation2(userId, operationType) {
|
|
|
2632
4005
|
getOperationLabel,
|
|
2633
4006
|
getOperationLabels,
|
|
2634
4007
|
getReservationExpiryMs,
|
|
4008
|
+
getSqlState,
|
|
2635
4009
|
getTierConfig,
|
|
2636
4010
|
getTotalCredits,
|
|
2637
4011
|
getUnlimitedSentinelBalance,
|
|
2638
4012
|
getValidOperationTypes,
|
|
2639
4013
|
getValidTiers,
|
|
4014
|
+
hasPlacedHold,
|
|
2640
4015
|
initializeConfig,
|
|
2641
4016
|
isCreditError,
|
|
2642
4017
|
isCreditErrorMessage,
|
|
4018
|
+
isCreditedOutcome,
|
|
2643
4019
|
isFeatureEnabled,
|
|
2644
4020
|
isFreeTier,
|
|
4021
|
+
isIdempotencyConflictError,
|
|
2645
4022
|
isInsufficientCreditsError,
|
|
2646
4023
|
isPreviewMode,
|
|
4024
|
+
isRepresentableAmount,
|
|
4025
|
+
isReservationStatus,
|
|
4026
|
+
isReservedJournalKey,
|
|
4027
|
+
isReservedOutcome,
|
|
4028
|
+
isTerminalReservationStatus,
|
|
4029
|
+
isTransientDatabaseError,
|
|
4030
|
+
isTransientError,
|
|
4031
|
+
isUniqueViolation,
|
|
2647
4032
|
isUnlimitedTier,
|
|
4033
|
+
isValidCreditAmount,
|
|
4034
|
+
isValidIdempotencyKey,
|
|
2648
4035
|
isValidOperationType,
|
|
2649
4036
|
isValidTier,
|
|
4037
|
+
isWinningOutcome,
|
|
2650
4038
|
loadConfigFromEnv,
|
|
4039
|
+
monthlyResetBalance,
|
|
2651
4040
|
noopDeferred,
|
|
4041
|
+
normalizePaymentRef,
|
|
2652
4042
|
notifySubscriptionExpired,
|
|
2653
4043
|
notifySubscriptionExpiring,
|
|
4044
|
+
numericToCents,
|
|
2654
4045
|
parseApiError,
|
|
2655
4046
|
parseCreditError,
|
|
2656
4047
|
parseTier,
|
|
2657
4048
|
registerConfigProvider,
|
|
2658
4049
|
registerNotificationHandler,
|
|
2659
4050
|
releaseReservationWithJournal,
|
|
4051
|
+
releaseThroughRepository,
|
|
4052
|
+
reservationJournalKey,
|
|
2660
4053
|
reserveCreditsForOperation,
|
|
4054
|
+
reserveThroughRepository,
|
|
2661
4055
|
resetConfig,
|
|
4056
|
+
sameAmount,
|
|
4057
|
+
storedMonthlyLimit,
|
|
4058
|
+
sumAmounts,
|
|
4059
|
+
supportsCreditsV2,
|
|
4060
|
+
supportsIdempotentCredit,
|
|
2662
4061
|
synchronousDeferred,
|
|
4062
|
+
terminalStatusOf,
|
|
2663
4063
|
tierSchema,
|
|
4064
|
+
toCents,
|
|
2664
4065
|
toClientUserCredits,
|
|
2665
4066
|
toDate,
|
|
2666
4067
|
toPortableTimestamp,
|