@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
@@ -24,7 +24,9 @@ export declare class DrizzleBotInstanceRepository implements IBotInstanceReposit
24
24
  suspend(botId: string, graceDays: number): Promise<void>;
25
25
  reactivate(botId: string): Promise<void>;
26
26
  markDestroyed(botId: string): Promise<void>;
27
- register(botId: string, tenantId: string, name: string): Promise<void>;
27
+ register(botId: string, tenantId: string, name: string, billingState?: BillingState): Promise<void>;
28
+ startBilling(botId: string): Promise<void>;
29
+ stopBilling(botId: string): Promise<void>;
28
30
  getStorageTier(botId: string): Promise<string | null>;
29
31
  setStorageTier(botId: string, tier: string): Promise<void>;
30
32
  listActiveStorageTiers(tenantId: string): Promise<string[]>;
@@ -194,14 +194,20 @@ export class DrizzleBotInstanceRepository {
194
194
  })
195
195
  .where(eq(botInstances.id, botId));
196
196
  }
197
- async register(botId, tenantId, name) {
197
+ async register(botId, tenantId, name, billingState = "inactive") {
198
198
  await this.db.insert(botInstances).values({
199
199
  id: botId,
200
200
  tenantId,
201
201
  name,
202
- billingState: "active",
202
+ billingState,
203
203
  });
204
204
  }
205
+ async startBilling(botId) {
206
+ await this.db.update(botInstances).set({ billingState: "active" }).where(eq(botInstances.id, botId));
207
+ }
208
+ async stopBilling(botId) {
209
+ await this.db.update(botInstances).set({ billingState: "inactive" }).where(eq(botInstances.id, botId));
210
+ }
205
211
  async getStorageTier(botId) {
206
212
  const row = (await this.db
207
213
  .select({ storageTier: botInstances.storageTier })
@@ -51,6 +51,14 @@ export declare class Instance {
51
51
  private withLock;
52
52
  /** Emit bot.created — call only from FleetManager.create(), not getInstance() */
53
53
  emitCreated(): void;
54
+ /**
55
+ * Start billing for this instance ($5/month prorated daily).
56
+ * Call after creation for persistent, billable instances (e.g., Paperclip).
57
+ * Ephemeral instances (e.g., Holy Ship) skip this — they bill per-token at the gateway.
58
+ */
59
+ startBilling(): Promise<void>;
60
+ /** Stop billing for this instance (e.g., on suspend or downgrade). */
61
+ stopBilling(): Promise<void>;
54
62
  start(): Promise<void>;
55
63
  stop(): Promise<void>;
56
64
  /**
@@ -71,6 +71,26 @@ export class Instance {
71
71
  emitCreated() {
72
72
  this.emit("bot.created");
73
73
  }
74
+ /**
75
+ * Start billing for this instance ($5/month prorated daily).
76
+ * Call after creation for persistent, billable instances (e.g., Paperclip).
77
+ * Ephemeral instances (e.g., Holy Ship) skip this — they bill per-token at the gateway.
78
+ */
79
+ async startBilling() {
80
+ if (!this.instanceRepo) {
81
+ logger.warn("startBilling() called but no instanceRepo available", { id: this.id });
82
+ return;
83
+ }
84
+ await this.instanceRepo.setBillingState(this.id, "active");
85
+ logger.info("Billing started for instance", { id: this.id, name: this.profile.name });
86
+ }
87
+ /** Stop billing for this instance (e.g., on suspend or downgrade). */
88
+ async stopBilling() {
89
+ if (!this.instanceRepo)
90
+ return;
91
+ await this.instanceRepo.setBillingState(this.id, "suspended");
92
+ logger.info("Billing stopped for instance", { id: this.id, name: this.profile.name });
93
+ }
74
94
  async start() {
75
95
  this.assertLocal("start()");
76
96
  return this.withLock(async () => {
@@ -48,7 +48,7 @@ export interface SelfHostedNodeRegistration extends NodeRegistration {
48
48
  nodeSecretHash: string;
49
49
  }
50
50
  /** Billing lifecycle states for a bot instance. */
51
- export type BillingState = "active" | "suspended" | "destroyed";
51
+ export type BillingState = "active" | "inactive" | "suspended" | "destroyed";
52
52
  /** Plain domain object for a bot instance — mirrors `bot_instances` table. */
53
53
  export interface BotInstance {
54
54
  id: string;
@@ -19,7 +19,11 @@ import { creditBalanceCheck, debitCredits } from "./credit-gate.js";
19
19
  import { mapBudgetError, mapProviderError } from "./error-mapping.js";
20
20
  import { resolveTokenRates } from "./rate-lookup.js";
21
21
  import { proxySSEStream } from "./streaming.js";
22
- const DEFAULT_MARGIN = 1.3;
22
+ /**
23
+ * Fallback only used when resolveMargin is not provided (tests only).
24
+ * Production MUST provide resolveMargin — mountRoutes enforces this.
25
+ */
26
+ const TEST_ONLY_MARGIN = 1.3;
23
27
  /** Max call duration cap: 4 hours = 240 minutes. */
24
28
  const MAX_CALL_DURATION_MINUTES = 240;
25
29
  const phoneInboundBodySchema = z.object({
@@ -54,7 +58,11 @@ export function buildProxyDeps(config) {
54
58
  providers: config.providers,
55
59
  defaultModel: config.defaultModel,
56
60
  resolveDefaultModel: config.resolveDefaultModel,
57
- defaultMargin: config.defaultMargin ?? DEFAULT_MARGIN,
61
+ get defaultMargin() {
62
+ if (config.resolveMargin)
63
+ return config.resolveMargin();
64
+ return config.defaultMargin ?? TEST_ONLY_MARGIN;
65
+ },
58
66
  fetchFn: config.fetchFn ?? fetch,
59
67
  arbitrageRouter: config.arbitrageRouter,
60
68
  rateLookupFn: config.rateLookupFn,
@@ -119,8 +119,10 @@ export interface GatewayConfig {
119
119
  graceBufferCents?: number;
120
120
  /** Upstream provider credentials */
121
121
  providers: ProviderConfig;
122
- /** Default margin multiplier (default: 1.3 = 30%) */
122
+ /** Static margin (for tests only). Production should use resolveMargin. */
123
123
  defaultMargin?: number;
124
+ /** Live margin resolver — called per-request, reads from DB. Takes priority over defaultMargin. */
125
+ resolveMargin?: () => number;
124
126
  /** Optional arbitrage router for multi-provider cost optimization (WOP-463) */
125
127
  arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
126
128
  /** Injectable fetch for testing */
package/dist/index.js CHANGED
@@ -24,3 +24,7 @@ export * from "./security/index.js";
24
24
  export * from "./tenancy/index.js";
25
25
  // tRPC
26
26
  export * from "./trpc/index.js";
27
+ // monorepo e2e cutover test
28
+ // hybrid dockerfile e2e
29
+ // sequential build test
30
+ // lockfile build
@@ -50,6 +50,7 @@ function createMockDeps(nodeId = "node-1") {
50
50
  describe("BotBilling", () => {
51
51
  let pool;
52
52
  let db;
53
+ let repo;
53
54
  let billing;
54
55
  let ledger;
55
56
  beforeAll(async () => {
@@ -60,13 +61,15 @@ describe("BotBilling", () => {
60
61
  });
61
62
  beforeEach(async () => {
62
63
  await truncateAllTables(pool);
63
- billing = new BotBilling(new DrizzleBotInstanceRepository(db));
64
+ repo = new DrizzleBotInstanceRepository(db);
65
+ billing = new BotBilling(repo);
64
66
  ledger = new DrizzleLedger(db);
65
67
  await ledger.seedSystemAccounts();
66
68
  });
67
69
  describe("registerBot", () => {
68
70
  it("registers a bot in active billing state", async () => {
69
71
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
72
+ await repo.startBilling("bot-1");
70
73
  const info = await billing.getBotBilling("bot-1");
71
74
  expect(info).not.toBeNull();
72
75
  // biome-ignore lint/suspicious/noExplicitAny: intentional test cast
@@ -87,19 +90,25 @@ describe("BotBilling", () => {
87
90
  });
88
91
  it("counts only active bots for the tenant", async () => {
89
92
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
93
+ await repo.startBilling("bot-1");
90
94
  await billing.registerBot("bot-2", "tenant-1", "bot-b");
95
+ await repo.startBilling("bot-2");
91
96
  await billing.registerBot("bot-3", "tenant-2", "bot-c");
97
+ await repo.startBilling("bot-3");
92
98
  expect(await billing.getActiveBotCount("tenant-1")).toBe(2);
93
99
  expect(await billing.getActiveBotCount("tenant-2")).toBe(1);
94
100
  });
95
101
  it("does not count suspended bots", async () => {
96
102
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
103
+ await repo.startBilling("bot-1");
97
104
  await billing.registerBot("bot-2", "tenant-1", "bot-b");
105
+ await repo.startBilling("bot-2");
98
106
  await billing.suspendBot("bot-1");
99
107
  expect(await billing.getActiveBotCount("tenant-1")).toBe(1);
100
108
  });
101
109
  it("does not count destroyed bots", async () => {
102
110
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
111
+ await repo.startBilling("bot-1");
103
112
  await billing.destroyBot("bot-1");
104
113
  expect(await billing.getActiveBotCount("tenant-1")).toBe(0);
105
114
  });
@@ -107,6 +116,7 @@ describe("BotBilling", () => {
107
116
  describe("suspendBot", () => {
108
117
  it("transitions bot from active to suspended", async () => {
109
118
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
119
+ await repo.startBilling("bot-1");
110
120
  await billing.suspendBot("bot-1");
111
121
  const info = await billing.getBotBilling("bot-1");
112
122
  // biome-ignore lint/suspicious/noExplicitAny: intentional test cast
@@ -118,6 +128,7 @@ describe("BotBilling", () => {
118
128
  });
119
129
  it("sets destroyAfter to 30 days after suspension", async () => {
120
130
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
131
+ await repo.startBilling("bot-1");
121
132
  await billing.suspendBot("bot-1");
122
133
  const info = await billing.getBotBilling("bot-1");
123
134
  expect(info).not.toBeNull();
@@ -132,8 +143,11 @@ describe("BotBilling", () => {
132
143
  describe("suspendAllForTenant", () => {
133
144
  it("suspends all active bots for a tenant", async () => {
134
145
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
146
+ await repo.startBilling("bot-1");
135
147
  await billing.registerBot("bot-2", "tenant-1", "bot-b");
148
+ await repo.startBilling("bot-2");
136
149
  await billing.registerBot("bot-3", "tenant-2", "bot-c");
150
+ await repo.startBilling("bot-3");
137
151
  const suspended = await billing.suspendAllForTenant("tenant-1");
138
152
  expect(suspended.sort()).toEqual(["bot-1", "bot-2"]);
139
153
  expect(await billing.getActiveBotCount("tenant-1")).toBe(0);
@@ -147,6 +161,7 @@ describe("BotBilling", () => {
147
161
  describe("reactivateBot", () => {
148
162
  it("transitions bot from suspended to active", async () => {
149
163
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
164
+ await repo.startBilling("bot-1");
150
165
  await billing.suspendBot("bot-1");
151
166
  await billing.reactivateBot("bot-1");
152
167
  const info = await billing.getBotBilling("bot-1");
@@ -159,6 +174,7 @@ describe("BotBilling", () => {
159
174
  });
160
175
  it("does not reactivate a destroyed bot", async () => {
161
176
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
177
+ await repo.startBilling("bot-1");
162
178
  await billing.destroyBot("bot-1");
163
179
  await billing.reactivateBot("bot-1");
164
180
  const info = await billing.getBotBilling("bot-1");
@@ -167,6 +183,7 @@ describe("BotBilling", () => {
167
183
  });
168
184
  it("does not affect already-active bots", async () => {
169
185
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
186
+ await repo.startBilling("bot-1");
170
187
  await billing.reactivateBot("bot-1");
171
188
  const info = await billing.getBotBilling("bot-1");
172
189
  // biome-ignore lint/suspicious/noExplicitAny: intentional test cast
@@ -176,7 +193,9 @@ describe("BotBilling", () => {
176
193
  describe("checkReactivation", () => {
177
194
  it("reactivates suspended bots when balance is positive", async () => {
178
195
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
196
+ await repo.startBilling("bot-1");
179
197
  await billing.registerBot("bot-2", "tenant-1", "bot-b");
198
+ await repo.startBilling("bot-2");
180
199
  await billing.suspendBot("bot-1");
181
200
  await billing.suspendBot("bot-2");
182
201
  await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", {
@@ -190,6 +209,7 @@ describe("BotBilling", () => {
190
209
  });
191
210
  it("does not reactivate when balance is zero", async () => {
192
211
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
212
+ await repo.startBilling("bot-1");
193
213
  await billing.suspendBot("bot-1");
194
214
  const reactivated = await billing.checkReactivation("tenant-1", ledger);
195
215
  expect(reactivated).toEqual([]);
@@ -197,6 +217,7 @@ describe("BotBilling", () => {
197
217
  });
198
218
  it("does not reactivate destroyed bots", async () => {
199
219
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
220
+ await repo.startBilling("bot-1");
200
221
  await billing.destroyBot("bot-1");
201
222
  await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", {
202
223
  description: "test credit",
@@ -219,6 +240,7 @@ describe("BotBilling", () => {
219
240
  describe("destroyBot", () => {
220
241
  it("marks bot as destroyed", async () => {
221
242
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
243
+ await repo.startBilling("bot-1");
222
244
  await billing.destroyBot("bot-1");
223
245
  const info = await billing.getBotBilling("bot-1");
224
246
  // biome-ignore lint/suspicious/noExplicitAny: intentional test cast
@@ -228,6 +250,7 @@ describe("BotBilling", () => {
228
250
  describe("destroyExpiredBots", () => {
229
251
  it("destroys bots past their grace period", async () => {
230
252
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
253
+ await repo.startBilling("bot-1");
231
254
  // Set destroyAfter to the past using drizzle sql
232
255
  await db
233
256
  .update(botInstances)
@@ -245,6 +268,7 @@ describe("BotBilling", () => {
245
268
  });
246
269
  it("does not destroy bots still within grace period", async () => {
247
270
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
271
+ await repo.startBilling("bot-1");
248
272
  await billing.suspendBot("bot-1");
249
273
  const destroyed = await billing.destroyExpiredBots();
250
274
  expect(destroyed).toEqual([]);
@@ -254,6 +278,7 @@ describe("BotBilling", () => {
254
278
  });
255
279
  it("does not touch active bots", async () => {
256
280
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
281
+ await repo.startBilling("bot-1");
257
282
  const destroyed = await billing.destroyExpiredBots();
258
283
  expect(destroyed).toEqual([]);
259
284
  });
@@ -316,13 +341,16 @@ describe("BotBilling", () => {
316
341
  });
317
342
  it("returns correct daily cost for known storage tiers", async () => {
318
343
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
344
+ await repo.startBilling("bot-1");
319
345
  await billing.setStorageTier("bot-1", "pro");
320
346
  await billing.registerBot("bot-2", "tenant-1", "bot-b");
347
+ await repo.startBilling("bot-2");
321
348
  await billing.setStorageTier("bot-2", "plus");
322
349
  expect((await billing.getStorageTierCostsForTenant("tenant-1")).toCents()).toBe(11);
323
350
  });
324
351
  it("returns 0 for unknown storage tier (fallback branch)", async () => {
325
352
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
353
+ await repo.startBilling("bot-1");
326
354
  // Bypass setStorageTier to insert an unrecognized tier value directly
327
355
  await pool.query(`UPDATE bot_instances SET storage_tier = 'unknown_tier' WHERE id = 'bot-1'`);
328
356
  // STORAGE_TIERS['unknown_tier'] is undefined → Credit.ZERO fallback
@@ -330,6 +358,7 @@ describe("BotBilling", () => {
330
358
  });
331
359
  it("does not include suspended bots in storage tier cost", async () => {
332
360
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
361
+ await repo.startBilling("bot-1");
333
362
  await billing.setStorageTier("bot-1", "pro");
334
363
  await billing.suspendBot("bot-1");
335
364
  expect((await billing.getStorageTierCostsForTenant("tenant-1")).toCents()).toBe(0);
@@ -338,8 +367,11 @@ describe("BotBilling", () => {
338
367
  describe("listForTenant", () => {
339
368
  it("lists all bots regardless of billing state", async () => {
340
369
  await billing.registerBot("bot-1", "tenant-1", "bot-a");
370
+ await repo.startBilling("bot-1");
341
371
  await billing.registerBot("bot-2", "tenant-1", "bot-b");
372
+ await repo.startBilling("bot-2");
342
373
  await billing.registerBot("bot-3", "tenant-2", "bot-c");
374
+ await repo.startBilling("bot-3");
343
375
  await billing.suspendBot("bot-2");
344
376
  const bots = await billing.listForTenant("tenant-1");
345
377
  // biome-ignore lint/suspicious/noExplicitAny: intentional test cast
@@ -349,6 +381,7 @@ describe("BotBilling", () => {
349
381
  describe("full lifecycle", () => {
350
382
  it("active -> suspended -> reactivated -> active", async () => {
351
383
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
384
+ await repo.startBilling("bot-1");
352
385
  // biome-ignore lint/suspicious/noExplicitAny: intentional test cast
353
386
  expect((await billing.getBotBilling("bot-1"))?.billingState).toBe("active");
354
387
  await billing.suspendBot("bot-1");
@@ -365,6 +398,7 @@ describe("BotBilling", () => {
365
398
  });
366
399
  it("active -> suspended -> destroyed (after grace period)", async () => {
367
400
  await billing.registerBot("bot-1", "tenant-1", "my-bot");
401
+ await repo.startBilling("bot-1");
368
402
  await billing.suspendBot("bot-1");
369
403
  await db.update(botInstances).set({ destroyAfter: sql `now() - interval '1 day'` }).where(sql `id = 'bot-1'`);
370
404
  await billing.destroyExpiredBots();
@@ -1,9 +1,17 @@
1
1
  import type { ILedger } from "@wopr-network/platform-core/credits";
2
2
  import { Credit } from "@wopr-network/platform-core/credits";
3
3
  import type { IBotInstanceRepository } from "../../fleet/bot-instance-repository.js";
4
+ /** Monthly bot cost in dollars. */
5
+ export declare const MONTHLY_BOT_COST_DOLLARS = 5;
4
6
  /**
5
- * Bot runtime cost: $5/bot/month prorated daily.
6
- * $5.00 / 30 $0.1667/day, rounded to $0.17.
7
+ * Compute the daily bot cost for a given date, prorated by the actual
8
+ * number of days in that month. Uses nano-dollar precision so totals
9
+ * sum to exactly $5.00/month (no over/under-billing).
10
+ */
11
+ export declare function dailyBotCost(date: string): Credit;
12
+ /**
13
+ * @deprecated Use dailyBotCost(date) for accurate per-month proration.
14
+ * Kept for backwards compat in tests.
7
15
  */
8
16
  export declare const DAILY_BOT_COST: Credit;
9
17
  /** Callback invoked when a tenant's balance hits zero during deduction. */
@@ -1,9 +1,23 @@
1
1
  import { Credit, InsufficientBalanceError } from "@wopr-network/platform-core/credits";
2
2
  import { logger } from "../../config/logger.js";
3
3
  import { RESOURCE_TIERS } from "../../fleet/resource-tiers.js";
4
+ /** Monthly bot cost in dollars. */
5
+ export const MONTHLY_BOT_COST_DOLLARS = 5;
4
6
  /**
5
- * Bot runtime cost: $5/bot/month prorated daily.
6
- * $5.00 / 30 $0.1667/day, rounded to $0.17.
7
+ * Compute the daily bot cost for a given date, prorated by the actual
8
+ * number of days in that month. Uses nano-dollar precision so totals
9
+ * sum to exactly $5.00/month (no over/under-billing).
10
+ */
11
+ export function dailyBotCost(date) {
12
+ const d = new Date(date);
13
+ const year = d.getFullYear();
14
+ const month = d.getMonth();
15
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
16
+ return Credit.fromDollars(MONTHLY_BOT_COST_DOLLARS / daysInMonth);
17
+ }
18
+ /**
19
+ * @deprecated Use dailyBotCost(date) for accurate per-month proration.
20
+ * Kept for backwards compat in tests.
7
21
  */
8
22
  export const DAILY_BOT_COST = Credit.fromCents(17);
9
23
  /** Low balance threshold ($1.00 = 20% of signup grant). */
@@ -66,14 +80,15 @@ export async function runRuntimeDeductions(cfg) {
66
80
  result.skipped.push(tenantId);
67
81
  continue;
68
82
  }
69
- const totalCost = DAILY_BOT_COST.multiply(botCount);
83
+ const dailyCost = dailyBotCost(cfg.date);
84
+ const totalCost = dailyCost.multiply(botCount);
70
85
  let didBillAnything = false;
71
86
  // Bill runtime debit (skipped if already billed on a previous run)
72
87
  if (!runtimeAlreadyBilled) {
73
88
  if (!balance.lessThan(totalCost)) {
74
89
  // Full deduction
75
90
  await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
76
- description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
91
+ description: `Daily runtime: ${botCount} bot(s) x $${dailyCost.toDollars().toFixed(4)}`,
77
92
  referenceId: runtimeRef,
78
93
  });
79
94
  }