@wopr-network/platform-core 1.39.3 → 1.39.5

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.
@@ -81,6 +81,36 @@ describe("runCreditExpiryCron", () => {
81
81
  const balanceAfterSecond = await ledger.balance("tenant-1");
82
82
  expect(balanceAfterSecond.toCents()).toBe(balanceAfterFirst.toCents());
83
83
  });
84
+ it("skips expiry when balance has been fully consumed before cron runs", async () => {
85
+ // Simulate: grant expires but tenant spent everything before cron ran
86
+ await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
87
+ description: "Promo",
88
+ referenceId: "promo:fully-consumed",
89
+ expiresAt: "2026-01-10T00:00:00Z",
90
+ });
91
+ // Tenant spends entire balance before expiry cron runs
92
+ await ledger.debit("tenant-1", Credit.fromCents(500), "bot_runtime", { description: "Full spend" });
93
+ const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
94
+ // Zero balance — nothing to expire
95
+ expect(result.processed).toBe(0);
96
+ const balance = await ledger.balance("tenant-1");
97
+ expect(balance.toCents()).toBe(0);
98
+ });
99
+ it("only expires remaining balance when usage reduced it between grant and expiry", async () => {
100
+ // Grant $5, spend $3 before cron, cron should only expire remaining $2
101
+ await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
102
+ description: "Promo",
103
+ referenceId: "promo:partial-concurrent",
104
+ expiresAt: "2026-01-10T00:00:00Z",
105
+ });
106
+ await ledger.debit("tenant-1", Credit.fromCents(300), "bot_runtime", { description: "Partial spend" });
107
+ const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
108
+ expect(result.processed).toBe(1);
109
+ expect(result.expired).toContain("tenant-1");
110
+ // $5 granted - $3 used - $2 expired = $0
111
+ const balance = await ledger.balance("tenant-1");
112
+ expect(balance.toCents()).toBe(0);
113
+ });
84
114
  it("does not return unknown entry type even with expiresAt metadata", async () => {
85
115
  // Simulate a hypothetical new entry type that has expiresAt in metadata.
86
116
  // With the old denylist approach, this would be incorrectly returned.
@@ -169,19 +169,25 @@ export class DrizzleLedger {
169
169
  throw new Error("Journal entry must have at least 2 lines");
170
170
  }
171
171
  // Verify balance before hitting DB
172
- let totalDebit = 0;
173
- let totalCredit = 0;
172
+ let totalDebit = 0n;
173
+ let totalCredit = 0n;
174
174
  for (const line of input.lines) {
175
175
  if (line.amount.isZero() || line.amount.isNegative()) {
176
176
  throw new Error("Journal line amounts must be positive");
177
177
  }
178
178
  if (line.side === "debit")
179
- totalDebit += line.amount.toRaw();
179
+ totalDebit += BigInt(line.amount.toRaw());
180
180
  else
181
- totalCredit += line.amount.toRaw();
181
+ totalCredit += BigInt(line.amount.toRaw());
182
182
  }
183
183
  if (totalDebit !== totalCredit) {
184
- throw new Error(`Unbalanced entry: debits=${Credit.fromRaw(totalDebit).toDisplayString()}, credits=${Credit.fromRaw(totalCredit).toDisplayString()}`);
184
+ const fmtDebit = totalDebit <= BigInt(Number.MAX_SAFE_INTEGER)
185
+ ? Credit.fromRaw(Number(totalDebit)).toDisplayString()
186
+ : `${totalDebit} raw`;
187
+ const fmtCredit = totalCredit <= BigInt(Number.MAX_SAFE_INTEGER)
188
+ ? Credit.fromRaw(Number(totalCredit)).toDisplayString()
189
+ : `${totalCredit} raw`;
190
+ throw new Error(`Unbalanced entry: debits=${fmtDebit}, credits=${fmtCredit}`);
185
191
  }
186
192
  return this.db.transaction(async (tx) => {
187
193
  const entryId = crypto.randomUUID();
@@ -38,6 +38,21 @@ describe("DrizzleLedger", () => {
38
38
  ],
39
39
  })).rejects.toThrow("Unbalanced");
40
40
  });
41
+ it("throws Unbalanced entry (not RangeError) when BigInt totals exceed MAX_SAFE_INTEGER", async () => {
42
+ // Two debit lines each at MAX_SAFE_INTEGER raw — their BigInt sum exceeds MAX_SAFE_INTEGER.
43
+ // Before the fix, Credit.fromRaw(Number(totalDebit)) would throw RangeError, masking the real error.
44
+ const big = Credit.fromRaw(Number.MAX_SAFE_INTEGER);
45
+ const small = Credit.fromRaw(1);
46
+ await expect(ledger.post({
47
+ entryType: "purchase",
48
+ tenantId: "t1",
49
+ lines: [
50
+ { accountCode: "1000", amount: big, side: "debit" },
51
+ { accountCode: "1000", amount: big, side: "debit" },
52
+ { accountCode: "2000:t1", amount: small, side: "credit" },
53
+ ],
54
+ })).rejects.toThrow("Unbalanced entry");
55
+ });
41
56
  it("rejects zero-amount lines", async () => {
42
57
  await expect(ledger.post({
43
58
  entryType: "purchase",
@@ -209,6 +224,22 @@ describe("DrizzleLedger", () => {
209
224
  it("rejects zero amount", async () => {
210
225
  await expect(ledger.debit("t1", Credit.ZERO, "bot_runtime")).rejects.toThrow("must be positive");
211
226
  });
227
+ it("concurrent debits do not overdraft", async () => {
228
+ // Balance is $10.00 from beforeEach credit.
229
+ // Two concurrent $8 debits — only one should succeed.
230
+ const results = await Promise.allSettled([
231
+ ledger.debit("t1", Credit.fromCents(800), "bot_runtime"),
232
+ ledger.debit("t1", Credit.fromCents(800), "bot_runtime"),
233
+ ]);
234
+ const successes = results.filter((r) => r.status === "fulfilled");
235
+ const failures = results.filter((r) => r.status === "rejected");
236
+ expect(successes).toHaveLength(1);
237
+ expect(failures).toHaveLength(1);
238
+ expect(failures[0].reason).toBeInstanceOf(InsufficientBalanceError);
239
+ // Balance should be $2.00, not -$6.00
240
+ const bal = await ledger.balance("t1");
241
+ expect(bal.toCentsRounded()).toBe(200);
242
+ });
212
243
  });
213
244
  // -----------------------------------------------------------------------
214
245
  // balance()
@@ -268,6 +299,20 @@ describe("DrizzleLedger", () => {
268
299
  expect(tb.balanced).toBe(true);
269
300
  expect(tb.totalDebits.equals(tb.totalCredits)).toBe(true);
270
301
  });
302
+ it("detects imbalance from direct DB corruption", async () => {
303
+ await ledger.credit("t1", Credit.fromCents(100), "purchase");
304
+ // Corrupt the ledger: insert an unmatched debit line directly into journal_lines.
305
+ const entryRows = await pool.query("SELECT id FROM journal_entries LIMIT 1");
306
+ const accountRows = await pool.query("SELECT id FROM accounts WHERE code = '1000' LIMIT 1");
307
+ const entryId = entryRows.rows[0].id;
308
+ const accountId = accountRows.rows[0].id;
309
+ // Insert an unmatched debit line worth 999 raw units — no corresponding credit
310
+ await pool.query(`INSERT INTO journal_lines (id, journal_entry_id, account_id, amount, side)
311
+ VALUES ('corrupt-line-1', $1, $2, 999, 'debit')`, [entryId, accountId]);
312
+ const tb = await ledger.trialBalance();
313
+ expect(tb.balanced).toBe(false);
314
+ expect(tb.difference.toRaw()).toBe(999);
315
+ });
271
316
  });
272
317
  // -----------------------------------------------------------------------
273
318
  // history()
@@ -491,4 +536,46 @@ describe("DrizzleLedger", () => {
491
536
  expect(tb.balanced).toBe(true);
492
537
  });
493
538
  });
539
+ // -----------------------------------------------------------------------
540
+ // deadlock prevention — concurrent multi-line entries
541
+ // -----------------------------------------------------------------------
542
+ describe("deadlock prevention", () => {
543
+ it("concurrent multi-line entries with overlapping accounts succeed", async () => {
544
+ // Fund two tenants
545
+ await ledger.credit("t1", Credit.fromCents(1000), "purchase");
546
+ await ledger.credit("t2", Credit.fromCents(1000), "purchase");
547
+ // Two entries that touch accounts in potentially reverse order.
548
+ // Both touch account 4000 (revenue), creating a potential lock conflict.
549
+ const results = await Promise.allSettled([
550
+ ledger.debit("t1", Credit.fromCents(50), "bot_runtime"),
551
+ ledger.debit("t2", Credit.fromCents(30), "bot_runtime"),
552
+ ]);
553
+ // Both should succeed — lock ordering prevents deadlock
554
+ expect(results.every((r) => r.status === "fulfilled")).toBe(true);
555
+ // Verify balances are correct
556
+ expect((await ledger.balance("t1")).toCentsRounded()).toBe(950);
557
+ expect((await ledger.balance("t2")).toCentsRounded()).toBe(970);
558
+ // Revenue account should reflect both debits
559
+ expect((await ledger.accountBalance("4000")).toCentsRounded()).toBe(80);
560
+ // Trial balance must still be balanced
561
+ const tb = await ledger.trialBalance();
562
+ expect(tb.balanced).toBe(true);
563
+ });
564
+ it("concurrent multi-line entries on same tenant serialize correctly", async () => {
565
+ await ledger.credit("t1", Credit.fromCents(1000), "purchase");
566
+ // Two debits on the same tenant, touching overlapping accounts.
567
+ const results = await Promise.allSettled([
568
+ ledger.debit("t1", Credit.fromCents(100), "bot_runtime"),
569
+ ledger.debit("t1", Credit.fromCents(200), "adapter_usage"),
570
+ ]);
571
+ expect(results.every((r) => r.status === "fulfilled")).toBe(true);
572
+ // Balance: $10 - $1 - $2 = $7
573
+ expect((await ledger.balance("t1")).toCentsRounded()).toBe(700);
574
+ // Revenue accounts
575
+ expect((await ledger.accountBalance("4000")).toCentsRounded()).toBe(100); // bot_runtime
576
+ expect((await ledger.accountBalance("4010")).toCentsRounded()).toBe(200); // adapter_usage
577
+ const tb = await ledger.trialBalance();
578
+ expect(tb.balanced).toBe(true);
579
+ });
580
+ });
494
581
  });
@@ -55,6 +55,9 @@ export const journalLines = pgTable("journal_lines", {
55
55
  accountId: text("account_id")
56
56
  .notNull()
57
57
  .references(() => accounts.id),
58
+ // mode: "number" truncates values > Number.MAX_SAFE_INTEGER (≈$9.007M in nanodollars).
59
+ // Credit.fromRaw() guards against this with a RangeError.
60
+ // If balances approach $9M, migrate to mode: "bigint" (returns string from Drizzle).
58
61
  amount: bigint("amount", { mode: "number" }).notNull(), // nanodollars, always positive
59
62
  side: entrySideEnum("side").notNull(),
60
63
  }, (table) => [
@@ -71,6 +74,9 @@ export const accountBalances = pgTable("account_balances", {
71
74
  accountId: text("account_id")
72
75
  .primaryKey()
73
76
  .references(() => accounts.id),
77
+ // mode: "number" truncates values > Number.MAX_SAFE_INTEGER (≈$9.007M in nanodollars).
78
+ // Credit.fromRaw() guards against this with a RangeError.
79
+ // If balances approach $9M, migrate to mode: "bigint" (returns string from Drizzle).
74
80
  balance: bigint("balance", { mode: "number" }).notNull().default(0), // net balance in nanodollars
75
81
  lastUpdated: text("last_updated").notNull().default(sql `(now())`),
76
82
  });
@@ -59,69 +59,95 @@ export async function runRuntimeDeductions(cfg) {
59
59
  for (const { tenantId, balance } of tenants) {
60
60
  try {
61
61
  const runtimeRef = `runtime:${cfg.date}:${tenantId}`;
62
- if (await cfg.ledger.hasReferenceId(runtimeRef)) {
63
- result.skipped.push(tenantId);
64
- continue;
65
- }
62
+ const runtimeAlreadyBilled = await cfg.ledger.hasReferenceId(runtimeRef);
66
63
  const botCount = await cfg.getActiveBotCount(tenantId);
67
- if (botCount <= 0)
64
+ if (botCount <= 0) {
65
+ if (runtimeAlreadyBilled)
66
+ result.skipped.push(tenantId);
68
67
  continue;
68
+ }
69
69
  const totalCost = DAILY_BOT_COST.multiply(botCount);
70
- if (!balance.lessThan(totalCost)) {
71
- // Full deduction
72
- await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
73
- description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
74
- referenceId: runtimeRef,
75
- });
76
- // Debit resource tier surcharges (if any)
77
- if (cfg.getResourceTierCosts) {
70
+ let didBillAnything = false;
71
+ // Bill runtime debit (skipped if already billed on a previous run)
72
+ if (!runtimeAlreadyBilled) {
73
+ if (!balance.lessThan(totalCost)) {
74
+ // Full deduction
75
+ await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
76
+ description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
77
+ referenceId: runtimeRef,
78
+ });
79
+ }
80
+ else {
81
+ // Partial deduction — balance insufficient to cover full cost; debit what's available and suspend
82
+ if (balance.greaterThan(Credit.ZERO)) {
83
+ await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
84
+ description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
85
+ referenceId: runtimeRef,
86
+ });
87
+ }
88
+ if (!result.suspended.includes(tenantId)) {
89
+ result.suspended.push(tenantId);
90
+ if (cfg.onSuspend)
91
+ await cfg.onSuspend(tenantId);
92
+ }
93
+ }
94
+ didBillAnything = true;
95
+ }
96
+ // Debit resource tier surcharges (if any) — independent idempotency
97
+ if (cfg.getResourceTierCosts) {
98
+ const tierRef = `runtime-tier:${cfg.date}:${tenantId}`;
99
+ if (!(await cfg.ledger.hasReferenceId(tierRef))) {
78
100
  const tierCost = await cfg.getResourceTierCosts(tenantId);
79
101
  if (!tierCost.isZero()) {
80
102
  const balanceAfterRuntime = await cfg.ledger.balance(tenantId);
81
103
  if (!balanceAfterRuntime.lessThan(tierCost)) {
82
104
  await cfg.ledger.debit(tenantId, tierCost, "resource_upgrade", {
83
105
  description: "Daily resource tier surcharge",
84
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
106
+ referenceId: tierRef,
85
107
  });
86
108
  }
87
109
  else if (balanceAfterRuntime.greaterThan(Credit.ZERO)) {
88
110
  await cfg.ledger.debit(tenantId, balanceAfterRuntime, "resource_upgrade", {
89
111
  description: "Partial resource tier surcharge (balance exhausted)",
90
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
112
+ referenceId: tierRef,
91
113
  });
92
114
  }
115
+ didBillAnything = true;
93
116
  }
94
117
  }
95
- const newBalance = await cfg.ledger.balance(tenantId);
96
- // Fire onLowBalance if balance crossed below threshold from above
97
- if (newBalance.greaterThan(Credit.ZERO) &&
98
- !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
99
- balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
100
- cfg.onLowBalance) {
101
- await cfg.onLowBalance(tenantId, newBalance);
102
- }
103
- // Fire onCreditsExhausted if balance just hit 0
104
- if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
105
- await cfg.onCreditsExhausted(tenantId);
106
- }
107
- // Suspend tenant when balance hits zero after full deduction (zero-crossing guard)
108
- if (!newBalance.greaterThan(Credit.ZERO) &&
109
- balance.greaterThan(Credit.ZERO) &&
110
- !result.suspended.includes(tenantId)) {
111
- result.suspended.push(tenantId);
112
- if (cfg.onSuspend) {
113
- await cfg.onSuspend(tenantId);
114
- }
118
+ }
119
+ const newBalance = await cfg.ledger.balance(tenantId);
120
+ // Fire onLowBalance if balance crossed below threshold from above
121
+ if (newBalance.greaterThan(Credit.ZERO) &&
122
+ !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
123
+ balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
124
+ cfg.onLowBalance) {
125
+ await cfg.onLowBalance(tenantId, newBalance);
126
+ }
127
+ // Fire onCreditsExhausted if balance just hit 0
128
+ if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
129
+ await cfg.onCreditsExhausted(tenantId);
130
+ }
131
+ // Suspend tenant when balance hits zero (zero-crossing guard)
132
+ if (!newBalance.greaterThan(Credit.ZERO) &&
133
+ balance.greaterThan(Credit.ZERO) &&
134
+ !result.suspended.includes(tenantId)) {
135
+ result.suspended.push(tenantId);
136
+ if (cfg.onSuspend) {
137
+ await cfg.onSuspend(tenantId);
115
138
  }
116
- // Debit storage tier surcharges (if any)
117
- if (cfg.getStorageTierCosts) {
139
+ }
140
+ // Debit storage tier surcharges (if any) — independent idempotency
141
+ if (cfg.getStorageTierCosts) {
142
+ const storageRef = `runtime-storage:${cfg.date}:${tenantId}`;
143
+ if (!(await cfg.ledger.hasReferenceId(storageRef))) {
118
144
  const storageCost = await cfg.getStorageTierCosts(tenantId);
119
145
  if (!storageCost.isZero()) {
120
146
  const currentBalance = await cfg.ledger.balance(tenantId);
121
147
  if (!currentBalance.lessThan(storageCost)) {
122
148
  await cfg.ledger.debit(tenantId, storageCost, "storage_upgrade", {
123
149
  description: "Daily storage tier surcharge",
124
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
150
+ referenceId: storageRef,
125
151
  });
126
152
  }
127
153
  else {
@@ -129,7 +155,7 @@ export async function runRuntimeDeductions(cfg) {
129
155
  if (currentBalance.greaterThan(Credit.ZERO)) {
130
156
  await cfg.ledger.debit(tenantId, currentBalance, "storage_upgrade", {
131
157
  description: "Partial storage tier surcharge (balance exhausted)",
132
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
158
+ referenceId: storageRef,
133
159
  });
134
160
  }
135
161
  if (!result.suspended.includes(tenantId)) {
@@ -138,17 +164,21 @@ export async function runRuntimeDeductions(cfg) {
138
164
  await cfg.onSuspend(tenantId);
139
165
  }
140
166
  }
167
+ didBillAnything = true;
141
168
  }
142
169
  }
143
- // Debit infrastructure add-on costs (if any)
144
- if (cfg.getAddonCosts) {
170
+ }
171
+ // Debit infrastructure add-on costs (if any) — independent idempotency
172
+ if (cfg.getAddonCosts) {
173
+ const addonRef = `runtime-addon:${cfg.date}:${tenantId}`;
174
+ if (!(await cfg.ledger.hasReferenceId(addonRef))) {
145
175
  const addonCost = await cfg.getAddonCosts(tenantId);
146
176
  if (!addonCost.isZero()) {
147
177
  const currentBalance = await cfg.ledger.balance(tenantId);
148
178
  if (!currentBalance.lessThan(addonCost)) {
149
179
  await cfg.ledger.debit(tenantId, addonCost, "addon", {
150
180
  description: "Daily infrastructure add-on charges",
151
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
181
+ referenceId: addonRef,
152
182
  });
153
183
  }
154
184
  else {
@@ -156,7 +186,7 @@ export async function runRuntimeDeductions(cfg) {
156
186
  if (currentBalance.greaterThan(Credit.ZERO)) {
157
187
  await cfg.ledger.debit(tenantId, currentBalance, "addon", {
158
188
  description: "Partial add-on charges (balance exhausted)",
159
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
189
+ referenceId: addonRef,
160
190
  });
161
191
  }
162
192
  if (!result.suspended.includes(tenantId)) {
@@ -165,26 +195,16 @@ export async function runRuntimeDeductions(cfg) {
165
195
  await cfg.onSuspend(tenantId);
166
196
  }
167
197
  }
198
+ didBillAnything = true;
168
199
  }
169
200
  }
170
201
  }
202
+ if (didBillAnything) {
203
+ result.processed++;
204
+ }
171
205
  else {
172
- // Partial deduction — debit remaining balance, then suspend
173
- if (balance.greaterThan(Credit.ZERO)) {
174
- await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
175
- description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
176
- referenceId: runtimeRef,
177
- });
178
- }
179
- if (cfg.onCreditsExhausted) {
180
- await cfg.onCreditsExhausted(tenantId);
181
- }
182
- result.suspended.push(tenantId);
183
- if (cfg.onSuspend) {
184
- await cfg.onSuspend(tenantId);
185
- }
206
+ result.skipped.push(tenantId);
186
207
  }
187
- result.processed++;
188
208
  }
189
209
  catch (err) {
190
210
  if (err instanceof InsufficientBalanceError) {
@@ -314,4 +314,110 @@ describe("runRuntimeDeductions", () => {
314
314
  // Balance unchanged after second run
315
315
  expect((await ledger.balance("tenant-1")).toCents()).toBe(500 - 17);
316
316
  });
317
+ it("bills surcharges on retry when runtime was already billed (crash recovery)", async () => {
318
+ // Setup: tenant with enough balance for runtime + tier + storage + addon
319
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
320
+ const cfg = {
321
+ ledger,
322
+ date: "2025-07-01",
323
+ getActiveBotCount: async () => 1,
324
+ getResourceTierCosts: async () => Credit.fromCents(10),
325
+ getStorageTierCosts: async () => Credit.fromCents(8),
326
+ getAddonCosts: async () => Credit.fromCents(5),
327
+ };
328
+ // First run — bills everything
329
+ const first = await runRuntimeDeductions(cfg);
330
+ expect(first.processed).toBe(1);
331
+ // 1000 - 17 (runtime) - 10 (tier) - 8 (storage) - 5 (addon) = 960
332
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
333
+ // Second run — all already billed, should skip
334
+ const second = await runRuntimeDeductions(cfg);
335
+ expect(second.skipped).toContain("tenant-1");
336
+ expect(second.processed).toBe(0);
337
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
338
+ });
339
+ it("bills remaining surcharges when runtime was billed but surcharges were not (simulated crash)", async () => {
340
+ // Setup: tenant with enough balance
341
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
342
+ // Simulate crash: manually debit only the runtime charge (as if the cron crashed after this)
343
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
344
+ description: "Daily runtime: 1 bot(s) x $0.17",
345
+ referenceId: `runtime:2025-07-02:tenant-1`,
346
+ });
347
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(983); // 1000 - 17
348
+ // Retry run — runtime already billed, but surcharges should still be billed
349
+ const result = await runRuntimeDeductions({
350
+ ledger,
351
+ date: "2025-07-02",
352
+ getActiveBotCount: async () => 1,
353
+ getResourceTierCosts: async () => Credit.fromCents(10),
354
+ getStorageTierCosts: async () => Credit.fromCents(8),
355
+ getAddonCosts: async () => Credit.fromCents(5),
356
+ });
357
+ expect(result.processed).toBe(1);
358
+ expect(result.skipped).not.toContain("tenant-1");
359
+ // 983 - 10 (tier) - 8 (storage) - 5 (addon) = 960
360
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
361
+ });
362
+ it("bills only missing surcharges when some were already committed (simulated partial crash)", async () => {
363
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
364
+ // Simulate: runtime + tier already billed, storage + addon not yet
365
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
366
+ description: "Daily runtime: 1 bot(s) x $0.17",
367
+ referenceId: `runtime:2025-07-03:tenant-1`,
368
+ });
369
+ await ledger.debit("tenant-1", Credit.fromCents(10), "resource_upgrade", {
370
+ description: "Daily resource tier surcharge",
371
+ referenceId: `runtime-tier:2025-07-03:tenant-1`,
372
+ });
373
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(973); // 1000 - 17 - 10
374
+ const result = await runRuntimeDeductions({
375
+ ledger,
376
+ date: "2025-07-03",
377
+ getActiveBotCount: async () => 1,
378
+ getResourceTierCosts: async () => Credit.fromCents(10),
379
+ getStorageTierCosts: async () => Credit.fromCents(8),
380
+ getAddonCosts: async () => Credit.fromCents(5),
381
+ });
382
+ expect(result.processed).toBe(1);
383
+ // 973 - 8 (storage) - 5 (addon) = 960
384
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
385
+ });
386
+ it("does not double-debit runtime on retry after partial deduction + crash", async () => {
387
+ await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
388
+ // Simulate: partial runtime debit already committed (balance was 10, cost was 17)
389
+ await ledger.debit("tenant-1", Credit.fromCents(10), "bot_runtime", {
390
+ description: "Partial daily runtime (balance exhausted): 1 bot(s)",
391
+ referenceId: `runtime:2025-07-04:tenant-1`,
392
+ allowNegative: true,
393
+ });
394
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
395
+ // Retry — runtime already billed, balance is 0, nothing should happen
396
+ const result = await runRuntimeDeductions({
397
+ ledger,
398
+ date: "2025-07-04",
399
+ getActiveBotCount: async () => 1,
400
+ });
401
+ // Tenant still has 0 balance (tenantsWithBalance returns only positive), so won't be processed
402
+ expect(result.processed).toBe(0);
403
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
404
+ });
405
+ it("trial balance remains balanced after crash-recovery billing", async () => {
406
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
407
+ // Simulate crash: only runtime billed
408
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
409
+ description: "Daily runtime: 1 bot(s) x $0.17",
410
+ referenceId: `runtime:2025-07-05:tenant-1`,
411
+ });
412
+ // Retry — surcharges billed
413
+ await runRuntimeDeductions({
414
+ ledger,
415
+ date: "2025-07-05",
416
+ getActiveBotCount: async () => 1,
417
+ getResourceTierCosts: async () => Credit.fromCents(10),
418
+ getStorageTierCosts: async () => Credit.fromCents(8),
419
+ });
420
+ const tb = await ledger.trialBalance();
421
+ expect(tb.balanced).toBe(true);
422
+ });
317
423
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.39.3",
3
+ "version": "1.39.5",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -104,6 +104,42 @@ describe("runCreditExpiryCron", () => {
104
104
  expect(balanceAfterSecond.toCents()).toBe(balanceAfterFirst.toCents());
105
105
  });
106
106
 
107
+ it("skips expiry when balance has been fully consumed before cron runs", async () => {
108
+ // Simulate: grant expires but tenant spent everything before cron ran
109
+ await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
110
+ description: "Promo",
111
+ referenceId: "promo:fully-consumed",
112
+ expiresAt: "2026-01-10T00:00:00Z",
113
+ });
114
+ // Tenant spends entire balance before expiry cron runs
115
+ await ledger.debit("tenant-1", Credit.fromCents(500), "bot_runtime", { description: "Full spend" });
116
+
117
+ const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
118
+ // Zero balance — nothing to expire
119
+ expect(result.processed).toBe(0);
120
+
121
+ const balance = await ledger.balance("tenant-1");
122
+ expect(balance.toCents()).toBe(0);
123
+ });
124
+
125
+ it("only expires remaining balance when usage reduced it between grant and expiry", async () => {
126
+ // Grant $5, spend $3 before cron, cron should only expire remaining $2
127
+ await ledger.credit("tenant-1", Credit.fromCents(500), "promo", {
128
+ description: "Promo",
129
+ referenceId: "promo:partial-concurrent",
130
+ expiresAt: "2026-01-10T00:00:00Z",
131
+ });
132
+ await ledger.debit("tenant-1", Credit.fromCents(300), "bot_runtime", { description: "Partial spend" });
133
+
134
+ const result = await runCreditExpiryCron({ ledger, now: "2026-01-15T00:00:00Z" });
135
+ expect(result.processed).toBe(1);
136
+ expect(result.expired).toContain("tenant-1");
137
+
138
+ // $5 granted - $3 used - $2 expired = $0
139
+ const balance = await ledger.balance("tenant-1");
140
+ expect(balance.toCents()).toBe(0);
141
+ });
142
+
107
143
  it("does not return unknown entry type even with expiresAt metadata", async () => {
108
144
  // Simulate a hypothetical new entry type that has expiresAt in metadata.
109
145
  // With the old denylist approach, this would be incorrectly returned.
@@ -53,6 +53,24 @@ describe("DrizzleLedger", () => {
53
53
  ).rejects.toThrow("Unbalanced");
54
54
  });
55
55
 
56
+ it("throws Unbalanced entry (not RangeError) when BigInt totals exceed MAX_SAFE_INTEGER", async () => {
57
+ // Two debit lines each at MAX_SAFE_INTEGER raw — their BigInt sum exceeds MAX_SAFE_INTEGER.
58
+ // Before the fix, Credit.fromRaw(Number(totalDebit)) would throw RangeError, masking the real error.
59
+ const big = Credit.fromRaw(Number.MAX_SAFE_INTEGER);
60
+ const small = Credit.fromRaw(1);
61
+ await expect(
62
+ ledger.post({
63
+ entryType: "purchase",
64
+ tenantId: "t1",
65
+ lines: [
66
+ { accountCode: "1000", amount: big, side: "debit" },
67
+ { accountCode: "1000", amount: big, side: "debit" },
68
+ { accountCode: "2000:t1", amount: small, side: "credit" },
69
+ ],
70
+ }),
71
+ ).rejects.toThrow("Unbalanced entry");
72
+ });
73
+
56
74
  it("rejects zero-amount lines", async () => {
57
75
  await expect(
58
76
  ledger.post({
@@ -260,6 +278,25 @@ describe("DrizzleLedger", () => {
260
278
  it("rejects zero amount", async () => {
261
279
  await expect(ledger.debit("t1", Credit.ZERO, "bot_runtime")).rejects.toThrow("must be positive");
262
280
  });
281
+
282
+ it("concurrent debits do not overdraft", async () => {
283
+ // Balance is $10.00 from beforeEach credit.
284
+ // Two concurrent $8 debits — only one should succeed.
285
+ const results = await Promise.allSettled([
286
+ ledger.debit("t1", Credit.fromCents(800), "bot_runtime"),
287
+ ledger.debit("t1", Credit.fromCents(800), "bot_runtime"),
288
+ ]);
289
+
290
+ const successes = results.filter((r) => r.status === "fulfilled");
291
+ const failures = results.filter((r) => r.status === "rejected");
292
+ expect(successes).toHaveLength(1);
293
+ expect(failures).toHaveLength(1);
294
+ expect((failures[0] as PromiseRejectedResult).reason).toBeInstanceOf(InsufficientBalanceError);
295
+
296
+ // Balance should be $2.00, not -$6.00
297
+ const bal = await ledger.balance("t1");
298
+ expect(bal.toCentsRounded()).toBe(200);
299
+ });
263
300
  });
264
301
 
265
302
  // -----------------------------------------------------------------------
@@ -334,6 +371,27 @@ describe("DrizzleLedger", () => {
334
371
  expect(tb.balanced).toBe(true);
335
372
  expect(tb.totalDebits.equals(tb.totalCredits)).toBe(true);
336
373
  });
374
+
375
+ it("detects imbalance from direct DB corruption", async () => {
376
+ await ledger.credit("t1", Credit.fromCents(100), "purchase");
377
+
378
+ // Corrupt the ledger: insert an unmatched debit line directly into journal_lines.
379
+ const entryRows = await pool.query<{ id: string }>("SELECT id FROM journal_entries LIMIT 1");
380
+ const accountRows = await pool.query<{ id: string }>("SELECT id FROM accounts WHERE code = '1000' LIMIT 1");
381
+ const entryId = entryRows.rows[0].id;
382
+ const accountId = accountRows.rows[0].id;
383
+
384
+ // Insert an unmatched debit line worth 999 raw units — no corresponding credit
385
+ await pool.query(
386
+ `INSERT INTO journal_lines (id, journal_entry_id, account_id, amount, side)
387
+ VALUES ('corrupt-line-1', $1, $2, 999, 'debit')`,
388
+ [entryId, accountId],
389
+ );
390
+
391
+ const tb = await ledger.trialBalance();
392
+ expect(tb.balanced).toBe(false);
393
+ expect(tb.difference.toRaw()).toBe(999);
394
+ });
337
395
  });
338
396
 
339
397
  // -----------------------------------------------------------------------
@@ -601,4 +659,59 @@ describe("DrizzleLedger", () => {
601
659
  expect(tb.balanced).toBe(true);
602
660
  });
603
661
  });
662
+
663
+ // -----------------------------------------------------------------------
664
+ // deadlock prevention — concurrent multi-line entries
665
+ // -----------------------------------------------------------------------
666
+
667
+ describe("deadlock prevention", () => {
668
+ it("concurrent multi-line entries with overlapping accounts succeed", async () => {
669
+ // Fund two tenants
670
+ await ledger.credit("t1", Credit.fromCents(1000), "purchase");
671
+ await ledger.credit("t2", Credit.fromCents(1000), "purchase");
672
+
673
+ // Two entries that touch accounts in potentially reverse order.
674
+ // Both touch account 4000 (revenue), creating a potential lock conflict.
675
+ const results = await Promise.allSettled([
676
+ ledger.debit("t1", Credit.fromCents(50), "bot_runtime"),
677
+ ledger.debit("t2", Credit.fromCents(30), "bot_runtime"),
678
+ ]);
679
+
680
+ // Both should succeed — lock ordering prevents deadlock
681
+ expect(results.every((r) => r.status === "fulfilled")).toBe(true);
682
+
683
+ // Verify balances are correct
684
+ expect((await ledger.balance("t1")).toCentsRounded()).toBe(950);
685
+ expect((await ledger.balance("t2")).toCentsRounded()).toBe(970);
686
+
687
+ // Revenue account should reflect both debits
688
+ expect((await ledger.accountBalance("4000")).toCentsRounded()).toBe(80);
689
+
690
+ // Trial balance must still be balanced
691
+ const tb = await ledger.trialBalance();
692
+ expect(tb.balanced).toBe(true);
693
+ });
694
+
695
+ it("concurrent multi-line entries on same tenant serialize correctly", async () => {
696
+ await ledger.credit("t1", Credit.fromCents(1000), "purchase");
697
+
698
+ // Two debits on the same tenant, touching overlapping accounts.
699
+ const results = await Promise.allSettled([
700
+ ledger.debit("t1", Credit.fromCents(100), "bot_runtime"),
701
+ ledger.debit("t1", Credit.fromCents(200), "adapter_usage"),
702
+ ]);
703
+
704
+ expect(results.every((r) => r.status === "fulfilled")).toBe(true);
705
+
706
+ // Balance: $10 - $1 - $2 = $7
707
+ expect((await ledger.balance("t1")).toCentsRounded()).toBe(700);
708
+
709
+ // Revenue accounts
710
+ expect((await ledger.accountBalance("4000")).toCentsRounded()).toBe(100); // bot_runtime
711
+ expect((await ledger.accountBalance("4010")).toCentsRounded()).toBe(200); // adapter_usage
712
+
713
+ const tb = await ledger.trialBalance();
714
+ expect(tb.balanced).toBe(true);
715
+ });
716
+ });
604
717
  });
@@ -372,19 +372,25 @@ export class DrizzleLedger implements ILedger {
372
372
  }
373
373
 
374
374
  // Verify balance before hitting DB
375
- let totalDebit = 0;
376
- let totalCredit = 0;
375
+ let totalDebit = 0n;
376
+ let totalCredit = 0n;
377
377
  for (const line of input.lines) {
378
378
  if (line.amount.isZero() || line.amount.isNegative()) {
379
379
  throw new Error("Journal line amounts must be positive");
380
380
  }
381
- if (line.side === "debit") totalDebit += line.amount.toRaw();
382
- else totalCredit += line.amount.toRaw();
381
+ if (line.side === "debit") totalDebit += BigInt(line.amount.toRaw());
382
+ else totalCredit += BigInt(line.amount.toRaw());
383
383
  }
384
384
  if (totalDebit !== totalCredit) {
385
- throw new Error(
386
- `Unbalanced entry: debits=${Credit.fromRaw(totalDebit).toDisplayString()}, credits=${Credit.fromRaw(totalCredit).toDisplayString()}`,
387
- );
385
+ const fmtDebit =
386
+ totalDebit <= BigInt(Number.MAX_SAFE_INTEGER)
387
+ ? Credit.fromRaw(Number(totalDebit)).toDisplayString()
388
+ : `${totalDebit} raw`;
389
+ const fmtCredit =
390
+ totalCredit <= BigInt(Number.MAX_SAFE_INTEGER)
391
+ ? Credit.fromRaw(Number(totalCredit)).toDisplayString()
392
+ : `${totalCredit} raw`;
393
+ throw new Error(`Unbalanced entry: debits=${fmtDebit}, credits=${fmtCredit}`);
388
394
  }
389
395
 
390
396
  return this.db.transaction(async (tx) => {
@@ -70,6 +70,9 @@ export const journalLines = pgTable(
70
70
  accountId: text("account_id")
71
71
  .notNull()
72
72
  .references(() => accounts.id),
73
+ // mode: "number" truncates values > Number.MAX_SAFE_INTEGER (≈$9.007M in nanodollars).
74
+ // Credit.fromRaw() guards against this with a RangeError.
75
+ // If balances approach $9M, migrate to mode: "bigint" (returns string from Drizzle).
73
76
  amount: bigint("amount", { mode: "number" }).notNull(), // nanodollars, always positive
74
77
  side: entrySideEnum("side").notNull(),
75
78
  },
@@ -89,6 +92,9 @@ export const accountBalances = pgTable("account_balances", {
89
92
  accountId: text("account_id")
90
93
  .primaryKey()
91
94
  .references(() => accounts.id),
95
+ // mode: "number" truncates values > Number.MAX_SAFE_INTEGER (≈$9.007M in nanodollars).
96
+ // Credit.fromRaw() guards against this with a RangeError.
97
+ // If balances approach $9M, migrate to mode: "bigint" (returns string from Drizzle).
92
98
  balance: bigint("balance", { mode: "number" }).notNull().default(0), // net balance in nanodollars
93
99
  lastUpdated: text("last_updated").notNull().default(sql`(now())`),
94
100
  });
@@ -356,4 +356,130 @@ describe("runRuntimeDeductions", () => {
356
356
  // Balance unchanged after second run
357
357
  expect((await ledger.balance("tenant-1")).toCents()).toBe(500 - 17);
358
358
  });
359
+
360
+ it("bills surcharges on retry when runtime was already billed (crash recovery)", async () => {
361
+ // Setup: tenant with enough balance for runtime + tier + storage + addon
362
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
363
+
364
+ const cfg = {
365
+ ledger,
366
+ date: "2025-07-01",
367
+ getActiveBotCount: async () => 1,
368
+ getResourceTierCosts: async () => Credit.fromCents(10),
369
+ getStorageTierCosts: async () => Credit.fromCents(8),
370
+ getAddonCosts: async () => Credit.fromCents(5),
371
+ };
372
+
373
+ // First run — bills everything
374
+ const first = await runRuntimeDeductions(cfg);
375
+ expect(first.processed).toBe(1);
376
+ // 1000 - 17 (runtime) - 10 (tier) - 8 (storage) - 5 (addon) = 960
377
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
378
+
379
+ // Second run — all already billed, should skip
380
+ const second = await runRuntimeDeductions(cfg);
381
+ expect(second.skipped).toContain("tenant-1");
382
+ expect(second.processed).toBe(0);
383
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
384
+ });
385
+
386
+ it("bills remaining surcharges when runtime was billed but surcharges were not (simulated crash)", async () => {
387
+ // Setup: tenant with enough balance
388
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
389
+
390
+ // Simulate crash: manually debit only the runtime charge (as if the cron crashed after this)
391
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
392
+ description: "Daily runtime: 1 bot(s) x $0.17",
393
+ referenceId: `runtime:2025-07-02:tenant-1`,
394
+ });
395
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(983); // 1000 - 17
396
+
397
+ // Retry run — runtime already billed, but surcharges should still be billed
398
+ const result = await runRuntimeDeductions({
399
+ ledger,
400
+ date: "2025-07-02",
401
+ getActiveBotCount: async () => 1,
402
+ getResourceTierCosts: async () => Credit.fromCents(10),
403
+ getStorageTierCosts: async () => Credit.fromCents(8),
404
+ getAddonCosts: async () => Credit.fromCents(5),
405
+ });
406
+
407
+ expect(result.processed).toBe(1);
408
+ expect(result.skipped).not.toContain("tenant-1");
409
+ // 983 - 10 (tier) - 8 (storage) - 5 (addon) = 960
410
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
411
+ });
412
+
413
+ it("bills only missing surcharges when some were already committed (simulated partial crash)", async () => {
414
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
415
+
416
+ // Simulate: runtime + tier already billed, storage + addon not yet
417
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
418
+ description: "Daily runtime: 1 bot(s) x $0.17",
419
+ referenceId: `runtime:2025-07-03:tenant-1`,
420
+ });
421
+ await ledger.debit("tenant-1", Credit.fromCents(10), "resource_upgrade", {
422
+ description: "Daily resource tier surcharge",
423
+ referenceId: `runtime-tier:2025-07-03:tenant-1`,
424
+ });
425
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(973); // 1000 - 17 - 10
426
+
427
+ const result = await runRuntimeDeductions({
428
+ ledger,
429
+ date: "2025-07-03",
430
+ getActiveBotCount: async () => 1,
431
+ getResourceTierCosts: async () => Credit.fromCents(10),
432
+ getStorageTierCosts: async () => Credit.fromCents(8),
433
+ getAddonCosts: async () => Credit.fromCents(5),
434
+ });
435
+
436
+ expect(result.processed).toBe(1);
437
+ // 973 - 8 (storage) - 5 (addon) = 960
438
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(960);
439
+ });
440
+
441
+ it("does not double-debit runtime on retry after partial deduction + crash", async () => {
442
+ await ledger.credit("tenant-1", Credit.fromCents(10), "purchase", { description: "top-up" });
443
+
444
+ // Simulate: partial runtime debit already committed (balance was 10, cost was 17)
445
+ await ledger.debit("tenant-1", Credit.fromCents(10), "bot_runtime", {
446
+ description: "Partial daily runtime (balance exhausted): 1 bot(s)",
447
+ referenceId: `runtime:2025-07-04:tenant-1`,
448
+ allowNegative: true,
449
+ });
450
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
451
+
452
+ // Retry — runtime already billed, balance is 0, nothing should happen
453
+ const result = await runRuntimeDeductions({
454
+ ledger,
455
+ date: "2025-07-04",
456
+ getActiveBotCount: async () => 1,
457
+ });
458
+
459
+ // Tenant still has 0 balance (tenantsWithBalance returns only positive), so won't be processed
460
+ expect(result.processed).toBe(0);
461
+ expect((await ledger.balance("tenant-1")).toCents()).toBe(0);
462
+ });
463
+
464
+ it("trial balance remains balanced after crash-recovery billing", async () => {
465
+ await ledger.credit("tenant-1", Credit.fromCents(1000), "purchase", { description: "top-up" });
466
+
467
+ // Simulate crash: only runtime billed
468
+ await ledger.debit("tenant-1", DAILY_BOT_COST, "bot_runtime", {
469
+ description: "Daily runtime: 1 bot(s) x $0.17",
470
+ referenceId: `runtime:2025-07-05:tenant-1`,
471
+ });
472
+
473
+ // Retry — surcharges billed
474
+ await runRuntimeDeductions({
475
+ ledger,
476
+ date: "2025-07-05",
477
+ getActiveBotCount: async () => 1,
478
+ getResourceTierCosts: async () => Credit.fromCents(10),
479
+ getStorageTierCosts: async () => Credit.fromCents(8),
480
+ });
481
+
482
+ const tb = await ledger.trialBalance();
483
+ expect(tb.balanced).toBe(true);
484
+ });
359
485
  });
@@ -110,87 +110,111 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
110
110
  for (const { tenantId, balance } of tenants) {
111
111
  try {
112
112
  const runtimeRef = `runtime:${cfg.date}:${tenantId}`;
113
- if (await cfg.ledger.hasReferenceId(runtimeRef)) {
114
- result.skipped.push(tenantId);
115
- continue;
116
- }
113
+ const runtimeAlreadyBilled = await cfg.ledger.hasReferenceId(runtimeRef);
117
114
 
118
115
  const botCount = await cfg.getActiveBotCount(tenantId);
119
- if (botCount <= 0) continue;
116
+ if (botCount <= 0) {
117
+ if (runtimeAlreadyBilled) result.skipped.push(tenantId);
118
+ continue;
119
+ }
120
120
 
121
121
  const totalCost = DAILY_BOT_COST.multiply(botCount);
122
+ let didBillAnything = false;
122
123
 
123
- if (!balance.lessThan(totalCost)) {
124
- // Full deduction
125
- await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
126
- description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
127
- referenceId: runtimeRef,
128
- });
124
+ // Bill runtime debit (skipped if already billed on a previous run)
125
+ if (!runtimeAlreadyBilled) {
126
+ if (!balance.lessThan(totalCost)) {
127
+ // Full deduction
128
+ await cfg.ledger.debit(tenantId, totalCost, "bot_runtime", {
129
+ description: `Daily runtime: ${botCount} bot(s) x $${DAILY_BOT_COST.toDollars().toFixed(2)}`,
130
+ referenceId: runtimeRef,
131
+ });
132
+ } else {
133
+ // Partial deduction — balance insufficient to cover full cost; debit what's available and suspend
134
+ if (balance.greaterThan(Credit.ZERO)) {
135
+ await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
136
+ description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
137
+ referenceId: runtimeRef,
138
+ });
139
+ }
140
+ if (!result.suspended.includes(tenantId)) {
141
+ result.suspended.push(tenantId);
142
+ if (cfg.onSuspend) await cfg.onSuspend(tenantId);
143
+ }
144
+ }
145
+ didBillAnything = true;
146
+ }
129
147
 
130
- // Debit resource tier surcharges (if any)
131
- if (cfg.getResourceTierCosts) {
148
+ // Debit resource tier surcharges (if any) — independent idempotency
149
+ if (cfg.getResourceTierCosts) {
150
+ const tierRef = `runtime-tier:${cfg.date}:${tenantId}`;
151
+ if (!(await cfg.ledger.hasReferenceId(tierRef))) {
132
152
  const tierCost = await cfg.getResourceTierCosts(tenantId);
133
153
  if (!tierCost.isZero()) {
134
154
  const balanceAfterRuntime = await cfg.ledger.balance(tenantId);
135
155
  if (!balanceAfterRuntime.lessThan(tierCost)) {
136
156
  await cfg.ledger.debit(tenantId, tierCost, "resource_upgrade", {
137
157
  description: "Daily resource tier surcharge",
138
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
158
+ referenceId: tierRef,
139
159
  });
140
160
  } else if (balanceAfterRuntime.greaterThan(Credit.ZERO)) {
141
161
  await cfg.ledger.debit(tenantId, balanceAfterRuntime, "resource_upgrade", {
142
162
  description: "Partial resource tier surcharge (balance exhausted)",
143
- referenceId: `runtime-tier:${cfg.date}:${tenantId}`,
163
+ referenceId: tierRef,
144
164
  });
145
165
  }
166
+ didBillAnything = true;
146
167
  }
147
168
  }
169
+ }
148
170
 
149
- const newBalance = await cfg.ledger.balance(tenantId);
171
+ const newBalance = await cfg.ledger.balance(tenantId);
150
172
 
151
- // Fire onLowBalance if balance crossed below threshold from above
152
- if (
153
- newBalance.greaterThan(Credit.ZERO) &&
154
- !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
155
- balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
156
- cfg.onLowBalance
157
- ) {
158
- await cfg.onLowBalance(tenantId, newBalance);
159
- }
173
+ // Fire onLowBalance if balance crossed below threshold from above
174
+ if (
175
+ newBalance.greaterThan(Credit.ZERO) &&
176
+ !newBalance.greaterThan(LOW_BALANCE_THRESHOLD) &&
177
+ balance.greaterThan(LOW_BALANCE_THRESHOLD) &&
178
+ cfg.onLowBalance
179
+ ) {
180
+ await cfg.onLowBalance(tenantId, newBalance);
181
+ }
160
182
 
161
- // Fire onCreditsExhausted if balance just hit 0
162
- if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
163
- await cfg.onCreditsExhausted(tenantId);
164
- }
183
+ // Fire onCreditsExhausted if balance just hit 0
184
+ if (!newBalance.greaterThan(Credit.ZERO) && balance.greaterThan(Credit.ZERO) && cfg.onCreditsExhausted) {
185
+ await cfg.onCreditsExhausted(tenantId);
186
+ }
165
187
 
166
- // Suspend tenant when balance hits zero after full deduction (zero-crossing guard)
167
- if (
168
- !newBalance.greaterThan(Credit.ZERO) &&
169
- balance.greaterThan(Credit.ZERO) &&
170
- !result.suspended.includes(tenantId)
171
- ) {
172
- result.suspended.push(tenantId);
173
- if (cfg.onSuspend) {
174
- await cfg.onSuspend(tenantId);
175
- }
188
+ // Suspend tenant when balance hits zero (zero-crossing guard)
189
+ if (
190
+ !newBalance.greaterThan(Credit.ZERO) &&
191
+ balance.greaterThan(Credit.ZERO) &&
192
+ !result.suspended.includes(tenantId)
193
+ ) {
194
+ result.suspended.push(tenantId);
195
+ if (cfg.onSuspend) {
196
+ await cfg.onSuspend(tenantId);
176
197
  }
198
+ }
177
199
 
178
- // Debit storage tier surcharges (if any)
179
- if (cfg.getStorageTierCosts) {
200
+ // Debit storage tier surcharges (if any) — independent idempotency
201
+ if (cfg.getStorageTierCosts) {
202
+ const storageRef = `runtime-storage:${cfg.date}:${tenantId}`;
203
+ if (!(await cfg.ledger.hasReferenceId(storageRef))) {
180
204
  const storageCost = await cfg.getStorageTierCosts(tenantId);
181
205
  if (!storageCost.isZero()) {
182
206
  const currentBalance = await cfg.ledger.balance(tenantId);
183
207
  if (!currentBalance.lessThan(storageCost)) {
184
208
  await cfg.ledger.debit(tenantId, storageCost, "storage_upgrade", {
185
209
  description: "Daily storage tier surcharge",
186
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
210
+ referenceId: storageRef,
187
211
  });
188
212
  } else {
189
213
  // Partial debit — take what's left, then suspend
190
214
  if (currentBalance.greaterThan(Credit.ZERO)) {
191
215
  await cfg.ledger.debit(tenantId, currentBalance, "storage_upgrade", {
192
216
  description: "Partial storage tier surcharge (balance exhausted)",
193
- referenceId: `runtime-storage:${cfg.date}:${tenantId}`,
217
+ referenceId: storageRef,
194
218
  });
195
219
  }
196
220
  if (!result.suspended.includes(tenantId)) {
@@ -198,25 +222,29 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
198
222
  if (cfg.onSuspend) await cfg.onSuspend(tenantId);
199
223
  }
200
224
  }
225
+ didBillAnything = true;
201
226
  }
202
227
  }
228
+ }
203
229
 
204
- // Debit infrastructure add-on costs (if any)
205
- if (cfg.getAddonCosts) {
230
+ // Debit infrastructure add-on costs (if any) — independent idempotency
231
+ if (cfg.getAddonCosts) {
232
+ const addonRef = `runtime-addon:${cfg.date}:${tenantId}`;
233
+ if (!(await cfg.ledger.hasReferenceId(addonRef))) {
206
234
  const addonCost = await cfg.getAddonCosts(tenantId);
207
235
  if (!addonCost.isZero()) {
208
236
  const currentBalance = await cfg.ledger.balance(tenantId);
209
237
  if (!currentBalance.lessThan(addonCost)) {
210
238
  await cfg.ledger.debit(tenantId, addonCost, "addon", {
211
239
  description: "Daily infrastructure add-on charges",
212
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
240
+ referenceId: addonRef,
213
241
  });
214
242
  } else {
215
243
  // Partial debit — take what's left, then suspend
216
244
  if (currentBalance.greaterThan(Credit.ZERO)) {
217
245
  await cfg.ledger.debit(tenantId, currentBalance, "addon", {
218
246
  description: "Partial add-on charges (balance exhausted)",
219
- referenceId: `runtime-addon:${cfg.date}:${tenantId}`,
247
+ referenceId: addonRef,
220
248
  });
221
249
  }
222
250
  if (!result.suspended.includes(tenantId)) {
@@ -224,28 +252,16 @@ export async function runRuntimeDeductions(cfg: RuntimeCronConfig): Promise<Runt
224
252
  if (cfg.onSuspend) await cfg.onSuspend(tenantId);
225
253
  }
226
254
  }
255
+ didBillAnything = true;
227
256
  }
228
257
  }
229
- } else {
230
- // Partial deduction — debit remaining balance, then suspend
231
- if (balance.greaterThan(Credit.ZERO)) {
232
- await cfg.ledger.debit(tenantId, balance, "bot_runtime", {
233
- description: `Partial daily runtime (balance exhausted): ${botCount} bot(s)`,
234
- referenceId: runtimeRef,
235
- });
236
- }
237
-
238
- if (cfg.onCreditsExhausted) {
239
- await cfg.onCreditsExhausted(tenantId);
240
- }
241
-
242
- result.suspended.push(tenantId);
243
- if (cfg.onSuspend) {
244
- await cfg.onSuspend(tenantId);
245
- }
246
258
  }
247
259
 
248
- result.processed++;
260
+ if (didBillAnything) {
261
+ result.processed++;
262
+ } else {
263
+ result.skipped.push(tenantId);
264
+ }
249
265
  } catch (err) {
250
266
  if (err instanceof InsufficientBalanceError) {
251
267
  result.suspended.push(tenantId);
package/vitest.config.ts CHANGED
@@ -1,6 +1,15 @@
1
+ import { resolve } from "node:path";
1
2
  import { defineConfig } from "vitest/config";
2
3
 
3
4
  export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ "@wopr-network/platform-core/billing": resolve(__dirname, "src/billing/index.ts"),
8
+ "@wopr-network/platform-core/credits": resolve(__dirname, "src/credits/index.ts"),
9
+ "@wopr-network/platform-core/email": resolve(__dirname, "src/email/index.ts"),
10
+ "@wopr-network/platform-core/metering": resolve(__dirname, "src/metering/index.ts"),
11
+ },
12
+ },
4
13
  test: {
5
14
  testTimeout: 30000,
6
15
  hookTimeout: 30000,