@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.
- package/dist/fleet/drizzle-bot-instance-repository.d.ts +3 -1
- package/dist/fleet/drizzle-bot-instance-repository.js +8 -2
- 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/gateway/proxy.js +10 -2
- package/dist/gateway/types.d.ts +3 -1
- package/dist/index.js +4 -0
- 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/__tests__/container.test.js +5 -1
- package/dist/server/container.d.ts +2 -0
- package/dist/server/container.js +6 -1
- package/dist/server/index.js +1 -1
- package/dist/server/mount-routes.d.ts +1 -1
- package/dist/server/mount-routes.js +35 -2
- package/dist/server/routes/__tests__/admin.test.js +3 -3
- package/package.json +1 -1
- package/src/fleet/drizzle-bot-instance-repository.ts +15 -2
- package/src/fleet/instance.ts +21 -0
- package/src/fleet/repository-types.ts +1 -1
- package/src/gateway/proxy.ts +9 -2
- package/src/gateway/types.ts +3 -1
- package/src/index.ts +4 -0
- 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/__tests__/container.test.ts +5 -1
- package/src/server/container.ts +9 -1
- package/src/server/index.ts +1 -1
- package/src/server/mount-routes.ts +41 -3
- package/src/server/routes/__tests__/admin.test.ts +3 -3
|
@@ -24,7 +24,9 @@ export declare class DrizzleBotInstanceRepository implements IBotInstanceReposit
|
|
|
24
24
|
suspend(botId: string, graceDays: number): Promise<void>;
|
|
25
25
|
reactivate(botId: string): Promise<void>;
|
|
26
26
|
markDestroyed(botId: string): Promise<void>;
|
|
27
|
-
register(botId: string, tenantId: string, name: string): Promise<void>;
|
|
27
|
+
register(botId: string, tenantId: string, name: string, billingState?: BillingState): Promise<void>;
|
|
28
|
+
startBilling(botId: string): Promise<void>;
|
|
29
|
+
stopBilling(botId: string): Promise<void>;
|
|
28
30
|
getStorageTier(botId: string): Promise<string | null>;
|
|
29
31
|
setStorageTier(botId: string, tier: string): Promise<void>;
|
|
30
32
|
listActiveStorageTiers(tenantId: string): Promise<string[]>;
|
|
@@ -194,14 +194,20 @@ export class DrizzleBotInstanceRepository {
|
|
|
194
194
|
})
|
|
195
195
|
.where(eq(botInstances.id, botId));
|
|
196
196
|
}
|
|
197
|
-
async register(botId, tenantId, name) {
|
|
197
|
+
async register(botId, tenantId, name, billingState = "inactive") {
|
|
198
198
|
await this.db.insert(botInstances).values({
|
|
199
199
|
id: botId,
|
|
200
200
|
tenantId,
|
|
201
201
|
name,
|
|
202
|
-
billingState
|
|
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 })
|
package/dist/fleet/instance.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/fleet/instance.js
CHANGED
|
@@ -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;
|
package/dist/gateway/proxy.js
CHANGED
|
@@ -19,7 +19,11 @@ import { creditBalanceCheck, debitCredits } from "./credit-gate.js";
|
|
|
19
19
|
import { mapBudgetError, mapProviderError } from "./error-mapping.js";
|
|
20
20
|
import { resolveTokenRates } from "./rate-lookup.js";
|
|
21
21
|
import { proxySSEStream } from "./streaming.js";
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Fallback only used when resolveMargin is not provided (tests only).
|
|
24
|
+
* Production MUST provide resolveMargin — mountRoutes enforces this.
|
|
25
|
+
*/
|
|
26
|
+
const TEST_ONLY_MARGIN = 1.3;
|
|
23
27
|
/** Max call duration cap: 4 hours = 240 minutes. */
|
|
24
28
|
const MAX_CALL_DURATION_MINUTES = 240;
|
|
25
29
|
const phoneInboundBodySchema = z.object({
|
|
@@ -54,7 +58,11 @@ export function buildProxyDeps(config) {
|
|
|
54
58
|
providers: config.providers,
|
|
55
59
|
defaultModel: config.defaultModel,
|
|
56
60
|
resolveDefaultModel: config.resolveDefaultModel,
|
|
57
|
-
|
|
61
|
+
get defaultMargin() {
|
|
62
|
+
if (config.resolveMargin)
|
|
63
|
+
return config.resolveMargin();
|
|
64
|
+
return config.defaultMargin ?? TEST_ONLY_MARGIN;
|
|
65
|
+
},
|
|
58
66
|
fetchFn: config.fetchFn ?? fetch,
|
|
59
67
|
arbitrageRouter: config.arbitrageRouter,
|
|
60
68
|
rateLookupFn: config.rateLookupFn,
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -119,8 +119,10 @@ export interface GatewayConfig {
|
|
|
119
119
|
graceBufferCents?: number;
|
|
120
120
|
/** Upstream provider credentials */
|
|
121
121
|
providers: ProviderConfig;
|
|
122
|
-
/**
|
|
122
|
+
/** Static margin (for tests only). Production should use resolveMargin. */
|
|
123
123
|
defaultMargin?: number;
|
|
124
|
+
/** Live margin resolver — called per-request, reads from DB. Takes priority over defaultMargin. */
|
|
125
|
+
resolveMargin?: () => number;
|
|
124
126
|
/** Optional arbitrage router for multi-provider cost optimization (WOP-463) */
|
|
125
127
|
arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
|
|
126
128
|
/** Injectable fetch for testing */
|
package/dist/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 $${
|
|
91
|
+
description: `Daily runtime: ${botCount} bot(s) x $${dailyCost.toDollars().toFixed(4)}`,
|
|
77
92
|
referenceId: runtimeRef,
|
|
78
93
|
});
|
|
79
94
|
}
|