@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.
@@ -62,6 +62,7 @@ function createMocks() {
62
62
  existsByReferenceIdLike: vi.fn(),
63
63
  sumPurchasesForPeriod: vi.fn(),
64
64
  getActiveTenantIdsInWindow: vi.fn(),
65
+ debitCapped: vi.fn(),
65
66
  };
66
67
  const autoTopupEventLog = {
67
68
  writeEvent: vi.fn(),
@@ -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
- const balance = await cfg.ledger.balance(grant.tenantId);
20
- if (balance.isZero()) {
21
- result.skippedZeroBalance++;
22
- continue;
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
- if (err instanceof InsufficientBalanceError) {
37
- result.skippedZeroBalance++;
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) {
@@ -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>;
@@ -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
- * Caller is responsible for destroying the stream when done.
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
- /** Get the underlying profile store */
111
- get profiles(): IProfileStore;
98
+ /** Fields that require container recreation when changed. */
99
+ private static readonly CONTAINER_FIELDS;
112
100
  /**
113
- * Assert that a container's current state is valid for the requested operation.
114
- * Guards against undefined/null Status values from Docker (uses "unknown" as fallback).
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
- private assertValidState;
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
- }