@frockbot/plugin-billing 0.0.0 → 0.3.20

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/src/shared.ts ADDED
@@ -0,0 +1,355 @@
1
+ export const USAGE_DETAIL_RETENTION_DAYS_V1 = 45;
2
+ export const USAGE_DETAIL_MAX_ROWS_V1 = 50_000;
3
+ export const USAGE_MONTH_RETENTION_V1 = 120;
4
+ export const USAGE_OUTBOX_MAX_V1 = 1_024;
5
+ export const USAGE_ENTRY_PAGE_MAX_V1 = 1_024;
6
+
7
+ export type UsageKindV1 = "model" | "voice";
8
+
9
+ export interface UsageEntryV1 {
10
+ schemaVersion: 1;
11
+ entryId: string;
12
+ kind: UsageKindV1;
13
+ botId?: string;
14
+ runId?: string;
15
+ turnId?: string;
16
+ turn?: number;
17
+ requestId?: string;
18
+ at: string;
19
+ provider: string;
20
+ model: string;
21
+ bindingId?: string;
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ cachedInputTokens: number;
25
+ reasoningTokens: number;
26
+ voiceSeconds: number;
27
+ latencyMs: number;
28
+ estimated: boolean;
29
+ unknownPrice: boolean;
30
+ priceTableVersion: string;
31
+ costMicros: number;
32
+ }
33
+
34
+ export interface UsageBreakdownV1 {
35
+ id: string;
36
+ costMicros: number;
37
+ inputTokens: number;
38
+ outputTokens: number;
39
+ voiceSeconds: number;
40
+ estimatedCalls: number;
41
+ unknownPriceCalls: number;
42
+ }
43
+
44
+ export interface UsageDayV1 {
45
+ day: string;
46
+ costMicros: number;
47
+ }
48
+
49
+ export interface UsageReportV1 {
50
+ schemaVersion: 1;
51
+ month: string;
52
+ currentMonthCostMicros: number;
53
+ lifetimeCostMicros: number;
54
+ currentMonthInputTokens: number;
55
+ currentMonthOutputTokens: number;
56
+ currentMonthVoiceSeconds: number;
57
+ estimatedCalls: number;
58
+ unknownPriceCalls: number;
59
+ bots: UsageBreakdownV1[];
60
+ models: UsageBreakdownV1[];
61
+ days: UsageDayV1[];
62
+ }
63
+
64
+ export class UsageDecodeError extends Error {
65
+ constructor(message: string) {
66
+ super(message);
67
+ this.name = "UsageDecodeError";
68
+ }
69
+ }
70
+
71
+ function record(value: unknown, label: string): Record<string, unknown> {
72
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
73
+ throw new UsageDecodeError(`${label} must be an object`);
74
+ }
75
+ return value as Record<string, unknown>;
76
+ }
77
+
78
+ function exactKeys(
79
+ value: Record<string, unknown>,
80
+ required: readonly string[],
81
+ optional: readonly string[],
82
+ label: string,
83
+ ): void {
84
+ const allowed = new Set([...required, ...optional]);
85
+ for (const key of Reflect.ownKeys(value)) {
86
+ if (typeof key !== "string" || !allowed.has(key)) {
87
+ throw new UsageDecodeError(`${label}.${String(key)} is not allowed`);
88
+ }
89
+ }
90
+ for (const key of required) {
91
+ if (!Object.hasOwn(value, key)) {
92
+ throw new UsageDecodeError(`${label}.${key} is required`);
93
+ }
94
+ }
95
+ }
96
+
97
+ function text(value: unknown, label: string, maximum = 256): string {
98
+ if (
99
+ typeof value !== "string" ||
100
+ value.length === 0 ||
101
+ value.length > maximum
102
+ ) {
103
+ throw new UsageDecodeError(`${label} must be a bounded string`);
104
+ }
105
+ return value;
106
+ }
107
+
108
+ function integer(value: unknown, label: string): number {
109
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
110
+ throw new UsageDecodeError(`${label} must be a non-negative integer`);
111
+ }
112
+ return value as number;
113
+ }
114
+
115
+ function optionalText(value: unknown, label: string): string | undefined {
116
+ return value === undefined ? undefined : text(value, label);
117
+ }
118
+
119
+ function optionalInteger(value: unknown, label: string): number | undefined {
120
+ return value === undefined ? undefined : integer(value, label);
121
+ }
122
+
123
+ function flag(value: unknown, label: string): boolean {
124
+ if (typeof value !== "boolean") {
125
+ throw new UsageDecodeError(`${label} must be a boolean`);
126
+ }
127
+ return value;
128
+ }
129
+
130
+ const ENTRY_REQUIRED = [
131
+ "schemaVersion",
132
+ "entryId",
133
+ "kind",
134
+ "at",
135
+ "provider",
136
+ "model",
137
+ "inputTokens",
138
+ "outputTokens",
139
+ "cachedInputTokens",
140
+ "reasoningTokens",
141
+ "voiceSeconds",
142
+ "latencyMs",
143
+ "estimated",
144
+ "unknownPrice",
145
+ "priceTableVersion",
146
+ "costMicros",
147
+ ] as const;
148
+ const ENTRY_OPTIONAL = [
149
+ "botId",
150
+ "runId",
151
+ "turnId",
152
+ "turn",
153
+ "requestId",
154
+ "bindingId",
155
+ ] as const;
156
+
157
+ export function decodeUsageEntryV1(value: unknown): UsageEntryV1 {
158
+ const entry = record(value, "usage entry");
159
+ exactKeys(entry, ENTRY_REQUIRED, ENTRY_OPTIONAL, "usage entry");
160
+ if (entry.schemaVersion !== 1) {
161
+ throw new UsageDecodeError("usage entry.schemaVersion is invalid");
162
+ }
163
+ if (entry.kind !== "model" && entry.kind !== "voice") {
164
+ throw new UsageDecodeError("usage entry.kind is invalid");
165
+ }
166
+ const at = text(entry.at, "usage entry.at", 64);
167
+ if (!Number.isFinite(Date.parse(at))) {
168
+ throw new UsageDecodeError("usage entry.at must be a timestamp");
169
+ }
170
+ const inputTokens = integer(entry.inputTokens, "usage entry.inputTokens");
171
+ const outputTokens = integer(entry.outputTokens, "usage entry.outputTokens");
172
+ const cachedInputTokens = integer(
173
+ entry.cachedInputTokens,
174
+ "usage entry.cachedInputTokens",
175
+ );
176
+ const reasoningTokens = integer(
177
+ entry.reasoningTokens,
178
+ "usage entry.reasoningTokens",
179
+ );
180
+ if (cachedInputTokens > inputTokens || reasoningTokens > outputTokens) {
181
+ throw new UsageDecodeError("usage entry token details exceed their totals");
182
+ }
183
+ return {
184
+ schemaVersion: 1,
185
+ entryId: text(entry.entryId, "usage entry.entryId"),
186
+ kind: entry.kind,
187
+ ...(optionalText(entry.botId, "usage entry.botId")
188
+ ? {
189
+ botId: optionalText(entry.botId, "usage entry.botId"),
190
+ }
191
+ : {}),
192
+ ...(optionalText(entry.runId, "usage entry.runId")
193
+ ? {
194
+ runId: optionalText(entry.runId, "usage entry.runId"),
195
+ }
196
+ : {}),
197
+ ...(optionalText(entry.turnId, "usage entry.turnId")
198
+ ? {
199
+ turnId: optionalText(entry.turnId, "usage entry.turnId"),
200
+ }
201
+ : {}),
202
+ ...(optionalInteger(entry.turn, "usage entry.turn") === undefined
203
+ ? {}
204
+ : {
205
+ turn: optionalInteger(entry.turn, "usage entry.turn"),
206
+ }),
207
+ ...(optionalText(entry.requestId, "usage entry.requestId")
208
+ ? {
209
+ requestId: optionalText(entry.requestId, "usage entry.requestId"),
210
+ }
211
+ : {}),
212
+ at,
213
+ provider: text(entry.provider, "usage entry.provider"),
214
+ model: text(entry.model, "usage entry.model"),
215
+ ...(optionalText(entry.bindingId, "usage entry.bindingId")
216
+ ? {
217
+ bindingId: optionalText(entry.bindingId, "usage entry.bindingId"),
218
+ }
219
+ : {}),
220
+ inputTokens,
221
+ outputTokens,
222
+ cachedInputTokens,
223
+ reasoningTokens,
224
+ voiceSeconds: integer(entry.voiceSeconds, "usage entry.voiceSeconds"),
225
+ latencyMs: integer(entry.latencyMs, "usage entry.latencyMs"),
226
+ estimated: flag(entry.estimated, "usage entry.estimated"),
227
+ unknownPrice: flag(entry.unknownPrice, "usage entry.unknownPrice"),
228
+ priceTableVersion: text(
229
+ entry.priceTableVersion,
230
+ "usage entry.priceTableVersion",
231
+ 64,
232
+ ),
233
+ costMicros: integer(entry.costMicros, "usage entry.costMicros"),
234
+ };
235
+ }
236
+
237
+ function decodeBreakdownV1(value: unknown, label: string): UsageBreakdownV1 {
238
+ const row = record(value, label);
239
+ const keys = [
240
+ "id",
241
+ "costMicros",
242
+ "inputTokens",
243
+ "outputTokens",
244
+ "voiceSeconds",
245
+ "estimatedCalls",
246
+ "unknownPriceCalls",
247
+ ];
248
+ exactKeys(row, keys, [], label);
249
+ return {
250
+ id: text(row.id, `${label}.id`, 512),
251
+ costMicros: integer(row.costMicros, `${label}.costMicros`),
252
+ inputTokens: integer(row.inputTokens, `${label}.inputTokens`),
253
+ outputTokens: integer(row.outputTokens, `${label}.outputTokens`),
254
+ voiceSeconds: integer(row.voiceSeconds, `${label}.voiceSeconds`),
255
+ estimatedCalls: integer(row.estimatedCalls, `${label}.estimatedCalls`),
256
+ unknownPriceCalls: integer(
257
+ row.unknownPriceCalls,
258
+ `${label}.unknownPriceCalls`,
259
+ ),
260
+ };
261
+ }
262
+
263
+ export function decodeUsageReportV1(value: unknown): UsageReportV1 {
264
+ const report = record(value, "usage report");
265
+ const keys = [
266
+ "schemaVersion",
267
+ "month",
268
+ "currentMonthCostMicros",
269
+ "lifetimeCostMicros",
270
+ "currentMonthInputTokens",
271
+ "currentMonthOutputTokens",
272
+ "currentMonthVoiceSeconds",
273
+ "estimatedCalls",
274
+ "unknownPriceCalls",
275
+ "bots",
276
+ "models",
277
+ "days",
278
+ ];
279
+ exactKeys(report, keys, [], "usage report");
280
+ if (
281
+ report.schemaVersion !== 1 ||
282
+ !/^\d{4}-\d{2}$/.test(String(report.month))
283
+ ) {
284
+ throw new UsageDecodeError("usage report version or month is invalid");
285
+ }
286
+ if (
287
+ !Array.isArray(report.bots) ||
288
+ !Array.isArray(report.models) ||
289
+ !Array.isArray(report.days)
290
+ ) {
291
+ throw new UsageDecodeError("usage report breakdowns must be arrays");
292
+ }
293
+ if (
294
+ report.bots.length > 10_000 ||
295
+ report.models.length > 10_000 ||
296
+ report.days.length > 31
297
+ ) {
298
+ throw new UsageDecodeError("usage report exceeds its bounds");
299
+ }
300
+ return {
301
+ schemaVersion: 1,
302
+ month: String(report.month),
303
+ currentMonthCostMicros: integer(
304
+ report.currentMonthCostMicros,
305
+ "usage report.currentMonthCostMicros",
306
+ ),
307
+ lifetimeCostMicros: integer(
308
+ report.lifetimeCostMicros,
309
+ "usage report.lifetimeCostMicros",
310
+ ),
311
+ currentMonthInputTokens: integer(
312
+ report.currentMonthInputTokens,
313
+ "usage report.currentMonthInputTokens",
314
+ ),
315
+ currentMonthOutputTokens: integer(
316
+ report.currentMonthOutputTokens,
317
+ "usage report.currentMonthOutputTokens",
318
+ ),
319
+ currentMonthVoiceSeconds: integer(
320
+ report.currentMonthVoiceSeconds,
321
+ "usage report.currentMonthVoiceSeconds",
322
+ ),
323
+ estimatedCalls: integer(
324
+ report.estimatedCalls,
325
+ "usage report.estimatedCalls",
326
+ ),
327
+ unknownPriceCalls: integer(
328
+ report.unknownPriceCalls,
329
+ "usage report.unknownPriceCalls",
330
+ ),
331
+ bots: report.bots.map((row, index) =>
332
+ decodeBreakdownV1(row, `usage report.bots[${index}]`),
333
+ ),
334
+ models: report.models.map((row, index) =>
335
+ decodeBreakdownV1(row, `usage report.models[${index}]`),
336
+ ),
337
+ days: report.days.map((value, index) => {
338
+ const day = record(value, `usage report.days[${index}]`);
339
+ exactKeys(day, ["day", "costMicros"], [], `usage report.days[${index}]`);
340
+ const date = text(day.day, `usage report.days[${index}].day`, 10);
341
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
342
+ throw new UsageDecodeError(
343
+ `usage report.days[${index}].day is invalid`,
344
+ );
345
+ }
346
+ return {
347
+ day: date,
348
+ costMicros: integer(
349
+ day.costMicros,
350
+ `usage report.days[${index}].costMicros`,
351
+ ),
352
+ };
353
+ }),
354
+ };
355
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import type { UsageEntryV1 } from "./shared.js";
4
+ import { UsageStoreV1, type UsageSqlV1 } from "./store.js";
5
+
6
+ function sqlV1(database: Database): UsageSqlV1 {
7
+ return {
8
+ exec(query, ...bindings) {
9
+ const statement = database.query(query);
10
+ if (/^\s*(SELECT|WITH|PRAGMA)/i.test(query)) {
11
+ return { toArray: () => statement.all(...bindings) as never[] };
12
+ }
13
+ statement.run(...bindings);
14
+ return { toArray: () => [] };
15
+ },
16
+ };
17
+ }
18
+
19
+ function entry(
20
+ entryId: string,
21
+ at: string,
22
+ overrides: Partial<UsageEntryV1> = {},
23
+ ): UsageEntryV1 {
24
+ return {
25
+ schemaVersion: 1,
26
+ entryId,
27
+ kind: "model",
28
+ botId: "bot-a",
29
+ runId: `run-${entryId}`,
30
+ turnId: `run-${entryId}:1`,
31
+ turn: 1,
32
+ requestId: `request-${entryId}`,
33
+ at,
34
+ provider: "flock-ai",
35
+ model: "@frock/deepseek-ai/deepseek-v4-flash-0731",
36
+ inputTokens: 100,
37
+ outputTokens: 20,
38
+ cachedInputTokens: 10,
39
+ reasoningTokens: 5,
40
+ voiceSeconds: 0,
41
+ latencyMs: 300,
42
+ estimated: false,
43
+ unknownPrice: false,
44
+ priceTableVersion: "test",
45
+ costMicros: 70,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ describe("UsageStoreV1", () => {
51
+ test("deduplicates entries and builds monthly, daily, bot and model totals", () => {
52
+ const database = new Database(":memory:");
53
+ const store = new UsageStoreV1({
54
+ sql: sqlV1(database),
55
+ now: () => Date.parse("2026-09-04T12:00:00.000Z"),
56
+ });
57
+ const first = entry("one", "2026-09-03T10:00:00.000Z");
58
+ const second = entry("two", "2026-09-04T10:00:00.000Z", {
59
+ botId: "bot-b",
60
+ inputTokens: 50,
61
+ outputTokens: 10,
62
+ costMicros: 30,
63
+ estimated: true,
64
+ unknownPrice: true,
65
+ });
66
+
67
+ expect(store.record([first, second, first])).toBe(2);
68
+ const report = store.report(new Date("2026-09-04T12:00:00.000Z"));
69
+
70
+ expect(report).toMatchObject({
71
+ currentMonthCostMicros: 100,
72
+ lifetimeCostMicros: 100,
73
+ currentMonthInputTokens: 150,
74
+ currentMonthOutputTokens: 30,
75
+ estimatedCalls: 1,
76
+ unknownPriceCalls: 1,
77
+ });
78
+ expect(report.bots).toEqual([
79
+ expect.objectContaining({ id: "bot-a", costMicros: 70 }),
80
+ expect.objectContaining({ id: "bot-b", costMicros: 30 }),
81
+ ]);
82
+ expect(report.models).toEqual([
83
+ expect.objectContaining({
84
+ id: "flock-ai/@frock/deepseek-ai/deepseek-v4-flash-0731",
85
+ costMicros: 100,
86
+ }),
87
+ ]);
88
+ expect(report.days.slice(-2)).toEqual([
89
+ { day: "2026-09-03", costMicros: 70 },
90
+ { day: "2026-09-04", costMicros: 30 },
91
+ ]);
92
+ database.close();
93
+ });
94
+
95
+ test("removes old detail and daily rows while preserving monthly and lifetime totals", () => {
96
+ const database = new Database(":memory:");
97
+ const store = new UsageStoreV1({
98
+ sql: sqlV1(database),
99
+ now: () => Date.parse("2026-09-04T12:00:00.000Z"),
100
+ detailRetentionDays: 5,
101
+ });
102
+ store.record([entry("old", "2026-08-20T10:00:00.000Z")]);
103
+
104
+ expect(
105
+ database.query("SELECT count(*) AS n FROM usage_entries").get() as {
106
+ n: number;
107
+ },
108
+ ).toEqual({ n: 0 });
109
+ expect(store.report(new Date("2026-08-25T00:00:00.000Z"))).toMatchObject({
110
+ currentMonthCostMicros: 70,
111
+ lifetimeCostMicros: 70,
112
+ });
113
+ database.close();
114
+ });
115
+ });