@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
|
@@ -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 })
|
|
@@ -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);
|
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;
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
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")).
|
|
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),
|
|
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),
|
|
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.
|
|
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",
|
|
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 *
|
|
201
|
-
await ledger.credit("tenant-1",
|
|
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",
|
|
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",
|
|
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
|
|
273
|
-
const
|
|
274
|
-
await ledger.credit("tenant-1",
|
|
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
|
-
|
|
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:
|
|
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")).
|
|
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")).
|
|
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:
|
|
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
|
-
|
|
332
|
-
|
|
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")).
|
|
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",
|
|
344
|
-
description:
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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",
|
|
366
|
-
description:
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
expect((await ledger.balance("tenant-1")).
|
|
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",
|
|
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
|
});
|