@wopr-network/platform-core 1.39.4 → 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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.39.4",
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
  });