@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.
Files changed (38) hide show
  1. package/dist/fleet/drizzle-bot-instance-repository.d.ts +3 -1
  2. package/dist/fleet/drizzle-bot-instance-repository.js +8 -2
  3. package/dist/fleet/instance.d.ts +8 -0
  4. package/dist/fleet/instance.js +20 -0
  5. package/dist/fleet/repository-types.d.ts +1 -1
  6. package/dist/gateway/proxy.js +10 -2
  7. package/dist/gateway/types.d.ts +3 -1
  8. package/dist/index.js +4 -0
  9. package/dist/monetization/credits/bot-billing.test.js +35 -1
  10. package/dist/monetization/credits/runtime-cron.d.ts +10 -2
  11. package/dist/monetization/credits/runtime-cron.js +19 -4
  12. package/dist/monetization/credits/runtime-cron.test.js +49 -34
  13. package/dist/monetization/credits/storage-tier-billing.test.js +9 -1
  14. package/dist/monetization/credits/storage-tier-cron.test.js +13 -7
  15. package/dist/server/__tests__/container.test.js +5 -1
  16. package/dist/server/container.d.ts +2 -0
  17. package/dist/server/container.js +6 -1
  18. package/dist/server/index.js +1 -1
  19. package/dist/server/mount-routes.d.ts +1 -1
  20. package/dist/server/mount-routes.js +35 -2
  21. package/dist/server/routes/__tests__/admin.test.js +3 -3
  22. package/package.json +1 -1
  23. package/src/fleet/drizzle-bot-instance-repository.ts +15 -2
  24. package/src/fleet/instance.ts +21 -0
  25. package/src/fleet/repository-types.ts +1 -1
  26. package/src/gateway/proxy.ts +9 -2
  27. package/src/gateway/types.ts +3 -1
  28. package/src/index.ts +4 -0
  29. package/src/monetization/credits/bot-billing.test.ts +35 -1
  30. package/src/monetization/credits/runtime-cron.test.ts +51 -38
  31. package/src/monetization/credits/runtime-cron.ts +21 -4
  32. package/src/monetization/credits/storage-tier-billing.test.ts +9 -1
  33. package/src/monetization/credits/storage-tier-cron.test.ts +13 -7
  34. package/src/server/__tests__/container.test.ts +5 -1
  35. package/src/server/container.ts +9 -1
  36. package/src/server/index.ts +1 -1
  37. package/src/server/mount-routes.ts +41 -3
  38. 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
- billing = new BotBilling(new DrizzleBotInstanceRepository(db));
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.toCents()).toBe(17);
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")).toCents()).toBe(500 - 2 * 17);
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.toCents()).toBe(93);
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", Credit.fromCents(17), "purchase", { description: "top-up" });
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 * DAILY_BOT_COST = 17 cents → full deduction → 0
227
- await ledger.credit("tenant-1", Credit.fromCents(17), "purchase", { description: "top-up" });
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", Credit.fromCents(17), "purchase", { description: "top-up" });
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", Credit.fromCents(17), "purchase", { description: "top-up" });
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.toCents();
304
- const startBalance = 17 + proTierCost + 10;
305
- await ledger.credit("tenant-1", Credit.fromCents(startBalance), "purchase", { description: "top-up" });
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
- const expected = startBalance - 17 - proTierCost;
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: "2025-06-15",
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")).toCents()).toBe(500 - 17);
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")).toCents()).toBe(500 - 17);
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: "2025-07-01",
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
- // 1000 - 17 (runtime) - 10 (tier) - 8 (storage) - 5 (addon) = 960
377
- expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
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")).toCents()).toBe(960);
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", DAILY_BOT_COST, "bot_runtime", {
392
- description: "Daily runtime: 1 bot(s) x $0.17",
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
- expect((await ledger.balance("tenant-1")).toCents()).toBe(983); // 1000 - 17
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
- // 983 - 10 (tier) - 8 (storage) - 5 (addon) = 960
410
- expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
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", DAILY_BOT_COST, "bot_runtime", {
418
- description: "Daily runtime: 1 bot(s) x $0.17",
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
- expect((await ledger.balance("tenant-1")).toCents()).toBe(973); // 1000 - 17 - 10
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
- // 973 - 8 (storage) - 5 (addon) = 960
438
- expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
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", DAILY_BOT_COST, "bot_runtime", {
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
- * Bot runtime cost: $5/bot/month prorated daily.
9
- * $5.00 / 30 $0.1667/day, rounded to $0.17.
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 totalCost = DAILY_BOT_COST.multiply(botCount);
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 $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
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
- billing = new DrizzleBotBilling(new DrizzleBotInstanceRepository(db));
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 - 17 (base) - 8 (pro storage surcharge) = 975
40
- expect(balance.toCents()).toBe(975);
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
- expect((await ledger.balance("t1")).toCents()).toBe(983); // 1000 - 17
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
- expect((await ledger.balance("t1")).toCents()).toBe(983); // 1000 - 17
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
- await ledger.credit("t1", Credit.fromCents(20), "purchase"); // Only 20 cents
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
- // 20 - 17 = 3 remaining, then 8 surcharge > 3, so partial debit + suspend
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({ gateway: { serviceKeyRepo: {} as never } });
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();