@nehorai/credits 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/adapters/index.cjs +463 -0
- package/dist/adapters/index.cjs.map +1 -0
- package/dist/adapters/index.d.cts +66 -0
- package/dist/adapters/index.d.ts +66 -3
- package/dist/adapters/index.js +435 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/auth/index.cjs +96 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/{api-key.d.ts → index.d.cts} +9 -6
- package/dist/auth/index.d.ts +37 -5
- package/dist/auth/index.js +65 -7
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.cjs +522 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +119 -0
- package/dist/core/index.d.ts +115 -25
- package/dist/core/index.js +475 -27
- package/dist/core/index.js.map +1 -1
- package/dist/{core/deferred.d.ts → deferred-C7rrRfkm.d.cts} +7 -6
- package/dist/{core/deferred.js → deferred-C7rrRfkm.d.ts} +23 -29
- package/dist/index.cjs +2514 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +437 -0
- package/dist/index.d.ts +429 -50
- package/dist/index.js +2407 -63
- package/dist/index.js.map +1 -1
- package/dist/repository/index.cjs +819 -0
- package/dist/repository/index.cjs.map +1 -0
- package/dist/repository/{memory/index.d.ts → index.d.cts} +35 -5
- package/dist/repository/index.d.ts +97 -7
- package/dist/repository/index.js +786 -10
- package/dist/repository/index.js.map +1 -1
- package/dist/sdk/index.cjs +608 -0
- package/dist/sdk/index.cjs.map +1 -0
- package/dist/sdk/index.d.cts +495 -0
- package/dist/sdk/index.d.ts +491 -9
- package/dist/sdk/index.js +568 -12
- package/dist/sdk/index.js.map +1 -1
- package/dist/service/index.cjs +675 -0
- package/dist/service/index.cjs.map +1 -0
- package/dist/service/{credits-service.d.ts → index.d.cts} +25 -8
- package/dist/service/index.d.ts +156 -3
- package/dist/service/index.js +646 -1
- package/dist/service/index.js.map +1 -1
- package/dist/{core/types.d.ts → types-BBxIWHK2.d.cts} +25 -24
- package/dist/types-BBxIWHK2.d.ts +277 -0
- package/dist/types-D1vdeBcA.d.cts +305 -0
- package/dist/{auth/types.d.ts → types-OZU3I5OM.d.cts} +5 -4
- package/dist/types-OZU3I5OM.d.ts +32 -0
- package/dist/{repository/types.d.ts → types-aJsyv8df.d.ts} +35 -13
- package/package.json +21 -19
- package/dist/adapters/generic.d.ts +0 -18
- package/dist/adapters/generic.d.ts.map +0 -1
- package/dist/adapters/generic.js +0 -151
- package/dist/adapters/generic.js.map +0 -1
- package/dist/adapters/index.d.ts.map +0 -1
- package/dist/adapters/types.d.ts +0 -45
- package/dist/adapters/types.d.ts.map +0 -1
- package/dist/adapters/types.js +0 -8
- package/dist/adapters/types.js.map +0 -1
- package/dist/auth/api-key.d.ts.map +0 -1
- package/dist/auth/api-key.js +0 -94
- package/dist/auth/api-key.js.map +0 -1
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/types.d.ts.map +0 -1
- package/dist/auth/types.js +0 -2
- package/dist/auth/types.js.map +0 -1
- package/dist/config/costs.d.ts +0 -56
- package/dist/config/costs.d.ts.map +0 -1
- package/dist/config/costs.js +0 -71
- package/dist/config/costs.js.map +0 -1
- package/dist/config/index.d.ts +0 -184
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js +0 -342
- package/dist/config/index.js.map +0 -1
- package/dist/config/provider.d.ts +0 -12
- package/dist/config/provider.d.ts.map +0 -1
- package/dist/config/provider.js +0 -14
- package/dist/config/provider.js.map +0 -1
- package/dist/core/deferred.d.ts.map +0 -1
- package/dist/core/deferred.js.map +0 -1
- package/dist/core/errors.d.ts +0 -78
- package/dist/core/errors.d.ts.map +0 -1
- package/dist/core/errors.js +0 -110
- package/dist/core/errors.js.map +0 -1
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/operations.d.ts +0 -36
- package/dist/core/operations.d.ts.map +0 -1
- package/dist/core/operations.js +0 -88
- package/dist/core/operations.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js +0 -82
- package/dist/core/types.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/notifications/index.d.ts +0 -76
- package/dist/notifications/index.d.ts.map +0 -1
- package/dist/notifications/index.js +0 -174
- package/dist/notifications/index.js.map +0 -1
- package/dist/repository/index.d.ts.map +0 -1
- package/dist/repository/memory/index.d.ts.map +0 -1
- package/dist/repository/memory/index.js +0 -518
- package/dist/repository/memory/index.js.map +0 -1
- package/dist/repository/types.d.ts.map +0 -1
- package/dist/repository/types.js +0 -39
- package/dist/repository/types.js.map +0 -1
- package/dist/repository/utils.d.ts +0 -22
- package/dist/repository/utils.d.ts.map +0 -1
- package/dist/repository/utils.js +0 -40
- package/dist/repository/utils.js.map +0 -1
- package/dist/sdk/admin-client.d.ts +0 -146
- package/dist/sdk/admin-client.d.ts.map +0 -1
- package/dist/sdk/admin-client.js +0 -196
- package/dist/sdk/admin-client.js.map +0 -1
- package/dist/sdk/client.d.ts +0 -144
- package/dist/sdk/client.d.ts.map +0 -1
- package/dist/sdk/client.js +0 -247
- package/dist/sdk/client.js.map +0 -1
- package/dist/sdk/errors.d.ts +0 -91
- package/dist/sdk/errors.d.ts.map +0 -1
- package/dist/sdk/errors.js +0 -185
- package/dist/sdk/errors.js.map +0 -1
- package/dist/sdk/index.d.ts.map +0 -1
- package/dist/sdk/types.d.ts +0 -110
- package/dist/sdk/types.d.ts.map +0 -1
- package/dist/sdk/types.js +0 -20
- package/dist/sdk/types.js.map +0 -1
- package/dist/service/credits-service.d.ts.map +0 -1
- package/dist/service/credits-service.js +0 -368
- package/dist/service/credits-service.js.map +0 -1
- package/dist/service/index.d.ts.map +0 -1
- package/dist/utils/error-utils.d.ts +0 -48
- package/dist/utils/error-utils.d.ts.map +0 -1
- package/dist/utils/error-utils.js +0 -57
- package/dist/utils/error-utils.js.map +0 -1
- package/dist/utils/index.d.ts +0 -54
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -72
- package/dist/utils/index.js.map +0 -1
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2514 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
AdminCreditsClient: () => AdminCreditsClient,
|
|
24
|
+
ApiKeyCreditsProvider: () => ApiKeyCreditsProvider,
|
|
25
|
+
AuthenticationError: () => AuthenticationError,
|
|
26
|
+
AuthorizationError: () => AuthorizationError,
|
|
27
|
+
ConsoleNotificationHandler: () => ConsoleNotificationHandler,
|
|
28
|
+
CreditError: () => CreditError,
|
|
29
|
+
CreditErrorCode: () => CreditErrorCode,
|
|
30
|
+
CreditsClient: () => CreditsClient,
|
|
31
|
+
CreditsSDKError: () => CreditsSDKError,
|
|
32
|
+
CreditsService: () => CreditsService,
|
|
33
|
+
InMemoryCreditRepository: () => InMemoryCreditRepository,
|
|
34
|
+
NetworkError: () => NetworkError,
|
|
35
|
+
SDKErrorCode: () => SDKErrorCode,
|
|
36
|
+
SDKInsufficientCreditsError: () => InsufficientCreditsError,
|
|
37
|
+
SDKReservationExpiredError: () => ReservationExpiredError,
|
|
38
|
+
SDKReservationNotFoundError: () => ReservationNotFoundError,
|
|
39
|
+
ServerError: () => ServerError,
|
|
40
|
+
ValidationError: () => ValidationError,
|
|
41
|
+
calculateAvailableCredits: () => calculateAvailableCredits,
|
|
42
|
+
checkAndNotifyLowBalance: () => checkAndNotifyLowBalance,
|
|
43
|
+
clearConfigProvider: () => clearConfigProvider,
|
|
44
|
+
clearCooldownState: () => clearCooldownState,
|
|
45
|
+
clearNotificationHandlers: () => clearNotificationHandlers,
|
|
46
|
+
commitReservationWithJournal: () => commitReservationWithJournal,
|
|
47
|
+
createApiKeyProvider: () => createApiKeyProvider,
|
|
48
|
+
createCreditsService: () => createCreditsService,
|
|
49
|
+
createDeferredExecutor: () => createDeferredExecutor,
|
|
50
|
+
createDummyReservation: () => createDummyReservation2,
|
|
51
|
+
createGenericAdapter: () => createGenericAdapter,
|
|
52
|
+
createInMemoryCreditRepository: () => createInMemoryCreditRepository,
|
|
53
|
+
createInsufficientCreditsError: () => createInsufficientCreditsError,
|
|
54
|
+
createInvalidOperationTypeError: () => createInvalidOperationTypeError,
|
|
55
|
+
createReservationAlreadyProcessedError: () => createReservationAlreadyProcessedError,
|
|
56
|
+
createReservationExpiredError: () => createReservationExpiredError,
|
|
57
|
+
createReservationNotFoundError: () => createReservationNotFoundError,
|
|
58
|
+
createUserNotFoundError: () => createUserNotFoundError,
|
|
59
|
+
generateId: () => generateId,
|
|
60
|
+
generateRequestId: () => generateRequestId2,
|
|
61
|
+
genericDeferred: () => genericDeferred,
|
|
62
|
+
getConfig: () => getConfig,
|
|
63
|
+
getConfigMonthlyLimit: () => getConfigMonthlyLimit,
|
|
64
|
+
getConfigOperationCost: () => getConfigOperationCost,
|
|
65
|
+
getConfigProvider: () => getConfigProvider,
|
|
66
|
+
getConfigTierConfig: () => getConfigTierConfig,
|
|
67
|
+
getDefaultFreeCredits: () => getDefaultFreeCredits,
|
|
68
|
+
getMonthlyLimit: () => getMonthlyLimit,
|
|
69
|
+
getNextMonthlyReset: () => getNextMonthlyReset,
|
|
70
|
+
getOperationCost: () => getOperationCost,
|
|
71
|
+
getOperationCosts: () => getOperationCosts,
|
|
72
|
+
getOperationCostsWithLabels: () => getOperationCostsWithLabels,
|
|
73
|
+
getOperationLabel: () => getOperationLabel,
|
|
74
|
+
getOperationLabels: () => getOperationLabels,
|
|
75
|
+
getReservationExpiryMs: () => getReservationExpiryMs,
|
|
76
|
+
getTierConfig: () => getTierConfig,
|
|
77
|
+
getTotalCredits: () => getTotalCredits,
|
|
78
|
+
getValidOperationTypes: () => getValidOperationTypes,
|
|
79
|
+
initializeConfig: () => initializeConfig,
|
|
80
|
+
isCreditError: () => isCreditError,
|
|
81
|
+
isCreditErrorMessage: () => isCreditErrorMessage,
|
|
82
|
+
isFeatureEnabled: () => isFeatureEnabled,
|
|
83
|
+
isInsufficientCreditsError: () => isInsufficientCreditsError,
|
|
84
|
+
isPreviewMode: () => isPreviewMode2,
|
|
85
|
+
isValidOperationType: () => isValidOperationType,
|
|
86
|
+
loadConfigFromEnv: () => loadConfigFromEnv,
|
|
87
|
+
noopDeferred: () => noopDeferred,
|
|
88
|
+
notifySubscriptionExpired: () => notifySubscriptionExpired,
|
|
89
|
+
notifySubscriptionExpiring: () => notifySubscriptionExpiring,
|
|
90
|
+
parseApiError: () => parseApiError,
|
|
91
|
+
parseCreditError: () => parseCreditError,
|
|
92
|
+
registerConfigProvider: () => registerConfigProvider,
|
|
93
|
+
registerNotificationHandler: () => registerNotificationHandler,
|
|
94
|
+
releaseReservationWithJournal: () => releaseReservationWithJournal,
|
|
95
|
+
reserveCreditsForOperation: () => reserveCreditsForOperation,
|
|
96
|
+
resetConfig: () => resetConfig,
|
|
97
|
+
synchronousDeferred: () => synchronousDeferred,
|
|
98
|
+
toClientUserCredits: () => toClientUserCredits,
|
|
99
|
+
toDate: () => toDate,
|
|
100
|
+
toPortableTimestamp: () => toPortableTimestamp,
|
|
101
|
+
verifyAdminApiKey: () => verifyAdminApiKey,
|
|
102
|
+
verifyBearerToken: () => verifyBearerToken
|
|
103
|
+
});
|
|
104
|
+
module.exports = __toCommonJS(src_exports);
|
|
105
|
+
|
|
106
|
+
// src/core/types.ts
|
|
107
|
+
function calculateAvailableCredits(balance, bonusCredits, reserved) {
|
|
108
|
+
return balance + bonusCredits - reserved;
|
|
109
|
+
}
|
|
110
|
+
function toPortableTimestamp(value) {
|
|
111
|
+
if (!value) {
|
|
112
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === "string") {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
if (value instanceof Date) {
|
|
118
|
+
return value.toISOString();
|
|
119
|
+
}
|
|
120
|
+
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
121
|
+
return value.toDate().toISOString();
|
|
122
|
+
}
|
|
123
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
124
|
+
}
|
|
125
|
+
function toDate(value) {
|
|
126
|
+
if (value instanceof Date) {
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
if (typeof value === "string") {
|
|
130
|
+
return new Date(value);
|
|
131
|
+
}
|
|
132
|
+
if (typeof value === "object" && value !== null && "toDate" in value && typeof value.toDate === "function") {
|
|
133
|
+
return value.toDate();
|
|
134
|
+
}
|
|
135
|
+
return /* @__PURE__ */ new Date();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/core/errors.ts
|
|
139
|
+
var CreditErrorCode = {
|
|
140
|
+
INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
|
|
141
|
+
RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
|
|
142
|
+
RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
|
|
143
|
+
RESERVATION_ALREADY_PROCESSED: "RESERVATION_ALREADY_PROCESSED",
|
|
144
|
+
USER_NOT_FOUND: "USER_NOT_FOUND",
|
|
145
|
+
INVALID_OPERATION_TYPE: "INVALID_OPERATION_TYPE",
|
|
146
|
+
CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
|
|
147
|
+
DATABASE_ERROR: "DATABASE_ERROR"
|
|
148
|
+
};
|
|
149
|
+
var CreditError = class _CreditError extends Error {
|
|
150
|
+
code;
|
|
151
|
+
details;
|
|
152
|
+
constructor(message, code, details) {
|
|
153
|
+
super(message);
|
|
154
|
+
this.name = "CreditError";
|
|
155
|
+
this.code = code;
|
|
156
|
+
this.details = details;
|
|
157
|
+
if (Error.captureStackTrace) {
|
|
158
|
+
Error.captureStackTrace(this, _CreditError);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Convert to JSON-serializable object
|
|
163
|
+
*/
|
|
164
|
+
toJSON() {
|
|
165
|
+
return {
|
|
166
|
+
name: this.name,
|
|
167
|
+
code: this.code,
|
|
168
|
+
message: this.message,
|
|
169
|
+
...this.details && { details: this.details }
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
function isCreditError(error) {
|
|
174
|
+
return error instanceof CreditError;
|
|
175
|
+
}
|
|
176
|
+
function isInsufficientCreditsError(error) {
|
|
177
|
+
if (isCreditError(error)) {
|
|
178
|
+
return error.code === CreditErrorCode.INSUFFICIENT_CREDITS;
|
|
179
|
+
}
|
|
180
|
+
if (error instanceof Error) {
|
|
181
|
+
return error.message.toLowerCase().includes("insufficient credits");
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
function createInsufficientCreditsError(required, available) {
|
|
186
|
+
return new CreditError(
|
|
187
|
+
`Insufficient credits: available ${available}, required ${required}`,
|
|
188
|
+
CreditErrorCode.INSUFFICIENT_CREDITS,
|
|
189
|
+
{ required, available, shortfall: required - available }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
function createReservationNotFoundError(reservationId) {
|
|
193
|
+
return new CreditError(
|
|
194
|
+
`Reservation ${reservationId} not found`,
|
|
195
|
+
CreditErrorCode.RESERVATION_NOT_FOUND,
|
|
196
|
+
{ reservationId }
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
function createReservationExpiredError(reservationId) {
|
|
200
|
+
return new CreditError(
|
|
201
|
+
`Reservation ${reservationId} has expired`,
|
|
202
|
+
CreditErrorCode.RESERVATION_EXPIRED,
|
|
203
|
+
{ reservationId }
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
function createReservationAlreadyProcessedError(reservationId, status) {
|
|
207
|
+
return new CreditError(
|
|
208
|
+
`Reservation ${reservationId} has already been ${status}`,
|
|
209
|
+
CreditErrorCode.RESERVATION_ALREADY_PROCESSED,
|
|
210
|
+
{ reservationId, status }
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
function createUserNotFoundError(userId) {
|
|
214
|
+
return new CreditError(
|
|
215
|
+
`User ${userId} not found`,
|
|
216
|
+
CreditErrorCode.USER_NOT_FOUND,
|
|
217
|
+
{ userId }
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
function createInvalidOperationTypeError(operationType, validTypes) {
|
|
221
|
+
return new CreditError(
|
|
222
|
+
`Invalid operation type: ${operationType}. Valid types: ${validTypes.join(", ")}`,
|
|
223
|
+
CreditErrorCode.INVALID_OPERATION_TYPE,
|
|
224
|
+
{ operationType, validTypes }
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/core/deferred.ts
|
|
229
|
+
var genericDeferred = {
|
|
230
|
+
defer(fn) {
|
|
231
|
+
const scheduler = typeof setImmediate !== "undefined" ? setImmediate : setTimeout;
|
|
232
|
+
scheduler(() => {
|
|
233
|
+
fn().catch((error) => {
|
|
234
|
+
console.error("[Credits] Deferred task error:", error);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
var synchronousDeferred = {
|
|
240
|
+
defer(fn) {
|
|
241
|
+
fn().catch((error) => {
|
|
242
|
+
console.error("[Credits] Deferred task error:", error);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
function createDeferredExecutor(schedule) {
|
|
247
|
+
return {
|
|
248
|
+
defer: schedule
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
var noopDeferred = {
|
|
252
|
+
defer(_fn) {
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/config/index.ts
|
|
257
|
+
var import_zod = require("zod");
|
|
258
|
+
|
|
259
|
+
// src/config/provider.ts
|
|
260
|
+
var configProvider = null;
|
|
261
|
+
function registerConfigProvider(provider) {
|
|
262
|
+
configProvider = provider;
|
|
263
|
+
}
|
|
264
|
+
function getConfigProvider() {
|
|
265
|
+
return configProvider;
|
|
266
|
+
}
|
|
267
|
+
function clearConfigProvider() {
|
|
268
|
+
configProvider = null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/config/index.ts
|
|
272
|
+
var tierConfigSchema = import_zod.z.object({
|
|
273
|
+
tier: import_zod.z.enum(["free", "basic", "premium", "unlimited"]),
|
|
274
|
+
monthlyCredits: import_zod.z.number().min(0),
|
|
275
|
+
priceUsd: import_zod.z.number().min(0),
|
|
276
|
+
features: import_zod.z.array(import_zod.z.string())
|
|
277
|
+
});
|
|
278
|
+
var creditSystemConfigSchema = import_zod.z.object({
|
|
279
|
+
/**
|
|
280
|
+
* Operation costs in credits
|
|
281
|
+
* Keys are operation type names (any non-empty string)
|
|
282
|
+
* Values are positive numbers representing credit cost
|
|
283
|
+
*/
|
|
284
|
+
operationCosts: import_zod.z.record(
|
|
285
|
+
import_zod.z.string().min(1),
|
|
286
|
+
import_zod.z.number().positive()
|
|
287
|
+
),
|
|
288
|
+
/**
|
|
289
|
+
* Display labels for operation types (English canonical labels).
|
|
290
|
+
* Falls back to auto-generating from snake_case keys if not provided.
|
|
291
|
+
*/
|
|
292
|
+
operationLabels: import_zod.z.record(
|
|
293
|
+
import_zod.z.string().min(1),
|
|
294
|
+
import_zod.z.string().min(1)
|
|
295
|
+
).optional().default({}),
|
|
296
|
+
/**
|
|
297
|
+
* Tier configurations
|
|
298
|
+
*/
|
|
299
|
+
tierConfigs: import_zod.z.record(
|
|
300
|
+
import_zod.z.enum(["free", "basic", "premium", "unlimited"]),
|
|
301
|
+
tierConfigSchema
|
|
302
|
+
),
|
|
303
|
+
/**
|
|
304
|
+
* Reservation expiry time in milliseconds
|
|
305
|
+
*/
|
|
306
|
+
reservationExpiryMs: import_zod.z.number().positive().default(5 * 60 * 1e3),
|
|
307
|
+
/**
|
|
308
|
+
* Default credits for new users (free tier)
|
|
309
|
+
*/
|
|
310
|
+
defaultFreeCredits: import_zod.z.number().positive().default(25),
|
|
311
|
+
/**
|
|
312
|
+
* Grace period in days after subscription expires before downgrade
|
|
313
|
+
*/
|
|
314
|
+
subscriptionGracePeriodDays: import_zod.z.number().min(0).default(3),
|
|
315
|
+
/**
|
|
316
|
+
* Low balance notification threshold
|
|
317
|
+
*/
|
|
318
|
+
lowBalanceThreshold: import_zod.z.number().min(0).default(10),
|
|
319
|
+
/**
|
|
320
|
+
* Cooldown in hours between low balance notifications
|
|
321
|
+
*/
|
|
322
|
+
lowBalanceNotificationCooldownHours: import_zod.z.number().positive().default(24),
|
|
323
|
+
/**
|
|
324
|
+
* Feature flags for optional features
|
|
325
|
+
*/
|
|
326
|
+
features: import_zod.z.object({
|
|
327
|
+
journalEntries: import_zod.z.boolean().default(true),
|
|
328
|
+
notifications: import_zod.z.boolean().default(true),
|
|
329
|
+
subscriptionExpiry: import_zod.z.boolean().default(true),
|
|
330
|
+
usageHistory: import_zod.z.boolean().default(true)
|
|
331
|
+
}).default({
|
|
332
|
+
journalEntries: true,
|
|
333
|
+
notifications: true,
|
|
334
|
+
subscriptionExpiry: true,
|
|
335
|
+
usageHistory: true
|
|
336
|
+
})
|
|
337
|
+
});
|
|
338
|
+
var DEFAULT_CONFIG = {
|
|
339
|
+
operationCosts: {
|
|
340
|
+
story_generation: 5,
|
|
341
|
+
conversation: 2,
|
|
342
|
+
image_generation: 10,
|
|
343
|
+
template_generation: 10
|
|
344
|
+
},
|
|
345
|
+
operationLabels: {
|
|
346
|
+
story_generation: "Story generation",
|
|
347
|
+
conversation: "Conversation",
|
|
348
|
+
image_generation: "Image generation",
|
|
349
|
+
template_generation: "Template generation"
|
|
350
|
+
},
|
|
351
|
+
tierConfigs: {
|
|
352
|
+
free: {
|
|
353
|
+
tier: "free",
|
|
354
|
+
monthlyCredits: 25,
|
|
355
|
+
priceUsd: 0,
|
|
356
|
+
features: [
|
|
357
|
+
"25 credits per month",
|
|
358
|
+
"Basic story generation",
|
|
359
|
+
"Standard image quality"
|
|
360
|
+
]
|
|
361
|
+
},
|
|
362
|
+
basic: {
|
|
363
|
+
tier: "basic",
|
|
364
|
+
monthlyCredits: 200,
|
|
365
|
+
priceUsd: 9.99,
|
|
366
|
+
features: [
|
|
367
|
+
"200 credits per month",
|
|
368
|
+
"All story features",
|
|
369
|
+
"High quality images",
|
|
370
|
+
"Priority support"
|
|
371
|
+
]
|
|
372
|
+
},
|
|
373
|
+
premium: {
|
|
374
|
+
tier: "premium",
|
|
375
|
+
monthlyCredits: 500,
|
|
376
|
+
priceUsd: 19.99,
|
|
377
|
+
features: [
|
|
378
|
+
"500 credits per month",
|
|
379
|
+
"All features",
|
|
380
|
+
"Highest quality images",
|
|
381
|
+
"Early access to new features",
|
|
382
|
+
"Priority support"
|
|
383
|
+
]
|
|
384
|
+
},
|
|
385
|
+
unlimited: {
|
|
386
|
+
tier: "unlimited",
|
|
387
|
+
monthlyCredits: 0,
|
|
388
|
+
// 0 = unlimited
|
|
389
|
+
priceUsd: 49.99,
|
|
390
|
+
features: [
|
|
391
|
+
"Unlimited credits",
|
|
392
|
+
"All features",
|
|
393
|
+
"Highest quality images",
|
|
394
|
+
"Early access to new features",
|
|
395
|
+
"Dedicated support"
|
|
396
|
+
]
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
reservationExpiryMs: 5 * 60 * 1e3,
|
|
400
|
+
// 5 minutes
|
|
401
|
+
defaultFreeCredits: 25,
|
|
402
|
+
subscriptionGracePeriodDays: 3,
|
|
403
|
+
lowBalanceThreshold: 10,
|
|
404
|
+
lowBalanceNotificationCooldownHours: 24,
|
|
405
|
+
features: {
|
|
406
|
+
journalEntries: true,
|
|
407
|
+
notifications: true,
|
|
408
|
+
subscriptionExpiry: true,
|
|
409
|
+
usageHistory: true
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
var currentConfig = DEFAULT_CONFIG;
|
|
413
|
+
function loadConfigFromEnv() {
|
|
414
|
+
const envConfig = {};
|
|
415
|
+
const operationCostsEnv = process.env.CREDITS_OPERATION_COSTS;
|
|
416
|
+
if (operationCostsEnv) {
|
|
417
|
+
try {
|
|
418
|
+
const parsed = JSON.parse(operationCostsEnv);
|
|
419
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
420
|
+
envConfig.operationCosts = {
|
|
421
|
+
...DEFAULT_CONFIG.operationCosts,
|
|
422
|
+
...parsed
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
console.warn("[Credits Config] Failed to parse CREDITS_OPERATION_COSTS env var as JSON");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const reservationExpiry = process.env.CREDITS_RESERVATION_EXPIRY_MS;
|
|
430
|
+
if (reservationExpiry) {
|
|
431
|
+
envConfig.reservationExpiryMs = parseInt(reservationExpiry, 10);
|
|
432
|
+
}
|
|
433
|
+
const defaultCredits = process.env.CREDITS_DEFAULT_FREE;
|
|
434
|
+
if (defaultCredits) {
|
|
435
|
+
envConfig.defaultFreeCredits = parseInt(defaultCredits, 10);
|
|
436
|
+
}
|
|
437
|
+
const gracePeriod = process.env.CREDITS_GRACE_PERIOD_DAYS;
|
|
438
|
+
if (gracePeriod) {
|
|
439
|
+
envConfig.subscriptionGracePeriodDays = parseInt(gracePeriod, 10);
|
|
440
|
+
}
|
|
441
|
+
const lowBalanceThreshold = process.env.CREDITS_LOW_BALANCE_THRESHOLD;
|
|
442
|
+
if (lowBalanceThreshold) {
|
|
443
|
+
envConfig.lowBalanceThreshold = parseInt(lowBalanceThreshold, 10);
|
|
444
|
+
}
|
|
445
|
+
const featuresEnv = process.env.CREDITS_FEATURES;
|
|
446
|
+
if (featuresEnv) {
|
|
447
|
+
try {
|
|
448
|
+
const features = JSON.parse(featuresEnv);
|
|
449
|
+
envConfig.features = {
|
|
450
|
+
...DEFAULT_CONFIG.features,
|
|
451
|
+
...features
|
|
452
|
+
};
|
|
453
|
+
} catch {
|
|
454
|
+
console.warn("[Credits Config] Failed to parse CREDITS_FEATURES env var");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return envConfig;
|
|
458
|
+
}
|
|
459
|
+
function initializeConfig(overrides) {
|
|
460
|
+
const envConfig = loadConfigFromEnv();
|
|
461
|
+
const mergedConfig = {
|
|
462
|
+
...DEFAULT_CONFIG,
|
|
463
|
+
...envConfig,
|
|
464
|
+
...overrides,
|
|
465
|
+
operationCosts: {
|
|
466
|
+
...DEFAULT_CONFIG.operationCosts,
|
|
467
|
+
...envConfig.operationCosts,
|
|
468
|
+
...overrides?.operationCosts
|
|
469
|
+
},
|
|
470
|
+
operationLabels: {
|
|
471
|
+
...DEFAULT_CONFIG.operationLabels,
|
|
472
|
+
...envConfig.operationLabels,
|
|
473
|
+
...overrides?.operationLabels
|
|
474
|
+
},
|
|
475
|
+
tierConfigs: {
|
|
476
|
+
...DEFAULT_CONFIG.tierConfigs,
|
|
477
|
+
...envConfig.tierConfigs,
|
|
478
|
+
...overrides?.tierConfigs
|
|
479
|
+
},
|
|
480
|
+
features: {
|
|
481
|
+
...DEFAULT_CONFIG.features,
|
|
482
|
+
...envConfig.features,
|
|
483
|
+
...overrides?.features
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
const result = creditSystemConfigSchema.safeParse(mergedConfig);
|
|
487
|
+
if (!result.success) {
|
|
488
|
+
console.error("[Credits Config] Invalid configuration:", result.error.issues);
|
|
489
|
+
throw new Error("Invalid credit system configuration");
|
|
490
|
+
}
|
|
491
|
+
currentConfig = result.data;
|
|
492
|
+
return currentConfig;
|
|
493
|
+
}
|
|
494
|
+
function getConfig() {
|
|
495
|
+
const provider = getConfigProvider();
|
|
496
|
+
if (provider) {
|
|
497
|
+
return provider.getConfig();
|
|
498
|
+
}
|
|
499
|
+
return currentConfig;
|
|
500
|
+
}
|
|
501
|
+
function getValidOperationTypes() {
|
|
502
|
+
return Object.keys(getConfig().operationCosts);
|
|
503
|
+
}
|
|
504
|
+
function isValidOperationType(type) {
|
|
505
|
+
return type in getConfig().operationCosts;
|
|
506
|
+
}
|
|
507
|
+
function getConfigOperationCost(operationType) {
|
|
508
|
+
const cost = getConfig().operationCosts[operationType];
|
|
509
|
+
if (cost === void 0) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`Unknown operation type: ${operationType}. Valid types: ${getValidOperationTypes().join(", ")}`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
return cost;
|
|
515
|
+
}
|
|
516
|
+
function getConfigTierConfig(tier) {
|
|
517
|
+
return getConfig().tierConfigs[tier];
|
|
518
|
+
}
|
|
519
|
+
function getConfigMonthlyLimit(tier) {
|
|
520
|
+
const config = getConfig().tierConfigs[tier];
|
|
521
|
+
return config.monthlyCredits === 0 ? Infinity : config.monthlyCredits;
|
|
522
|
+
}
|
|
523
|
+
function isFeatureEnabled(feature) {
|
|
524
|
+
return getConfig().features[feature];
|
|
525
|
+
}
|
|
526
|
+
function snakeCaseToTitleCase(str) {
|
|
527
|
+
return str.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
528
|
+
}
|
|
529
|
+
function getOperationLabel(operationType) {
|
|
530
|
+
const labels = getConfig().operationLabels;
|
|
531
|
+
if (labels && operationType in labels) {
|
|
532
|
+
return labels[operationType];
|
|
533
|
+
}
|
|
534
|
+
return snakeCaseToTitleCase(operationType);
|
|
535
|
+
}
|
|
536
|
+
function getOperationLabels() {
|
|
537
|
+
const config = getConfig();
|
|
538
|
+
const result = {};
|
|
539
|
+
for (const key of Object.keys(config.operationCosts)) {
|
|
540
|
+
result[key] = config.operationLabels?.[key] ?? snakeCaseToTitleCase(key);
|
|
541
|
+
}
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
function resetConfig() {
|
|
545
|
+
currentConfig = DEFAULT_CONFIG;
|
|
546
|
+
}
|
|
547
|
+
initializeConfig();
|
|
548
|
+
|
|
549
|
+
// src/core/operations.ts
|
|
550
|
+
async function commitReservationWithJournal(repository, userId, reservationId) {
|
|
551
|
+
const reservation = await repository.getReservation(userId, reservationId);
|
|
552
|
+
if (!reservation) {
|
|
553
|
+
throw new Error(`Reservation ${reservationId} not found`);
|
|
554
|
+
}
|
|
555
|
+
await repository.commitReservationAtomic(userId, reservationId);
|
|
556
|
+
const credits = await repository.getUserCredits(userId);
|
|
557
|
+
if (credits) {
|
|
558
|
+
await repository.createJournalEntry({
|
|
559
|
+
userId,
|
|
560
|
+
entryType: "debit",
|
|
561
|
+
amount: reservation.amount,
|
|
562
|
+
balanceAfter: credits.balance,
|
|
563
|
+
source: "operation_commit",
|
|
564
|
+
referenceId: reservationId,
|
|
565
|
+
referenceType: "reservation",
|
|
566
|
+
description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
567
|
+
metadata: {
|
|
568
|
+
operationType: reservation.operationType
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function releaseReservationWithJournal(repository, userId, reservationId) {
|
|
574
|
+
const reservation = await repository.getReservation(userId, reservationId);
|
|
575
|
+
await repository.releaseReservationAtomic(userId, reservationId);
|
|
576
|
+
if (reservation?.status === "reserved") {
|
|
577
|
+
const credits = await repository.getUserCredits(userId);
|
|
578
|
+
if (credits) {
|
|
579
|
+
await repository.createJournalEntry({
|
|
580
|
+
userId,
|
|
581
|
+
entryType: "credit",
|
|
582
|
+
amount: 0,
|
|
583
|
+
// No actual credits returned (they were reserved, not spent)
|
|
584
|
+
balanceAfter: credits.balance,
|
|
585
|
+
source: "operation_release",
|
|
586
|
+
referenceId: reservationId,
|
|
587
|
+
referenceType: "reservation",
|
|
588
|
+
description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
589
|
+
metadata: {
|
|
590
|
+
operationType: reservation.operationType,
|
|
591
|
+
amount: reservation.amount
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function reserveCreditsForOperation(repository, userId, amount, operationType, expiryMs = 5 * 60 * 1e3) {
|
|
598
|
+
const expiresAt = new Date(Date.now() + expiryMs);
|
|
599
|
+
return repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/config/costs.ts
|
|
603
|
+
function getOperationCosts() {
|
|
604
|
+
return { ...getConfig().operationCosts };
|
|
605
|
+
}
|
|
606
|
+
function getOperationCost(type) {
|
|
607
|
+
return getConfigOperationCost(type);
|
|
608
|
+
}
|
|
609
|
+
function getTierConfig(tier) {
|
|
610
|
+
return getConfigTierConfig(tier);
|
|
611
|
+
}
|
|
612
|
+
function getMonthlyLimit(tier) {
|
|
613
|
+
return getConfigMonthlyLimit(tier);
|
|
614
|
+
}
|
|
615
|
+
function getDefaultFreeCredits() {
|
|
616
|
+
return getConfig().defaultFreeCredits;
|
|
617
|
+
}
|
|
618
|
+
function getReservationExpiryMs() {
|
|
619
|
+
return getConfig().reservationExpiryMs;
|
|
620
|
+
}
|
|
621
|
+
function getOperationCostsWithLabels() {
|
|
622
|
+
const config = getConfig();
|
|
623
|
+
const result = {};
|
|
624
|
+
for (const [key, cost] of Object.entries(config.operationCosts)) {
|
|
625
|
+
result[key] = {
|
|
626
|
+
key,
|
|
627
|
+
cost,
|
|
628
|
+
label: getOperationLabel(key)
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/repository/types.ts
|
|
635
|
+
function toClientUserCredits(credits) {
|
|
636
|
+
return {
|
|
637
|
+
userId: credits.userId,
|
|
638
|
+
balance: credits.balance,
|
|
639
|
+
bonusCredits: credits.bonusCredits ?? 0,
|
|
640
|
+
reserved: credits.reserved,
|
|
641
|
+
tier: credits.tier,
|
|
642
|
+
monthlyLimit: credits.monthlyLimit,
|
|
643
|
+
monthlyUsed: credits.monthlyUsed,
|
|
644
|
+
monthlyResetAt: toISOString(credits.monthlyResetAt),
|
|
645
|
+
subscriptionExpiresAt: credits.subscriptionExpiresAt ? toISOString(credits.subscriptionExpiresAt) : null,
|
|
646
|
+
createdAt: toISOString(credits.createdAt),
|
|
647
|
+
updatedAt: toISOString(credits.updatedAt)
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function toISOString(value) {
|
|
651
|
+
if (!value) return (/* @__PURE__ */ new Date()).toISOString();
|
|
652
|
+
if (typeof value === "string") return value;
|
|
653
|
+
if (value instanceof Date) return value.toISOString();
|
|
654
|
+
if (typeof value === "object" && "toDate" in value && typeof value.toDate === "function") {
|
|
655
|
+
return value.toDate().toISOString();
|
|
656
|
+
}
|
|
657
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/repository/utils.ts
|
|
661
|
+
function generateId() {
|
|
662
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
663
|
+
}
|
|
664
|
+
function toDate2(value) {
|
|
665
|
+
if (value instanceof Date) return value;
|
|
666
|
+
if (typeof value === "string") return new Date(value);
|
|
667
|
+
if (typeof value === "object" && value !== null && "toDate" in value) {
|
|
668
|
+
return value.toDate();
|
|
669
|
+
}
|
|
670
|
+
return /* @__PURE__ */ new Date();
|
|
671
|
+
}
|
|
672
|
+
function getNextMonthlyReset(from = /* @__PURE__ */ new Date()) {
|
|
673
|
+
const next = new Date(from);
|
|
674
|
+
next.setMonth(next.getMonth() + 1);
|
|
675
|
+
next.setDate(1);
|
|
676
|
+
next.setHours(0, 0, 0, 0);
|
|
677
|
+
return next;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/repository/memory/index.ts
|
|
681
|
+
var InMemoryCreditRepository = class {
|
|
682
|
+
users = /* @__PURE__ */ new Map();
|
|
683
|
+
reservations = /* @__PURE__ */ new Map();
|
|
684
|
+
transactions = /* @__PURE__ */ new Map();
|
|
685
|
+
usageLogs = [];
|
|
686
|
+
journalEntries = /* @__PURE__ */ new Map();
|
|
687
|
+
// ==================== User Credits ====================
|
|
688
|
+
async getUserCredits(userId) {
|
|
689
|
+
return this.users.get(userId) ?? null;
|
|
690
|
+
}
|
|
691
|
+
async initializeUserCredits(userId, tier, initialBalance) {
|
|
692
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
693
|
+
const credits = {
|
|
694
|
+
userId,
|
|
695
|
+
balance: initialBalance,
|
|
696
|
+
bonusCredits: 0,
|
|
697
|
+
reserved: 0,
|
|
698
|
+
tier,
|
|
699
|
+
monthlyLimit: getConfigMonthlyLimit(tier),
|
|
700
|
+
monthlyUsed: 0,
|
|
701
|
+
monthlyResetAt: getNextMonthlyReset().toISOString(),
|
|
702
|
+
subscriptionExpiresAt: null,
|
|
703
|
+
createdAt: now,
|
|
704
|
+
updatedAt: now
|
|
705
|
+
};
|
|
706
|
+
this.users.set(userId, credits);
|
|
707
|
+
return credits;
|
|
708
|
+
}
|
|
709
|
+
async updateUserCredits(userId, updates) {
|
|
710
|
+
const credits = this.users.get(userId);
|
|
711
|
+
if (!credits) {
|
|
712
|
+
throw new Error(`User ${userId} not found`);
|
|
713
|
+
}
|
|
714
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
715
|
+
if (updates.balance !== void 0) credits.balance = updates.balance;
|
|
716
|
+
if (updates.bonusCredits !== void 0) credits.bonusCredits = updates.bonusCredits;
|
|
717
|
+
if (updates.reserved !== void 0) credits.reserved = updates.reserved;
|
|
718
|
+
if (updates.tier !== void 0) credits.tier = updates.tier;
|
|
719
|
+
if (updates.monthlyLimit !== void 0) credits.monthlyLimit = updates.monthlyLimit;
|
|
720
|
+
if (updates.monthlyUsed !== void 0) credits.monthlyUsed = updates.monthlyUsed;
|
|
721
|
+
if (updates.monthlyResetAt !== void 0) {
|
|
722
|
+
credits.monthlyResetAt = updates.monthlyResetAt instanceof Date ? updates.monthlyResetAt.toISOString() : updates.monthlyResetAt;
|
|
723
|
+
}
|
|
724
|
+
if (updates.subscriptionExpiresAt !== void 0) {
|
|
725
|
+
if (updates.subscriptionExpiresAt === null) {
|
|
726
|
+
credits.subscriptionExpiresAt = null;
|
|
727
|
+
} else {
|
|
728
|
+
credits.subscriptionExpiresAt = updates.subscriptionExpiresAt instanceof Date ? updates.subscriptionExpiresAt.toISOString() : updates.subscriptionExpiresAt;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (updates.balanceIncrement !== void 0) {
|
|
732
|
+
credits.balance += updates.balanceIncrement;
|
|
733
|
+
}
|
|
734
|
+
if (updates.bonusCreditsIncrement !== void 0) {
|
|
735
|
+
credits.bonusCredits += updates.bonusCreditsIncrement;
|
|
736
|
+
}
|
|
737
|
+
if (updates.reservedIncrement !== void 0) {
|
|
738
|
+
credits.reserved += updates.reservedIncrement;
|
|
739
|
+
}
|
|
740
|
+
if (updates.monthlyUsedIncrement !== void 0) {
|
|
741
|
+
credits.monthlyUsed += updates.monthlyUsedIncrement;
|
|
742
|
+
}
|
|
743
|
+
credits.updatedAt = now;
|
|
744
|
+
this.users.set(userId, credits);
|
|
745
|
+
}
|
|
746
|
+
async updateUserTier(userId, input) {
|
|
747
|
+
const credits = this.users.get(userId);
|
|
748
|
+
if (!credits) {
|
|
749
|
+
throw new Error(`User ${userId} not found`);
|
|
750
|
+
}
|
|
751
|
+
credits.tier = input.tier;
|
|
752
|
+
credits.monthlyLimit = input.monthlyLimit;
|
|
753
|
+
if (input.balance !== void 0) credits.balance = input.balance;
|
|
754
|
+
if (input.monthlyUsed !== void 0) credits.monthlyUsed = input.monthlyUsed;
|
|
755
|
+
if (input.subscriptionExpiresAt !== void 0) {
|
|
756
|
+
if (input.subscriptionExpiresAt === null) {
|
|
757
|
+
credits.subscriptionExpiresAt = null;
|
|
758
|
+
} else {
|
|
759
|
+
credits.subscriptionExpiresAt = input.subscriptionExpiresAt instanceof Date ? input.subscriptionExpiresAt.toISOString() : input.subscriptionExpiresAt;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
763
|
+
this.users.set(userId, credits);
|
|
764
|
+
}
|
|
765
|
+
// ==================== Reservations ====================
|
|
766
|
+
async createReservation(input) {
|
|
767
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
768
|
+
const reservation = {
|
|
769
|
+
id: generateId(),
|
|
770
|
+
userId: input.userId,
|
|
771
|
+
amount: input.amount,
|
|
772
|
+
operationType: input.operationType,
|
|
773
|
+
status: "reserved",
|
|
774
|
+
createdAt: now,
|
|
775
|
+
expiresAt: input.expiresAt.toISOString()
|
|
776
|
+
};
|
|
777
|
+
if (!this.reservations.has(input.userId)) {
|
|
778
|
+
this.reservations.set(input.userId, /* @__PURE__ */ new Map());
|
|
779
|
+
}
|
|
780
|
+
this.reservations.get(input.userId).set(reservation.id, reservation);
|
|
781
|
+
return reservation;
|
|
782
|
+
}
|
|
783
|
+
async getReservation(userId, reservationId) {
|
|
784
|
+
const userReservations = this.reservations.get(userId);
|
|
785
|
+
if (!userReservations) return null;
|
|
786
|
+
return userReservations.get(reservationId) ?? null;
|
|
787
|
+
}
|
|
788
|
+
async updateReservationStatus(userId, reservationId, status, completedAt) {
|
|
789
|
+
const userReservations = this.reservations.get(userId);
|
|
790
|
+
if (!userReservations) {
|
|
791
|
+
throw new Error(`No reservations found for user ${userId}`);
|
|
792
|
+
}
|
|
793
|
+
const reservation = userReservations.get(reservationId);
|
|
794
|
+
if (!reservation) {
|
|
795
|
+
throw new Error(`Reservation ${reservationId} not found`);
|
|
796
|
+
}
|
|
797
|
+
reservation.status = status;
|
|
798
|
+
if (completedAt) reservation.completedAt = completedAt.toISOString();
|
|
799
|
+
userReservations.set(reservationId, reservation);
|
|
800
|
+
}
|
|
801
|
+
// ==================== Atomic Operations ====================
|
|
802
|
+
async reserveCreditsAtomic(userId, amount, operationType, expiresAt) {
|
|
803
|
+
const credits = this.users.get(userId);
|
|
804
|
+
if (!credits) {
|
|
805
|
+
throw new Error(`User ${userId} not found`);
|
|
806
|
+
}
|
|
807
|
+
const available = credits.balance + credits.bonusCredits - credits.reserved;
|
|
808
|
+
if (available < amount) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`Insufficient credits. Available: ${available}, Required: ${amount}`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const reservation = await this.createReservation({
|
|
814
|
+
userId,
|
|
815
|
+
amount,
|
|
816
|
+
operationType,
|
|
817
|
+
expiresAt
|
|
818
|
+
});
|
|
819
|
+
credits.reserved += amount;
|
|
820
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
821
|
+
this.users.set(userId, credits);
|
|
822
|
+
return reservation;
|
|
823
|
+
}
|
|
824
|
+
async commitReservationAtomic(userId, reservationId) {
|
|
825
|
+
const credits = this.users.get(userId);
|
|
826
|
+
if (!credits) {
|
|
827
|
+
throw new Error(`User ${userId} not found`);
|
|
828
|
+
}
|
|
829
|
+
const reservation = await this.getReservation(userId, reservationId);
|
|
830
|
+
if (!reservation) {
|
|
831
|
+
throw new Error(`Reservation ${reservationId} not found`);
|
|
832
|
+
}
|
|
833
|
+
if (reservation.status === "committed") {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (reservation.status !== "reserved") {
|
|
837
|
+
throw new Error(
|
|
838
|
+
`Cannot commit reservation in ${reservation.status} state`
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
const amount = reservation.amount;
|
|
842
|
+
credits.balance -= amount;
|
|
843
|
+
credits.reserved -= amount;
|
|
844
|
+
credits.monthlyUsed += amount;
|
|
845
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
846
|
+
this.users.set(userId, credits);
|
|
847
|
+
await this.updateReservationStatus(userId, reservationId, "committed", /* @__PURE__ */ new Date());
|
|
848
|
+
}
|
|
849
|
+
async releaseReservationAtomic(userId, reservationId) {
|
|
850
|
+
const credits = this.users.get(userId);
|
|
851
|
+
if (!credits) {
|
|
852
|
+
throw new Error(`User ${userId} not found`);
|
|
853
|
+
}
|
|
854
|
+
const reservation = await this.getReservation(userId, reservationId);
|
|
855
|
+
if (!reservation) {
|
|
856
|
+
throw new Error(`Reservation ${reservationId} not found`);
|
|
857
|
+
}
|
|
858
|
+
if (reservation.status !== "reserved") {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
credits.reserved -= reservation.amount;
|
|
862
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
863
|
+
this.users.set(userId, credits);
|
|
864
|
+
await this.updateReservationStatus(userId, reservationId, "released", /* @__PURE__ */ new Date());
|
|
865
|
+
}
|
|
866
|
+
async addCreditsAtomic(userId, amount, description, paymentRef) {
|
|
867
|
+
const credits = this.users.get(userId);
|
|
868
|
+
if (!credits) {
|
|
869
|
+
throw new Error(`User ${userId} not found`);
|
|
870
|
+
}
|
|
871
|
+
const previousBalance = credits.bonusCredits;
|
|
872
|
+
credits.bonusCredits += amount;
|
|
873
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
874
|
+
this.users.set(userId, credits);
|
|
875
|
+
await this.createTransaction({
|
|
876
|
+
userId,
|
|
877
|
+
type: "purchase",
|
|
878
|
+
amount,
|
|
879
|
+
description,
|
|
880
|
+
paymentRef,
|
|
881
|
+
previousBalance,
|
|
882
|
+
newBalance: credits.bonusCredits
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
async deductCreditsAtomic(userId, amount) {
|
|
886
|
+
if (amount <= 0) {
|
|
887
|
+
throw new Error(`deductCreditsAtomic amount must be positive (got ${amount})`);
|
|
888
|
+
}
|
|
889
|
+
const credits = this.users.get(userId);
|
|
890
|
+
if (!credits) {
|
|
891
|
+
throw new Error(`User credits not found for userId: ${userId}`);
|
|
892
|
+
}
|
|
893
|
+
const available = credits.balance + credits.bonusCredits - credits.reserved;
|
|
894
|
+
if (available < amount) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
`Insufficient credits. Available: ${available}, requested: ${amount}`
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
const balanceDeduction = Math.min(credits.balance, amount);
|
|
900
|
+
const bonusDeduction = amount - balanceDeduction;
|
|
901
|
+
const previousBalance = credits.balance + credits.bonusCredits;
|
|
902
|
+
credits.balance -= balanceDeduction;
|
|
903
|
+
credits.bonusCredits -= bonusDeduction;
|
|
904
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
905
|
+
this.users.set(userId, credits);
|
|
906
|
+
return { previousBalance, newBalance: previousBalance - amount };
|
|
907
|
+
}
|
|
908
|
+
// ==================== Transactions ====================
|
|
909
|
+
async createTransaction(input) {
|
|
910
|
+
const transaction = {
|
|
911
|
+
id: generateId(),
|
|
912
|
+
userId: input.userId,
|
|
913
|
+
type: input.type,
|
|
914
|
+
amount: input.amount,
|
|
915
|
+
description: input.description,
|
|
916
|
+
paymentRef: input.paymentRef,
|
|
917
|
+
previousBalance: input.previousBalance,
|
|
918
|
+
newBalance: input.newBalance,
|
|
919
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
920
|
+
};
|
|
921
|
+
if (!this.transactions.has(input.userId)) {
|
|
922
|
+
this.transactions.set(input.userId, []);
|
|
923
|
+
}
|
|
924
|
+
this.transactions.get(input.userId).push(transaction);
|
|
925
|
+
return transaction;
|
|
926
|
+
}
|
|
927
|
+
async getTransactions(userId, limit = 50, offset = 0) {
|
|
928
|
+
const userTransactions = this.transactions.get(userId) ?? [];
|
|
929
|
+
const sorted = [...userTransactions].sort((a, b) => {
|
|
930
|
+
const aDate = toDate2(a.createdAt).getTime();
|
|
931
|
+
const bDate = toDate2(b.createdAt).getTime();
|
|
932
|
+
return bDate - aDate;
|
|
933
|
+
});
|
|
934
|
+
return sorted.slice(offset, offset + limit);
|
|
935
|
+
}
|
|
936
|
+
// ==================== Usage Logs ====================
|
|
937
|
+
async logUsage(input) {
|
|
938
|
+
const log = {
|
|
939
|
+
id: generateId(),
|
|
940
|
+
userId: input.userId,
|
|
941
|
+
operationType: input.operationType,
|
|
942
|
+
provider: input.provider,
|
|
943
|
+
creditsUsed: input.creditsUsed,
|
|
944
|
+
success: input.success,
|
|
945
|
+
errorMessage: input.errorMessage,
|
|
946
|
+
resourceId: input.resourceId,
|
|
947
|
+
resourceType: input.resourceType,
|
|
948
|
+
requestId: input.requestId,
|
|
949
|
+
metadata: input.metadata,
|
|
950
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
951
|
+
};
|
|
952
|
+
this.usageLogs.push(log);
|
|
953
|
+
return log;
|
|
954
|
+
}
|
|
955
|
+
async getUsageLogs(query) {
|
|
956
|
+
let results = [...this.usageLogs];
|
|
957
|
+
if (query.userId) {
|
|
958
|
+
results = results.filter((log) => log.userId === query.userId);
|
|
959
|
+
}
|
|
960
|
+
if (query.operationType) {
|
|
961
|
+
results = results.filter((log) => log.operationType === query.operationType);
|
|
962
|
+
}
|
|
963
|
+
if (query.success !== void 0) {
|
|
964
|
+
results = results.filter((log) => log.success === query.success);
|
|
965
|
+
}
|
|
966
|
+
if (query.startDate) {
|
|
967
|
+
const startTime = query.startDate.getTime();
|
|
968
|
+
results = results.filter(
|
|
969
|
+
(log) => toDate2(log.createdAt).getTime() >= startTime
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
if (query.endDate) {
|
|
973
|
+
const endTime = query.endDate.getTime();
|
|
974
|
+
results = results.filter(
|
|
975
|
+
(log) => toDate2(log.createdAt).getTime() <= endTime
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
results.sort((a, b) => {
|
|
979
|
+
return toDate2(b.createdAt).getTime() - toDate2(a.createdAt).getTime();
|
|
980
|
+
});
|
|
981
|
+
const offset = query.offset ?? 0;
|
|
982
|
+
const limit = query.limit ?? 50;
|
|
983
|
+
return results.slice(offset, offset + limit);
|
|
984
|
+
}
|
|
985
|
+
async getUsageLogsCount(query) {
|
|
986
|
+
const results = await this.getUsageLogs({ ...query, limit: Infinity, offset: 0 });
|
|
987
|
+
return results.length;
|
|
988
|
+
}
|
|
989
|
+
// ==================== Journal Entries ====================
|
|
990
|
+
async createJournalEntry(input) {
|
|
991
|
+
const entry = {
|
|
992
|
+
id: generateId(),
|
|
993
|
+
userId: input.userId,
|
|
994
|
+
entryType: input.entryType,
|
|
995
|
+
amount: input.amount,
|
|
996
|
+
balanceAfter: input.balanceAfter,
|
|
997
|
+
source: input.source,
|
|
998
|
+
referenceId: input.referenceId,
|
|
999
|
+
referenceType: input.referenceType,
|
|
1000
|
+
description: input.description,
|
|
1001
|
+
metadata: input.metadata,
|
|
1002
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1003
|
+
};
|
|
1004
|
+
if (!this.journalEntries.has(input.userId)) {
|
|
1005
|
+
this.journalEntries.set(input.userId, []);
|
|
1006
|
+
}
|
|
1007
|
+
this.journalEntries.get(input.userId).push(entry);
|
|
1008
|
+
return entry;
|
|
1009
|
+
}
|
|
1010
|
+
async getJournalEntries(query) {
|
|
1011
|
+
let results = this.journalEntries.get(query.userId) ?? [];
|
|
1012
|
+
if (query.source) {
|
|
1013
|
+
results = results.filter((entry) => entry.source === query.source);
|
|
1014
|
+
}
|
|
1015
|
+
if (query.referenceType) {
|
|
1016
|
+
results = results.filter((entry) => entry.referenceType === query.referenceType);
|
|
1017
|
+
}
|
|
1018
|
+
if (query.startDate) {
|
|
1019
|
+
const startTime = query.startDate.getTime();
|
|
1020
|
+
results = results.filter(
|
|
1021
|
+
(entry) => toDate2(entry.createdAt).getTime() >= startTime
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
if (query.endDate) {
|
|
1025
|
+
const endTime = query.endDate.getTime();
|
|
1026
|
+
results = results.filter(
|
|
1027
|
+
(entry) => toDate2(entry.createdAt).getTime() <= endTime
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
results = [...results].sort((a, b) => {
|
|
1031
|
+
return toDate2(b.createdAt).getTime() - toDate2(a.createdAt).getTime();
|
|
1032
|
+
});
|
|
1033
|
+
const offset = query.offset ?? 0;
|
|
1034
|
+
const limit = query.limit ?? 50;
|
|
1035
|
+
return results.slice(offset, offset + limit);
|
|
1036
|
+
}
|
|
1037
|
+
async getJournalEntriesCount(query) {
|
|
1038
|
+
const results = await this.getJournalEntries({
|
|
1039
|
+
...query,
|
|
1040
|
+
limit: Infinity,
|
|
1041
|
+
offset: 0
|
|
1042
|
+
});
|
|
1043
|
+
return results.length;
|
|
1044
|
+
}
|
|
1045
|
+
// ==================== Cleanup Operations ====================
|
|
1046
|
+
async findAndExpireReservations(_batchSize = 100, _maxIterations = 100) {
|
|
1047
|
+
const now = /* @__PURE__ */ new Date();
|
|
1048
|
+
let expiredCount = 0;
|
|
1049
|
+
let creditsReleased = 0;
|
|
1050
|
+
const errors = [];
|
|
1051
|
+
for (const [userId, userReservations] of this.reservations) {
|
|
1052
|
+
for (const [reservationId, reservation] of userReservations) {
|
|
1053
|
+
if (reservation.status === "reserved" && toDate2(reservation.expiresAt).getTime() < now.getTime()) {
|
|
1054
|
+
try {
|
|
1055
|
+
await this.releaseReservationAtomic(userId, reservationId);
|
|
1056
|
+
reservation.status = "expired";
|
|
1057
|
+
userReservations.set(reservationId, reservation);
|
|
1058
|
+
expiredCount++;
|
|
1059
|
+
creditsReleased += reservation.amount;
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
errors.push(
|
|
1062
|
+
`Failed to expire reservation ${reservationId}: ${error}`
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return { expiredCount, creditsReleased, errors };
|
|
1069
|
+
}
|
|
1070
|
+
// ==================== Atomic Monthly Reset ====================
|
|
1071
|
+
async atomicMonthlyReset(userId, tier, expectedResetAt) {
|
|
1072
|
+
const credits = this.users.get(userId);
|
|
1073
|
+
if (!credits) {
|
|
1074
|
+
throw new Error(`User ${userId} not found`);
|
|
1075
|
+
}
|
|
1076
|
+
const currentResetAt = toDate2(credits.monthlyResetAt).getTime();
|
|
1077
|
+
const expected = toDate2(expectedResetAt).getTime();
|
|
1078
|
+
if (currentResetAt !== expected) {
|
|
1079
|
+
return { wasReset: false, credits };
|
|
1080
|
+
}
|
|
1081
|
+
const newBalance = getConfigMonthlyLimit(tier);
|
|
1082
|
+
const nextReset = getNextMonthlyReset();
|
|
1083
|
+
credits.balance = newBalance === Infinity ? credits.balance : newBalance;
|
|
1084
|
+
credits.monthlyUsed = 0;
|
|
1085
|
+
credits.monthlyResetAt = nextReset.toISOString();
|
|
1086
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1087
|
+
this.users.set(userId, credits);
|
|
1088
|
+
return { wasReset: true, credits };
|
|
1089
|
+
}
|
|
1090
|
+
// ==================== Subscription Expiry ====================
|
|
1091
|
+
async checkAndHandleSubscriptionExpiry(userId, gracePeriodDays = 3) {
|
|
1092
|
+
const credits = this.users.get(userId);
|
|
1093
|
+
if (!credits) {
|
|
1094
|
+
throw new Error(`User ${userId} not found`);
|
|
1095
|
+
}
|
|
1096
|
+
if (credits.tier === "free" || !credits.subscriptionExpiresAt) {
|
|
1097
|
+
return {
|
|
1098
|
+
wasDowngraded: false,
|
|
1099
|
+
inGracePeriod: false,
|
|
1100
|
+
graceDaysRemaining: 0,
|
|
1101
|
+
credits
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
const now = /* @__PURE__ */ new Date();
|
|
1105
|
+
const expiresAt = toDate2(credits.subscriptionExpiresAt);
|
|
1106
|
+
const daysSinceExpiry = (now.getTime() - expiresAt.getTime()) / (1e3 * 60 * 60 * 24);
|
|
1107
|
+
if (daysSinceExpiry <= 0) {
|
|
1108
|
+
return {
|
|
1109
|
+
wasDowngraded: false,
|
|
1110
|
+
inGracePeriod: false,
|
|
1111
|
+
graceDaysRemaining: 0,
|
|
1112
|
+
credits
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
if (daysSinceExpiry <= gracePeriodDays) {
|
|
1116
|
+
return {
|
|
1117
|
+
wasDowngraded: false,
|
|
1118
|
+
inGracePeriod: true,
|
|
1119
|
+
graceDaysRemaining: Math.ceil(gracePeriodDays - daysSinceExpiry),
|
|
1120
|
+
credits
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
const config = getConfig();
|
|
1124
|
+
const freeTierConfig = config.tierConfigs.free;
|
|
1125
|
+
credits.tier = "free";
|
|
1126
|
+
credits.monthlyLimit = freeTierConfig.monthlyCredits;
|
|
1127
|
+
credits.balance = Math.min(credits.balance, freeTierConfig.monthlyCredits);
|
|
1128
|
+
credits.subscriptionExpiresAt = null;
|
|
1129
|
+
credits.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1130
|
+
this.users.set(userId, credits);
|
|
1131
|
+
return {
|
|
1132
|
+
wasDowngraded: true,
|
|
1133
|
+
inGracePeriod: false,
|
|
1134
|
+
graceDaysRemaining: 0,
|
|
1135
|
+
credits
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
// ==================== Testing Utilities ====================
|
|
1139
|
+
/**
|
|
1140
|
+
* Clear all data (useful for testing)
|
|
1141
|
+
*/
|
|
1142
|
+
clear() {
|
|
1143
|
+
this.users.clear();
|
|
1144
|
+
this.reservations.clear();
|
|
1145
|
+
this.transactions.clear();
|
|
1146
|
+
this.usageLogs = [];
|
|
1147
|
+
this.journalEntries.clear();
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Get all users (useful for testing/debugging)
|
|
1151
|
+
*/
|
|
1152
|
+
getAllUsers() {
|
|
1153
|
+
return Array.from(this.users.values());
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Get all reservations for a user (useful for testing)
|
|
1157
|
+
*/
|
|
1158
|
+
getAllReservations(userId) {
|
|
1159
|
+
const userReservations = this.reservations.get(userId);
|
|
1160
|
+
if (!userReservations) return [];
|
|
1161
|
+
return Array.from(userReservations.values());
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
function createInMemoryCreditRepository() {
|
|
1165
|
+
return new InMemoryCreditRepository();
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// src/auth/api-key.ts
|
|
1169
|
+
function timingSafeEqual(a, b) {
|
|
1170
|
+
if (!a || !b) {
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
const maxLength = Math.max(a.length, b.length);
|
|
1174
|
+
const paddedA = a.padEnd(maxLength);
|
|
1175
|
+
const paddedB = b.padEnd(maxLength);
|
|
1176
|
+
let result = 0;
|
|
1177
|
+
for (let i = 0; i < maxLength; i++) {
|
|
1178
|
+
result |= paddedA.charCodeAt(i) ^ paddedB.charCodeAt(i);
|
|
1179
|
+
}
|
|
1180
|
+
return result === 0 && a.length === b.length;
|
|
1181
|
+
}
|
|
1182
|
+
function verifyBearerToken(header, secret) {
|
|
1183
|
+
if (!header || !secret) {
|
|
1184
|
+
return false;
|
|
1185
|
+
}
|
|
1186
|
+
const token = header.replace(/^Bearer\s+/i, "");
|
|
1187
|
+
return timingSafeEqual(token, secret);
|
|
1188
|
+
}
|
|
1189
|
+
var ApiKeyCreditsProvider = class {
|
|
1190
|
+
apiKey;
|
|
1191
|
+
isValidated = false;
|
|
1192
|
+
constructor(authHeader, apiKey) {
|
|
1193
|
+
this.apiKey = apiKey ?? process.env.CREDITS_ADMIN_API_KEY;
|
|
1194
|
+
if (authHeader && this.apiKey) {
|
|
1195
|
+
this.isValidated = verifyBearerToken(authHeader, this.apiKey);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async getCurrentUser() {
|
|
1199
|
+
if (!this.isValidated) {
|
|
1200
|
+
return null;
|
|
1201
|
+
}
|
|
1202
|
+
return {
|
|
1203
|
+
id: "api-admin",
|
|
1204
|
+
email: "api@admin.internal",
|
|
1205
|
+
name: "API Admin"
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
async verifyAdminAccess() {
|
|
1209
|
+
return this.isValidated;
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
function verifyAdminApiKey(authHeader, apiKey) {
|
|
1213
|
+
if (!authHeader) {
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
const expectedKey = apiKey ?? process.env.CREDITS_ADMIN_API_KEY;
|
|
1217
|
+
if (!expectedKey) {
|
|
1218
|
+
console.warn("CREDITS_ADMIN_API_KEY not configured");
|
|
1219
|
+
return false;
|
|
1220
|
+
}
|
|
1221
|
+
return verifyBearerToken(authHeader, expectedKey);
|
|
1222
|
+
}
|
|
1223
|
+
function createApiKeyProvider(request, apiKey) {
|
|
1224
|
+
const authHeader = request.headers.get("Authorization");
|
|
1225
|
+
return new ApiKeyCreditsProvider(authHeader, apiKey);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// src/service/credits-service.ts
|
|
1229
|
+
function isPastMonthlyReset(resetAt) {
|
|
1230
|
+
if (!resetAt) return false;
|
|
1231
|
+
const resetDate = toDate(resetAt);
|
|
1232
|
+
return /* @__PURE__ */ new Date() >= resetDate;
|
|
1233
|
+
}
|
|
1234
|
+
var CreditsService = class {
|
|
1235
|
+
constructor(repository) {
|
|
1236
|
+
this.repository = repository;
|
|
1237
|
+
}
|
|
1238
|
+
lowBalanceCallback;
|
|
1239
|
+
subscriptionExpiredCallback;
|
|
1240
|
+
/**
|
|
1241
|
+
* Set callback for low balance notifications
|
|
1242
|
+
*/
|
|
1243
|
+
setLowBalanceCallback(callback) {
|
|
1244
|
+
this.lowBalanceCallback = callback;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Set callback for subscription expired notifications
|
|
1248
|
+
*/
|
|
1249
|
+
setSubscriptionExpiredCallback(callback) {
|
|
1250
|
+
this.subscriptionExpiredCallback = callback;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Get user credits, performing monthly reset and subscription expiry checks if needed
|
|
1254
|
+
*
|
|
1255
|
+
* This method uses atomic operations to prevent race conditions:
|
|
1256
|
+
* 1. Checks subscription expiry with grace period
|
|
1257
|
+
* 2. Atomically performs monthly reset if needed (with optimistic locking)
|
|
1258
|
+
*
|
|
1259
|
+
* @param userId - User ID
|
|
1260
|
+
* @returns User credits or null if not found
|
|
1261
|
+
*/
|
|
1262
|
+
async getUserCredits(userId) {
|
|
1263
|
+
let data = await this.repository.getUserCredits(userId);
|
|
1264
|
+
if (!data) {
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
if (data.tier !== "free" && data.subscriptionExpiresAt) {
|
|
1268
|
+
const expiryResult = await this.repository.checkAndHandleSubscriptionExpiry(
|
|
1269
|
+
userId,
|
|
1270
|
+
getConfig().subscriptionGracePeriodDays
|
|
1271
|
+
);
|
|
1272
|
+
if (expiryResult.wasDowngraded) {
|
|
1273
|
+
await this.repository.createJournalEntry({
|
|
1274
|
+
userId,
|
|
1275
|
+
entryType: "debit",
|
|
1276
|
+
amount: 0,
|
|
1277
|
+
// No credits deducted, just tier change
|
|
1278
|
+
balanceAfter: expiryResult.credits.balance,
|
|
1279
|
+
source: "subscription_downgrade",
|
|
1280
|
+
referenceId: `downgrade-${Date.now()}`,
|
|
1281
|
+
referenceType: "subscription",
|
|
1282
|
+
description: `Subscription expired. Downgraded from ${data.tier} to free tier.`,
|
|
1283
|
+
metadata: {
|
|
1284
|
+
previousTier: data.tier,
|
|
1285
|
+
previousBalance: data.balance,
|
|
1286
|
+
newBalance: expiryResult.credits.balance
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
if (this.subscriptionExpiredCallback) {
|
|
1290
|
+
this.subscriptionExpiredCallback(userId, true).catch((error) => {
|
|
1291
|
+
console.error("[Credits] Failed to send subscription expired notification:", error);
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
data = expiryResult.credits;
|
|
1296
|
+
}
|
|
1297
|
+
if (isPastMonthlyReset(data.monthlyResetAt)) {
|
|
1298
|
+
const expectedResetAt = toDate(data.monthlyResetAt);
|
|
1299
|
+
const resetResult = await this.repository.atomicMonthlyReset(
|
|
1300
|
+
userId,
|
|
1301
|
+
data.tier,
|
|
1302
|
+
expectedResetAt
|
|
1303
|
+
);
|
|
1304
|
+
if (resetResult.wasReset) {
|
|
1305
|
+
const balanceChange = resetResult.credits.balance - data.balance;
|
|
1306
|
+
if (balanceChange !== 0) {
|
|
1307
|
+
await this.repository.createJournalEntry({
|
|
1308
|
+
userId,
|
|
1309
|
+
entryType: balanceChange > 0 ? "credit" : "debit",
|
|
1310
|
+
amount: Math.abs(balanceChange),
|
|
1311
|
+
balanceAfter: resetResult.credits.balance,
|
|
1312
|
+
source: "monthly_reset",
|
|
1313
|
+
referenceId: `reset-${Date.now()}`,
|
|
1314
|
+
referenceType: "reset",
|
|
1315
|
+
description: `Monthly credit reset for ${data.tier} tier.`,
|
|
1316
|
+
metadata: {
|
|
1317
|
+
tier: data.tier,
|
|
1318
|
+
previousBalance: data.balance,
|
|
1319
|
+
newBalance: resetResult.credits.balance
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
data = resetResult.credits;
|
|
1325
|
+
}
|
|
1326
|
+
return toClientUserCredits(data);
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Initialize credits for a new user with free tier
|
|
1330
|
+
* @param userId - User ID
|
|
1331
|
+
* @returns Initialized user credits
|
|
1332
|
+
*/
|
|
1333
|
+
async initializeUserCredits(userId) {
|
|
1334
|
+
const credits = await this.repository.initializeUserCredits(
|
|
1335
|
+
userId,
|
|
1336
|
+
"free",
|
|
1337
|
+
getConfig().defaultFreeCredits
|
|
1338
|
+
);
|
|
1339
|
+
return toClientUserCredits(credits);
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Get or create user credits
|
|
1343
|
+
* Initializes with free tier if not exists
|
|
1344
|
+
* @param userId - User ID
|
|
1345
|
+
* @returns User credits
|
|
1346
|
+
*/
|
|
1347
|
+
async getOrCreateUserCredits(userId) {
|
|
1348
|
+
const existing = await this.getUserCredits(userId);
|
|
1349
|
+
if (existing) {
|
|
1350
|
+
return existing;
|
|
1351
|
+
}
|
|
1352
|
+
return this.initializeUserCredits(userId);
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Check if user has sufficient credits for an operation
|
|
1356
|
+
* @param userId - User ID
|
|
1357
|
+
* @param requiredCredits - Credits required
|
|
1358
|
+
* @returns Credit check result
|
|
1359
|
+
*/
|
|
1360
|
+
async checkCredits(userId, requiredCredits) {
|
|
1361
|
+
const credits = await this.getOrCreateUserCredits(userId);
|
|
1362
|
+
const totalBalance = credits.balance + credits.bonusCredits;
|
|
1363
|
+
const available = totalBalance - credits.reserved;
|
|
1364
|
+
const hasCredits = available >= requiredCredits;
|
|
1365
|
+
return {
|
|
1366
|
+
hasCredits,
|
|
1367
|
+
balance: totalBalance,
|
|
1368
|
+
required: requiredCredits,
|
|
1369
|
+
shortfall: hasCredits ? 0 : requiredCredits - available
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Reserve credits for an operation (phase 1 of two-phase commit)
|
|
1374
|
+
* Creates a reservation and locks the credits
|
|
1375
|
+
* @param userId - User ID
|
|
1376
|
+
* @param amount - Credits to reserve
|
|
1377
|
+
* @param operationType - Operation type for tracking
|
|
1378
|
+
* @param options - Optional settings (e.g. a custom `ttlMs` for long-running async jobs)
|
|
1379
|
+
* @returns Reservation object
|
|
1380
|
+
* @throws Error if insufficient credits
|
|
1381
|
+
*/
|
|
1382
|
+
async reserveCredits(userId, amount, operationType, options) {
|
|
1383
|
+
const ttlMs = options?.ttlMs ?? getConfig().reservationExpiryMs;
|
|
1384
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
1385
|
+
return this.repository.reserveCreditsAtomic(userId, amount, operationType, expiresAt);
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Commit a reservation (phase 2 of two-phase commit - success)
|
|
1389
|
+
* Deducts credits and marks reservation as committed
|
|
1390
|
+
* Also triggers low balance notifications if balance drops below threshold
|
|
1391
|
+
*/
|
|
1392
|
+
async commitCredits(userId, reservationId) {
|
|
1393
|
+
const reservation = await this.repository.getReservation(userId, reservationId);
|
|
1394
|
+
if (!reservation) {
|
|
1395
|
+
throw new Error(`Reservation ${reservationId} not found`);
|
|
1396
|
+
}
|
|
1397
|
+
if (reservation.status === "committed") {
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
await this.repository.commitReservationAtomic(userId, reservationId);
|
|
1401
|
+
const credits = await this.repository.getUserCredits(userId);
|
|
1402
|
+
if (credits) {
|
|
1403
|
+
await this.repository.createJournalEntry({
|
|
1404
|
+
userId,
|
|
1405
|
+
entryType: "debit",
|
|
1406
|
+
amount: reservation.amount,
|
|
1407
|
+
balanceAfter: credits.balance,
|
|
1408
|
+
source: "operation_commit",
|
|
1409
|
+
referenceId: reservationId,
|
|
1410
|
+
referenceType: "reservation",
|
|
1411
|
+
description: `Committed ${reservation.amount} credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1412
|
+
metadata: {
|
|
1413
|
+
operationType: reservation.operationType
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
if (this.lowBalanceCallback) {
|
|
1417
|
+
this.lowBalanceCallback(userId, credits.balance).catch((error) => {
|
|
1418
|
+
console.error("[Credits] Failed to send low balance notification:", error);
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Release a reservation (phase 2 of two-phase commit - failure)
|
|
1425
|
+
* Returns reserved credits and marks reservation as released
|
|
1426
|
+
*/
|
|
1427
|
+
async releaseCredits(userId, reservationId) {
|
|
1428
|
+
const reservation = await this.repository.getReservation(userId, reservationId);
|
|
1429
|
+
await this.repository.releaseReservationAtomic(userId, reservationId);
|
|
1430
|
+
if (reservation?.status === "reserved") {
|
|
1431
|
+
const credits = await this.repository.getUserCredits(userId);
|
|
1432
|
+
if (credits) {
|
|
1433
|
+
await this.repository.createJournalEntry({
|
|
1434
|
+
userId,
|
|
1435
|
+
entryType: "credit",
|
|
1436
|
+
amount: 0,
|
|
1437
|
+
// No actual credits returned (they were reserved, not spent)
|
|
1438
|
+
balanceAfter: credits.balance,
|
|
1439
|
+
source: "operation_release",
|
|
1440
|
+
referenceId: reservationId,
|
|
1441
|
+
referenceType: "reservation",
|
|
1442
|
+
description: `Released ${reservation.amount} reserved credits for ${getOperationLabel(reservation.operationType)}`,
|
|
1443
|
+
metadata: {
|
|
1444
|
+
operationType: reservation.operationType,
|
|
1445
|
+
amount: reservation.amount
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Log usage for audit trail
|
|
1453
|
+
* @param log - Usage log data
|
|
1454
|
+
*/
|
|
1455
|
+
async logUsage(log) {
|
|
1456
|
+
await this.repository.logUsage(log);
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Add credits to user account (for purchases, bonuses, etc.)
|
|
1460
|
+
* @param userId - User ID
|
|
1461
|
+
* @param amount - Credits to add
|
|
1462
|
+
* @param description - Transaction description
|
|
1463
|
+
* @param paymentRef - Optional payment reference
|
|
1464
|
+
*/
|
|
1465
|
+
async addCredits(userId, amount, description, paymentRef) {
|
|
1466
|
+
return this.repository.addCreditsAtomic(userId, amount, description, paymentRef);
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Update user subscription tier
|
|
1470
|
+
* @param userId - User ID
|
|
1471
|
+
* @param tier - New subscription tier
|
|
1472
|
+
* @param expiresAt - Subscription expiry date (optional)
|
|
1473
|
+
*/
|
|
1474
|
+
async updateTier(userId, tier, expiresAt) {
|
|
1475
|
+
const monthlyLimit = getConfigMonthlyLimit(tier);
|
|
1476
|
+
await this.repository.updateUserTier(userId, {
|
|
1477
|
+
tier,
|
|
1478
|
+
monthlyLimit: monthlyLimit === Infinity ? 0 : monthlyLimit,
|
|
1479
|
+
// Reset balance to new tier limit if upgrading
|
|
1480
|
+
balance: tier !== "free" ? monthlyLimit === Infinity ? 999999 : monthlyLimit : void 0,
|
|
1481
|
+
monthlyUsed: tier !== "free" ? 0 : void 0,
|
|
1482
|
+
subscriptionExpiresAt: expiresAt ? expiresAt.toISOString() : null
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Get usage logs with optional filtering
|
|
1487
|
+
* @param userId - Optional user ID filter
|
|
1488
|
+
* @param limit - Max results
|
|
1489
|
+
* @param offset - Skip results
|
|
1490
|
+
* @returns List of usage logs
|
|
1491
|
+
*/
|
|
1492
|
+
async getUsageLogs(userId, limit = 50, offset = 0) {
|
|
1493
|
+
return this.repository.getUsageLogs({ userId, limit, offset });
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Get user-friendly usage history
|
|
1497
|
+
* Combines journal entries into a user-facing format
|
|
1498
|
+
*
|
|
1499
|
+
* @param userId - User ID
|
|
1500
|
+
* @param limit - Max results per page
|
|
1501
|
+
* @param offset - Skip results for pagination
|
|
1502
|
+
* @returns Paginated usage history response
|
|
1503
|
+
*/
|
|
1504
|
+
async getUsageHistory(userId, limit = 20, offset = 0) {
|
|
1505
|
+
const [entries, total] = await Promise.all([
|
|
1506
|
+
this.repository.getJournalEntries({ userId, limit, offset }),
|
|
1507
|
+
this.repository.getJournalEntriesCount({ userId })
|
|
1508
|
+
]);
|
|
1509
|
+
const historyEntries = entries.map((entry) => {
|
|
1510
|
+
const type = this.mapSourceToHistoryType(entry.source);
|
|
1511
|
+
const creditsChange = entry.entryType === "credit" ? entry.amount : -entry.amount;
|
|
1512
|
+
return {
|
|
1513
|
+
id: entry.id,
|
|
1514
|
+
type,
|
|
1515
|
+
creditsChange,
|
|
1516
|
+
balanceAfter: entry.balanceAfter,
|
|
1517
|
+
description: entry.description,
|
|
1518
|
+
createdAt: typeof entry.createdAt === "string" ? entry.createdAt : toDate(entry.createdAt).toISOString()
|
|
1519
|
+
};
|
|
1520
|
+
});
|
|
1521
|
+
return {
|
|
1522
|
+
entries: historyEntries,
|
|
1523
|
+
pagination: {
|
|
1524
|
+
total,
|
|
1525
|
+
limit,
|
|
1526
|
+
offset,
|
|
1527
|
+
hasMore: offset + entries.length < total
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Map journal entry source to user-friendly history type
|
|
1533
|
+
*/
|
|
1534
|
+
mapSourceToHistoryType(source) {
|
|
1535
|
+
switch (source) {
|
|
1536
|
+
case "operation_commit":
|
|
1537
|
+
case "reservation_expired":
|
|
1538
|
+
return "usage";
|
|
1539
|
+
case "purchase":
|
|
1540
|
+
return "purchase";
|
|
1541
|
+
case "subscription_grant":
|
|
1542
|
+
case "subscription_upgrade":
|
|
1543
|
+
case "bonus":
|
|
1544
|
+
return "bonus";
|
|
1545
|
+
case "monthly_reset":
|
|
1546
|
+
return "reset";
|
|
1547
|
+
case "refund":
|
|
1548
|
+
case "operation_release":
|
|
1549
|
+
return "refund";
|
|
1550
|
+
case "admin_adjustment":
|
|
1551
|
+
case "subscription_downgrade":
|
|
1552
|
+
case "expiry":
|
|
1553
|
+
default:
|
|
1554
|
+
return "adjustment";
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Get journal entries directly (for admin or debugging)
|
|
1559
|
+
* @param query - Journal entry query parameters
|
|
1560
|
+
* @returns List of journal entries
|
|
1561
|
+
*/
|
|
1562
|
+
async getJournalEntries(query) {
|
|
1563
|
+
return this.repository.getJournalEntries(query);
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Get the underlying repository (for advanced use cases)
|
|
1567
|
+
*/
|
|
1568
|
+
getRepository() {
|
|
1569
|
+
return this.repository;
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
function createCreditsService(repository) {
|
|
1573
|
+
return new CreditsService(repository);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/adapters/generic.ts
|
|
1577
|
+
function generateRequestId() {
|
|
1578
|
+
const timestamp = Date.now();
|
|
1579
|
+
const randomPart = Math.random().toString(36).substring(2, 14);
|
|
1580
|
+
return `req_${timestamp}_${randomPart}`;
|
|
1581
|
+
}
|
|
1582
|
+
function createGenericAdapter(config) {
|
|
1583
|
+
const { repository, authProvider, deferred, operationCosts } = config;
|
|
1584
|
+
function getOperationCost2(operationType) {
|
|
1585
|
+
const cost = operationCosts[operationType];
|
|
1586
|
+
if (cost === void 0) {
|
|
1587
|
+
throw new Error(
|
|
1588
|
+
`Unknown operation type: "${operationType}". Configured types: ${Object.keys(operationCosts).join(", ")}`
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
return cost;
|
|
1592
|
+
}
|
|
1593
|
+
function logUsage(params) {
|
|
1594
|
+
deferred.defer(async () => {
|
|
1595
|
+
await repository.logUsage({
|
|
1596
|
+
userId: params.userId,
|
|
1597
|
+
operationType: params.operationType,
|
|
1598
|
+
provider: "gemini",
|
|
1599
|
+
creditsUsed: params.creditsUsed,
|
|
1600
|
+
success: params.success,
|
|
1601
|
+
errorMessage: params.errorMessage,
|
|
1602
|
+
resourceId: params.resourceId,
|
|
1603
|
+
resourceType: params.resourceType,
|
|
1604
|
+
requestId: params.requestId
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
return {
|
|
1609
|
+
withCredits(options, handler) {
|
|
1610
|
+
return async (data) => {
|
|
1611
|
+
const requestId = generateRequestId();
|
|
1612
|
+
const user = await authProvider.getCurrentUser();
|
|
1613
|
+
if (!user?.id) {
|
|
1614
|
+
return { success: false, error: "Authentication required" };
|
|
1615
|
+
}
|
|
1616
|
+
if (isPreviewMode(data)) {
|
|
1617
|
+
const dummyReservation = createDummyReservation(user.id, options.operationType);
|
|
1618
|
+
return handler(user, data, dummyReservation);
|
|
1619
|
+
}
|
|
1620
|
+
let reservation;
|
|
1621
|
+
try {
|
|
1622
|
+
const cost = options.customCost ?? getOperationCost2(options.operationType);
|
|
1623
|
+
reservation = await reserveCreditsForOperation(
|
|
1624
|
+
repository,
|
|
1625
|
+
user.id,
|
|
1626
|
+
cost,
|
|
1627
|
+
options.operationType
|
|
1628
|
+
);
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
const message = error instanceof Error ? error.message : "Failed to reserve credits";
|
|
1631
|
+
logUsage({
|
|
1632
|
+
userId: user.id,
|
|
1633
|
+
operationType: options.operationType,
|
|
1634
|
+
creditsUsed: 0,
|
|
1635
|
+
success: false,
|
|
1636
|
+
errorMessage: message,
|
|
1637
|
+
resourceId: options.resourceId,
|
|
1638
|
+
resourceType: options.resourceType,
|
|
1639
|
+
requestId
|
|
1640
|
+
});
|
|
1641
|
+
return { success: false, error: message };
|
|
1642
|
+
}
|
|
1643
|
+
try {
|
|
1644
|
+
const result = await handler(user, data, reservation);
|
|
1645
|
+
if (result.success) {
|
|
1646
|
+
await commitReservationWithJournal(repository, user.id, reservation.id);
|
|
1647
|
+
logUsage({
|
|
1648
|
+
userId: user.id,
|
|
1649
|
+
operationType: options.operationType,
|
|
1650
|
+
creditsUsed: reservation.amount,
|
|
1651
|
+
success: true,
|
|
1652
|
+
resourceId: options.resourceId,
|
|
1653
|
+
resourceType: options.resourceType,
|
|
1654
|
+
requestId
|
|
1655
|
+
});
|
|
1656
|
+
} else {
|
|
1657
|
+
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
1658
|
+
logUsage({
|
|
1659
|
+
userId: user.id,
|
|
1660
|
+
operationType: options.operationType,
|
|
1661
|
+
creditsUsed: 0,
|
|
1662
|
+
success: false,
|
|
1663
|
+
errorMessage: result.error,
|
|
1664
|
+
resourceId: options.resourceId,
|
|
1665
|
+
resourceType: options.resourceType,
|
|
1666
|
+
requestId
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
return result;
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
1672
|
+
await releaseReservationWithJournal(repository, user.id, reservation.id);
|
|
1673
|
+
logUsage({
|
|
1674
|
+
userId: user.id,
|
|
1675
|
+
operationType: options.operationType,
|
|
1676
|
+
creditsUsed: 0,
|
|
1677
|
+
success: false,
|
|
1678
|
+
errorMessage: message,
|
|
1679
|
+
resourceId: options.resourceId,
|
|
1680
|
+
resourceType: options.resourceType,
|
|
1681
|
+
requestId
|
|
1682
|
+
});
|
|
1683
|
+
console.error("Action error:", error);
|
|
1684
|
+
return { success: false, error: message };
|
|
1685
|
+
}
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
function isPreviewMode(data) {
|
|
1691
|
+
return typeof data === "object" && data !== null && "preview" in data && data.preview === true;
|
|
1692
|
+
}
|
|
1693
|
+
function createDummyReservation(userId, operationType) {
|
|
1694
|
+
return {
|
|
1695
|
+
id: "preview-mode",
|
|
1696
|
+
userId,
|
|
1697
|
+
amount: 0,
|
|
1698
|
+
operationType,
|
|
1699
|
+
status: "released",
|
|
1700
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1701
|
+
expiresAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// src/notifications/index.ts
|
|
1706
|
+
var cooldownState = /* @__PURE__ */ new Map();
|
|
1707
|
+
function isNotificationAllowed(userId, eventType) {
|
|
1708
|
+
const state = cooldownState.get(userId);
|
|
1709
|
+
if (!state) return true;
|
|
1710
|
+
const now = Date.now();
|
|
1711
|
+
let lastNotification;
|
|
1712
|
+
switch (eventType) {
|
|
1713
|
+
case "lowBalance":
|
|
1714
|
+
lastNotification = state.lastLowBalanceNotification;
|
|
1715
|
+
break;
|
|
1716
|
+
case "depleted":
|
|
1717
|
+
lastNotification = state.lastDepletedNotification;
|
|
1718
|
+
break;
|
|
1719
|
+
case "expiring":
|
|
1720
|
+
lastNotification = state.lastExpiringNotification;
|
|
1721
|
+
break;
|
|
1722
|
+
}
|
|
1723
|
+
if (!lastNotification) return true;
|
|
1724
|
+
return now - lastNotification.getTime() > getConfig().lowBalanceNotificationCooldownHours * 60 * 60 * 1e3;
|
|
1725
|
+
}
|
|
1726
|
+
function recordNotification(userId, eventType) {
|
|
1727
|
+
const state = cooldownState.get(userId) || {};
|
|
1728
|
+
const now = /* @__PURE__ */ new Date();
|
|
1729
|
+
switch (eventType) {
|
|
1730
|
+
case "lowBalance":
|
|
1731
|
+
state.lastLowBalanceNotification = now;
|
|
1732
|
+
break;
|
|
1733
|
+
case "depleted":
|
|
1734
|
+
state.lastDepletedNotification = now;
|
|
1735
|
+
break;
|
|
1736
|
+
case "expiring":
|
|
1737
|
+
state.lastExpiringNotification = now;
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
cooldownState.set(userId, state);
|
|
1741
|
+
}
|
|
1742
|
+
var notificationHandlers = [];
|
|
1743
|
+
function registerNotificationHandler(handler) {
|
|
1744
|
+
notificationHandlers.push(handler);
|
|
1745
|
+
}
|
|
1746
|
+
function clearNotificationHandlers() {
|
|
1747
|
+
notificationHandlers.length = 0;
|
|
1748
|
+
}
|
|
1749
|
+
function clearCooldownState() {
|
|
1750
|
+
cooldownState.clear();
|
|
1751
|
+
}
|
|
1752
|
+
async function dispatchNotification(event) {
|
|
1753
|
+
if (notificationHandlers.length === 0) {
|
|
1754
|
+
console.debug(`[Credits] No notification handlers registered for: ${event.type}`);
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
await Promise.allSettled(
|
|
1758
|
+
notificationHandlers.map(
|
|
1759
|
+
(handler) => handler.handleNotification(event).catch((error) => {
|
|
1760
|
+
console.error(`[Credits] Notification handler error:`, error);
|
|
1761
|
+
})
|
|
1762
|
+
)
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
async function checkAndNotifyLowBalance(userId, newBalance, threshold = getConfig().lowBalanceThreshold) {
|
|
1766
|
+
if (newBalance <= 0 && isNotificationAllowed(userId, "depleted")) {
|
|
1767
|
+
recordNotification(userId, "depleted");
|
|
1768
|
+
await dispatchNotification({
|
|
1769
|
+
type: "balance_depleted",
|
|
1770
|
+
userId
|
|
1771
|
+
});
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
if (newBalance > 0 && newBalance <= threshold && isNotificationAllowed(userId, "lowBalance")) {
|
|
1775
|
+
recordNotification(userId, "lowBalance");
|
|
1776
|
+
await dispatchNotification({
|
|
1777
|
+
type: "low_balance",
|
|
1778
|
+
userId,
|
|
1779
|
+
balance: newBalance,
|
|
1780
|
+
threshold
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
async function notifySubscriptionExpiring(userId, expiresAt, daysRemaining) {
|
|
1785
|
+
if (!isNotificationAllowed(userId, "expiring")) {
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
recordNotification(userId, "expiring");
|
|
1789
|
+
await dispatchNotification({
|
|
1790
|
+
type: "subscription_expiring",
|
|
1791
|
+
userId,
|
|
1792
|
+
expiresAt,
|
|
1793
|
+
daysRemaining
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
async function notifySubscriptionExpired(userId, wasDowngraded) {
|
|
1797
|
+
await dispatchNotification({
|
|
1798
|
+
type: "subscription_expired",
|
|
1799
|
+
userId,
|
|
1800
|
+
wasDowngraded
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
var ConsoleNotificationHandler = class {
|
|
1804
|
+
async handleNotification(event) {
|
|
1805
|
+
switch (event.type) {
|
|
1806
|
+
case "low_balance":
|
|
1807
|
+
console.log(
|
|
1808
|
+
`[Credits Notification] Low balance alert for user ${event.userId}: ${event.balance} credits remaining (threshold: ${event.threshold})`
|
|
1809
|
+
);
|
|
1810
|
+
break;
|
|
1811
|
+
case "balance_depleted":
|
|
1812
|
+
console.log(
|
|
1813
|
+
`[Credits Notification] Balance depleted for user ${event.userId}`
|
|
1814
|
+
);
|
|
1815
|
+
break;
|
|
1816
|
+
case "subscription_expiring":
|
|
1817
|
+
console.log(
|
|
1818
|
+
`[Credits Notification] Subscription expiring for user ${event.userId}: ${event.daysRemaining} days remaining`
|
|
1819
|
+
);
|
|
1820
|
+
break;
|
|
1821
|
+
case "subscription_expired":
|
|
1822
|
+
console.log(
|
|
1823
|
+
`[Credits Notification] Subscription expired for user ${event.userId}: downgraded=${event.wasDowngraded}`
|
|
1824
|
+
);
|
|
1825
|
+
break;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
// src/sdk/types.ts
|
|
1831
|
+
var SDKErrorCode = {
|
|
1832
|
+
NETWORK_ERROR: "NETWORK_ERROR",
|
|
1833
|
+
AUTHENTICATION_ERROR: "AUTHENTICATION_ERROR",
|
|
1834
|
+
AUTHORIZATION_ERROR: "AUTHORIZATION_ERROR",
|
|
1835
|
+
INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
|
|
1836
|
+
RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
|
|
1837
|
+
RESERVATION_EXPIRED: "RESERVATION_EXPIRED",
|
|
1838
|
+
VALIDATION_ERROR: "VALIDATION_ERROR",
|
|
1839
|
+
SERVER_ERROR: "SERVER_ERROR",
|
|
1840
|
+
UNKNOWN_ERROR: "UNKNOWN_ERROR"
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
// src/sdk/errors.ts
|
|
1844
|
+
var CreditsSDKError = class extends Error {
|
|
1845
|
+
code;
|
|
1846
|
+
details;
|
|
1847
|
+
statusCode;
|
|
1848
|
+
constructor(message, code, options) {
|
|
1849
|
+
super(message, { cause: options?.cause });
|
|
1850
|
+
this.name = "CreditsSDKError";
|
|
1851
|
+
this.code = code;
|
|
1852
|
+
this.details = options?.details;
|
|
1853
|
+
this.statusCode = options?.statusCode;
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Check if this is a specific error type
|
|
1857
|
+
*/
|
|
1858
|
+
is(code) {
|
|
1859
|
+
return this.code === code;
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Create a JSON representation for logging
|
|
1863
|
+
*/
|
|
1864
|
+
toJSON() {
|
|
1865
|
+
return {
|
|
1866
|
+
name: this.name,
|
|
1867
|
+
message: this.message,
|
|
1868
|
+
code: this.code,
|
|
1869
|
+
details: this.details,
|
|
1870
|
+
statusCode: this.statusCode
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
var NetworkError = class extends CreditsSDKError {
|
|
1875
|
+
constructor(message, cause) {
|
|
1876
|
+
super(message, SDKErrorCode.NETWORK_ERROR, { cause });
|
|
1877
|
+
this.name = "NetworkError";
|
|
1878
|
+
}
|
|
1879
|
+
};
|
|
1880
|
+
var AuthenticationError = class extends CreditsSDKError {
|
|
1881
|
+
constructor(message = "Authentication required") {
|
|
1882
|
+
super(message, SDKErrorCode.AUTHENTICATION_ERROR, { statusCode: 401 });
|
|
1883
|
+
this.name = "AuthenticationError";
|
|
1884
|
+
}
|
|
1885
|
+
};
|
|
1886
|
+
var AuthorizationError = class extends CreditsSDKError {
|
|
1887
|
+
constructor(message = "Access denied") {
|
|
1888
|
+
super(message, SDKErrorCode.AUTHORIZATION_ERROR, { statusCode: 403 });
|
|
1889
|
+
this.name = "AuthorizationError";
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
var InsufficientCreditsError = class extends CreditsSDKError {
|
|
1893
|
+
available;
|
|
1894
|
+
required;
|
|
1895
|
+
constructor(available, required) {
|
|
1896
|
+
super(
|
|
1897
|
+
`Insufficient credits: available ${available}, required ${required}`,
|
|
1898
|
+
SDKErrorCode.INSUFFICIENT_CREDITS,
|
|
1899
|
+
{
|
|
1900
|
+
statusCode: 402,
|
|
1901
|
+
details: { available, required }
|
|
1902
|
+
}
|
|
1903
|
+
);
|
|
1904
|
+
this.name = "InsufficientCreditsError";
|
|
1905
|
+
this.available = available;
|
|
1906
|
+
this.required = required;
|
|
1907
|
+
}
|
|
1908
|
+
};
|
|
1909
|
+
var ReservationNotFoundError = class extends CreditsSDKError {
|
|
1910
|
+
reservationId;
|
|
1911
|
+
constructor(reservationId) {
|
|
1912
|
+
super(
|
|
1913
|
+
`Reservation not found: ${reservationId}`,
|
|
1914
|
+
SDKErrorCode.RESERVATION_NOT_FOUND,
|
|
1915
|
+
{
|
|
1916
|
+
statusCode: 404,
|
|
1917
|
+
details: { reservationId }
|
|
1918
|
+
}
|
|
1919
|
+
);
|
|
1920
|
+
this.name = "ReservationNotFoundError";
|
|
1921
|
+
this.reservationId = reservationId;
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
var ReservationExpiredError = class extends CreditsSDKError {
|
|
1925
|
+
reservationId;
|
|
1926
|
+
constructor(reservationId) {
|
|
1927
|
+
super(
|
|
1928
|
+
`Reservation expired: ${reservationId}`,
|
|
1929
|
+
SDKErrorCode.RESERVATION_EXPIRED,
|
|
1930
|
+
{
|
|
1931
|
+
statusCode: 410,
|
|
1932
|
+
details: { reservationId }
|
|
1933
|
+
}
|
|
1934
|
+
);
|
|
1935
|
+
this.name = "ReservationExpiredError";
|
|
1936
|
+
this.reservationId = reservationId;
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
var ValidationError = class extends CreditsSDKError {
|
|
1940
|
+
field;
|
|
1941
|
+
constructor(message, field) {
|
|
1942
|
+
super(message, SDKErrorCode.VALIDATION_ERROR, {
|
|
1943
|
+
statusCode: 400,
|
|
1944
|
+
details: field ? { field } : void 0
|
|
1945
|
+
});
|
|
1946
|
+
this.name = "ValidationError";
|
|
1947
|
+
this.field = field;
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
var ServerError = class extends CreditsSDKError {
|
|
1951
|
+
constructor(message = "Internal server error") {
|
|
1952
|
+
super(message, SDKErrorCode.SERVER_ERROR, { statusCode: 500 });
|
|
1953
|
+
this.name = "ServerError";
|
|
1954
|
+
}
|
|
1955
|
+
};
|
|
1956
|
+
function parseApiError(statusCode, body) {
|
|
1957
|
+
const errorCode = body.error?.code || "";
|
|
1958
|
+
const errorMessage = body.error?.message || "An error occurred";
|
|
1959
|
+
const details = body.error?.details;
|
|
1960
|
+
switch (statusCode) {
|
|
1961
|
+
case 401:
|
|
1962
|
+
return new AuthenticationError(errorMessage);
|
|
1963
|
+
case 403:
|
|
1964
|
+
return new AuthorizationError(errorMessage);
|
|
1965
|
+
case 402:
|
|
1966
|
+
if (details?.available !== void 0 && details?.required !== void 0) {
|
|
1967
|
+
return new InsufficientCreditsError(
|
|
1968
|
+
details.available,
|
|
1969
|
+
details.required
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
return new CreditsSDKError(errorMessage, SDKErrorCode.INSUFFICIENT_CREDITS, {
|
|
1973
|
+
statusCode,
|
|
1974
|
+
details
|
|
1975
|
+
});
|
|
1976
|
+
case 404:
|
|
1977
|
+
if (errorCode === "RESERVATION_NOT_FOUND" && details?.reservationId) {
|
|
1978
|
+
return new ReservationNotFoundError(details.reservationId);
|
|
1979
|
+
}
|
|
1980
|
+
return new CreditsSDKError(errorMessage, SDKErrorCode.RESERVATION_NOT_FOUND, {
|
|
1981
|
+
statusCode,
|
|
1982
|
+
details
|
|
1983
|
+
});
|
|
1984
|
+
case 410:
|
|
1985
|
+
if (errorCode === "RESERVATION_EXPIRED" && details?.reservationId) {
|
|
1986
|
+
return new ReservationExpiredError(details.reservationId);
|
|
1987
|
+
}
|
|
1988
|
+
return new CreditsSDKError(errorMessage, SDKErrorCode.RESERVATION_EXPIRED, {
|
|
1989
|
+
statusCode,
|
|
1990
|
+
details
|
|
1991
|
+
});
|
|
1992
|
+
case 400:
|
|
1993
|
+
case 422:
|
|
1994
|
+
return new ValidationError(errorMessage, details?.field);
|
|
1995
|
+
case 500:
|
|
1996
|
+
default:
|
|
1997
|
+
if (statusCode >= 500) {
|
|
1998
|
+
return new ServerError(errorMessage);
|
|
1999
|
+
}
|
|
2000
|
+
return new CreditsSDKError(errorMessage, SDKErrorCode.UNKNOWN_ERROR, {
|
|
2001
|
+
statusCode,
|
|
2002
|
+
details
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// src/sdk/client.ts
|
|
2008
|
+
var CreditsClient = class {
|
|
2009
|
+
baseUrl;
|
|
2010
|
+
getAuthToken;
|
|
2011
|
+
fetchFn;
|
|
2012
|
+
constructor(config) {
|
|
2013
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
2014
|
+
this.getAuthToken = config.getAuthToken;
|
|
2015
|
+
this.fetchFn = config.fetch ?? fetch;
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Make an authenticated request to the API
|
|
2019
|
+
*/
|
|
2020
|
+
async request(path, options = {}) {
|
|
2021
|
+
const { method = "GET", body, params } = options;
|
|
2022
|
+
let url = `${this.baseUrl}${path}`;
|
|
2023
|
+
if (params) {
|
|
2024
|
+
const searchParams = new URLSearchParams();
|
|
2025
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2026
|
+
if (value !== void 0) {
|
|
2027
|
+
searchParams.set(key, String(value));
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
const queryString = searchParams.toString();
|
|
2031
|
+
if (queryString) {
|
|
2032
|
+
url += `?${queryString}`;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
const headers = {
|
|
2036
|
+
"Content-Type": "application/json"
|
|
2037
|
+
};
|
|
2038
|
+
if (this.getAuthToken) {
|
|
2039
|
+
try {
|
|
2040
|
+
const token = await this.getAuthToken();
|
|
2041
|
+
if (token) {
|
|
2042
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
2043
|
+
}
|
|
2044
|
+
} catch {
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
let response;
|
|
2048
|
+
try {
|
|
2049
|
+
response = await this.fetchFn(url, {
|
|
2050
|
+
method,
|
|
2051
|
+
headers,
|
|
2052
|
+
body: body ? JSON.stringify(body) : void 0
|
|
2053
|
+
});
|
|
2054
|
+
} catch (error) {
|
|
2055
|
+
throw new NetworkError(
|
|
2056
|
+
error instanceof Error ? error.message : "Network request failed",
|
|
2057
|
+
error instanceof Error ? error : void 0
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
const responseBody = await response.json();
|
|
2061
|
+
if (!response.ok) {
|
|
2062
|
+
throw parseApiError(response.status, responseBody);
|
|
2063
|
+
}
|
|
2064
|
+
return responseBody.data;
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Get the current user's credit balance
|
|
2068
|
+
*
|
|
2069
|
+
* @returns User's credit information
|
|
2070
|
+
* @throws AuthenticationError if not authenticated
|
|
2071
|
+
*/
|
|
2072
|
+
async getBalance() {
|
|
2073
|
+
return this.request("/api/v1/credits/balance");
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Check if the user has sufficient credits for an operation
|
|
2077
|
+
*
|
|
2078
|
+
* @param operationType - Type of operation to check
|
|
2079
|
+
* @param customCost - Optional custom cost to check against
|
|
2080
|
+
* @returns Credit availability status
|
|
2081
|
+
* @throws AuthenticationError if not authenticated
|
|
2082
|
+
*/
|
|
2083
|
+
async checkAvailability(operationType, customCost) {
|
|
2084
|
+
return this.request("/api/v1/credits/availability", {
|
|
2085
|
+
params: {
|
|
2086
|
+
operationType,
|
|
2087
|
+
customCost: customCost?.toString()
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Reserve credits for an operation
|
|
2093
|
+
*
|
|
2094
|
+
* This is the first step in the two-phase commit pattern.
|
|
2095
|
+
* Reserved credits are held for 5 minutes before expiring.
|
|
2096
|
+
*
|
|
2097
|
+
* @param operationType - Type of operation
|
|
2098
|
+
* @param options - Optional reservation options
|
|
2099
|
+
* @returns Reservation details including ID and expiry
|
|
2100
|
+
* @throws InsufficientCreditsError if not enough credits
|
|
2101
|
+
* @throws AuthenticationError if not authenticated
|
|
2102
|
+
*/
|
|
2103
|
+
async reserve(operationType, options) {
|
|
2104
|
+
return this.request("/api/v1/credits/reserve", {
|
|
2105
|
+
method: "POST",
|
|
2106
|
+
body: {
|
|
2107
|
+
operationType,
|
|
2108
|
+
...options
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Commit a reservation (deduct credits)
|
|
2114
|
+
*
|
|
2115
|
+
* This is the second step in the two-phase commit pattern.
|
|
2116
|
+
* Call this after your operation succeeds.
|
|
2117
|
+
*
|
|
2118
|
+
* @param reservationId - ID of the reservation to commit
|
|
2119
|
+
* @throws ReservationNotFoundError if reservation doesn't exist
|
|
2120
|
+
* @throws ReservationExpiredError if reservation has expired
|
|
2121
|
+
* @throws AuthenticationError if not authenticated
|
|
2122
|
+
*/
|
|
2123
|
+
async commit(reservationId) {
|
|
2124
|
+
await this.request("/api/v1/credits/commit", {
|
|
2125
|
+
method: "POST",
|
|
2126
|
+
body: { reservationId }
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Release a reservation (refund credits)
|
|
2131
|
+
*
|
|
2132
|
+
* Call this if your operation fails after reserving credits.
|
|
2133
|
+
*
|
|
2134
|
+
* @param reservationId - ID of the reservation to release
|
|
2135
|
+
* @throws ReservationNotFoundError if reservation doesn't exist
|
|
2136
|
+
* @throws AuthenticationError if not authenticated
|
|
2137
|
+
*/
|
|
2138
|
+
async release(reservationId) {
|
|
2139
|
+
await this.request("/api/v1/credits/release", {
|
|
2140
|
+
method: "POST",
|
|
2141
|
+
body: { reservationId }
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Get usage history for the current user
|
|
2146
|
+
*
|
|
2147
|
+
* @param options - Optional pagination options
|
|
2148
|
+
* @returns Paginated list of usage entries
|
|
2149
|
+
* @throws AuthenticationError if not authenticated
|
|
2150
|
+
*/
|
|
2151
|
+
async getHistory(options) {
|
|
2152
|
+
return this.request("/api/v1/credits/history", {
|
|
2153
|
+
params: {
|
|
2154
|
+
page: options?.page,
|
|
2155
|
+
limit: options?.limit
|
|
2156
|
+
}
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Get the available credits (balance + bonus - reserved)
|
|
2161
|
+
*
|
|
2162
|
+
* Convenience method that calculates available credits from balance.
|
|
2163
|
+
*
|
|
2164
|
+
* @returns Number of available credits
|
|
2165
|
+
* @throws AuthenticationError if not authenticated
|
|
2166
|
+
*/
|
|
2167
|
+
async getAvailableCredits() {
|
|
2168
|
+
const balance = await this.getBalance();
|
|
2169
|
+
return balance.balance + balance.bonusCredits - balance.reserved;
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Execute an operation with automatic credit handling
|
|
2173
|
+
*
|
|
2174
|
+
* This is a convenience method that handles the full two-phase commit
|
|
2175
|
+
* pattern automatically.
|
|
2176
|
+
*
|
|
2177
|
+
* @param operationType - Type of operation
|
|
2178
|
+
* @param operation - The operation to execute
|
|
2179
|
+
* @param options - Optional reservation options
|
|
2180
|
+
* @returns Result of the operation
|
|
2181
|
+
* @throws InsufficientCreditsError if not enough credits
|
|
2182
|
+
* @throws AuthenticationError if not authenticated
|
|
2183
|
+
*
|
|
2184
|
+
* @example
|
|
2185
|
+
* ```typescript
|
|
2186
|
+
* const result = await client.withCredits(
|
|
2187
|
+
* "story_generation",
|
|
2188
|
+
* async () => {
|
|
2189
|
+
* return generateStory(data);
|
|
2190
|
+
* }
|
|
2191
|
+
* );
|
|
2192
|
+
* ```
|
|
2193
|
+
*/
|
|
2194
|
+
async withCredits(operationType, operation, options) {
|
|
2195
|
+
const reservation = await this.reserve(operationType, options);
|
|
2196
|
+
try {
|
|
2197
|
+
const result = await operation();
|
|
2198
|
+
await this.commit(reservation.reservationId);
|
|
2199
|
+
return result;
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
try {
|
|
2202
|
+
await this.release(reservation.reservationId);
|
|
2203
|
+
} catch {
|
|
2204
|
+
}
|
|
2205
|
+
throw error;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
|
|
2210
|
+
// src/sdk/admin-client.ts
|
|
2211
|
+
var AdminCreditsClient = class {
|
|
2212
|
+
baseUrl;
|
|
2213
|
+
apiKey;
|
|
2214
|
+
fetchFn;
|
|
2215
|
+
constructor(config) {
|
|
2216
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
2217
|
+
this.apiKey = config.apiKey;
|
|
2218
|
+
this.fetchFn = config.fetch ?? fetch;
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Make an authenticated admin request to the API
|
|
2222
|
+
*/
|
|
2223
|
+
async request(path, options = {}) {
|
|
2224
|
+
const { method = "GET", body, params } = options;
|
|
2225
|
+
let url = `${this.baseUrl}${path}`;
|
|
2226
|
+
if (params) {
|
|
2227
|
+
const searchParams = new URLSearchParams();
|
|
2228
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2229
|
+
if (value !== void 0) {
|
|
2230
|
+
searchParams.set(key, String(value));
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
const queryString = searchParams.toString();
|
|
2234
|
+
if (queryString) {
|
|
2235
|
+
url += `?${queryString}`;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
const headers = {
|
|
2239
|
+
"Content-Type": "application/json",
|
|
2240
|
+
"x-api-key": this.apiKey
|
|
2241
|
+
};
|
|
2242
|
+
let response;
|
|
2243
|
+
try {
|
|
2244
|
+
response = await this.fetchFn(url, {
|
|
2245
|
+
method,
|
|
2246
|
+
headers,
|
|
2247
|
+
body: body ? JSON.stringify(body) : void 0
|
|
2248
|
+
});
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
throw new NetworkError(
|
|
2251
|
+
error instanceof Error ? error.message : "Network request failed",
|
|
2252
|
+
error instanceof Error ? error : void 0
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
const responseBody = await response.json();
|
|
2256
|
+
if (!response.ok) {
|
|
2257
|
+
throw parseApiError(response.status, responseBody);
|
|
2258
|
+
}
|
|
2259
|
+
return responseBody.data;
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Get a specific user's credits
|
|
2263
|
+
*
|
|
2264
|
+
* @param userId - ID of the user
|
|
2265
|
+
* @returns User's credit information
|
|
2266
|
+
* @throws AuthorizationError if API key is invalid
|
|
2267
|
+
*/
|
|
2268
|
+
async getUserCredits(userId) {
|
|
2269
|
+
return this.request(`/api/admin/credits/users/${userId}`);
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Add credits to a user's account
|
|
2273
|
+
*
|
|
2274
|
+
* Credits are added to bonusCredits, which don't expire on monthly reset.
|
|
2275
|
+
*
|
|
2276
|
+
* @param userId - ID of the user
|
|
2277
|
+
* @param amount - Amount of credits to add
|
|
2278
|
+
* @param description - Reason for adding credits
|
|
2279
|
+
* @throws AuthorizationError if API key is invalid
|
|
2280
|
+
*/
|
|
2281
|
+
async addCredits(userId, amount, description) {
|
|
2282
|
+
await this.request(
|
|
2283
|
+
`/api/admin/credits/users/${userId}/add`,
|
|
2284
|
+
{
|
|
2285
|
+
method: "POST",
|
|
2286
|
+
body: { amount, description }
|
|
2287
|
+
}
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Update a user's subscription tier
|
|
2292
|
+
*
|
|
2293
|
+
* @param userId - ID of the user
|
|
2294
|
+
* @param tier - New subscription tier
|
|
2295
|
+
* @param expiresAt - Optional expiration date (ISO 8601)
|
|
2296
|
+
* @throws AuthorizationError if API key is invalid
|
|
2297
|
+
*/
|
|
2298
|
+
async updateSubscription(userId, tier, expiresAt) {
|
|
2299
|
+
const body = { tier };
|
|
2300
|
+
if (expiresAt) {
|
|
2301
|
+
body.expiresAt = expiresAt;
|
|
2302
|
+
}
|
|
2303
|
+
await this.request(
|
|
2304
|
+
`/api/admin/credits/users/${userId}/subscription`,
|
|
2305
|
+
{
|
|
2306
|
+
method: "PUT",
|
|
2307
|
+
body
|
|
2308
|
+
}
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Get the current credits configuration
|
|
2313
|
+
*
|
|
2314
|
+
* @returns Credits system configuration
|
|
2315
|
+
* @throws AuthorizationError if API key is invalid
|
|
2316
|
+
*/
|
|
2317
|
+
async getConfig() {
|
|
2318
|
+
return this.request("/api/admin/credits/config");
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2321
|
+
* Update operation costs
|
|
2322
|
+
*
|
|
2323
|
+
* @param costs - New operation costs (operation type -> cost)
|
|
2324
|
+
* @throws AuthorizationError if API key is invalid
|
|
2325
|
+
*/
|
|
2326
|
+
async updateCosts(costs) {
|
|
2327
|
+
await this.request("/api/admin/credits/config/costs", {
|
|
2328
|
+
method: "PUT",
|
|
2329
|
+
body: { operationCosts: costs }
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* List all users with their credits
|
|
2334
|
+
*
|
|
2335
|
+
* @param options - Pagination and filtering options
|
|
2336
|
+
* @returns Paginated list of users
|
|
2337
|
+
* @throws AuthorizationError if API key is invalid
|
|
2338
|
+
*/
|
|
2339
|
+
async listUsers(options) {
|
|
2340
|
+
return this.request("/api/admin/credits/users", {
|
|
2341
|
+
params: {
|
|
2342
|
+
page: options?.page,
|
|
2343
|
+
limit: options?.limit,
|
|
2344
|
+
tier: options?.tier,
|
|
2345
|
+
search: options?.search
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Deduct credits from a user's account
|
|
2351
|
+
*
|
|
2352
|
+
* @param userId - ID of the user
|
|
2353
|
+
* @param amount - Amount of credits to deduct
|
|
2354
|
+
* @param description - Reason for deducting credits
|
|
2355
|
+
* @throws AuthorizationError if API key is invalid
|
|
2356
|
+
*/
|
|
2357
|
+
async deductCredits(userId, amount, description) {
|
|
2358
|
+
await this.request(
|
|
2359
|
+
`/api/admin/credits/users/${userId}/deduct`,
|
|
2360
|
+
{
|
|
2361
|
+
method: "POST",
|
|
2362
|
+
body: { amount, description }
|
|
2363
|
+
}
|
|
2364
|
+
);
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* Get usage statistics for a user
|
|
2368
|
+
*
|
|
2369
|
+
* @param userId - ID of the user
|
|
2370
|
+
* @param options - Optional date range
|
|
2371
|
+
* @returns Usage statistics
|
|
2372
|
+
* @throws AuthorizationError if API key is invalid
|
|
2373
|
+
*/
|
|
2374
|
+
async getUserStats(userId, options) {
|
|
2375
|
+
return this.request(`/api/admin/credits/users/${userId}/stats`, {
|
|
2376
|
+
params: {
|
|
2377
|
+
startDate: options?.startDate,
|
|
2378
|
+
endDate: options?.endDate
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2384
|
+
// src/utils/error-utils.ts
|
|
2385
|
+
function parseCreditError(error) {
|
|
2386
|
+
const match = error.match(/Available:\s*(\d+)\s*,\s*Required:\s*(\d+)/i);
|
|
2387
|
+
if (!match) {
|
|
2388
|
+
return null;
|
|
2389
|
+
}
|
|
2390
|
+
const available = parseInt(match[1], 10);
|
|
2391
|
+
const required = parseInt(match[2], 10);
|
|
2392
|
+
if (isNaN(available) || isNaN(required)) {
|
|
2393
|
+
return null;
|
|
2394
|
+
}
|
|
2395
|
+
return {
|
|
2396
|
+
available,
|
|
2397
|
+
required,
|
|
2398
|
+
shortfall: required - available,
|
|
2399
|
+
rawMessage: error
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
function isCreditErrorMessage(error) {
|
|
2403
|
+
const lowerError = error.toLowerCase();
|
|
2404
|
+
return lowerError.includes("insufficient credits") || lowerError.includes("not enough credits");
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// src/utils/index.ts
|
|
2408
|
+
function getTotalCredits(credits) {
|
|
2409
|
+
return credits.balance + credits.bonusCredits;
|
|
2410
|
+
}
|
|
2411
|
+
function generateRequestId2() {
|
|
2412
|
+
const timestamp = Date.now();
|
|
2413
|
+
const randomPart = Math.random().toString(36).substring(2, 14);
|
|
2414
|
+
return `req_${timestamp}_${randomPart}`;
|
|
2415
|
+
}
|
|
2416
|
+
function isPreviewMode2(data) {
|
|
2417
|
+
return typeof data === "object" && data !== null && "preview" in data && data.preview === true;
|
|
2418
|
+
}
|
|
2419
|
+
function createDummyReservation2(userId, operationType) {
|
|
2420
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2421
|
+
return {
|
|
2422
|
+
id: "preview-mode",
|
|
2423
|
+
userId,
|
|
2424
|
+
amount: 0,
|
|
2425
|
+
operationType,
|
|
2426
|
+
status: "released",
|
|
2427
|
+
createdAt: now,
|
|
2428
|
+
expiresAt: now
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2432
|
+
0 && (module.exports = {
|
|
2433
|
+
AdminCreditsClient,
|
|
2434
|
+
ApiKeyCreditsProvider,
|
|
2435
|
+
AuthenticationError,
|
|
2436
|
+
AuthorizationError,
|
|
2437
|
+
ConsoleNotificationHandler,
|
|
2438
|
+
CreditError,
|
|
2439
|
+
CreditErrorCode,
|
|
2440
|
+
CreditsClient,
|
|
2441
|
+
CreditsSDKError,
|
|
2442
|
+
CreditsService,
|
|
2443
|
+
InMemoryCreditRepository,
|
|
2444
|
+
NetworkError,
|
|
2445
|
+
SDKErrorCode,
|
|
2446
|
+
SDKInsufficientCreditsError,
|
|
2447
|
+
SDKReservationExpiredError,
|
|
2448
|
+
SDKReservationNotFoundError,
|
|
2449
|
+
ServerError,
|
|
2450
|
+
ValidationError,
|
|
2451
|
+
calculateAvailableCredits,
|
|
2452
|
+
checkAndNotifyLowBalance,
|
|
2453
|
+
clearConfigProvider,
|
|
2454
|
+
clearCooldownState,
|
|
2455
|
+
clearNotificationHandlers,
|
|
2456
|
+
commitReservationWithJournal,
|
|
2457
|
+
createApiKeyProvider,
|
|
2458
|
+
createCreditsService,
|
|
2459
|
+
createDeferredExecutor,
|
|
2460
|
+
createDummyReservation,
|
|
2461
|
+
createGenericAdapter,
|
|
2462
|
+
createInMemoryCreditRepository,
|
|
2463
|
+
createInsufficientCreditsError,
|
|
2464
|
+
createInvalidOperationTypeError,
|
|
2465
|
+
createReservationAlreadyProcessedError,
|
|
2466
|
+
createReservationExpiredError,
|
|
2467
|
+
createReservationNotFoundError,
|
|
2468
|
+
createUserNotFoundError,
|
|
2469
|
+
generateId,
|
|
2470
|
+
generateRequestId,
|
|
2471
|
+
genericDeferred,
|
|
2472
|
+
getConfig,
|
|
2473
|
+
getConfigMonthlyLimit,
|
|
2474
|
+
getConfigOperationCost,
|
|
2475
|
+
getConfigProvider,
|
|
2476
|
+
getConfigTierConfig,
|
|
2477
|
+
getDefaultFreeCredits,
|
|
2478
|
+
getMonthlyLimit,
|
|
2479
|
+
getNextMonthlyReset,
|
|
2480
|
+
getOperationCost,
|
|
2481
|
+
getOperationCosts,
|
|
2482
|
+
getOperationCostsWithLabels,
|
|
2483
|
+
getOperationLabel,
|
|
2484
|
+
getOperationLabels,
|
|
2485
|
+
getReservationExpiryMs,
|
|
2486
|
+
getTierConfig,
|
|
2487
|
+
getTotalCredits,
|
|
2488
|
+
getValidOperationTypes,
|
|
2489
|
+
initializeConfig,
|
|
2490
|
+
isCreditError,
|
|
2491
|
+
isCreditErrorMessage,
|
|
2492
|
+
isFeatureEnabled,
|
|
2493
|
+
isInsufficientCreditsError,
|
|
2494
|
+
isPreviewMode,
|
|
2495
|
+
isValidOperationType,
|
|
2496
|
+
loadConfigFromEnv,
|
|
2497
|
+
noopDeferred,
|
|
2498
|
+
notifySubscriptionExpired,
|
|
2499
|
+
notifySubscriptionExpiring,
|
|
2500
|
+
parseApiError,
|
|
2501
|
+
parseCreditError,
|
|
2502
|
+
registerConfigProvider,
|
|
2503
|
+
registerNotificationHandler,
|
|
2504
|
+
releaseReservationWithJournal,
|
|
2505
|
+
reserveCreditsForOperation,
|
|
2506
|
+
resetConfig,
|
|
2507
|
+
synchronousDeferred,
|
|
2508
|
+
toClientUserCredits,
|
|
2509
|
+
toDate,
|
|
2510
|
+
toPortableTimestamp,
|
|
2511
|
+
verifyAdminApiKey,
|
|
2512
|
+
verifyBearerToken
|
|
2513
|
+
});
|
|
2514
|
+
//# sourceMappingURL=index.cjs.map
|