@wopr-network/platform-core 1.73.0 → 1.74.1

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.
@@ -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 })
@@ -125,6 +125,15 @@ export class FleetManager {
125
125
  }
126
126
  const instance = await this.buildInstance(profile);
127
127
  instance.emitCreated();
128
+ // Register in bot_instances DB table for billing
129
+ if (this.instanceRepo) {
130
+ try {
131
+ await this.instanceRepo.register(id, profile.tenantId, profile.name);
132
+ }
133
+ catch (err) {
134
+ logger.warn("Failed to register bot instance in DB (non-fatal)", { id, err });
135
+ }
136
+ }
128
137
  return instance;
129
138
  };
130
139
  return hasExplicitId ? this.withLock(id, doCreate) : doCreate();
@@ -208,6 +217,15 @@ export class FleetManager {
208
217
  if (this.networkPolicy) {
209
218
  await this.networkPolicy.cleanupAfterRemoval(profile.tenantId);
210
219
  }
220
+ // Remove from bot_instances DB table
221
+ if (this.instanceRepo) {
222
+ try {
223
+ await this.instanceRepo.deleteById(id);
224
+ }
225
+ catch (err) {
226
+ logger.warn("Failed to delete bot instance from DB (non-fatal)", { id, err });
227
+ }
228
+ }
211
229
  await this.store.delete(id);
212
230
  logger.info(`Removed bot ${id}`);
213
231
  this.emitEvent("bot.removed", id, profile.tenantId);
@@ -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;
@@ -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
  }
@@ -2,9 +2,11 @@ import { Credit, DrizzleLedger, InsufficientBalanceError } from "@wopr-network/p
2
2
  import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { RESOURCE_TIERS } from "../../fleet/resource-tiers.js";
4
4
  import { createTestDb, truncateAllTables } from "../../test/db.js";
5
- import { buildResourceTierCosts, DAILY_BOT_COST, runRuntimeDeductions } from "./runtime-cron.js";
5
+ import { buildResourceTierCosts, DAILY_BOT_COST, dailyBotCost, runRuntimeDeductions } from "./runtime-cron.js";
6
6
  describe("runRuntimeDeductions", () => {
7
7
  const TODAY = "2025-01-01";
8
+ /** Dynamic daily cost for TODAY's month (January = 31 days) */
9
+ const COST = dailyBotCost(TODAY);
8
10
  let pool;
9
11
  let ledger;
10
12
  beforeAll(async () => {
@@ -20,7 +22,7 @@ describe("runRuntimeDeductions", () => {
20
22
  await ledger.seedSystemAccounts();
21
23
  });
22
24
  it("DAILY_BOT_COST equals 17 cents", () => {
23
- expect(DAILY_BOT_COST.toCents()).toBe(17);
25
+ expect(DAILY_BOT_COST.toCentsRounded()).toBe(17);
24
26
  });
25
27
  it("returns empty result when no tenants have balance", async () => {
26
28
  const result = await runRuntimeDeductions({
@@ -51,7 +53,7 @@ describe("runRuntimeDeductions", () => {
51
53
  });
52
54
  expect(result.processed).toBe(1);
53
55
  expect(result.suspended).toEqual([]);
54
- expect((await ledger.balance("tenant-1")).toCents()).toBe(500 - 2 * 17);
56
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(Credit.fromCents(500).subtract(COST.multiply(2)).toCentsRounded());
55
57
  });
56
58
  it("partial deduction and suspension when balance is insufficient", async () => {
57
59
  await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
@@ -120,7 +122,7 @@ describe("runRuntimeDeductions", () => {
120
122
  });
121
123
  it("catches InsufficientBalanceError from debit and suspends", async () => {
122
124
  await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
123
- vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), Credit.fromCents(17)));
125
+ vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), COST));
124
126
  const onSuspend = vi.fn();
125
127
  const result = await runRuntimeDeductions({
126
128
  ledger,
@@ -135,7 +137,7 @@ describe("runRuntimeDeductions", () => {
135
137
  });
136
138
  it("catches InsufficientBalanceError without onSuspend callback", async () => {
137
139
  await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
138
- vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), Credit.fromCents(17)));
140
+ vi.spyOn(ledger, "debit").mockRejectedValue(new InsufficientBalanceError(Credit.fromCents(0), COST));
139
141
  const result = await runRuntimeDeductions({
140
142
  ledger,
141
143
  date: TODAY,
@@ -171,7 +173,7 @@ describe("runRuntimeDeductions", () => {
171
173
  expect(onLowBalance).toHaveBeenCalledOnce();
172
174
  const [calledTenant, calledBalance] = onLowBalance.mock.calls[0];
173
175
  expect(calledTenant).toBe("tenant-1");
174
- expect(calledBalance.toCents()).toBe(93);
176
+ expect(calledBalance.toCentsRounded()).toBe(Credit.fromCents(110).subtract(COST).toCentsRounded());
175
177
  });
176
178
  it("does NOT fire onLowBalance when balance was already below threshold before deduction", async () => {
177
179
  await ledger.credit("tenant-1", Credit.fromCents(90), "purchase", { description: "top-up" });
@@ -185,7 +187,7 @@ describe("runRuntimeDeductions", () => {
185
187
  expect(onLowBalance).not.toHaveBeenCalled();
186
188
  });
187
189
  it("fires onCreditsExhausted when full deduction causes balance to drop to 0", async () => {
188
- await ledger.credit("tenant-1", Credit.fromCents(17), "purchase", { description: "top-up" });
190
+ await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
189
191
  const onCreditsExhausted = vi.fn();
190
192
  await runRuntimeDeductions({
191
193
  ledger,
@@ -197,8 +199,8 @@ describe("runRuntimeDeductions", () => {
197
199
  expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
198
200
  });
199
201
  it("suspends tenant when full deduction causes balance to drop to exactly 0", async () => {
200
- // Balance = exactly 1 bot * DAILY_BOT_COST = 17 cents → full deduction → 0
201
- await ledger.credit("tenant-1", Credit.fromCents(17), "purchase", { description: "top-up" });
202
+ // Balance = exactly 1 bot * dailyBotCost → full deduction → 0
203
+ await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
202
204
  const onSuspend = vi.fn();
203
205
  const onCreditsExhausted = vi.fn();
204
206
  const result = await runRuntimeDeductions({
@@ -237,7 +239,7 @@ describe("runRuntimeDeductions", () => {
237
239
  expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
238
240
  });
239
241
  it("skips resource tier partial debit when balance is exactly 0 after runtime", async () => {
240
- await ledger.credit("tenant-1", Credit.fromCents(17), "purchase", { description: "top-up" });
242
+ await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
241
243
  const onCreditsExhausted = vi.fn();
242
244
  const result = await runRuntimeDeductions({
243
245
  ledger,
@@ -255,7 +257,7 @@ describe("runRuntimeDeductions", () => {
255
257
  // triggering the zero-crossing suspend in the runtime block.
256
258
  // Storage cost (5 cents) then tries to suspend again via its else-branch (balance 0 < 5).
257
259
  // The !result.suspended.includes(tenantId) guard must prevent onSuspend being called twice.
258
- await ledger.credit("tenant-1", Credit.fromCents(17), "purchase", { description: "top-up" });
260
+ await ledger.credit("tenant-1", COST, "purchase", { description: "top-up" });
259
261
  const onSuspend = vi.fn();
260
262
  const result = await runRuntimeDeductions({
261
263
  ledger,
@@ -269,9 +271,9 @@ describe("runRuntimeDeductions", () => {
269
271
  expect(onSuspend).toHaveBeenCalledTimes(1);
270
272
  });
271
273
  it("buildResourceTierCosts: deducts pro tier surcharge via getResourceTierCosts", async () => {
272
- const proTierCost = RESOURCE_TIERS.pro.dailyCost.toCents();
273
- const startBalance = 17 + proTierCost + 10;
274
- await ledger.credit("tenant-1", Credit.fromCents(startBalance), "purchase", { description: "top-up" });
274
+ const proTierCost = RESOURCE_TIERS.pro.dailyCost;
275
+ const startCredit = COST.add(proTierCost).add(Credit.fromCents(10));
276
+ await ledger.credit("tenant-1", startCredit, "purchase", { description: "top-up" });
275
277
  const mockRepo = {
276
278
  getResourceTier: async (_botId) => "pro",
277
279
  };
@@ -282,8 +284,7 @@ describe("runRuntimeDeductions", () => {
282
284
  getActiveBotCount: async () => 1,
283
285
  getResourceTierCosts,
284
286
  });
285
- const expected = startBalance - 17 - proTierCost;
286
- expect((await ledger.balance("tenant-1")).toCents()).toBe(expected);
287
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(10);
287
288
  });
288
289
  it("treats unique constraint violation from concurrent debit as already-billed (skip, not error)", async () => {
289
290
  await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
@@ -300,26 +301,31 @@ describe("runRuntimeDeductions", () => {
300
301
  });
301
302
  it("is idempotent — second run on same date does not double-deduct", async () => {
302
303
  await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", { description: "top-up" });
304
+ const JUNE_DATE = "2025-06-15";
305
+ const JUNE_COST = dailyBotCost(JUNE_DATE);
306
+ const expectedBalance = Credit.fromCents(500).subtract(JUNE_COST);
303
307
  const cfg = {
304
308
  ledger,
305
309
  getActiveBotCount: async () => 1,
306
- date: "2025-06-15",
310
+ date: JUNE_DATE,
307
311
  };
308
312
  const first = await runRuntimeDeductions(cfg);
309
313
  expect(first.processed).toBe(1);
310
- expect((await ledger.balance("tenant-1")).toCents()).toBe(500 - 17);
314
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedBalance.toCentsRounded());
311
315
  const second = await runRuntimeDeductions(cfg);
312
316
  expect(second.processed).toBe(0);
313
317
  expect(second.skipped).toContain("tenant-1");
314
318
  // Balance unchanged after second run
315
- expect((await ledger.balance("tenant-1")).toCents()).toBe(500 - 17);
319
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedBalance.toCentsRounded());
316
320
  });
317
321
  it("bills surcharges on retry when runtime was already billed (crash recovery)", async () => {
318
322
  // Setup: tenant with enough balance for runtime + tier + storage + addon
319
323
  await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
324
+ const JUL_DATE = "2025-07-01";
325
+ const JUL_COST = dailyBotCost(JUL_DATE);
320
326
  const cfg = {
321
327
  ledger,
322
- date: "2025-07-01",
328
+ date: JUL_DATE,
323
329
  getActiveBotCount: async () => 1,
324
330
  getResourceTierCosts: async () => Credit.fromCents(10),
325
331
  getStorageTierCosts: async () => Credit.fromCents(8),
@@ -328,23 +334,28 @@ describe("runRuntimeDeductions", () => {
328
334
  // First run — bills everything
329
335
  const first = await runRuntimeDeductions(cfg);
330
336
  expect(first.processed).toBe(1);
331
- // 1000 - 17 (runtime) - 10 (tier) - 8 (storage) - 5 (addon) = 960
332
- expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
337
+ const expectedAfterFirst = Credit.fromCents(1000)
338
+ .subtract(JUL_COST)
339
+ .subtract(Credit.fromCents(10))
340
+ .subtract(Credit.fromCents(8))
341
+ .subtract(Credit.fromCents(5));
342
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedAfterFirst.toCentsRounded());
333
343
  // Second run — all already billed, should skip
334
344
  const second = await runRuntimeDeductions(cfg);
335
345
  expect(second.skipped).toContain("tenant-1");
336
346
  expect(second.processed).toBe(0);
337
- expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
347
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedAfterFirst.toCentsRounded());
338
348
  });
339
349
  it("bills remaining surcharges when runtime was billed but surcharges were not (simulated crash)", async () => {
340
350
  // Setup: tenant with enough balance
341
351
  await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
342
352
  // Simulate crash: manually debit only the runtime charge (as if the cron crashed after this)
343
- await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
344
- description: "Daily runtime: 1 bot(s) x $0.17",
353
+ await ledger.debit("tenant-1", COST, "bot_runtime", {
354
+ description: `Daily runtime: 1 bot(s) x $${COST.toDollars().toFixed(4)}`,
345
355
  referenceId: `runtime:2025-07-02:tenant-1`,
346
356
  });
347
- expect((await ledger.balance("tenant-1")).toCents()).toBe(983); // 1000 - 17
357
+ const afterRuntime = Credit.fromCents(1000).subtract(COST);
358
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(afterRuntime.toCentsRounded());
348
359
  // Retry run — runtime already billed, but surcharges should still be billed
349
360
  const result = await runRuntimeDeductions({
350
361
  ledger,
@@ -356,21 +367,25 @@ describe("runRuntimeDeductions", () => {
356
367
  });
357
368
  expect(result.processed).toBe(1);
358
369
  expect(result.skipped).not.toContain("tenant-1");
359
- // 983 - 10 (tier) - 8 (storage) - 5 (addon) = 960
360
- expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
370
+ const expectedFinal = afterRuntime
371
+ .subtract(Credit.fromCents(10))
372
+ .subtract(Credit.fromCents(8))
373
+ .subtract(Credit.fromCents(5));
374
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedFinal.toCentsRounded());
361
375
  });
362
376
  it("bills only missing surcharges when some were already committed (simulated partial crash)", async () => {
363
377
  await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
364
378
  // Simulate: runtime + tier already billed, storage + addon not yet
365
- await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
366
- description: "Daily runtime: 1 bot(s) x $0.17",
379
+ await ledger.debit("tenant-1", COST, "bot_runtime", {
380
+ description: `Daily runtime: 1 bot(s) x $${COST.toDollars().toFixed(4)}`,
367
381
  referenceId: `runtime:2025-07-03:tenant-1`,
368
382
  });
369
383
  await ledger.debit("tenant-1", Credit.fromCents(10), "resource_upgrade", {
370
384
  description: "Daily resource tier surcharge",
371
385
  referenceId: `runtime-tier:2025-07-03:tenant-1`,
372
386
  });
373
- expect((await ledger.balance("tenant-1")).toCents()).toBe(973); // 1000 - 17 - 10
387
+ const afterRuntimeAndTier = Credit.fromCents(1000).subtract(COST).subtract(Credit.fromCents(10));
388
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(afterRuntimeAndTier.toCentsRounded());
374
389
  const result = await runRuntimeDeductions({
375
390
  ledger,
376
391
  date: "2025-07-03",
@@ -380,8 +395,8 @@ describe("runRuntimeDeductions", () => {
380
395
  getAddonCosts: async () => Credit.fromCents(5),
381
396
  });
382
397
  expect(result.processed).toBe(1);
383
- // 973 - 8 (storage) - 5 (addon) = 960
384
- expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
398
+ const expectedFinal = afterRuntimeAndTier.subtract(Credit.fromCents(8)).subtract(Credit.fromCents(5));
399
+ expect((await ledger.balance("tenant-1")).toCentsRounded()).toBe(expectedFinal.toCentsRounded());
385
400
  });
386
401
  it("does not double-debit runtime on retry after partial deduction + crash", async () => {
387
402
  await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
@@ -405,7 +420,7 @@ describe("runRuntimeDeductions", () => {
405
420
  it("trial balance remains balanced after crash-recovery billing", async () => {
406
421
  await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
407
422
  // Simulate crash: only runtime billed
408
- await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
423
+ await ledger.debit("tenant-1", COST, "bot_runtime", {
409
424
  description: "Daily runtime: 1 bot(s) x $0.17",
410
425
  referenceId: `runtime:2025-07-05:tenant-1`,
411
426
  });