@wopr-network/platform-core 1.74.0 → 1.75.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/dist/fleet/drizzle-bot-instance-repository.d.ts +3 -1
- package/dist/fleet/drizzle-bot-instance-repository.js +8 -2
- package/dist/fleet/instance.d.ts +8 -0
- package/dist/fleet/instance.js +20 -0
- package/dist/fleet/repository-types.d.ts +1 -1
- package/dist/gateway/proxy.js +10 -2
- package/dist/gateway/types.d.ts +3 -1
- package/dist/index.js +4 -0
- package/dist/monetization/credits/bot-billing.test.js +35 -1
- package/dist/monetization/credits/runtime-cron.d.ts +10 -2
- package/dist/monetization/credits/runtime-cron.js +19 -4
- package/dist/monetization/credits/runtime-cron.test.js +49 -34
- package/dist/monetization/credits/storage-tier-billing.test.js +9 -1
- package/dist/monetization/credits/storage-tier-cron.test.js +13 -7
- package/dist/server/__tests__/container.test.js +5 -1
- package/dist/server/container.d.ts +2 -0
- package/dist/server/container.js +6 -1
- package/dist/server/index.js +1 -1
- package/dist/server/mount-routes.d.ts +1 -1
- package/dist/server/mount-routes.js +35 -2
- package/dist/server/routes/__tests__/admin.test.js +3 -3
- package/package.json +1 -1
- package/src/fleet/drizzle-bot-instance-repository.ts +15 -2
- package/src/fleet/instance.ts +21 -0
- package/src/fleet/repository-types.ts +1 -1
- package/src/gateway/proxy.ts +9 -2
- package/src/gateway/types.ts +3 -1
- package/src/index.ts +4 -0
- package/src/monetization/credits/bot-billing.test.ts +35 -1
- package/src/monetization/credits/runtime-cron.test.ts +51 -38
- package/src/monetization/credits/runtime-cron.ts +21 -4
- package/src/monetization/credits/storage-tier-billing.test.ts +9 -1
- package/src/monetization/credits/storage-tier-cron.test.ts +13 -7
- package/src/server/__tests__/container.test.ts +5 -1
- package/src/server/container.ts +9 -1
- package/src/server/index.ts +1 -1
- package/src/server/mount-routes.ts +41 -3
- package/src/server/routes/__tests__/admin.test.ts +3 -3
|
@@ -56,6 +56,7 @@ function createMockDeps(nodeId: string | null = "node-1") {
|
|
|
56
56
|
describe("BotBilling", () => {
|
|
57
57
|
let pool: PGlite;
|
|
58
58
|
let db: DrizzleDb;
|
|
59
|
+
let repo: DrizzleBotInstanceRepository;
|
|
59
60
|
let billing: BotBilling;
|
|
60
61
|
let ledger: DrizzleLedger;
|
|
61
62
|
|
|
@@ -69,7 +70,8 @@ describe("BotBilling", () => {
|
|
|
69
70
|
|
|
70
71
|
beforeEach(async () => {
|
|
71
72
|
await truncateAllTables(pool);
|
|
72
|
-
|
|
73
|
+
repo = new DrizzleBotInstanceRepository(db);
|
|
74
|
+
billing = new BotBilling(repo);
|
|
73
75
|
ledger = new DrizzleLedger(db);
|
|
74
76
|
|
|
75
77
|
await ledger.seedSystemAccounts();
|
|
@@ -78,6 +80,7 @@ describe("BotBilling", () => {
|
|
|
78
80
|
describe("registerBot", () => {
|
|
79
81
|
it("registers a bot in active billing state", async () => {
|
|
80
82
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
83
|
+
await repo.startBilling("bot-1");
|
|
81
84
|
const info = await billing.getBotBilling("bot-1");
|
|
82
85
|
expect(info).not.toBeNull();
|
|
83
86
|
// biome-ignore lint/suspicious/noExplicitAny: intentional test cast
|
|
@@ -100,8 +103,11 @@ describe("BotBilling", () => {
|
|
|
100
103
|
|
|
101
104
|
it("counts only active bots for the tenant", async () => {
|
|
102
105
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
106
|
+
await repo.startBilling("bot-1");
|
|
103
107
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
108
|
+
await repo.startBilling("bot-2");
|
|
104
109
|
await billing.registerBot("bot-3", "tenant-2", "bot-c");
|
|
110
|
+
await repo.startBilling("bot-3");
|
|
105
111
|
|
|
106
112
|
expect(await billing.getActiveBotCount("tenant-1")).toBe(2);
|
|
107
113
|
expect(await billing.getActiveBotCount("tenant-2")).toBe(1);
|
|
@@ -109,7 +115,9 @@ describe("BotBilling", () => {
|
|
|
109
115
|
|
|
110
116
|
it("does not count suspended bots", async () => {
|
|
111
117
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
118
|
+
await repo.startBilling("bot-1");
|
|
112
119
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
120
|
+
await repo.startBilling("bot-2");
|
|
113
121
|
await billing.suspendBot("bot-1");
|
|
114
122
|
|
|
115
123
|
expect(await billing.getActiveBotCount("tenant-1")).toBe(1);
|
|
@@ -117,6 +125,7 @@ describe("BotBilling", () => {
|
|
|
117
125
|
|
|
118
126
|
it("does not count destroyed bots", async () => {
|
|
119
127
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
128
|
+
await repo.startBilling("bot-1");
|
|
120
129
|
await billing.destroyBot("bot-1");
|
|
121
130
|
|
|
122
131
|
expect(await billing.getActiveBotCount("tenant-1")).toBe(0);
|
|
@@ -126,6 +135,7 @@ describe("BotBilling", () => {
|
|
|
126
135
|
describe("suspendBot", () => {
|
|
127
136
|
it("transitions bot from active to suspended", async () => {
|
|
128
137
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
138
|
+
await repo.startBilling("bot-1");
|
|
129
139
|
await billing.suspendBot("bot-1");
|
|
130
140
|
|
|
131
141
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -139,6 +149,7 @@ describe("BotBilling", () => {
|
|
|
139
149
|
|
|
140
150
|
it("sets destroyAfter to 30 days after suspension", async () => {
|
|
141
151
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
152
|
+
await repo.startBilling("bot-1");
|
|
142
153
|
await billing.suspendBot("bot-1");
|
|
143
154
|
|
|
144
155
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -155,8 +166,11 @@ describe("BotBilling", () => {
|
|
|
155
166
|
describe("suspendAllForTenant", () => {
|
|
156
167
|
it("suspends all active bots for a tenant", async () => {
|
|
157
168
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
169
|
+
await repo.startBilling("bot-1");
|
|
158
170
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
171
|
+
await repo.startBilling("bot-2");
|
|
159
172
|
await billing.registerBot("bot-3", "tenant-2", "bot-c");
|
|
173
|
+
await repo.startBilling("bot-3");
|
|
160
174
|
|
|
161
175
|
const suspended = await billing.suspendAllForTenant("tenant-1");
|
|
162
176
|
|
|
@@ -174,6 +188,7 @@ describe("BotBilling", () => {
|
|
|
174
188
|
describe("reactivateBot", () => {
|
|
175
189
|
it("transitions bot from suspended to active", async () => {
|
|
176
190
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
191
|
+
await repo.startBilling("bot-1");
|
|
177
192
|
await billing.suspendBot("bot-1");
|
|
178
193
|
await billing.reactivateBot("bot-1");
|
|
179
194
|
|
|
@@ -188,6 +203,7 @@ describe("BotBilling", () => {
|
|
|
188
203
|
|
|
189
204
|
it("does not reactivate a destroyed bot", async () => {
|
|
190
205
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
206
|
+
await repo.startBilling("bot-1");
|
|
191
207
|
await billing.destroyBot("bot-1");
|
|
192
208
|
await billing.reactivateBot("bot-1");
|
|
193
209
|
|
|
@@ -198,6 +214,7 @@ describe("BotBilling", () => {
|
|
|
198
214
|
|
|
199
215
|
it("does not affect already-active bots", async () => {
|
|
200
216
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
217
|
+
await repo.startBilling("bot-1");
|
|
201
218
|
await billing.reactivateBot("bot-1");
|
|
202
219
|
|
|
203
220
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -209,7 +226,9 @@ describe("BotBilling", () => {
|
|
|
209
226
|
describe("checkReactivation", () => {
|
|
210
227
|
it("reactivates suspended bots when balance is positive", async () => {
|
|
211
228
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
229
|
+
await repo.startBilling("bot-1");
|
|
212
230
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
231
|
+
await repo.startBilling("bot-2");
|
|
213
232
|
await billing.suspendBot("bot-1");
|
|
214
233
|
await billing.suspendBot("bot-2");
|
|
215
234
|
|
|
@@ -226,6 +245,7 @@ describe("BotBilling", () => {
|
|
|
226
245
|
|
|
227
246
|
it("does not reactivate when balance is zero", async () => {
|
|
228
247
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
248
|
+
await repo.startBilling("bot-1");
|
|
229
249
|
await billing.suspendBot("bot-1");
|
|
230
250
|
|
|
231
251
|
const reactivated = await billing.checkReactivation("tenant-1", ledger);
|
|
@@ -235,6 +255,7 @@ describe("BotBilling", () => {
|
|
|
235
255
|
|
|
236
256
|
it("does not reactivate destroyed bots", async () => {
|
|
237
257
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
258
|
+
await repo.startBilling("bot-1");
|
|
238
259
|
await billing.destroyBot("bot-1");
|
|
239
260
|
|
|
240
261
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", {
|
|
@@ -261,6 +282,7 @@ describe("BotBilling", () => {
|
|
|
261
282
|
describe("destroyBot", () => {
|
|
262
283
|
it("marks bot as destroyed", async () => {
|
|
263
284
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
285
|
+
await repo.startBilling("bot-1");
|
|
264
286
|
await billing.destroyBot("bot-1");
|
|
265
287
|
|
|
266
288
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -272,6 +294,7 @@ describe("BotBilling", () => {
|
|
|
272
294
|
describe("destroyExpiredBots", () => {
|
|
273
295
|
it("destroys bots past their grace period", async () => {
|
|
274
296
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
297
|
+
await repo.startBilling("bot-1");
|
|
275
298
|
|
|
276
299
|
// Set destroyAfter to the past using drizzle sql
|
|
277
300
|
await db
|
|
@@ -293,6 +316,7 @@ describe("BotBilling", () => {
|
|
|
293
316
|
|
|
294
317
|
it("does not destroy bots still within grace period", async () => {
|
|
295
318
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
319
|
+
await repo.startBilling("bot-1");
|
|
296
320
|
await billing.suspendBot("bot-1");
|
|
297
321
|
|
|
298
322
|
const destroyed = await billing.destroyExpiredBots();
|
|
@@ -305,6 +329,7 @@ describe("BotBilling", () => {
|
|
|
305
329
|
|
|
306
330
|
it("does not touch active bots", async () => {
|
|
307
331
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
332
|
+
await repo.startBilling("bot-1");
|
|
308
333
|
|
|
309
334
|
const destroyed = await billing.destroyExpiredBots();
|
|
310
335
|
expect(destroyed).toEqual([]);
|
|
@@ -386,8 +411,10 @@ describe("BotBilling", () => {
|
|
|
386
411
|
|
|
387
412
|
it("returns correct daily cost for known storage tiers", async () => {
|
|
388
413
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
414
|
+
await repo.startBilling("bot-1");
|
|
389
415
|
await billing.setStorageTier("bot-1", "pro");
|
|
390
416
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
417
|
+
await repo.startBilling("bot-2");
|
|
391
418
|
await billing.setStorageTier("bot-2", "plus");
|
|
392
419
|
|
|
393
420
|
expect((await billing.getStorageTierCostsForTenant("tenant-1")).toCents()).toBe(11);
|
|
@@ -395,6 +422,7 @@ describe("BotBilling", () => {
|
|
|
395
422
|
|
|
396
423
|
it("returns 0 for unknown storage tier (fallback branch)", async () => {
|
|
397
424
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
425
|
+
await repo.startBilling("bot-1");
|
|
398
426
|
// Bypass setStorageTier to insert an unrecognized tier value directly
|
|
399
427
|
await pool.query(`UPDATE bot_instances SET storage_tier = 'unknown_tier' WHERE id = 'bot-1'`);
|
|
400
428
|
|
|
@@ -404,6 +432,7 @@ describe("BotBilling", () => {
|
|
|
404
432
|
|
|
405
433
|
it("does not include suspended bots in storage tier cost", async () => {
|
|
406
434
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
435
|
+
await repo.startBilling("bot-1");
|
|
407
436
|
await billing.setStorageTier("bot-1", "pro");
|
|
408
437
|
await billing.suspendBot("bot-1");
|
|
409
438
|
|
|
@@ -414,8 +443,11 @@ describe("BotBilling", () => {
|
|
|
414
443
|
describe("listForTenant", () => {
|
|
415
444
|
it("lists all bots regardless of billing state", async () => {
|
|
416
445
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
446
|
+
await repo.startBilling("bot-1");
|
|
417
447
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
448
|
+
await repo.startBilling("bot-2");
|
|
418
449
|
await billing.registerBot("bot-3", "tenant-2", "bot-c");
|
|
450
|
+
await repo.startBilling("bot-3");
|
|
419
451
|
await billing.suspendBot("bot-2");
|
|
420
452
|
|
|
421
453
|
const bots = await billing.listForTenant("tenant-1");
|
|
@@ -427,6 +459,7 @@ describe("BotBilling", () => {
|
|
|
427
459
|
describe("full lifecycle", () => {
|
|
428
460
|
it("active -> suspended -> reactivated -> active", async () => {
|
|
429
461
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
462
|
+
await repo.startBilling("bot-1");
|
|
430
463
|
// biome-ignore lint/suspicious/noExplicitAny: intentional test cast
|
|
431
464
|
expect(((await billing.getBotBilling("bot-1")) as any)?.billingState).toBe("active");
|
|
432
465
|
|
|
@@ -446,6 +479,7 @@ describe("BotBilling", () => {
|
|
|
446
479
|
|
|
447
480
|
it("active -> suspended -> destroyed (after grace period)", async () => {
|
|
448
481
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
482
|
+
await repo.startBilling("bot-1");
|
|
449
483
|
await billing.suspendBot("bot-1");
|
|
450
484
|
|
|
451
485
|
await db.update(botInstances).set({ destroyAfter: sql`now() - interval '1 day'` }).where(sql`id = 'bot-1'`);
|
|
@@ -3,10 +3,12 @@ import { Credit, DrizzleLedger, InsufficientBalanceError } from "@wopr-network/p
|
|
|
3
3
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { RESOURCE_TIERS } from "../../fleet/resource-tiers.js";
|
|
5
5
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
6
|
-
import { buildResourceTierCosts, DAILY_BOT_COST, runRuntimeDeductions } from "./runtime-cron.js";
|
|
6
|
+
import { buildResourceTierCosts, DAILY_BOT_COST, dailyBotCost, runRuntimeDeductions } from "./runtime-cron.js";
|
|
7
7
|
|
|
8
8
|
describe("runRuntimeDeductions", () => {
|
|
9
9
|
const TODAY = "2025-01-01";
|
|
10
|
+
/** Dynamic daily cost for TODAY's month (January = 31 days) */
|
|
11
|
+
const COST = dailyBotCost(TODAY);
|
|
10
12
|
let pool: PGlite;
|
|
11
13
|
let ledger: DrizzleLedger;
|
|
12
14
|
|
|
@@ -26,7 +28,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
it("DAILY_BOT_COST equals 17 cents", () => {
|
|
29
|
-
expect(DAILY_BOT_COST.
|
|
31
|
+
expect(DAILY_BOT_COST.toCentsRounded()).toBe(17);
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
it("returns empty result when no tenants have balance", async () => {
|
|
@@ -60,7 +62,9 @@ describe("runRuntimeDeductions", () => {
|
|
|
60
62
|
});
|
|
61
63
|
expect(result.processed).toBe(1);
|
|
62
64
|
expect(result.suspended).toEqual([]);
|
|
63
|
-
expect((await ledger.balance("tenant-1")).
|
|
65
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(
|
|
66
|
+
Credit.fromCents(500).subtract(COST.multiply(2)).toCentsRounded(),
|
|
67
|
+
);
|
|
64
68
|
});
|
|
65
69
|
|
|
66
70
|
it("partial deduction and suspension when balance is insufficient", async () => {
|
|
@@ -136,9 +140,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
136
140
|
|
|
137
141
|
it("catches InsufficientBalanceError from debit and suspends", async () => {
|
|
138
142
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
|
|
139
|
-
vi.spyOn(ledger, "debit").mockRejectedValue(
|
|
140
|
-
new InsufficientBalanceError(Credit.fromCents(0), Credit.fromCents(17)),
|
|
141
|
-
);
|
|
143
|
+
vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), COST));
|
|
142
144
|
const onSuspend = vi.fn();
|
|
143
145
|
const result = await runRuntimeDeductions({
|
|
144
146
|
ledger,
|
|
@@ -154,9 +156,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
154
156
|
|
|
155
157
|
it("catches InsufficientBalanceError without onSuspend callback", async () => {
|
|
156
158
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
|
|
157
|
-
vi.spyOn(ledger, "debit").mockRejectedValue(
|
|
158
|
-
new InsufficientBalanceError(Credit.fromCents(0), Credit.fromCents(17)),
|
|
159
|
-
);
|
|
159
|
+
vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), COST));
|
|
160
160
|
const result = await runRuntimeDeductions({
|
|
161
161
|
ledger,
|
|
162
162
|
date: TODAY,
|
|
@@ -194,7 +194,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
194
194
|
expect(onLowBalance).toHaveBeenCalledOnce();
|
|
195
195
|
const [calledTenant, calledBalance] = onLowBalance.mock.calls[0];
|
|
196
196
|
expect(calledTenant).toBe("tenant-1");
|
|
197
|
-
expect(calledBalance.
|
|
197
|
+
expect(calledBalance.toCentsRounded()).toBe(Credit.fromCents(110).subtract(COST).toCentsRounded());
|
|
198
198
|
});
|
|
199
199
|
|
|
200
200
|
it("does NOT fire onLowBalance when balance was already below threshold before deduction", async () => {
|
|
@@ -210,7 +210,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
210
210
|
});
|
|
211
211
|
|
|
212
212
|
it("fires onCreditsExhausted when full deduction causes balance to drop to 0", async () => {
|
|
213
|
-
await ledger.credit("tenant-1",
|
|
213
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
214
214
|
const onCreditsExhausted = vi.fn();
|
|
215
215
|
await runRuntimeDeductions({
|
|
216
216
|
ledger,
|
|
@@ -223,8 +223,8 @@ describe("runRuntimeDeductions", () => {
|
|
|
223
223
|
});
|
|
224
224
|
|
|
225
225
|
it("suspends tenant when full deduction causes balance to drop to exactly 0", async () => {
|
|
226
|
-
// Balance = exactly 1 bot *
|
|
227
|
-
await ledger.credit("tenant-1",
|
|
226
|
+
// Balance = exactly 1 bot * dailyBotCost → full deduction → 0
|
|
227
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
228
228
|
const onSuspend = vi.fn();
|
|
229
229
|
const onCreditsExhausted = vi.fn();
|
|
230
230
|
const result = await runRuntimeDeductions({
|
|
@@ -266,7 +266,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
266
266
|
});
|
|
267
267
|
|
|
268
268
|
it("skips resource tier partial debit when balance is exactly 0 after runtime", async () => {
|
|
269
|
-
await ledger.credit("tenant-1",
|
|
269
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
270
270
|
const onCreditsExhausted = vi.fn();
|
|
271
271
|
const result = await runRuntimeDeductions({
|
|
272
272
|
ledger,
|
|
@@ -285,7 +285,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
285
285
|
// triggering the zero-crossing suspend in the runtime block.
|
|
286
286
|
// Storage cost (5 cents) then tries to suspend again via its else-branch (balance 0 < 5).
|
|
287
287
|
// The !result.suspended.includes(tenantId) guard must prevent onSuspend being called twice.
|
|
288
|
-
await ledger.credit("tenant-1",
|
|
288
|
+
await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
|
|
289
289
|
const onSuspend = vi.fn();
|
|
290
290
|
const result = await runRuntimeDeductions({
|
|
291
291
|
ledger,
|
|
@@ -300,9 +300,9 @@ describe("runRuntimeDeductions", () => {
|
|
|
300
300
|
});
|
|
301
301
|
|
|
302
302
|
it("buildResourceTierCosts: deducts pro tier surcharge via getResourceTierCosts", async () => {
|
|
303
|
-
const proTierCost = RESOURCE_TIERS.pro.dailyCost
|
|
304
|
-
const
|
|
305
|
-
await ledger.credit("tenant-1",
|
|
303
|
+
const proTierCost = RESOURCE_TIERS.pro.dailyCost;
|
|
304
|
+
const startCredit = COST.add(proTierCost).add(Credit.fromCents(10));
|
|
305
|
+
await ledger.credit("tenant-1", startCredit, "purchase", { description: "top-up" });
|
|
306
306
|
|
|
307
307
|
const mockRepo = {
|
|
308
308
|
getResourceTier: async (_botId: string): Promise<string | null> => "pro",
|
|
@@ -320,8 +320,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
320
320
|
getResourceTierCosts,
|
|
321
321
|
});
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
expect((await ledger.balance("tenant-1")).toCents()).toBe(expected);
|
|
323
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(10);
|
|
325
324
|
});
|
|
326
325
|
|
|
327
326
|
it("treats unique constraint violation from concurrent debit as already-billed (skip, not error)", async () => {
|
|
@@ -340,30 +339,35 @@ describe("runRuntimeDeductions", () => {
|
|
|
340
339
|
|
|
341
340
|
it("is idempotent — second run on same date does not double-deduct", async () => {
|
|
342
341
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
|
|
342
|
+
const JUNE_DATE = "2025-06-15";
|
|
343
|
+
const JUNE_COST = dailyBotCost(JUNE_DATE);
|
|
344
|
+
const expectedBalance = Credit.fromCents(500).subtract(JUNE_COST);
|
|
343
345
|
const cfg = {
|
|
344
346
|
ledger,
|
|
345
347
|
getActiveBotCount: async () => 1,
|
|
346
|
-
date:
|
|
348
|
+
date: JUNE_DATE,
|
|
347
349
|
};
|
|
348
350
|
|
|
349
351
|
const first = await runRuntimeDeductions(cfg);
|
|
350
352
|
expect(first.processed).toBe(1);
|
|
351
|
-
expect((await ledger.balance("tenant-1")).
|
|
353
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedBalance.toCentsRounded());
|
|
352
354
|
|
|
353
355
|
const second = await runRuntimeDeductions(cfg);
|
|
354
356
|
expect(second.processed).toBe(0);
|
|
355
357
|
expect(second.skipped).toContain("tenant-1");
|
|
356
358
|
// Balance unchanged after second run
|
|
357
|
-
expect((await ledger.balance("tenant-1")).
|
|
359
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedBalance.toCentsRounded());
|
|
358
360
|
});
|
|
359
361
|
|
|
360
362
|
it("bills surcharges on retry when runtime was already billed (crash recovery)", async () => {
|
|
361
363
|
// Setup: tenant with enough balance for runtime + tier + storage + addon
|
|
362
364
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
365
|
+
const JUL_DATE = "2025-07-01";
|
|
366
|
+
const JUL_COST = dailyBotCost(JUL_DATE);
|
|
363
367
|
|
|
364
368
|
const cfg = {
|
|
365
369
|
ledger,
|
|
366
|
-
date:
|
|
370
|
+
date: JUL_DATE,
|
|
367
371
|
getActiveBotCount: async () => 1,
|
|
368
372
|
getResourceTierCosts: async () => Credit.fromCents(10),
|
|
369
373
|
getStorageTierCosts: async () => Credit.fromCents(8),
|
|
@@ -373,14 +377,18 @@ describe("runRuntimeDeductions", () => {
|
|
|
373
377
|
// First run — bills everything
|
|
374
378
|
const first = await runRuntimeDeductions(cfg);
|
|
375
379
|
expect(first.processed).toBe(1);
|
|
376
|
-
|
|
377
|
-
|
|
380
|
+
const expectedAfterFirst = Credit.fromCents(1000)
|
|
381
|
+
.subtract(JUL_COST)
|
|
382
|
+
.subtract(Credit.fromCents(10))
|
|
383
|
+
.subtract(Credit.fromCents(8))
|
|
384
|
+
.subtract(Credit.fromCents(5));
|
|
385
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedAfterFirst.toCentsRounded());
|
|
378
386
|
|
|
379
387
|
// Second run — all already billed, should skip
|
|
380
388
|
const second = await runRuntimeDeductions(cfg);
|
|
381
389
|
expect(second.skipped).toContain("tenant-1");
|
|
382
390
|
expect(second.processed).toBe(0);
|
|
383
|
-
expect((await ledger.balance("tenant-1")).
|
|
391
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedAfterFirst.toCentsRounded());
|
|
384
392
|
});
|
|
385
393
|
|
|
386
394
|
it("bills remaining surcharges when runtime was billed but surcharges were not (simulated crash)", async () => {
|
|
@@ -388,11 +396,12 @@ describe("runRuntimeDeductions", () => {
|
|
|
388
396
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
389
397
|
|
|
390
398
|
// Simulate crash: manually debit only the runtime charge (as if the cron crashed after this)
|
|
391
|
-
await ledger.debit("tenant-1",
|
|
392
|
-
description:
|
|
399
|
+
await ledger.debit("tenant-1", COST, "bot_runtime", {
|
|
400
|
+
description: `Daily runtime: 1 bot(s) x $${COST.toDollars().toFixed(4)}`,
|
|
393
401
|
referenceId: `runtime:2025-07-02:tenant-1`,
|
|
394
402
|
});
|
|
395
|
-
|
|
403
|
+
const afterRuntime = Credit.fromCents(1000).subtract(COST);
|
|
404
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(afterRuntime.toCentsRounded());
|
|
396
405
|
|
|
397
406
|
// Retry run — runtime already billed, but surcharges should still be billed
|
|
398
407
|
const result = await runRuntimeDeductions({
|
|
@@ -406,23 +415,27 @@ describe("runRuntimeDeductions", () => {
|
|
|
406
415
|
|
|
407
416
|
expect(result.processed).toBe(1);
|
|
408
417
|
expect(result.skipped).not.toContain("tenant-1");
|
|
409
|
-
|
|
410
|
-
|
|
418
|
+
const expectedFinal = afterRuntime
|
|
419
|
+
.subtract(Credit.fromCents(10))
|
|
420
|
+
.subtract(Credit.fromCents(8))
|
|
421
|
+
.subtract(Credit.fromCents(5));
|
|
422
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedFinal.toCentsRounded());
|
|
411
423
|
});
|
|
412
424
|
|
|
413
425
|
it("bills only missing surcharges when some were already committed (simulated partial crash)", async () => {
|
|
414
426
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
415
427
|
|
|
416
428
|
// Simulate: runtime + tier already billed, storage + addon not yet
|
|
417
|
-
await ledger.debit("tenant-1",
|
|
418
|
-
description:
|
|
429
|
+
await ledger.debit("tenant-1", COST, "bot_runtime", {
|
|
430
|
+
description: `Daily runtime: 1 bot(s) x $${COST.toDollars().toFixed(4)}`,
|
|
419
431
|
referenceId: `runtime:2025-07-03:tenant-1`,
|
|
420
432
|
});
|
|
421
433
|
await ledger.debit("tenant-1", Credit.fromCents(10), "resource_upgrade", {
|
|
422
434
|
description: "Daily resource tier surcharge",
|
|
423
435
|
referenceId: `runtime-tier:2025-07-03:tenant-1`,
|
|
424
436
|
});
|
|
425
|
-
|
|
437
|
+
const afterRuntimeAndTier = Credit.fromCents(1000).subtract(COST).subtract(Credit.fromCents(10));
|
|
438
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(afterRuntimeAndTier.toCentsRounded());
|
|
426
439
|
|
|
427
440
|
const result = await runRuntimeDeductions({
|
|
428
441
|
ledger,
|
|
@@ -434,8 +447,8 @@ describe("runRuntimeDeductions", () => {
|
|
|
434
447
|
});
|
|
435
448
|
|
|
436
449
|
expect(result.processed).toBe(1);
|
|
437
|
-
|
|
438
|
-
expect((await ledger.balance("tenant-1")).
|
|
450
|
+
const expectedFinal = afterRuntimeAndTier.subtract(Credit.fromCents(8)).subtract(Credit.fromCents(5));
|
|
451
|
+
expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedFinal.toCentsRounded());
|
|
439
452
|
});
|
|
440
453
|
|
|
441
454
|
it("does not double-debit runtime on retry after partial deduction + crash", async () => {
|
|
@@ -465,7 +478,7 @@ describe("runRuntimeDeductions", () => {
|
|
|
465
478
|
await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
|
|
466
479
|
|
|
467
480
|
// Simulate crash: only runtime billed
|
|
468
|
-
await ledger.debit("tenant-1",
|
|
481
|
+
await ledger.debit("tenant-1", COST, "bot_runtime", {
|
|
469
482
|
description: "Daily runtime: 1 bot(s) x $0.17",
|
|
470
483
|
referenceId: `runtime:2025-07-05:tenant-1`,
|
|
471
484
|
});
|
|
@@ -4,9 +4,25 @@ import { logger } from "../../config/logger.js";
|
|
|
4
4
|
import type { IBotInstanceRepository } from "../../fleet/bot-instance-repository.js";
|
|
5
5
|
import { RESOURCE_TIERS } from "../../fleet/resource-tiers.js";
|
|
6
6
|
|
|
7
|
+
/** Monthly bot cost in dollars. */
|
|
8
|
+
export const MONTHLY_BOT_COST_DOLLARS = 5;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute the daily bot cost for a given date, prorated by the actual
|
|
12
|
+
* number of days in that month. Uses nano-dollar precision so totals
|
|
13
|
+
* sum to exactly $5.00/month (no over/under-billing).
|
|
14
|
+
*/
|
|
15
|
+
export function dailyBotCost(date: string): Credit {
|
|
16
|
+
const d = new Date(date);
|
|
17
|
+
const year = d.getFullYear();
|
|
18
|
+
const month = d.getMonth();
|
|
19
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
20
|
+
return Credit.fromDollars(MONTHLY_BOT_COST_DOLLARS / daysInMonth);
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
24
|
+
* @deprecated Use dailyBotCost(date) for accurate per-month proration.
|
|
25
|
+
* Kept for backwards compat in tests.
|
|
10
26
|
*/
|
|
11
27
|
export const DAILY_BOT_COST = Credit.fromCents(17);
|
|
12
28
|
|
|
@@ -118,7 +134,8 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
|
|
|
118
134
|
continue;
|
|
119
135
|
}
|
|
120
136
|
|
|
121
|
-
const
|
|
137
|
+
const dailyCost = dailyBotCost(cfg.date);
|
|
138
|
+
const totalCost = dailyCost.multiply(botCount);
|
|
122
139
|
let didBillAnything = false;
|
|
123
140
|
|
|
124
141
|
// Bill runtime debit (skipped if already billed on a previous run)
|
|
@@ -126,7 +143,7 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
|
|
|
126
143
|
if (!balance.lessThan(totalCost)) {
|
|
127
144
|
// Full deduction
|
|
128
145
|
await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
|
|
129
|
-
description: `Daily runtime: ${botCount} bot(s) x $${
|
|
146
|
+
description: `Daily runtime: ${botCount} bot(s) x $${dailyCost.toDollars().toFixed(4)}`,
|
|
130
147
|
referenceId: runtimeRef,
|
|
131
148
|
});
|
|
132
149
|
} else {
|
|
@@ -8,6 +8,7 @@ import { DrizzleBotBilling } from "./bot-billing.js";
|
|
|
8
8
|
describe("bot-billing storage tier", () => {
|
|
9
9
|
let pool: PGlite;
|
|
10
10
|
let db: DrizzleDb;
|
|
11
|
+
let repo: DrizzleBotInstanceRepository;
|
|
11
12
|
let billing: DrizzleBotBilling;
|
|
12
13
|
|
|
13
14
|
beforeAll(async () => {
|
|
@@ -22,16 +23,19 @@ describe("bot-billing storage tier", () => {
|
|
|
22
23
|
|
|
23
24
|
beforeEach(async () => {
|
|
24
25
|
await rollbackTestTransaction(pool);
|
|
25
|
-
|
|
26
|
+
repo = new DrizzleBotInstanceRepository(db);
|
|
27
|
+
billing = new DrizzleBotBilling(repo);
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
it("new bot defaults to standard storage tier", async () => {
|
|
29
31
|
await billing.registerBot("bot-1", "tenant-1", "TestBot");
|
|
32
|
+
await repo.startBilling("bot-1");
|
|
30
33
|
expect(await billing.getStorageTier("bot-1")).toBe("standard");
|
|
31
34
|
});
|
|
32
35
|
|
|
33
36
|
it("setStorageTier updates tier", async () => {
|
|
34
37
|
await billing.registerBot("bot-1", "tenant-1", "TestBot");
|
|
38
|
+
await repo.startBilling("bot-1");
|
|
35
39
|
await billing.setStorageTier("bot-1", "pro");
|
|
36
40
|
expect(await billing.getStorageTier("bot-1")).toBe("pro");
|
|
37
41
|
});
|
|
@@ -42,8 +46,11 @@ describe("bot-billing storage tier", () => {
|
|
|
42
46
|
|
|
43
47
|
it("getStorageTierCostsForTenant sums active bot storage costs", async () => {
|
|
44
48
|
await billing.registerBot("bot-1", "tenant-1", "Bot1");
|
|
49
|
+
await repo.startBilling("bot-1");
|
|
45
50
|
await billing.registerBot("bot-2", "tenant-1", "Bot2");
|
|
51
|
+
await repo.startBilling("bot-2");
|
|
46
52
|
await billing.registerBot("bot-3", "tenant-1", "Bot3");
|
|
53
|
+
await repo.startBilling("bot-3");
|
|
47
54
|
await billing.setStorageTier("bot-1", "plus"); // 3 credits/day
|
|
48
55
|
await billing.setStorageTier("bot-2", "max"); // 15 credits/day
|
|
49
56
|
// bot-3 stays standard // 0 credits/day
|
|
@@ -52,6 +59,7 @@ describe("bot-billing storage tier", () => {
|
|
|
52
59
|
|
|
53
60
|
it("getStorageTierCostsForTenant excludes suspended bots", async () => {
|
|
54
61
|
await billing.registerBot("bot-1", "tenant-1", "Bot1");
|
|
62
|
+
await repo.startBilling("bot-1");
|
|
55
63
|
await billing.setStorageTier("bot-1", "pro"); // 8 credits/day
|
|
56
64
|
await billing.suspendBot("bot-1");
|
|
57
65
|
expect((await billing.getStorageTierCostsForTenant("tenant-1")).toCents()).toBe(0);
|
|
@@ -3,10 +3,11 @@ import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
|
|
|
3
3
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
4
4
|
import type { DrizzleDb } from "../../db/index.js";
|
|
5
5
|
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
6
|
-
import { runRuntimeDeductions } from "./runtime-cron.js";
|
|
6
|
+
import { dailyBotCost, runRuntimeDeductions } from "./runtime-cron.js";
|
|
7
7
|
|
|
8
8
|
describe("runtime cron with storage tiers", () => {
|
|
9
9
|
const TODAY = "2025-01-01";
|
|
10
|
+
const BASE_COST_CREDIT = dailyBotCost(TODAY);
|
|
10
11
|
let pool: PGlite;
|
|
11
12
|
let db: DrizzleDb;
|
|
12
13
|
let ledger: DrizzleLedger;
|
|
@@ -36,8 +37,9 @@ describe("runtime cron with storage tiers", () => {
|
|
|
36
37
|
});
|
|
37
38
|
expect(result.processed).toBe(1);
|
|
38
39
|
const balance = await ledger.balance("t1");
|
|
39
|
-
// 1000 -
|
|
40
|
-
|
|
40
|
+
// 1000 - dailyBotCost (base) - 8 (pro storage surcharge)
|
|
41
|
+
const expected = Credit.fromCents(1000).subtract(BASE_COST_CREDIT).subtract(Credit.fromCents(8));
|
|
42
|
+
expect(balance.toCents()).toBe(expected.toCents());
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
it("debits only base cost for standard storage tier (zero surcharge)", async () => {
|
|
@@ -49,7 +51,8 @@ describe("runtime cron with storage tiers", () => {
|
|
|
49
51
|
getStorageTierCosts: async () => Credit.ZERO,
|
|
50
52
|
});
|
|
51
53
|
expect(result.processed).toBe(1);
|
|
52
|
-
|
|
54
|
+
const expectedStd = Credit.fromCents(1000).subtract(BASE_COST_CREDIT);
|
|
55
|
+
expect((await ledger.balance("t1")).toCents()).toBe(expectedStd.toCents());
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
it("skips storage surcharge when callback not provided (backward compat)", async () => {
|
|
@@ -60,11 +63,14 @@ describe("runtime cron with storage tiers", () => {
|
|
|
60
63
|
getActiveBotCount: async () => 1,
|
|
61
64
|
});
|
|
62
65
|
expect(result.processed).toBe(1);
|
|
63
|
-
|
|
66
|
+
const expectedBackcompat = Credit.fromCents(1000).subtract(BASE_COST_CREDIT);
|
|
67
|
+
expect((await ledger.balance("t1")).toCents()).toBe(expectedBackcompat.toCents());
|
|
64
68
|
});
|
|
65
69
|
|
|
66
70
|
it("suspends tenant when storage surcharge exhausts remaining balance", async () => {
|
|
67
|
-
|
|
71
|
+
// Seed just enough for base cost + 3 cents, so storage surcharge (8) exceeds remainder
|
|
72
|
+
const seed = BASE_COST_CREDIT.add(Credit.fromCents(3));
|
|
73
|
+
await ledger.credit("t1", seed, "purchase");
|
|
68
74
|
const suspended: string[] = [];
|
|
69
75
|
const result = await runRuntimeDeductions({
|
|
70
76
|
ledger,
|
|
@@ -75,7 +81,7 @@ describe("runtime cron with storage tiers", () => {
|
|
|
75
81
|
suspended.push(tenantId);
|
|
76
82
|
},
|
|
77
83
|
});
|
|
78
|
-
//
|
|
84
|
+
// seed - BASE_COST = 3 remaining, then 8 surcharge > 3, so partial debit + suspend
|
|
79
85
|
expect(result.processed).toBe(1);
|
|
80
86
|
expect(result.suspended).toContain("t1");
|
|
81
87
|
expect((await ledger.balance("t1")).toCents()).toBe(0);
|
|
@@ -169,6 +169,8 @@ describe("createTestContainer", () => {
|
|
|
169
169
|
|
|
170
170
|
const gateway: GatewayServices = {
|
|
171
171
|
serviceKeyRepo: {} as never,
|
|
172
|
+
meter: {} as never,
|
|
173
|
+
budgetChecker: {} as never,
|
|
172
174
|
};
|
|
173
175
|
|
|
174
176
|
const hotPool: HotPoolServices = {
|
|
@@ -189,7 +191,9 @@ describe("createTestContainer", () => {
|
|
|
189
191
|
});
|
|
190
192
|
|
|
191
193
|
it("overrides merge without affecting other defaults", () => {
|
|
192
|
-
const c = createTestContainer({
|
|
194
|
+
const c = createTestContainer({
|
|
195
|
+
gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never },
|
|
196
|
+
});
|
|
193
197
|
|
|
194
198
|
// Overridden field
|
|
195
199
|
expect(c.gateway).not.toBeNull();
|