responses-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +56 -0
  2. package/cli.js +118 -0
  3. package/dist/anthropic-messages.js +383 -0
  4. package/dist/anthropic-messages.test.js +209 -0
  5. package/dist/audit-log.js +138 -0
  6. package/dist/audit-log.test.js +480 -0
  7. package/dist/billing-expiration.js +70 -0
  8. package/dist/billing-expiration.test.js +114 -0
  9. package/dist/billing.js +716 -0
  10. package/dist/billing.test.js +228 -0
  11. package/dist/chatgpt-oauth-store.js +240 -0
  12. package/dist/chatgpt-oauth-store.test.js +88 -0
  13. package/dist/chatgpt-oauth.js +118 -0
  14. package/dist/chatgpt-oauth.test.js +63 -0
  15. package/dist/chatgpt-provider-auth.js +60 -0
  16. package/dist/chatgpt-provider-auth.test.js +101 -0
  17. package/dist/client/app-icon.svg +17 -0
  18. package/dist/client/assets/index-C7Vvhst8.js +14 -0
  19. package/dist/client/assets/index-DpqgYK3L.css +1 -0
  20. package/dist/client/favicon.svg +17 -0
  21. package/dist/client/index.html +31 -0
  22. package/dist/client-config-apply.js +345 -0
  23. package/dist/client-config-apply.test.js +185 -0
  24. package/dist/client-token-limits.js +111 -0
  25. package/dist/client-token-limits.test.js +129 -0
  26. package/dist/codex-config.js +47 -0
  27. package/dist/codex-setup.js +87 -0
  28. package/dist/codex-setup.test.js +30 -0
  29. package/dist/config.js +314 -0
  30. package/dist/cost-analytics.js +31 -0
  31. package/dist/cost-analytics.test.js +38 -0
  32. package/dist/customer-key-access.js +126 -0
  33. package/dist/customer-key-access.test.js +178 -0
  34. package/dist/customer-keys.js +209 -0
  35. package/dist/customer-keys.test.js +68 -0
  36. package/dist/customer-usage.js +18 -0
  37. package/dist/customer-usage.test.js +55 -0
  38. package/dist/dashboard-auth.js +318 -0
  39. package/dist/dashboard-auth.test.js +133 -0
  40. package/dist/dashboard-serving.test.js +235 -0
  41. package/dist/error-response.js +174 -0
  42. package/dist/error-response.test.js +88 -0
  43. package/dist/forward.js +357 -0
  44. package/dist/health-websocket-manager.js +174 -0
  45. package/dist/http-rate-limit.js +36 -0
  46. package/dist/http-rate-limit.test.js +62 -0
  47. package/dist/kiro-auth.js +136 -0
  48. package/dist/kiro-auth.test.js +234 -0
  49. package/dist/kiro-codewhisperer.js +646 -0
  50. package/dist/kiro-codewhisperer.test.js +219 -0
  51. package/dist/kiro-device-login.js +338 -0
  52. package/dist/kiro-eventstream.js +219 -0
  53. package/dist/kiro-eventstream.test.js +79 -0
  54. package/dist/kiro-forward.js +401 -0
  55. package/dist/kiro-import-cli.js +69 -0
  56. package/dist/kiro-import.js +94 -0
  57. package/dist/kiro-import.test.js +125 -0
  58. package/dist/kiro-token-store.js +196 -0
  59. package/dist/kiro-token-store.test.js +207 -0
  60. package/dist/krouter-usage.js +243 -0
  61. package/dist/model-combo-repository.js +147 -0
  62. package/dist/model-routing.js +69 -0
  63. package/dist/model-routing.test.js +41 -0
  64. package/dist/normalize-request.js +531 -0
  65. package/dist/normalize-request.test.js +277 -0
  66. package/dist/omv-public-firewall.test.js +11 -0
  67. package/dist/package.json +17 -0
  68. package/dist/prompt-cache-state.js +146 -0
  69. package/dist/prompt-cache-state.test.js +71 -0
  70. package/dist/prompt-cache.js +229 -0
  71. package/dist/provider-health-service.js +404 -0
  72. package/dist/provider-request-parameters.js +107 -0
  73. package/dist/provider-request-parameters.test.js +26 -0
  74. package/dist/provider-routing.js +114 -0
  75. package/dist/provider-routing.test.js +64 -0
  76. package/dist/provider-usage.js +314 -0
  77. package/dist/request-timeout-policy.js +61 -0
  78. package/dist/request-timeout-policy.test.js +40 -0
  79. package/dist/response-cache.js +69 -0
  80. package/dist/response-cache.test.js +28 -0
  81. package/dist/routing-combo-repository.js +300 -0
  82. package/dist/routing-engine.js +377 -0
  83. package/dist/routing-integration.js +155 -0
  84. package/dist/routing-simulation-engine.js +326 -0
  85. package/dist/rtk-layer.js +483 -0
  86. package/dist/rtk-layer.test.js +198 -0
  87. package/dist/runtime-provider-repository.js +1742 -0
  88. package/dist/runtime-provider-repository.test.js +1177 -0
  89. package/dist/schema.js +118 -0
  90. package/dist/schema.test.js +16 -0
  91. package/dist/sepay-webhook.js +87 -0
  92. package/dist/sepay-webhook.test.js +142 -0
  93. package/dist/server-body-limit.test.js +35 -0
  94. package/dist/server-client-token-limits.test.js +161 -0
  95. package/dist/server-codex-config-setup.test.js +76 -0
  96. package/dist/server-http-rate-limit.test.js +80 -0
  97. package/dist/server-response-cache.test.js +105 -0
  98. package/dist/server-routes-alias.test.js +39 -0
  99. package/dist/server-sepay-webhook-security.test.js +59 -0
  100. package/dist/server.js +5906 -0
  101. package/dist/session-log.js +178 -0
  102. package/dist/tailnet-funnel-script.test.js +33 -0
  103. package/dist/telegram-bot/actions.js +118 -0
  104. package/dist/telegram-bot/admin-actions.js +103 -0
  105. package/dist/telegram-bot/auth.js +46 -0
  106. package/dist/telegram-bot/auth.test.js +1 -0
  107. package/dist/telegram-bot/bot-identity-repository.js +189 -0
  108. package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
  109. package/dist/telegram-bot/callbacks.js +30 -0
  110. package/dist/telegram-bot/codex-config-delivery.js +38 -0
  111. package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
  112. package/dist/telegram-bot/commands/accounts.js +140 -0
  113. package/dist/telegram-bot/commands/apikey.js +737 -0
  114. package/dist/telegram-bot/commands/apply.js +265 -0
  115. package/dist/telegram-bot/commands/clients.js +13 -0
  116. package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
  117. package/dist/telegram-bot/commands/grant.js +138 -0
  118. package/dist/telegram-bot/commands/grant.test.js +217 -0
  119. package/dist/telegram-bot/commands/help.js +52 -0
  120. package/dist/telegram-bot/commands/me.js +53 -0
  121. package/dist/telegram-bot/commands/models.js +6 -0
  122. package/dist/telegram-bot/commands/oauth.js +64 -0
  123. package/dist/telegram-bot/commands/plans.js +96 -0
  124. package/dist/telegram-bot/commands/providers.js +27 -0
  125. package/dist/telegram-bot/commands/quota.js +10 -0
  126. package/dist/telegram-bot/commands/renew-user.js +139 -0
  127. package/dist/telegram-bot/commands/renew-user.test.js +184 -0
  128. package/dist/telegram-bot/commands/renew.js +1369 -0
  129. package/dist/telegram-bot/commands/renew.test.js +1633 -0
  130. package/dist/telegram-bot/commands/start.js +212 -0
  131. package/dist/telegram-bot/commands/start.test.js +280 -0
  132. package/dist/telegram-bot/commands/status.js +6 -0
  133. package/dist/telegram-bot/commands/tailscale.js +15 -0
  134. package/dist/telegram-bot/commands/tailscale.test.js +76 -0
  135. package/dist/telegram-bot/commands/test.js +51 -0
  136. package/dist/telegram-bot/commands/test.test.js +14 -0
  137. package/dist/telegram-bot/commands/usage.js +10 -0
  138. package/dist/telegram-bot/config.js +98 -0
  139. package/dist/telegram-bot/config.test.js +42 -0
  140. package/dist/telegram-bot/customer-actions.js +160 -0
  141. package/dist/telegram-bot/customer-api-keys.js +68 -0
  142. package/dist/telegram-bot/customer-billing.js +72 -0
  143. package/dist/telegram-bot/customer-workspace-repository.js +134 -0
  144. package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
  145. package/dist/telegram-bot/dashboard-login.js +39 -0
  146. package/dist/telegram-bot/format.js +140 -0
  147. package/dist/telegram-bot/grants.js +370 -0
  148. package/dist/telegram-bot/grants.test.js +290 -0
  149. package/dist/telegram-bot/index.js +85 -0
  150. package/dist/telegram-bot/message-cleanup.js +55 -0
  151. package/dist/telegram-bot/message-cleanup.test.js +77 -0
  152. package/dist/telegram-bot/message-format.js +45 -0
  153. package/dist/telegram-bot/message-format.test.js +10 -0
  154. package/dist/telegram-bot/proxy-client.js +174 -0
  155. package/dist/telegram-bot/rate-limit.js +95 -0
  156. package/dist/telegram-bot/rate-limit.test.js +58 -0
  157. package/dist/telegram-bot/sessions.js +171 -0
  158. package/dist/telegram-bot/sessions.test.js +107 -0
  159. package/dist/telegram-bot/telegram-adapter.js +126 -0
  160. package/dist/telegram-bot/worker.js +63 -0
  161. package/package.json +39 -0
@@ -0,0 +1,716 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import BetterSqlite3 from "better-sqlite3";
5
+ export class BillingRepository {
6
+ db;
7
+ constructor(db) {
8
+ this.db = db;
9
+ }
10
+ static create(dbFile) {
11
+ mkdirSync(path.dirname(dbFile), { recursive: true });
12
+ const db = new BetterSqlite3(dbFile);
13
+ ensureBillingSchema(db);
14
+ const repository = new BillingRepository(db);
15
+ repository.seedDefaultPlans();
16
+ return repository;
17
+ }
18
+ seedDefaultPlans(now = new Date()) {
19
+ const timestamp = now.toISOString();
20
+ const insertPlan = this.db.prepare(`INSERT INTO plans (
21
+ id,
22
+ name,
23
+ status,
24
+ price_cents,
25
+ currency,
26
+ billing_interval,
27
+ monthly_token_limit,
28
+ max_api_keys,
29
+ allowed_models_json,
30
+ metadata_json,
31
+ created_at,
32
+ updated_at
33
+ )
34
+ VALUES (?, ?, 'active', ?, 'VND', 'month', ?, ?, '[]', '{}', ?, ?)
35
+ ON CONFLICT(id) DO NOTHING`);
36
+ insertPlan.run("basic", "Basic", 5_000, 10_000_000, 1, timestamp, timestamp);
37
+ }
38
+ listPlans() {
39
+ const rows = this.db.prepare("SELECT * FROM plans ORDER BY id").all();
40
+ return rows.map(mapPlanRow);
41
+ }
42
+ getPlan(planId) {
43
+ const row = this.db.prepare("SELECT * FROM plans WHERE id = ?").get(planId);
44
+ return row ? mapPlanRow(row) : undefined;
45
+ }
46
+ createPlan(input) {
47
+ const now = input.now ?? new Date();
48
+ const timestamp = now.toISOString();
49
+ const status = input.status ?? "active";
50
+ const currency = (input.currency ?? "USD").toUpperCase();
51
+ const billingInterval = input.billingInterval ?? "month";
52
+ this.db
53
+ .prepare(`INSERT INTO plans (
54
+ id,
55
+ name,
56
+ status,
57
+ price_cents,
58
+ currency,
59
+ billing_interval,
60
+ monthly_token_limit,
61
+ max_api_keys,
62
+ allowed_models_json,
63
+ metadata_json,
64
+ created_at,
65
+ updated_at
66
+ )
67
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
68
+ .run(input.id, input.name, status, input.priceCents ?? 0, currency, billingInterval, input.monthlyTokenLimit, input.maxApiKeys, JSON.stringify(input.allowedModels ?? []), input.metadataJson ?? "{}", timestamp, timestamp);
69
+ const plan = this.getPlan(input.id);
70
+ if (!plan) {
71
+ throw new Error(`Plan not found after create: ${input.id}`);
72
+ }
73
+ return plan;
74
+ }
75
+ grantSubscription(input) {
76
+ const plan = this.getPlan(input.planId);
77
+ if (!plan) {
78
+ throw new Error(`Plan not found: ${input.planId}`);
79
+ }
80
+ const now = input.now ?? new Date();
81
+ const nowIso = now.toISOString();
82
+ const currentSubscription = this.getLatestSubscriptionForWorkspace(input.workspaceId);
83
+ const periodStart = currentSubscription &&
84
+ (currentSubscription.status === "active" || currentSubscription.status === "trialing") &&
85
+ new Date(currentSubscription.currentPeriodEnd).getTime() > now.getTime()
86
+ ? new Date(currentSubscription.currentPeriodEnd)
87
+ : now;
88
+ const periodStartIso = periodStart.toISOString();
89
+ const periodEnd = new Date(periodStart.getTime() + input.days * 24 * 60 * 60 * 1000);
90
+ const subscriptionId = currentSubscription?.id ?? randomUUID();
91
+ this.db
92
+ .prepare(`INSERT INTO subscriptions (
93
+ id,
94
+ workspace_id,
95
+ plan_id,
96
+ status,
97
+ current_period_start,
98
+ current_period_end,
99
+ cancel_at_period_end,
100
+ created_at,
101
+ updated_at
102
+ )
103
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
104
+ ON CONFLICT(id) DO UPDATE SET
105
+ plan_id = excluded.plan_id,
106
+ status = excluded.status,
107
+ current_period_start = excluded.current_period_start,
108
+ current_period_end = excluded.current_period_end,
109
+ updated_at = excluded.updated_at`)
110
+ .run(subscriptionId, input.workspaceId, plan.id, plan.id === "trial" ? "trialing" : "active", periodStartIso, periodEnd.toISOString(), currentSubscription?.createdAt ?? nowIso, nowIso);
111
+ const entitlementId = randomUUID();
112
+ this.db
113
+ .prepare(`INSERT INTO entitlements (
114
+ id,
115
+ workspace_id,
116
+ subscription_id,
117
+ monthly_token_limit,
118
+ remaining_tokens,
119
+ allowed_models_json,
120
+ max_api_keys,
121
+ valid_from,
122
+ valid_until,
123
+ status,
124
+ created_at,
125
+ updated_at
126
+ )
127
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`)
128
+ .run(entitlementId, input.workspaceId, subscriptionId, plan.monthlyTokenLimit, plan.monthlyTokenLimit, JSON.stringify(plan.allowedModels), plan.maxApiKeys, periodStartIso, periodEnd.toISOString(), nowIso, nowIso);
129
+ return {
130
+ subscription: this.getLatestSubscriptionForWorkspace(input.workspaceId),
131
+ entitlement: this.getLatestEntitlementForWorkspace(input.workspaceId),
132
+ };
133
+ }
134
+ getLatestSubscriptionForWorkspace(workspaceId) {
135
+ const row = this.db
136
+ .prepare(`SELECT * FROM subscriptions
137
+ WHERE workspace_id = ?
138
+ ORDER BY current_period_end DESC, updated_at DESC
139
+ LIMIT 1`)
140
+ .get(workspaceId);
141
+ return row ? mapSubscriptionRow(row) : undefined;
142
+ }
143
+ getActiveEntitlementForWorkspace(workspaceId, now = new Date()) {
144
+ const nowIso = now.toISOString();
145
+ const row = this.db
146
+ .prepare(`SELECT * FROM entitlements
147
+ WHERE workspace_id = ?
148
+ AND status = 'active'
149
+ AND valid_from <= ?
150
+ AND valid_until >= ?
151
+ ORDER BY valid_until DESC, updated_at DESC
152
+ LIMIT 1`)
153
+ .get(workspaceId, nowIso, nowIso);
154
+ return row ? mapEntitlementRow(row) : undefined;
155
+ }
156
+ getActiveEntitlementLotsForWorkspace(workspaceId, now = new Date()) {
157
+ const nowIso = now.toISOString();
158
+ const rows = this.db
159
+ .prepare(`SELECT * FROM entitlements
160
+ WHERE workspace_id = ?
161
+ AND status = 'active'
162
+ AND valid_from <= ?
163
+ AND valid_until >= ?
164
+ ORDER BY valid_until ASC, created_at ASC`)
165
+ .all(workspaceId, nowIso, nowIso);
166
+ return rows.map((row) => {
167
+ const entitlement = mapEntitlementRow(row);
168
+ const usage = this.getEntitlementUsage(entitlement.id) ?? buildEmptyUsage(entitlement.id, entitlement.workspaceId);
169
+ return {
170
+ entitlement,
171
+ usage,
172
+ remainingTokens: Math.max(0, entitlement.monthlyTokenLimit - usage.totalTokens),
173
+ };
174
+ });
175
+ }
176
+ getUsableActiveEntitlementForWorkspace(workspaceId, now = new Date()) {
177
+ return this.getActiveEntitlementLotsForWorkspace(workspaceId, now)
178
+ .find((lot) => lot.remainingTokens > 0)
179
+ ?.entitlement;
180
+ }
181
+ consumeWorkspaceUsage(input) {
182
+ const totalTokens = Math.max(0, Math.trunc(input.totalTokens));
183
+ if (totalTokens <= 0) {
184
+ return [];
185
+ }
186
+ const now = input.now ?? new Date();
187
+ let remaining = totalTokens;
188
+ let remainingInput = Math.max(0, Math.trunc(input.inputTokens));
189
+ let remainingOutput = Math.max(0, Math.trunc(input.outputTokens));
190
+ const updates = [];
191
+ const lots = this.getActiveEntitlementLotsForWorkspace(input.workspaceId, now)
192
+ .filter((lot) => lot.remainingTokens > 0)
193
+ .sort((left, right) => new Date(left.entitlement.validUntil).getTime() - new Date(right.entitlement.validUntil).getTime());
194
+ const available = lots.reduce((sum, lot) => sum + lot.remainingTokens, 0);
195
+ if (available < totalTokens) {
196
+ throw new Error(`Token lot quota exceeded for workspace ${input.workspaceId}.`);
197
+ }
198
+ for (let index = 0; index < lots.length; index += 1) {
199
+ const lot = lots[index];
200
+ if (remaining <= 0) {
201
+ break;
202
+ }
203
+ const consumed = Math.min(remaining, lot.remainingTokens);
204
+ const isLast = index === lots.length - 1 || remaining === consumed;
205
+ const inputTokens = isLast ? remainingInput : Math.min(remainingInput, Math.round((remainingInput * consumed) / remaining));
206
+ const outputTokens = isLast ? remainingOutput : Math.min(remainingOutput, Math.round((remainingOutput * consumed) / remaining));
207
+ remainingInput -= inputTokens;
208
+ remainingOutput -= outputTokens;
209
+ updates.push(this.incrementEntitlementUsage({
210
+ entitlementId: lot.entitlement.id,
211
+ workspaceId: input.workspaceId,
212
+ customerApiKeyId: input.customerApiKeyId,
213
+ inputTokens,
214
+ outputTokens,
215
+ totalTokens: consumed,
216
+ now,
217
+ }));
218
+ remaining -= consumed;
219
+ }
220
+ return updates;
221
+ }
222
+ getLatestEntitlementForWorkspace(workspaceId) {
223
+ const row = this.db
224
+ .prepare(`SELECT * FROM entitlements
225
+ WHERE workspace_id = ?
226
+ ORDER BY valid_until DESC, updated_at DESC
227
+ LIMIT 1`)
228
+ .get(workspaceId);
229
+ return row ? mapEntitlementRow(row) : undefined;
230
+ }
231
+ createTokenTopUpLot(input) {
232
+ const tokenDelta = Math.max(0, Math.trunc(input.tokenDelta));
233
+ const days = Math.max(1, Math.trunc(input.days));
234
+ if (tokenDelta <= 0) {
235
+ throw new Error("Token top-up amount must be positive.");
236
+ }
237
+ const now = input.now ?? new Date();
238
+ const validFrom = now.toISOString();
239
+ const validUntil = new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString();
240
+ const id = randomUUID();
241
+ const latest = this.getLatestEntitlementForWorkspace(input.workspaceId);
242
+ this.db
243
+ .prepare(`INSERT INTO entitlements (
244
+ id,
245
+ workspace_id,
246
+ subscription_id,
247
+ monthly_token_limit,
248
+ remaining_tokens,
249
+ allowed_models_json,
250
+ max_api_keys,
251
+ valid_from,
252
+ valid_until,
253
+ status,
254
+ created_at,
255
+ updated_at
256
+ )
257
+ VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`)
258
+ .run(id, input.workspaceId, tokenDelta, tokenDelta, JSON.stringify(input.allowedModels ?? latest?.allowedModels ?? []), input.maxApiKeys ?? latest?.maxApiKeys ?? 1, validFrom, validUntil, validFrom, validFrom);
259
+ return this.getEntitlementById(id);
260
+ }
261
+ getEntitlementById(id) {
262
+ const row = this.db
263
+ .prepare(`SELECT * FROM entitlements
264
+ WHERE id = ?`)
265
+ .get(id);
266
+ return row ? mapEntitlementRow(row) : undefined;
267
+ }
268
+ getEntitlementUsage(entitlementId) {
269
+ const row = this.db
270
+ .prepare(`SELECT * FROM entitlement_usage
271
+ WHERE entitlement_id = ?`)
272
+ .get(entitlementId);
273
+ return row ? mapEntitlementUsageRow(row) : undefined;
274
+ }
275
+ incrementEntitlementUsage(input) {
276
+ const now = (input.now ?? new Date()).toISOString();
277
+ this.db
278
+ .prepare(`INSERT INTO entitlement_usage (
279
+ entitlement_id,
280
+ workspace_id,
281
+ customer_api_key_id,
282
+ input_tokens,
283
+ output_tokens,
284
+ total_tokens,
285
+ created_at,
286
+ updated_at
287
+ )
288
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
289
+ ON CONFLICT(entitlement_id) DO UPDATE SET
290
+ workspace_id = excluded.workspace_id,
291
+ customer_api_key_id = COALESCE(excluded.customer_api_key_id, entitlement_usage.customer_api_key_id),
292
+ input_tokens = entitlement_usage.input_tokens + excluded.input_tokens,
293
+ output_tokens = entitlement_usage.output_tokens + excluded.output_tokens,
294
+ total_tokens = entitlement_usage.total_tokens + excluded.total_tokens,
295
+ updated_at = excluded.updated_at`)
296
+ .run(input.entitlementId, input.workspaceId, input.customerApiKeyId ?? null, Math.max(0, Math.trunc(input.inputTokens)), Math.max(0, Math.trunc(input.outputTokens)), Math.max(0, Math.trunc(input.totalTokens)), now, now);
297
+ return this.getEntitlementUsage(input.entitlementId);
298
+ }
299
+ createRenewalRequest(input) {
300
+ const kind = input.kind ?? "renewal";
301
+ const existing = this.getOpenRenewalRequestForWorkspace(input.workspaceId, kind);
302
+ if (existing) {
303
+ return { request: existing, created: false };
304
+ }
305
+ const now = (input.now ?? new Date()).toISOString();
306
+ const id = randomUUID();
307
+ this.db
308
+ .prepare(`INSERT INTO renewal_requests (
309
+ id,
310
+ workspace_id,
311
+ telegram_user_id,
312
+ request_kind,
313
+ requested_plan_id,
314
+ requested_days,
315
+ requested_token_delta,
316
+ requested_token_lot_days,
317
+ price_vnd,
318
+ status,
319
+ requested_at,
320
+ created_at,
321
+ updated_at
322
+ )
323
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?)`)
324
+ .run(id, input.workspaceId, input.telegramUserId, kind, input.requestedPlanId ?? null, input.requestedDays ?? null, input.requestedTokenDelta ?? null, input.requestedTokenLotDays ?? null, input.priceVnd ?? null, now, now, now);
325
+ return {
326
+ request: this.getRenewalRequest(id),
327
+ created: true,
328
+ };
329
+ }
330
+ getRenewalRequest(id) {
331
+ const row = this.db
332
+ .prepare(`SELECT * FROM renewal_requests
333
+ WHERE id = ?`)
334
+ .get(id);
335
+ return row ? mapRenewalRequestRow(row) : undefined;
336
+ }
337
+ getOpenRenewalRequestForWorkspace(workspaceId, kind = "renewal") {
338
+ const row = this.db
339
+ .prepare(`SELECT * FROM renewal_requests
340
+ WHERE workspace_id = ?
341
+ AND request_kind = ?
342
+ AND status IN ('open', 'payment_confirmed')
343
+ ORDER BY created_at DESC
344
+ LIMIT 1`)
345
+ .get(workspaceId, kind);
346
+ return row ? mapRenewalRequestRow(row) : undefined;
347
+ }
348
+ listRenewalRequests(status) {
349
+ const rows = status
350
+ ? this.db
351
+ .prepare(`SELECT * FROM renewal_requests
352
+ WHERE status = ?
353
+ ORDER BY created_at DESC`)
354
+ .all(status)
355
+ : this.db
356
+ .prepare(`SELECT * FROM renewal_requests
357
+ ORDER BY created_at DESC`)
358
+ .all();
359
+ return rows.map(mapRenewalRequestRow);
360
+ }
361
+ confirmRenewalPayment(input) {
362
+ return this.setRenewalRequestStatus({
363
+ id: input.id,
364
+ status: "payment_confirmed",
365
+ resolution: input.resolution ?? "payment_confirmed_manual",
366
+ expectedStatus: input.expectedStatus ?? "open",
367
+ now: input.now,
368
+ });
369
+ }
370
+ closeRenewalRequest(input) {
371
+ return this.setRenewalRequestStatus({
372
+ id: input.id,
373
+ status: "closed",
374
+ resolution: input.resolution,
375
+ expectedStatus: input.expectedStatus,
376
+ now: input.now,
377
+ });
378
+ }
379
+ approveRenewalRequest(input) {
380
+ return this.setRenewalRequestStatus({
381
+ id: input.id,
382
+ status: "approved",
383
+ resolution: input.resolution,
384
+ approvedPlanId: input.approvedPlanId,
385
+ approvedDays: input.approvedDays,
386
+ expectedStatus: input.expectedStatus ?? "payment_confirmed",
387
+ now: input.now,
388
+ });
389
+ }
390
+ approveTokenTopUpRequest(input) {
391
+ return this.setRenewalRequestStatus({
392
+ id: input.id,
393
+ status: "approved",
394
+ resolution: input.resolution,
395
+ approvedTokenDelta: input.approvedTokenDelta,
396
+ approvedTokenLotDays: input.approvedTokenLotDays,
397
+ expectedStatus: input.expectedStatus ?? "payment_confirmed",
398
+ now: input.now,
399
+ });
400
+ }
401
+ approveTokenTopUpAtomically(input) {
402
+ return this.db.transaction(() => {
403
+ const transitioned = this.setRenewalRequestStatus({
404
+ id: input.requestId,
405
+ status: "approved",
406
+ resolution: input.resolution,
407
+ approvedTokenDelta: input.tokenDelta,
408
+ approvedTokenLotDays: input.lotDays,
409
+ expectedStatus: "payment_confirmed",
410
+ now: input.now,
411
+ });
412
+ if (!transitioned) {
413
+ return undefined;
414
+ }
415
+ const lot = this.createTokenTopUpLot({
416
+ workspaceId: input.workspaceId,
417
+ tokenDelta: input.tokenDelta,
418
+ days: input.lotDays,
419
+ allowedModels: input.allowedModels,
420
+ maxApiKeys: input.maxApiKeys,
421
+ sourceRequestId: input.requestId,
422
+ now: input.now,
423
+ });
424
+ return { request: transitioned, lot };
425
+ })();
426
+ }
427
+ setRenewalRequestStatus(input) {
428
+ const now = (input.now ?? new Date()).toISOString();
429
+ const query = input.expectedStatus
430
+ ? `UPDATE renewal_requests
431
+ SET status = ?,
432
+ resolution = COALESCE(?, resolution),
433
+ approved_plan_id = COALESCE(?, approved_plan_id),
434
+ approved_days = COALESCE(?, approved_days),
435
+ approved_token_delta = COALESCE(?, approved_token_delta),
436
+ approved_token_lot_days = COALESCE(?, approved_token_lot_days),
437
+ closed_at = CASE WHEN ? IN ('approved', 'closed') THEN ? ELSE closed_at END,
438
+ updated_at = ?
439
+ WHERE id = ?
440
+ AND status = ?`
441
+ : `UPDATE renewal_requests
442
+ SET status = ?,
443
+ resolution = COALESCE(?, resolution),
444
+ approved_plan_id = COALESCE(?, approved_plan_id),
445
+ approved_days = COALESCE(?, approved_days),
446
+ approved_token_delta = COALESCE(?, approved_token_delta),
447
+ approved_token_lot_days = COALESCE(?, approved_token_lot_days),
448
+ closed_at = CASE WHEN ? IN ('approved', 'closed') THEN ? ELSE closed_at END,
449
+ updated_at = ?
450
+ WHERE id = ?`;
451
+ const params = [
452
+ input.status,
453
+ input.resolution ?? null,
454
+ input.approvedPlanId ?? null,
455
+ input.approvedDays ?? null,
456
+ input.approvedTokenDelta ?? null,
457
+ input.approvedTokenLotDays ?? null,
458
+ input.status,
459
+ now,
460
+ now,
461
+ input.id,
462
+ ];
463
+ if (input.expectedStatus) {
464
+ params.push(input.expectedStatus);
465
+ }
466
+ const result = this.db.prepare(query).run(...params);
467
+ if (result.changes === 0) {
468
+ return undefined;
469
+ }
470
+ return this.getRenewalRequest(input.id);
471
+ }
472
+ expireEntitlements(now = new Date()) {
473
+ const nowIso = now.toISOString();
474
+ const result = this.db
475
+ .prepare(`UPDATE entitlements
476
+ SET status = 'expired',
477
+ updated_at = ?
478
+ WHERE status = 'active'
479
+ AND valid_until < ?`)
480
+ .run(nowIso, nowIso);
481
+ this.db
482
+ .prepare(`UPDATE subscriptions
483
+ SET status = 'expired',
484
+ updated_at = ?
485
+ WHERE status IN ('active', 'trialing')
486
+ AND current_period_end < ?`)
487
+ .run(nowIso, nowIso);
488
+ return result.changes;
489
+ }
490
+ }
491
+ function ensureBillingSchema(db) {
492
+ db.exec(`
493
+ CREATE TABLE IF NOT EXISTS plans (
494
+ id TEXT PRIMARY KEY,
495
+ name TEXT NOT NULL,
496
+ status TEXT NOT NULL DEFAULT 'active',
497
+ price_cents INTEGER NOT NULL,
498
+ currency TEXT NOT NULL DEFAULT 'USD',
499
+ billing_interval TEXT NOT NULL,
500
+ monthly_token_limit INTEGER NOT NULL,
501
+ max_api_keys INTEGER NOT NULL DEFAULT 1,
502
+ allowed_models_json TEXT NOT NULL DEFAULT '[]',
503
+ metadata_json TEXT NOT NULL DEFAULT '{}',
504
+ created_at TEXT NOT NULL,
505
+ updated_at TEXT NOT NULL
506
+ );
507
+
508
+ CREATE TABLE IF NOT EXISTS subscriptions (
509
+ id TEXT PRIMARY KEY,
510
+ workspace_id TEXT NOT NULL,
511
+ plan_id TEXT NOT NULL,
512
+ status TEXT NOT NULL,
513
+ current_period_start TEXT NOT NULL,
514
+ current_period_end TEXT NOT NULL,
515
+ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
516
+ provider TEXT,
517
+ provider_customer_id TEXT,
518
+ provider_subscription_id TEXT,
519
+ created_at TEXT NOT NULL,
520
+ updated_at TEXT NOT NULL
521
+ );
522
+
523
+ CREATE TABLE IF NOT EXISTS entitlements (
524
+ id TEXT PRIMARY KEY,
525
+ workspace_id TEXT NOT NULL,
526
+ subscription_id TEXT,
527
+ monthly_token_limit INTEGER NOT NULL,
528
+ remaining_tokens INTEGER,
529
+ allowed_models_json TEXT NOT NULL DEFAULT '[]',
530
+ max_api_keys INTEGER NOT NULL DEFAULT 1,
531
+ valid_from TEXT NOT NULL,
532
+ valid_until TEXT NOT NULL,
533
+ status TEXT NOT NULL DEFAULT 'active',
534
+ created_at TEXT NOT NULL,
535
+ updated_at TEXT NOT NULL
536
+ );
537
+
538
+ CREATE INDEX IF NOT EXISTS idx_entitlements_workspace_valid_until
539
+ ON entitlements(workspace_id, status, valid_until, created_at);
540
+
541
+ CREATE TABLE IF NOT EXISTS entitlement_usage (
542
+ entitlement_id TEXT PRIMARY KEY,
543
+ workspace_id TEXT NOT NULL,
544
+ customer_api_key_id TEXT,
545
+ input_tokens INTEGER NOT NULL DEFAULT 0,
546
+ output_tokens INTEGER NOT NULL DEFAULT 0,
547
+ total_tokens INTEGER NOT NULL DEFAULT 0,
548
+ created_at TEXT NOT NULL,
549
+ updated_at TEXT NOT NULL,
550
+ FOREIGN KEY (entitlement_id) REFERENCES entitlements(id) ON DELETE CASCADE
551
+ );
552
+
553
+ CREATE TABLE IF NOT EXISTS renewal_requests (
554
+ id TEXT PRIMARY KEY,
555
+ workspace_id TEXT NOT NULL,
556
+ telegram_user_id TEXT NOT NULL,
557
+ request_kind TEXT NOT NULL DEFAULT 'renewal',
558
+ requested_plan_id TEXT,
559
+ requested_days INTEGER,
560
+ requested_token_delta INTEGER,
561
+ requested_token_lot_days INTEGER,
562
+ price_vnd INTEGER,
563
+ status TEXT NOT NULL DEFAULT 'open',
564
+ resolution TEXT,
565
+ approved_plan_id TEXT,
566
+ approved_days INTEGER,
567
+ approved_token_delta INTEGER,
568
+ approved_token_lot_days INTEGER,
569
+ requested_at TEXT NOT NULL,
570
+ closed_at TEXT,
571
+ created_at TEXT NOT NULL,
572
+ updated_at TEXT NOT NULL
573
+ );
574
+
575
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_workspace_status
576
+ ON subscriptions(workspace_id, status, current_period_end);
577
+
578
+ CREATE INDEX IF NOT EXISTS idx_entitlements_workspace_status
579
+ ON entitlements(workspace_id, status, valid_until);
580
+
581
+ CREATE INDEX IF NOT EXISTS idx_entitlement_usage_workspace
582
+ ON entitlement_usage(workspace_id, updated_at);
583
+
584
+ CREATE INDEX IF NOT EXISTS idx_renewal_requests_workspace_status
585
+ ON renewal_requests(workspace_id, status, created_at);
586
+ `);
587
+ ensureBillingColumn(db, "renewal_requests", "request_kind", "TEXT NOT NULL DEFAULT 'renewal'");
588
+ ensureBillingColumn(db, "renewal_requests", "requested_token_delta", "INTEGER");
589
+ ensureBillingColumn(db, "renewal_requests", "requested_token_lot_days", "INTEGER");
590
+ ensureBillingColumn(db, "renewal_requests", "price_vnd", "INTEGER");
591
+ ensureBillingColumn(db, "renewal_requests", "approved_token_delta", "INTEGER");
592
+ ensureBillingColumn(db, "renewal_requests", "approved_token_lot_days", "INTEGER");
593
+ }
594
+ function mapPlanRow(row) {
595
+ return {
596
+ id: row.id,
597
+ name: row.name,
598
+ status: row.status === "archived" ? "archived" : "active",
599
+ priceCents: row.price_cents,
600
+ currency: row.currency,
601
+ billingInterval: row.billing_interval === "year" || row.billing_interval === "one_time"
602
+ ? row.billing_interval
603
+ : "month",
604
+ monthlyTokenLimit: row.monthly_token_limit,
605
+ maxApiKeys: row.max_api_keys,
606
+ allowedModels: parseStringArray(row.allowed_models_json),
607
+ metadataJson: row.metadata_json,
608
+ createdAt: row.created_at,
609
+ updatedAt: row.updated_at,
610
+ };
611
+ }
612
+ function mapSubscriptionRow(row) {
613
+ return {
614
+ id: row.id,
615
+ workspaceId: row.workspace_id,
616
+ planId: row.plan_id,
617
+ status: row.status === "trialing" ||
618
+ row.status === "active" ||
619
+ row.status === "past_due" ||
620
+ row.status === "canceled" ||
621
+ row.status === "expired"
622
+ ? row.status
623
+ : "expired",
624
+ currentPeriodStart: row.current_period_start,
625
+ currentPeriodEnd: row.current_period_end,
626
+ cancelAtPeriodEnd: row.cancel_at_period_end !== 0,
627
+ provider: row.provider ?? undefined,
628
+ providerCustomerId: row.provider_customer_id ?? undefined,
629
+ providerSubscriptionId: row.provider_subscription_id ?? undefined,
630
+ createdAt: row.created_at,
631
+ updatedAt: row.updated_at,
632
+ };
633
+ }
634
+ function mapEntitlementRow(row) {
635
+ return {
636
+ id: row.id,
637
+ workspaceId: row.workspace_id,
638
+ subscriptionId: row.subscription_id ?? undefined,
639
+ monthlyTokenLimit: row.monthly_token_limit,
640
+ remainingTokens: row.remaining_tokens ?? undefined,
641
+ allowedModels: parseStringArray(row.allowed_models_json),
642
+ maxApiKeys: row.max_api_keys,
643
+ validFrom: row.valid_from,
644
+ validUntil: row.valid_until,
645
+ status: row.status === "expired" || row.status === "suspended" ? row.status : "active",
646
+ createdAt: row.created_at,
647
+ updatedAt: row.updated_at,
648
+ };
649
+ }
650
+ function mapEntitlementUsageRow(row) {
651
+ return {
652
+ entitlementId: row.entitlement_id,
653
+ workspaceId: row.workspace_id,
654
+ customerApiKeyId: row.customer_api_key_id ?? undefined,
655
+ inputTokens: Math.max(0, Number(row.input_tokens ?? 0)),
656
+ outputTokens: Math.max(0, Number(row.output_tokens ?? 0)),
657
+ totalTokens: Math.max(0, Number(row.total_tokens ?? 0)),
658
+ createdAt: row.created_at,
659
+ updatedAt: row.updated_at,
660
+ };
661
+ }
662
+ function mapRenewalRequestRow(row) {
663
+ return {
664
+ id: row.id,
665
+ workspaceId: row.workspace_id,
666
+ telegramUserId: row.telegram_user_id,
667
+ kind: row.request_kind === "token_topup" ? "token_topup" : "renewal",
668
+ requestedPlanId: row.requested_plan_id ?? undefined,
669
+ requestedDays: row.requested_days ?? undefined,
670
+ requestedTokenDelta: row.requested_token_delta ?? undefined,
671
+ requestedTokenLotDays: row.requested_token_lot_days ?? undefined,
672
+ priceVnd: row.price_vnd ?? undefined,
673
+ status: row.status === "payment_confirmed" || row.status === "approved" || row.status === "closed"
674
+ ? row.status
675
+ : "open",
676
+ resolution: row.resolution ?? undefined,
677
+ approvedPlanId: row.approved_plan_id ?? undefined,
678
+ approvedDays: row.approved_days ?? undefined,
679
+ approvedTokenDelta: row.approved_token_delta ?? undefined,
680
+ approvedTokenLotDays: row.approved_token_lot_days ?? undefined,
681
+ requestedAt: row.requested_at,
682
+ closedAt: row.closed_at ?? undefined,
683
+ createdAt: row.created_at,
684
+ updatedAt: row.updated_at,
685
+ };
686
+ }
687
+ function buildEmptyUsage(entitlementId, workspaceId) {
688
+ const timestamp = new Date(0).toISOString();
689
+ return {
690
+ entitlementId: entitlementId ?? "none",
691
+ workspaceId: workspaceId ?? "none",
692
+ inputTokens: 0,
693
+ outputTokens: 0,
694
+ totalTokens: 0,
695
+ createdAt: timestamp,
696
+ updatedAt: timestamp,
697
+ };
698
+ }
699
+ function ensureBillingColumn(db, tableName, columnName, columnDefinition) {
700
+ const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
701
+ if (columns.some((column) => column.name === columnName)) {
702
+ return;
703
+ }
704
+ db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`);
705
+ }
706
+ function parseStringArray(raw) {
707
+ try {
708
+ const parsed = JSON.parse(raw);
709
+ return Array.isArray(parsed)
710
+ ? parsed.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
711
+ : [];
712
+ }
713
+ catch {
714
+ return [];
715
+ }
716
+ }