@wopr-network/platform-core 1.39.5 → 1.39.7
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/billing/stripe/stripe-payment-processor.test.js +1 -0
- package/dist/credits/credit-expiry-cron.js +11 -17
- package/dist/credits/ledger.d.ts +7 -0
- package/dist/credits/ledger.js +106 -0
- package/dist/credits/ledger.test.js +56 -0
- package/dist/fleet/fleet-manager.d.ts +14 -35
- package/dist/fleet/fleet-manager.js +52 -236
- package/dist/fleet/fleet-manager.test.js +13 -85
- package/dist/fleet/instance.d.ts +58 -3
- package/dist/fleet/instance.js +297 -33
- package/dist/fleet/instance.test.d.ts +1 -0
- package/dist/fleet/instance.test.js +343 -0
- package/dist/monetization/stripe/stripe-payment-processor.test.js +1 -0
- package/dist/node-agent/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/billing/stripe/stripe-payment-processor.test.ts +1 -0
- package/src/credits/README.md +106 -0
- package/src/credits/credit-expiry-cron.ts +12 -18
- package/src/credits/ledger.test.ts +70 -0
- package/src/credits/ledger.ts +140 -1
- package/src/fleet/fleet-manager.test.ts +13 -111
- package/src/fleet/fleet-manager.ts +50 -255
- package/src/fleet/instance.test.ts +390 -0
- package/src/fleet/instance.ts +318 -33
- package/src/monetization/stripe/stripe-payment-processor.test.ts +1 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { logger } from "../config/logger.js";
|
|
2
|
-
import { InsufficientBalanceError } from "./ledger.js";
|
|
3
2
|
/**
|
|
4
3
|
* Sweep expired credit grants and debit the original grant amount
|
|
5
4
|
* (or remaining balance if partially consumed).
|
|
@@ -16,31 +15,26 @@ export async function runCreditExpiryCron(cfg) {
|
|
|
16
15
|
const expiredGrants = await cfg.ledger.expiredCredits(cfg.now);
|
|
17
16
|
for (const grant of expiredGrants) {
|
|
18
17
|
try {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
// Debit the lesser of the original grant amount or current balance
|
|
25
|
-
const debitAmount = balance.lessThan(grant.amount) ? balance : grant.amount;
|
|
26
|
-
await cfg.ledger.debit(grant.tenantId, debitAmount, "credit_expiry", {
|
|
18
|
+
// debitCapped never throws InsufficientBalanceError — it caps at the
|
|
19
|
+
// available balance and returns null when balance is zero. The catch
|
|
20
|
+
// below handles unexpected errors (DB failures, constraint violations).
|
|
21
|
+
const entry = await cfg.ledger.debitCapped(grant.tenantId, grant.amount, "credit_expiry", {
|
|
27
22
|
description: `Expired credit grant reclaimed: ${grant.entryId}`,
|
|
28
23
|
referenceId: `expiry:${grant.entryId}`,
|
|
29
24
|
});
|
|
25
|
+
if (entry === null) {
|
|
26
|
+
result.skippedZeroBalance++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
30
29
|
result.processed++;
|
|
31
30
|
if (!result.expired.includes(grant.tenantId)) {
|
|
32
31
|
result.expired.push(grant.tenantId);
|
|
33
32
|
}
|
|
34
33
|
}
|
|
35
34
|
catch (err) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
41
|
-
logger.error("Credit expiry failed", { tenantId: grant.tenantId, entryId: grant.entryId, error: msg });
|
|
42
|
-
result.errors.push(`${grant.tenantId}:${grant.entryId}: ${msg}`);
|
|
43
|
-
}
|
|
35
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
36
|
+
logger.error("Credit expiry failed", { tenantId: grant.tenantId, entryId: grant.entryId, error: msg });
|
|
37
|
+
result.errors.push(`${grant.tenantId}:${grant.entryId}: ${msg}`);
|
|
44
38
|
}
|
|
45
39
|
}
|
|
46
40
|
if (result.processed > 0) {
|
package/dist/credits/ledger.d.ts
CHANGED
|
@@ -154,6 +154,12 @@ export interface ILedger {
|
|
|
154
154
|
sumPurchasesForPeriod(startTs: string, endTs: string): Promise<Credit>;
|
|
155
155
|
/** Get distinct tenantIds with a purchase entry in [startTs, endTs). */
|
|
156
156
|
getActiveTenantIdsInWindow(startTs: string, endTs: string): Promise<string[]>;
|
|
157
|
+
/**
|
|
158
|
+
* Debit up to maxAmount from a tenant, capped at their current balance.
|
|
159
|
+
* Reads balance inside the transaction (TOCTOU-safe). Returns null if
|
|
160
|
+
* balance is zero (nothing to debit).
|
|
161
|
+
*/
|
|
162
|
+
debitCapped(tenantId: string, maxAmount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry | null>;
|
|
157
163
|
}
|
|
158
164
|
export declare class DrizzleLedger implements ILedger {
|
|
159
165
|
private readonly db;
|
|
@@ -177,6 +183,7 @@ export declare class DrizzleLedger implements ILedger {
|
|
|
177
183
|
post(input: PostEntryInput): Promise<JournalEntry>;
|
|
178
184
|
credit(tenantId: string, amount: Credit, type: CreditType, opts?: CreditOpts): Promise<JournalEntry>;
|
|
179
185
|
debit(tenantId: string, amount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry>;
|
|
186
|
+
debitCapped(tenantId: string, maxAmount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry | null>;
|
|
180
187
|
balance(tenantId: string): Promise<Credit>;
|
|
181
188
|
accountBalance(accountCode: string): Promise<Credit>;
|
|
182
189
|
hasReferenceId(referenceId: string): Promise<boolean>;
|
package/dist/credits/ledger.js
CHANGED
|
@@ -327,6 +327,112 @@ export class DrizzleLedger {
|
|
|
327
327
|
],
|
|
328
328
|
});
|
|
329
329
|
}
|
|
330
|
+
async debitCapped(tenantId, maxAmount, type, opts) {
|
|
331
|
+
const creditAccountCode = DEBIT_TYPE_ACCOUNT[type];
|
|
332
|
+
const tenantAccountCode = `2000:${tenantId}`;
|
|
333
|
+
// Everything happens inside ONE transaction so the FOR UPDATE lock is held
|
|
334
|
+
// continuously from balance read through journal posting (TOCTOU-safe).
|
|
335
|
+
return this.db.transaction(async (tx) => {
|
|
336
|
+
// Step 1: Lock BOTH accounts in accountCode-sorted order to match post()'s
|
|
337
|
+
// lock ordering and prevent ABBA deadlocks. For type="refund" the credit
|
|
338
|
+
// account is "1000" which sorts before "2000:<tenant>", so we must lock it
|
|
339
|
+
// first — the same order post() would use.
|
|
340
|
+
const sortedCodes = [tenantAccountCode, creditAccountCode].sort();
|
|
341
|
+
const lockedIds = new Map();
|
|
342
|
+
for (const code of sortedCodes) {
|
|
343
|
+
if (code.startsWith("2000:")) {
|
|
344
|
+
lockedIds.set(code, await this.ensureTenantAccountLocked(tx, code.slice(5)));
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
lockedIds.set(code, await this.resolveAccountLocked(tx, code));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const tenantAccountId = lockedIds.get(tenantAccountCode);
|
|
351
|
+
const creditAccountId = lockedIds.get(creditAccountCode);
|
|
352
|
+
if (!tenantAccountId || !creditAccountId) {
|
|
353
|
+
throw new Error("Failed to resolve account IDs during debitCapped");
|
|
354
|
+
}
|
|
355
|
+
// Step 2: Read balance while holding the lock.
|
|
356
|
+
const balRows = (await tx.execute(sql `SELECT ab.balance FROM account_balances ab
|
|
357
|
+
INNER JOIN accounts a ON a.id = ab.account_id
|
|
358
|
+
WHERE a.code = ${tenantAccountCode}`));
|
|
359
|
+
const currentBalance = Credit.fromRaw(Number(balRows.rows[0]?.balance ?? 0));
|
|
360
|
+
if (currentBalance.isZero()) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
// Step 3: Cap at lesser of maxAmount and currentBalance.
|
|
364
|
+
const debitAmount = currentBalance.lessThan(maxAmount) ? currentBalance : maxAmount;
|
|
365
|
+
// Step 4: Insert journal entry header.
|
|
366
|
+
const entryId = crypto.randomUUID();
|
|
367
|
+
const now = new Date().toISOString();
|
|
368
|
+
await tx.insert(journalEntries).values({
|
|
369
|
+
id: entryId,
|
|
370
|
+
postedAt: now,
|
|
371
|
+
entryType: type,
|
|
372
|
+
description: opts?.description ?? null,
|
|
373
|
+
referenceId: opts?.referenceId ?? null,
|
|
374
|
+
tenantId,
|
|
375
|
+
metadata: { attributedUserId: opts?.attributedUserId ?? null },
|
|
376
|
+
createdBy: opts?.createdBy ?? "system",
|
|
377
|
+
});
|
|
378
|
+
// Step 5: Both accounts already locked in step 1 — no additional locking needed.
|
|
379
|
+
// Step 6: Insert journal lines.
|
|
380
|
+
// Debit line (tenant liability account — reduces balance)
|
|
381
|
+
const debitLineId = crypto.randomUUID();
|
|
382
|
+
await tx.insert(journalLines).values({
|
|
383
|
+
id: debitLineId,
|
|
384
|
+
journalEntryId: entryId,
|
|
385
|
+
accountId: tenantAccountId,
|
|
386
|
+
amount: debitAmount.toRaw(),
|
|
387
|
+
side: "debit",
|
|
388
|
+
});
|
|
389
|
+
// Credit line (revenue/target account — increases balance)
|
|
390
|
+
const creditLineId = crypto.randomUUID();
|
|
391
|
+
await tx.insert(journalLines).values({
|
|
392
|
+
id: creditLineId,
|
|
393
|
+
journalEntryId: entryId,
|
|
394
|
+
accountId: creditAccountId,
|
|
395
|
+
amount: debitAmount.toRaw(),
|
|
396
|
+
side: "credit",
|
|
397
|
+
});
|
|
398
|
+
// Step 7: Update materialized balances.
|
|
399
|
+
// Tenant account is liability (normal_side=credit): debit decreases balance.
|
|
400
|
+
await tx
|
|
401
|
+
.update(accountBalances)
|
|
402
|
+
.set({
|
|
403
|
+
balance: sql `${accountBalances.balance} + ${-debitAmount.toRaw()}`,
|
|
404
|
+
lastUpdated: sql `(now())`,
|
|
405
|
+
})
|
|
406
|
+
.where(eq(accountBalances.accountId, tenantAccountId));
|
|
407
|
+
// Credit-side account: look up its normal_side to determine delta direction.
|
|
408
|
+
const acctRow = (await tx.execute(sql `SELECT normal_side FROM accounts WHERE id = ${creditAccountId}`));
|
|
409
|
+
const normalSide = acctRow.rows[0]?.normal_side;
|
|
410
|
+
if (!normalSide)
|
|
411
|
+
throw new Error(`Account ${creditAccountId} missing normal_side`);
|
|
412
|
+
const creditDelta = "credit" === normalSide ? debitAmount.toRaw() : -debitAmount.toRaw();
|
|
413
|
+
await tx
|
|
414
|
+
.update(accountBalances)
|
|
415
|
+
.set({
|
|
416
|
+
balance: sql `${accountBalances.balance} + ${creditDelta}`,
|
|
417
|
+
lastUpdated: sql `(now())`,
|
|
418
|
+
})
|
|
419
|
+
.where(eq(accountBalances.accountId, creditAccountId));
|
|
420
|
+
// Step 8: Return the journal entry.
|
|
421
|
+
return {
|
|
422
|
+
id: entryId,
|
|
423
|
+
postedAt: now,
|
|
424
|
+
entryType: type,
|
|
425
|
+
tenantId,
|
|
426
|
+
description: opts?.description ?? null,
|
|
427
|
+
referenceId: opts?.referenceId ?? null,
|
|
428
|
+
metadata: { attributedUserId: opts?.attributedUserId ?? null },
|
|
429
|
+
lines: [
|
|
430
|
+
{ accountCode: tenantAccountCode, amount: debitAmount, side: "debit" },
|
|
431
|
+
{ accountCode: creditAccountCode, amount: debitAmount, side: "credit" },
|
|
432
|
+
],
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
}
|
|
330
436
|
// -- Queries -------------------------------------------------------------
|
|
331
437
|
async balance(tenantId) {
|
|
332
438
|
const code = `2000:${tenantId}`;
|
|
@@ -578,4 +578,60 @@ describe("DrizzleLedger", () => {
|
|
|
578
578
|
expect(tb.balanced).toBe(true);
|
|
579
579
|
});
|
|
580
580
|
});
|
|
581
|
+
// -----------------------------------------------------------------------
|
|
582
|
+
// debitCapped()
|
|
583
|
+
// -----------------------------------------------------------------------
|
|
584
|
+
describe("debitCapped()", () => {
|
|
585
|
+
it("caps debit at current balance when maxAmount exceeds balance", async () => {
|
|
586
|
+
await ledger.credit("tenant-cap", Credit.fromCents(300), "promo", {
|
|
587
|
+
description: "Test grant",
|
|
588
|
+
});
|
|
589
|
+
const entry = await ledger.debitCapped("tenant-cap", Credit.fromCents(500), // maxAmount > balance
|
|
590
|
+
"credit_expiry", { description: "Expiry test", referenceId: "expiry:test-cap" });
|
|
591
|
+
if (entry === null)
|
|
592
|
+
throw new Error("expected non-null entry");
|
|
593
|
+
// Should have debited 300 (the balance), not 500 (the maxAmount)
|
|
594
|
+
const debitLine = entry.lines.find((l) => l.accountCode === "2000:tenant-cap" && l.side === "debit");
|
|
595
|
+
if (!debitLine)
|
|
596
|
+
throw new Error("expected debit line");
|
|
597
|
+
expect(debitLine.amount.toCents()).toBe(300);
|
|
598
|
+
const balance = await ledger.balance("tenant-cap");
|
|
599
|
+
expect(balance.toCents()).toBe(0);
|
|
600
|
+
});
|
|
601
|
+
it("returns null when balance is zero", async () => {
|
|
602
|
+
const entry = await ledger.debitCapped("tenant-zero", Credit.fromCents(500), "credit_expiry", {
|
|
603
|
+
description: "Expiry test",
|
|
604
|
+
referenceId: "expiry:test-zero",
|
|
605
|
+
});
|
|
606
|
+
expect(entry).toBeNull();
|
|
607
|
+
});
|
|
608
|
+
it("debits exact maxAmount when balance exceeds it", async () => {
|
|
609
|
+
await ledger.credit("tenant-over", Credit.fromCents(1000), "purchase", {
|
|
610
|
+
description: "Big deposit",
|
|
611
|
+
});
|
|
612
|
+
const entry = await ledger.debitCapped("tenant-over", Credit.fromCents(300), "credit_expiry", {
|
|
613
|
+
description: "Partial expiry",
|
|
614
|
+
referenceId: "expiry:test-over",
|
|
615
|
+
});
|
|
616
|
+
if (entry === null)
|
|
617
|
+
throw new Error("expected non-null entry");
|
|
618
|
+
const debitLine = entry.lines.find((l) => l.accountCode === "2000:tenant-over" && l.side === "debit");
|
|
619
|
+
if (!debitLine)
|
|
620
|
+
throw new Error("expected debit line");
|
|
621
|
+
expect(debitLine.amount.toCents()).toBe(300);
|
|
622
|
+
const balance = await ledger.balance("tenant-over");
|
|
623
|
+
expect(balance.toCents()).toBe(700);
|
|
624
|
+
});
|
|
625
|
+
it("books balance correctly after capped debit (trial balance holds)", async () => {
|
|
626
|
+
await ledger.credit("tenant-tb", Credit.fromCents(200), "promo", {
|
|
627
|
+
description: "Small grant",
|
|
628
|
+
});
|
|
629
|
+
await ledger.debitCapped("tenant-tb", Credit.fromCents(500), "credit_expiry", {
|
|
630
|
+
description: "Expiry",
|
|
631
|
+
referenceId: "expiry:test-tb",
|
|
632
|
+
});
|
|
633
|
+
const tb = await ledger.trialBalance();
|
|
634
|
+
expect(tb.balanced).toBe(true);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
581
637
|
});
|
|
@@ -53,20 +53,17 @@ export declare class FleetManager {
|
|
|
53
53
|
*/
|
|
54
54
|
getInstance(id: string): Promise<Instance>;
|
|
55
55
|
private buildInstance;
|
|
56
|
-
/**
|
|
57
|
-
* Restart: pull new image BEFORE restarting container to avoid downtime on pull failure.
|
|
58
|
-
* Valid from: running, stopped, exited, dead states.
|
|
59
|
-
* Throws InvalidStateTransitionError if the container is in an invalid state (e.g. paused).
|
|
60
|
-
* For remote bots, delegates to the node agent via NodeCommandBus.
|
|
61
|
-
*/
|
|
62
|
-
restart(id: string): Promise<void>;
|
|
63
56
|
/**
|
|
64
57
|
* Remove a bot: stop container, remove it, optionally remove volumes, delete profile.
|
|
65
58
|
* For remote bots, delegates stop+remove to the node agent via NodeCommandBus.
|
|
59
|
+
* Container removal is delegated to Instance.remove(); fleet-level cleanup
|
|
60
|
+
* (profile store, network policy) stays here.
|
|
66
61
|
*/
|
|
67
62
|
remove(id: string, removeVolumes?: boolean): Promise<void>;
|
|
68
63
|
/**
|
|
69
64
|
* Get live status of a single bot.
|
|
65
|
+
* Delegates to Instance.status() which returns a full BotStatus.
|
|
66
|
+
* Falls back to offline status when no container exists.
|
|
70
67
|
*/
|
|
71
68
|
status(id: string): Promise<BotStatus>;
|
|
72
69
|
/**
|
|
@@ -78,58 +75,40 @@ export declare class FleetManager {
|
|
|
78
75
|
*/
|
|
79
76
|
listByTenant(tenantId: string): Promise<BotStatus[]>;
|
|
80
77
|
/**
|
|
81
|
-
* Get container logs.
|
|
78
|
+
* Get container logs. Delegates to Instance.logs().
|
|
82
79
|
*/
|
|
83
80
|
logs(id: string, tail?: number): Promise<string>;
|
|
84
81
|
/**
|
|
85
82
|
* Stream container logs in real-time (follow mode).
|
|
86
|
-
* Returns a Node.js ReadableStream that emits plain-text log chunks (already demultiplexed).
|
|
87
83
|
* For remote bots, proxies via node-agent bot.logs command and returns a one-shot stream.
|
|
88
|
-
*
|
|
84
|
+
* For local bots, delegates to Instance.logStream().
|
|
89
85
|
*/
|
|
90
86
|
logStream(id: string, opts: {
|
|
91
87
|
since?: string;
|
|
92
88
|
tail?: number;
|
|
93
89
|
}): Promise<NodeJS.ReadableStream>;
|
|
94
|
-
/** Fields that require container recreation when changed. */
|
|
95
|
-
private static readonly CONTAINER_FIELDS;
|
|
96
|
-
/**
|
|
97
|
-
* Update a bot profile. Only recreates the container if container-relevant
|
|
98
|
-
* fields changed. Rolls back the profile if container recreation fails.
|
|
99
|
-
*/
|
|
100
|
-
update(id: string, updates: Partial<Omit<BotProfile, "id">>): Promise<BotProfile>;
|
|
101
90
|
/**
|
|
102
|
-
* Get disk usage for a bot's /data volume.
|
|
103
|
-
* Returns null if the container is not running or exec fails.
|
|
91
|
+
* Get disk usage for a bot's /data volume. Delegates to Instance.getVolumeUsage().
|
|
104
92
|
*/
|
|
105
93
|
getVolumeUsage(id: string): Promise<{
|
|
106
94
|
usedBytes: number;
|
|
107
95
|
totalBytes: number;
|
|
108
96
|
availableBytes: number;
|
|
109
97
|
} | null>;
|
|
110
|
-
/**
|
|
111
|
-
|
|
98
|
+
/** Fields that require container recreation when changed. */
|
|
99
|
+
private static readonly CONTAINER_FIELDS;
|
|
112
100
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* Throws InvalidStateTransitionError when the state is not in validStates.
|
|
101
|
+
* Update a bot profile. Only recreates the container if container-relevant
|
|
102
|
+
* fields changed. Rolls back the profile if container recreation fails.
|
|
116
103
|
*/
|
|
117
|
-
|
|
104
|
+
update(id: string, updates: Partial<Omit<BotProfile, "id">>): Promise<BotProfile>;
|
|
105
|
+
/** Get the underlying profile store */
|
|
106
|
+
get profiles(): IProfileStore;
|
|
118
107
|
private pullImage;
|
|
119
108
|
private createContainer;
|
|
120
109
|
private findContainer;
|
|
121
110
|
private statusForProfile;
|
|
122
|
-
private buildStatus;
|
|
123
|
-
private offlineStatus;
|
|
124
|
-
private getStats;
|
|
125
111
|
}
|
|
126
112
|
export declare class BotNotFoundError extends Error {
|
|
127
113
|
constructor(id: string);
|
|
128
114
|
}
|
|
129
|
-
export declare class InvalidStateTransitionError extends Error {
|
|
130
|
-
readonly botId: string;
|
|
131
|
-
readonly operation: string;
|
|
132
|
-
readonly currentState: string;
|
|
133
|
-
readonly validStates: string[];
|
|
134
|
-
constructor(botId: string, operation: string, currentState: string, validStates: string[]);
|
|
135
|
-
}
|