@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.
- package/dist/fleet/drizzle-bot-instance-repository.d.ts +3 -1
- package/dist/fleet/drizzle-bot-instance-repository.js +8 -2
- package/dist/fleet/fleet-manager.js +18 -0
- package/dist/fleet/instance.d.ts +8 -0
- package/dist/fleet/instance.js +20 -0
- package/dist/fleet/repository-types.d.ts +1 -1
- package/dist/monetization/credits/bot-billing.test.js +35 -1
- package/dist/monetization/credits/runtime-cron.d.ts +10 -2
- package/dist/monetization/credits/runtime-cron.js +19 -4
- package/dist/monetization/credits/runtime-cron.test.js +49 -34
- package/dist/monetization/credits/storage-tier-billing.test.js +9 -1
- package/dist/monetization/credits/storage-tier-cron.test.js +13 -7
- package/dist/server/container.js +4 -1
- package/dist/server/lifecycle.js +27 -0
- package/package.json +1 -1
- package/src/fleet/drizzle-bot-instance-repository.ts +15 -2
- package/src/fleet/fleet-manager.ts +19 -0
- package/src/fleet/instance.ts +21 -0
- package/src/fleet/repository-types.ts +1 -1
- package/src/monetization/credits/bot-billing.test.ts +35 -1
- package/src/monetization/credits/runtime-cron.test.ts +51 -38
- package/src/monetization/credits/runtime-cron.ts +21 -4
- package/src/monetization/credits/storage-tier-billing.test.ts +9 -1
- package/src/monetization/credits/storage-tier-cron.test.ts +13 -7
- package/src/server/container.ts +5 -0
- package/src/server/lifecycle.ts +26 -0
|
@@ -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
|
-
|
|
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 -
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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);
|
package/dist/server/container.js
CHANGED
|
@@ -80,9 +80,12 @@ export async function buildContainer(bootConfig) {
|
|
|
80
80
|
const profileStore = new ProfileStore(fleetDataDir);
|
|
81
81
|
const proxy = new ProxyManager();
|
|
82
82
|
const serviceKeyRepo = new DrizzleServiceKeyRepository(db);
|
|
83
|
+
const { DrizzleBotInstanceRepository } = await import("../fleet/drizzle-bot-instance-repository.js");
|
|
84
|
+
const botInstanceRepo = new DrizzleBotInstanceRepository(db);
|
|
83
85
|
const manager = new FleetManagerClass(docker, profileStore, undefined, // platformDiscovery
|
|
84
86
|
undefined, // networkPolicy
|
|
85
|
-
proxy
|
|
87
|
+
proxy, undefined, // commandBus
|
|
88
|
+
botInstanceRepo);
|
|
86
89
|
fleet = { manager, docker, proxy, profileStore, serviceKeyRepo };
|
|
87
90
|
}
|
|
88
91
|
// 9. Crypto services (when enabled)
|
package/dist/server/lifecycle.js
CHANGED
|
@@ -27,6 +27,33 @@ export async function startBackgroundServices(container) {
|
|
|
27
27
|
// Non-fatal — proxy sync will retry on next health tick
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
// Backfill bot_instances from YAML profiles (one-time sync on startup)
|
|
31
|
+
if (container.fleet) {
|
|
32
|
+
try {
|
|
33
|
+
const { DrizzleBotInstanceRepository } = await import("../fleet/drizzle-bot-instance-repository.js");
|
|
34
|
+
const botInstanceRepo = new DrizzleBotInstanceRepository(container.db);
|
|
35
|
+
const profiles = await container.fleet.profileStore.list();
|
|
36
|
+
let synced = 0;
|
|
37
|
+
for (const profile of profiles) {
|
|
38
|
+
const existing = await botInstanceRepo.getById(profile.id);
|
|
39
|
+
if (!existing) {
|
|
40
|
+
try {
|
|
41
|
+
await botInstanceRepo.register(profile.id, profile.tenantId, profile.name);
|
|
42
|
+
synced++;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Ignore duplicates / constraint violations
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (synced > 0) {
|
|
50
|
+
logger.info(`Backfilled ${synced} bot instances from profiles into DB`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
logger.warn("Failed to backfill bot_instances (non-fatal)", { error: String(err) });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
30
57
|
// Hot pool manager (if enabled)
|
|
31
58
|
if (container.hotPool) {
|
|
32
59
|
try {
|
package/package.json
CHANGED
|
@@ -218,15 +218,28 @@ export class DrizzleBotInstanceRepository implements IBotInstanceRepository {
|
|
|
218
218
|
.where(eq(botInstances.id, botId));
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
async register(
|
|
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
|
|
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
|
|
@@ -152,6 +152,16 @@ export class FleetManager {
|
|
|
152
152
|
|
|
153
153
|
const instance = await this.buildInstance(profile);
|
|
154
154
|
instance.emitCreated();
|
|
155
|
+
|
|
156
|
+
// Register in bot_instances DB table for billing
|
|
157
|
+
if (this.instanceRepo) {
|
|
158
|
+
try {
|
|
159
|
+
await this.instanceRepo.register(id, profile.tenantId, profile.name);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
logger.warn("Failed to register bot instance in DB (non-fatal)", { id, err });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
155
165
|
return instance;
|
|
156
166
|
};
|
|
157
167
|
|
|
@@ -239,6 +249,15 @@ export class FleetManager {
|
|
|
239
249
|
await this.networkPolicy.cleanupAfterRemoval(profile.tenantId);
|
|
240
250
|
}
|
|
241
251
|
|
|
252
|
+
// Remove from bot_instances DB table
|
|
253
|
+
if (this.instanceRepo) {
|
|
254
|
+
try {
|
|
255
|
+
await this.instanceRepo.deleteById(id);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
logger.warn("Failed to delete bot instance from DB (non-fatal)", { id, err });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
242
261
|
await this.store.delete(id);
|
|
243
262
|
logger.info(`Removed bot ${id}`);
|
|
244
263
|
this.emitEvent("bot.removed", id, profile.tenantId);
|
package/src/fleet/instance.ts
CHANGED
|
@@ -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 {
|
|
@@ -56,6 +56,7 @@ function createMockDeps(nodeId: string | null = "node-1") {
|
|
|
56
56
|
describe("BotBilling", () => {
|
|
57
57
|
let pool: PGlite;
|
|
58
58
|
let db: DrizzleDb;
|
|
59
|
+
let repo: DrizzleBotInstanceRepository;
|
|
59
60
|
let billing: BotBilling;
|
|
60
61
|
let ledger: DrizzleLedger;
|
|
61
62
|
|
|
@@ -69,7 +70,8 @@ describe("BotBilling", () => {
|
|
|
69
70
|
|
|
70
71
|
beforeEach(async () => {
|
|
71
72
|
await truncateAllTables(pool);
|
|
72
|
-
|
|
73
|
+
repo = new DrizzleBotInstanceRepository(db);
|
|
74
|
+
billing = new BotBilling(repo);
|
|
73
75
|
ledger = new DrizzleLedger(db);
|
|
74
76
|
|
|
75
77
|
await ledger.seedSystemAccounts();
|
|
@@ -78,6 +80,7 @@ describe("BotBilling", () => {
|
|
|
78
80
|
describe("registerBot", () => {
|
|
79
81
|
it("registers a bot in active billing state", async () => {
|
|
80
82
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
83
|
+
await repo.startBilling("bot-1");
|
|
81
84
|
const info = await billing.getBotBilling("bot-1");
|
|
82
85
|
expect(info).not.toBeNull();
|
|
83
86
|
// biome-ignore lint/suspicious/noExplicitAny: intentional test cast
|
|
@@ -100,8 +103,11 @@ describe("BotBilling", () => {
|
|
|
100
103
|
|
|
101
104
|
it("counts only active bots for the tenant", async () => {
|
|
102
105
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
106
|
+
await repo.startBilling("bot-1");
|
|
103
107
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
108
|
+
await repo.startBilling("bot-2");
|
|
104
109
|
await billing.registerBot("bot-3", "tenant-2", "bot-c");
|
|
110
|
+
await repo.startBilling("bot-3");
|
|
105
111
|
|
|
106
112
|
expect(await billing.getActiveBotCount("tenant-1")).toBe(2);
|
|
107
113
|
expect(await billing.getActiveBotCount("tenant-2")).toBe(1);
|
|
@@ -109,7 +115,9 @@ describe("BotBilling", () => {
|
|
|
109
115
|
|
|
110
116
|
it("does not count suspended bots", async () => {
|
|
111
117
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
118
|
+
await repo.startBilling("bot-1");
|
|
112
119
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
120
|
+
await repo.startBilling("bot-2");
|
|
113
121
|
await billing.suspendBot("bot-1");
|
|
114
122
|
|
|
115
123
|
expect(await billing.getActiveBotCount("tenant-1")).toBe(1);
|
|
@@ -117,6 +125,7 @@ describe("BotBilling", () => {
|
|
|
117
125
|
|
|
118
126
|
it("does not count destroyed bots", async () => {
|
|
119
127
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
128
|
+
await repo.startBilling("bot-1");
|
|
120
129
|
await billing.destroyBot("bot-1");
|
|
121
130
|
|
|
122
131
|
expect(await billing.getActiveBotCount("tenant-1")).toBe(0);
|
|
@@ -126,6 +135,7 @@ describe("BotBilling", () => {
|
|
|
126
135
|
describe("suspendBot", () => {
|
|
127
136
|
it("transitions bot from active to suspended", async () => {
|
|
128
137
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
138
|
+
await repo.startBilling("bot-1");
|
|
129
139
|
await billing.suspendBot("bot-1");
|
|
130
140
|
|
|
131
141
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -139,6 +149,7 @@ describe("BotBilling", () => {
|
|
|
139
149
|
|
|
140
150
|
it("sets destroyAfter to 30 days after suspension", async () => {
|
|
141
151
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
152
|
+
await repo.startBilling("bot-1");
|
|
142
153
|
await billing.suspendBot("bot-1");
|
|
143
154
|
|
|
144
155
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -155,8 +166,11 @@ describe("BotBilling", () => {
|
|
|
155
166
|
describe("suspendAllForTenant", () => {
|
|
156
167
|
it("suspends all active bots for a tenant", async () => {
|
|
157
168
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
169
|
+
await repo.startBilling("bot-1");
|
|
158
170
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
171
|
+
await repo.startBilling("bot-2");
|
|
159
172
|
await billing.registerBot("bot-3", "tenant-2", "bot-c");
|
|
173
|
+
await repo.startBilling("bot-3");
|
|
160
174
|
|
|
161
175
|
const suspended = await billing.suspendAllForTenant("tenant-1");
|
|
162
176
|
|
|
@@ -174,6 +188,7 @@ describe("BotBilling", () => {
|
|
|
174
188
|
describe("reactivateBot", () => {
|
|
175
189
|
it("transitions bot from suspended to active", async () => {
|
|
176
190
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
191
|
+
await repo.startBilling("bot-1");
|
|
177
192
|
await billing.suspendBot("bot-1");
|
|
178
193
|
await billing.reactivateBot("bot-1");
|
|
179
194
|
|
|
@@ -188,6 +203,7 @@ describe("BotBilling", () => {
|
|
|
188
203
|
|
|
189
204
|
it("does not reactivate a destroyed bot", async () => {
|
|
190
205
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
206
|
+
await repo.startBilling("bot-1");
|
|
191
207
|
await billing.destroyBot("bot-1");
|
|
192
208
|
await billing.reactivateBot("bot-1");
|
|
193
209
|
|
|
@@ -198,6 +214,7 @@ describe("BotBilling", () => {
|
|
|
198
214
|
|
|
199
215
|
it("does not affect already-active bots", async () => {
|
|
200
216
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
217
|
+
await repo.startBilling("bot-1");
|
|
201
218
|
await billing.reactivateBot("bot-1");
|
|
202
219
|
|
|
203
220
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -209,7 +226,9 @@ describe("BotBilling", () => {
|
|
|
209
226
|
describe("checkReactivation", () => {
|
|
210
227
|
it("reactivates suspended bots when balance is positive", async () => {
|
|
211
228
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
229
|
+
await repo.startBilling("bot-1");
|
|
212
230
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
231
|
+
await repo.startBilling("bot-2");
|
|
213
232
|
await billing.suspendBot("bot-1");
|
|
214
233
|
await billing.suspendBot("bot-2");
|
|
215
234
|
|
|
@@ -226,6 +245,7 @@ describe("BotBilling", () => {
|
|
|
226
245
|
|
|
227
246
|
it("does not reactivate when balance is zero", async () => {
|
|
228
247
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
248
|
+
await repo.startBilling("bot-1");
|
|
229
249
|
await billing.suspendBot("bot-1");
|
|
230
250
|
|
|
231
251
|
const reactivated = await billing.checkReactivation("tenant-1", ledger);
|
|
@@ -235,6 +255,7 @@ describe("BotBilling", () => {
|
|
|
235
255
|
|
|
236
256
|
it("does not reactivate destroyed bots", async () => {
|
|
237
257
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
258
|
+
await repo.startBilling("bot-1");
|
|
238
259
|
await billing.destroyBot("bot-1");
|
|
239
260
|
|
|
240
261
|
await ledger.credit("tenant-1", Credit.fromCents(500), "purchase", {
|
|
@@ -261,6 +282,7 @@ describe("BotBilling", () => {
|
|
|
261
282
|
describe("destroyBot", () => {
|
|
262
283
|
it("marks bot as destroyed", async () => {
|
|
263
284
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
285
|
+
await repo.startBilling("bot-1");
|
|
264
286
|
await billing.destroyBot("bot-1");
|
|
265
287
|
|
|
266
288
|
const info = await billing.getBotBilling("bot-1");
|
|
@@ -272,6 +294,7 @@ describe("BotBilling", () => {
|
|
|
272
294
|
describe("destroyExpiredBots", () => {
|
|
273
295
|
it("destroys bots past their grace period", async () => {
|
|
274
296
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
297
|
+
await repo.startBilling("bot-1");
|
|
275
298
|
|
|
276
299
|
// Set destroyAfter to the past using drizzle sql
|
|
277
300
|
await db
|
|
@@ -293,6 +316,7 @@ describe("BotBilling", () => {
|
|
|
293
316
|
|
|
294
317
|
it("does not destroy bots still within grace period", async () => {
|
|
295
318
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
319
|
+
await repo.startBilling("bot-1");
|
|
296
320
|
await billing.suspendBot("bot-1");
|
|
297
321
|
|
|
298
322
|
const destroyed = await billing.destroyExpiredBots();
|
|
@@ -305,6 +329,7 @@ describe("BotBilling", () => {
|
|
|
305
329
|
|
|
306
330
|
it("does not touch active bots", async () => {
|
|
307
331
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
332
|
+
await repo.startBilling("bot-1");
|
|
308
333
|
|
|
309
334
|
const destroyed = await billing.destroyExpiredBots();
|
|
310
335
|
expect(destroyed).toEqual([]);
|
|
@@ -386,8 +411,10 @@ describe("BotBilling", () => {
|
|
|
386
411
|
|
|
387
412
|
it("returns correct daily cost for known storage tiers", async () => {
|
|
388
413
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
414
|
+
await repo.startBilling("bot-1");
|
|
389
415
|
await billing.setStorageTier("bot-1", "pro");
|
|
390
416
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
417
|
+
await repo.startBilling("bot-2");
|
|
391
418
|
await billing.setStorageTier("bot-2", "plus");
|
|
392
419
|
|
|
393
420
|
expect((await billing.getStorageTierCostsForTenant("tenant-1")).toCents()).toBe(11);
|
|
@@ -395,6 +422,7 @@ describe("BotBilling", () => {
|
|
|
395
422
|
|
|
396
423
|
it("returns 0 for unknown storage tier (fallback branch)", async () => {
|
|
397
424
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
425
|
+
await repo.startBilling("bot-1");
|
|
398
426
|
// Bypass setStorageTier to insert an unrecognized tier value directly
|
|
399
427
|
await pool.query(`UPDATE bot_instances SET storage_tier = 'unknown_tier' WHERE id = 'bot-1'`);
|
|
400
428
|
|
|
@@ -404,6 +432,7 @@ describe("BotBilling", () => {
|
|
|
404
432
|
|
|
405
433
|
it("does not include suspended bots in storage tier cost", async () => {
|
|
406
434
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
435
|
+
await repo.startBilling("bot-1");
|
|
407
436
|
await billing.setStorageTier("bot-1", "pro");
|
|
408
437
|
await billing.suspendBot("bot-1");
|
|
409
438
|
|
|
@@ -414,8 +443,11 @@ describe("BotBilling", () => {
|
|
|
414
443
|
describe("listForTenant", () => {
|
|
415
444
|
it("lists all bots regardless of billing state", async () => {
|
|
416
445
|
await billing.registerBot("bot-1", "tenant-1", "bot-a");
|
|
446
|
+
await repo.startBilling("bot-1");
|
|
417
447
|
await billing.registerBot("bot-2", "tenant-1", "bot-b");
|
|
448
|
+
await repo.startBilling("bot-2");
|
|
418
449
|
await billing.registerBot("bot-3", "tenant-2", "bot-c");
|
|
450
|
+
await repo.startBilling("bot-3");
|
|
419
451
|
await billing.suspendBot("bot-2");
|
|
420
452
|
|
|
421
453
|
const bots = await billing.listForTenant("tenant-1");
|
|
@@ -427,6 +459,7 @@ describe("BotBilling", () => {
|
|
|
427
459
|
describe("full lifecycle", () => {
|
|
428
460
|
it("active -> suspended -> reactivated -> active", async () => {
|
|
429
461
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
462
|
+
await repo.startBilling("bot-1");
|
|
430
463
|
// biome-ignore lint/suspicious/noExplicitAny: intentional test cast
|
|
431
464
|
expect(((await billing.getBotBilling("bot-1")) as any)?.billingState).toBe("active");
|
|
432
465
|
|
|
@@ -446,6 +479,7 @@ describe("BotBilling", () => {
|
|
|
446
479
|
|
|
447
480
|
it("active -> suspended -> destroyed (after grace period)", async () => {
|
|
448
481
|
await billing.registerBot("bot-1", "tenant-1", "my-bot");
|
|
482
|
+
await repo.startBilling("bot-1");
|
|
449
483
|
await billing.suspendBot("bot-1");
|
|
450
484
|
|
|
451
485
|
await db.update(botInstances).set({ destroyAfter: sql`now() - interval '1 day'` }).where(sql`id = 'bot-1'`);
|