@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
@@ -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
  });
@@ -5,6 +5,7 @@ import { DrizzleBotBilling } from "./bot-billing.js";
5
5
  describe("bot-billing storage tier", () => {
6
6
  let pool;
7
7
  let db;
8
+ let repo;
8
9
  let billing;
9
10
  beforeAll(async () => {
10
11
  ({ db, pool } = await createTestDb());
@@ -16,14 +17,17 @@ describe("bot-billing storage tier", () => {
16
17
  });
17
18
  beforeEach(async () => {
18
19
  await rollbackTestTransaction(pool);
19
- billing = new DrizzleBotBilling(new DrizzleBotInstanceRepository(db));
20
+ repo = new DrizzleBotInstanceRepository(db);
21
+ billing = new DrizzleBotBilling(repo);
20
22
  });
21
23
  it("new bot defaults to standard storage tier", async () => {
22
24
  await billing.registerBot("bot-1", "tenant-1", "TestBot");
25
+ await repo.startBilling("bot-1");
23
26
  expect(await billing.getStorageTier("bot-1")).toBe("standard");
24
27
  });
25
28
  it("setStorageTier updates tier", async () => {
26
29
  await billing.registerBot("bot-1", "tenant-1", "TestBot");
30
+ await repo.startBilling("bot-1");
27
31
  await billing.setStorageTier("bot-1", "pro");
28
32
  expect(await billing.getStorageTier("bot-1")).toBe("pro");
29
33
  });
@@ -32,8 +36,11 @@ describe("bot-billing storage tier", () => {
32
36
  });
33
37
  it("getStorageTierCostsForTenant sums active bot storage costs", async () => {
34
38
  await billing.registerBot("bot-1", "tenant-1", "Bot1");
39
+ await repo.startBilling("bot-1");
35
40
  await billing.registerBot("bot-2", "tenant-1", "Bot2");
41
+ await repo.startBilling("bot-2");
36
42
  await billing.registerBot("bot-3", "tenant-1", "Bot3");
43
+ await repo.startBilling("bot-3");
37
44
  await billing.setStorageTier("bot-1", "plus"); // 3 credits/day
38
45
  await billing.setStorageTier("bot-2", "max"); // 15 credits/day
39
46
  // bot-3 stays standard // 0 credits/day
@@ -41,6 +48,7 @@ describe("bot-billing storage tier", () => {
41
48
  });
42
49
  it("getStorageTierCostsForTenant excludes suspended bots", async () => {
43
50
  await billing.registerBot("bot-1", "tenant-1", "Bot1");
51
+ await repo.startBilling("bot-1");
44
52
  await billing.setStorageTier("bot-1", "pro"); // 8 credits/day
45
53
  await billing.suspendBot("bot-1");
46
54
  expect((await billing.getStorageTierCostsForTenant("tenant-1")).toCents()).toBe(0);
@@ -1,9 +1,10 @@
1
1
  import { Credit, DrizzleLedger } from "@wopr-network/platform-core/credits";
2
2
  import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
3
3
  import { createTestDb, truncateAllTables } from "../../test/db.js";
4
- import { runRuntimeDeductions } from "./runtime-cron.js";
4
+ import { dailyBotCost, runRuntimeDeductions } from "./runtime-cron.js";
5
5
  describe("runtime cron with storage tiers", () => {
6
6
  const TODAY = "2025-01-01";
7
+ const BASE_COST_CREDIT = dailyBotCost(TODAY);
7
8
  let pool;
8
9
  let db;
9
10
  let ledger;
@@ -28,8 +29,9 @@ describe("runtime cron with storage tiers", () => {
28
29
  });
29
30
  expect(result.processed).toBe(1);
30
31
  const balance = await ledger.balance("t1");
31
- // 1000 - 17 (base) - 8 (pro storage surcharge) = 975
32
- expect(balance.toCents()).toBe(975);
32
+ // 1000 - dailyBotCost (base) - 8 (pro storage surcharge)
33
+ const expected = Credit.fromCents(1000).subtract(BASE_COST_CREDIT).subtract(Credit.fromCents(8));
34
+ expect(balance.toCents()).toBe(expected.toCents());
33
35
  });
34
36
  it("debits only base cost for standard storage tier (zero surcharge)", async () => {
35
37
  await ledger.credit("t1", Credit.fromCents(1000), "purchase");
@@ -40,7 +42,8 @@ describe("runtime cron with storage tiers", () => {
40
42
  getStorageTierCosts: async () => Credit.ZERO,
41
43
  });
42
44
  expect(result.processed).toBe(1);
43
- expect((await ledger.balance("t1")).toCents()).toBe(983); // 1000 - 17
45
+ const expectedStd = Credit.fromCents(1000).subtract(BASE_COST_CREDIT);
46
+ expect((await ledger.balance("t1")).toCents()).toBe(expectedStd.toCents());
44
47
  });
45
48
  it("skips storage surcharge when callback not provided (backward compat)", async () => {
46
49
  await ledger.credit("t1", Credit.fromCents(1000), "purchase");
@@ -50,10 +53,13 @@ describe("runtime cron with storage tiers", () => {
50
53
  getActiveBotCount: async () => 1,
51
54
  });
52
55
  expect(result.processed).toBe(1);
53
- expect((await ledger.balance("t1")).toCents()).toBe(983); // 1000 - 17
56
+ const expectedBackcompat = Credit.fromCents(1000).subtract(BASE_COST_CREDIT);
57
+ expect((await ledger.balance("t1")).toCents()).toBe(expectedBackcompat.toCents());
54
58
  });
55
59
  it("suspends tenant when storage surcharge exhausts remaining balance", async () => {
56
- await ledger.credit("t1", Credit.fromCents(20), "purchase"); // Only 20 cents
60
+ // Seed just enough for base cost + 3 cents, so storage surcharge (8) exceeds remainder
61
+ const seed = BASE_COST_CREDIT.add(Credit.fromCents(3));
62
+ await ledger.credit("t1", seed, "purchase");
57
63
  const suspended = [];
58
64
  const result = await runRuntimeDeductions({
59
65
  ledger,
@@ -64,7 +70,7 @@ describe("runtime cron with storage tiers", () => {
64
70
  suspended.push(tenantId);
65
71
  },
66
72
  });
67
- // 20 - 17 = 3 remaining, then 8 surcharge > 3, so partial debit + suspend
73
+ // seed - BASE_COST = 3 remaining, then 8 surcharge > 3, so partial debit + suspend
68
74
  expect(result.processed).toBe(1);
69
75
  expect(result.suspended).toContain("t1");
70
76
  expect((await ledger.balance("t1")).toCents()).toBe(0);
@@ -142,6 +142,8 @@ describe("createTestContainer", () => {
142
142
  };
143
143
  const gateway = {
144
144
  serviceKeyRepo: {},
145
+ meter: {},
146
+ budgetChecker: {},
145
147
  };
146
148
  const hotPool = {
147
149
  start: async () => ({ stop: () => { } }),
@@ -158,7 +160,9 @@ describe("createTestContainer", () => {
158
160
  expect(c.hotPool).not.toBeNull();
159
161
  });
160
162
  it("overrides merge without affecting other defaults", () => {
161
- const c = createTestContainer({ gateway: { serviceKeyRepo: {} } });
163
+ const c = createTestContainer({
164
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
165
+ });
162
166
  // Overridden field
163
167
  expect(c.gateway).not.toBeNull();
164
168
  // Other feature services remain null
@@ -44,6 +44,8 @@ export interface StripeServices {
44
44
  }
45
45
  export interface GatewayServices {
46
46
  serviceKeyRepo: IServiceKeyRepository;
47
+ meter: import("../metering/emitter.js").MeterEmitter;
48
+ budgetChecker: import("../monetization/budget/budget-checker.js").IBudgetChecker;
47
49
  }
48
50
  export interface HotPoolServices {
49
51
  /** Start the pool manager (replenish loop + cleanup). */
@@ -126,8 +126,13 @@ export async function buildContainer(bootConfig) {
126
126
  let gateway = null;
127
127
  if (bootConfig.features.gateway) {
128
128
  const { DrizzleServiceKeyRepository } = await import("../gateway/service-key-repository.js");
129
+ const { MeterEmitter } = await import("../metering/emitter.js");
130
+ const { DrizzleMeterEventRepository } = await import("../metering/meter-event-repository.js");
131
+ const { DrizzleBudgetChecker } = await import("../monetization/budget/budget-checker.js");
129
132
  const serviceKeyRepo = new DrizzleServiceKeyRepository(db);
130
- gateway = { serviceKeyRepo };
133
+ const meter = new MeterEmitter(new DrizzleMeterEventRepository(db), { flushIntervalMs: 5_000 });
134
+ const budgetChecker = new DrizzleBudgetChecker(db, { cacheTtlMs: 30_000 });
135
+ gateway = { serviceKeyRepo, meter, budgetChecker };
131
136
  }
132
137
  // 12. Build the container (hotPool bound after construction)
133
138
  const result = {
@@ -37,7 +37,7 @@ export { createTestContainer } from "./test-container.js";
37
37
  export async function bootPlatformServer(config) {
38
38
  const container = await buildContainer(config);
39
39
  const app = new Hono();
40
- mountRoutes(app, container, {
40
+ await mountRoutes(app, container, {
41
41
  provisionSecret: config.provisionSecret,
42
42
  cryptoServiceKey: config.cryptoServiceKey,
43
43
  platformDomain: container.productConfig.product?.domain ?? "localhost",
@@ -27,4 +27,4 @@ export interface MountConfig {
27
27
  * 6. Product-specific route plugins
28
28
  * 7. Tenant proxy middleware (catch-all — must be last)
29
29
  */
30
- export declare function mountRoutes(app: Hono, container: PlatformContainer, config: MountConfig, plugins?: RoutePlugin[]): void;
30
+ export declare function mountRoutes(app: Hono, container: PlatformContainer, config: MountConfig, plugins?: RoutePlugin[]): Promise<void>;
@@ -28,7 +28,7 @@ import { createStripeWebhookRoutes } from "./routes/stripe-webhook.js";
28
28
  * 6. Product-specific route plugins
29
29
  * 7. Tenant proxy middleware (catch-all — must be last)
30
30
  */
31
- export function mountRoutes(app, container, config, plugins = []) {
31
+ export async function mountRoutes(app, container, config, plugins = []) {
32
32
  // 1. CORS middleware
33
33
  const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains);
34
34
  app.use("*", cors({
@@ -60,7 +60,40 @@ export function mountRoutes(app, container, config, plugins = []) {
60
60
  maxInstancesPerTenant: fleetConfig?.maxInstances ?? 5,
61
61
  }));
62
62
  }
63
- // 6. Product-specific route plugins
63
+ // 6. Metered inference gateway (when gateway is enabled)
64
+ if (container.gateway) {
65
+ // Validate billing config exists in DB — fail hard, no silent defaults
66
+ const billingConfig = container.productConfig.billing;
67
+ const marginConfig = billingConfig?.marginConfig;
68
+ if (!marginConfig?.default) {
69
+ throw new Error("Gateway enabled but product_billing_config.margin_config.default is not set. " +
70
+ "Seed the DB: INSERT INTO product_billing_config (product_id, margin_config) VALUES ('<id>', '{\"default\": 4.0}')");
71
+ }
72
+ // Live margin — reads from productConfig per-request (DB-cached with TTL)
73
+ const initialMargin = marginConfig.default;
74
+ const resolveMargin = () => {
75
+ const cfg = container.productConfig.billing?.marginConfig;
76
+ return cfg?.default ?? initialMargin;
77
+ };
78
+ const gw = container.gateway;
79
+ const { mountGateway } = await import("../gateway/index.js");
80
+ mountGateway(app, {
81
+ meter: gw.meter,
82
+ budgetChecker: gw.budgetChecker,
83
+ creditLedger: container.creditLedger,
84
+ resolveMargin,
85
+ providers: {
86
+ openrouter: process.env.OPENROUTER_API_KEY
87
+ ? { apiKey: process.env.OPENROUTER_API_KEY, baseUrl: process.env.OPENROUTER_BASE_URL || undefined }
88
+ : undefined,
89
+ },
90
+ resolveServiceKey: async (key) => {
91
+ const tenant = await gw.serviceKeyRepo.resolve(key);
92
+ return tenant ?? null;
93
+ },
94
+ });
95
+ }
96
+ // 7. Product-specific route plugins
64
97
  for (const plugin of plugins) {
65
98
  app.route(plugin.path, plugin.handler(container));
66
99
  }
@@ -144,6 +144,7 @@ describe("createAdminRouter", () => {
144
144
  },
145
145
  serviceKeyRepo: {},
146
146
  },
147
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
147
148
  });
148
149
  const caller = makeCaller(container);
149
150
  const result = await caller.admin.listAllInstances();
@@ -185,6 +186,7 @@ describe("createAdminRouter", () => {
185
186
  },
186
187
  serviceKeyRepo: {},
187
188
  },
189
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
188
190
  });
189
191
  const caller = makeCaller(container);
190
192
  const result = await caller.admin.listAllInstances();
@@ -229,9 +231,7 @@ describe("createAdminRouter", () => {
229
231
  };
230
232
  const container = createTestContainer({
231
233
  pool: mockPool,
232
- gateway: {
233
- serviceKeyRepo: {},
234
- },
234
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
235
235
  });
236
236
  const caller = makeCaller(container);
237
237
  const result = await caller.admin.billingOverview();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.74.0",
3
+ "version": "1.75.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -218,15 +218,28 @@ export class DrizzleBotInstanceRepository implements IBotInstanceRepository {
218
218
  .where(eq(botInstances.id, botId));
219
219
  }
220
220
 
221
- async register(botId: string, tenantId: string, name: string): Promise<void> {
221
+ async register(
222
+ botId: string,
223
+ tenantId: string,
224
+ name: string,
225
+ billingState: BillingState = "inactive",
226
+ ): Promise<void> {
222
227
  await this.db.insert(botInstances).values({
223
228
  id: botId,
224
229
  tenantId,
225
230
  name,
226
- billingState: "active",
231
+ billingState,
227
232
  });
228
233
  }
229
234
 
235
+ async startBilling(botId: string): Promise<void> {
236
+ await this.db.update(botInstances).set({ billingState: "active" }).where(eq(botInstances.id, botId));
237
+ }
238
+
239
+ async stopBilling(botId: string): Promise<void> {
240
+ await this.db.update(botInstances).set({ billingState: "inactive" }).where(eq(botInstances.id, botId));
241
+ }
242
+
230
243
  async getStorageTier(botId: string): Promise<string | null> {
231
244
  const row = (
232
245
  await this.db
@@ -109,6 +109,27 @@ export class Instance {
109
109
  this.emit("bot.created");
110
110
  }
111
111
 
112
+ /**
113
+ * Start billing for this instance ($5/month prorated daily).
114
+ * Call after creation for persistent, billable instances (e.g., Paperclip).
115
+ * Ephemeral instances (e.g., Holy Ship) skip this — they bill per-token at the gateway.
116
+ */
117
+ async startBilling(): Promise<void> {
118
+ if (!this.instanceRepo) {
119
+ logger.warn("startBilling() called but no instanceRepo available", { id: this.id });
120
+ return;
121
+ }
122
+ await this.instanceRepo.setBillingState(this.id, "active");
123
+ logger.info("Billing started for instance", { id: this.id, name: this.profile.name });
124
+ }
125
+
126
+ /** Stop billing for this instance (e.g., on suspend or downgrade). */
127
+ async stopBilling(): Promise<void> {
128
+ if (!this.instanceRepo) return;
129
+ await this.instanceRepo.setBillingState(this.id, "suspended");
130
+ logger.info("Billing stopped for instance", { id: this.id, name: this.profile.name });
131
+ }
132
+
112
133
  async start(): Promise<void> {
113
134
  this.assertLocal("start()");
114
135
  return this.withLock(async () => {
@@ -78,7 +78,7 @@ export interface SelfHostedNodeRegistration extends NodeRegistration {
78
78
  // ---------------------------------------------------------------------------
79
79
 
80
80
  /** Billing lifecycle states for a bot instance. */
81
- export type BillingState = "active" | "suspended" | "destroyed";
81
+ export type BillingState = "active" | "inactive" | "suspended" | "destroyed";
82
82
 
83
83
  /** Plain domain object for a bot instance — mirrors `bot_instances` table. */
84
84
  export interface BotInstance {
@@ -29,7 +29,11 @@ import type { GatewayAuthEnv } from "./service-key-auth.js";
29
29
  import { proxySSEStream } from "./streaming.js";
30
30
  import type { FetchFn, GatewayConfig, ProviderConfig } from "./types.js";
31
31
 
32
- const DEFAULT_MARGIN = 1.3;
32
+ /**
33
+ * Fallback only used when resolveMargin is not provided (tests only).
34
+ * Production MUST provide resolveMargin — mountRoutes enforces this.
35
+ */
36
+ const TEST_ONLY_MARGIN = 1.3;
33
37
 
34
38
  /** Max call duration cap: 4 hours = 240 minutes. */
35
39
  const MAX_CALL_DURATION_MINUTES = 240;
@@ -96,7 +100,10 @@ export function buildProxyDeps(config: GatewayConfig): ProxyDeps {
96
100
  providers: config.providers,
97
101
  defaultModel: config.defaultModel,
98
102
  resolveDefaultModel: config.resolveDefaultModel,
99
- defaultMargin: config.defaultMargin ?? DEFAULT_MARGIN,
103
+ get defaultMargin() {
104
+ if (config.resolveMargin) return config.resolveMargin();
105
+ return config.defaultMargin ?? TEST_ONLY_MARGIN;
106
+ },
100
107
  fetchFn: config.fetchFn ?? fetch,
101
108
  arbitrageRouter: config.arbitrageRouter,
102
109
  rateLookupFn: config.rateLookupFn,
@@ -115,8 +115,10 @@ export interface GatewayConfig {
115
115
  graceBufferCents?: number;
116
116
  /** Upstream provider credentials */
117
117
  providers: ProviderConfig;
118
- /** Default margin multiplier (default: 1.3 = 30%) */
118
+ /** Static margin (for tests only). Production should use resolveMargin. */
119
119
  defaultMargin?: number;
120
+ /** Live margin resolver — called per-request, reads from DB. Takes priority over defaultMargin. */
121
+ resolveMargin?: () => number;
120
122
  /** Optional arbitrage router for multi-provider cost optimization (WOP-463) */
121
123
  arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
122
124
  /** Injectable fetch for testing */
package/src/index.ts CHANGED
@@ -48,3 +48,7 @@ export * from "./tenancy/index.js";
48
48
 
49
49
  // tRPC
50
50
  export * from "./trpc/index.js";
51
+ // monorepo e2e cutover test
52
+ // hybrid dockerfile e2e
53
+ // sequential build test
54
+ // lockfile build