@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.
- package/dist/credits/credit-expiry-cron.test.js +30 -0
- package/dist/credits/ledger.js +11 -5
- package/dist/credits/ledger.test.js +87 -0
- package/dist/db/schema/ledger.js +6 -0
- package/package.json +1 -1
- package/src/credits/credit-expiry-cron.test.ts +36 -0
- package/src/credits/ledger.test.ts +113 -0
- package/src/credits/ledger.ts +13 -7
- package/src/db/schema/ledger.ts +6 -0
|
@@ -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.
|
package/dist/credits/ledger.js
CHANGED
|
@@ -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 =
|
|
173
|
-
let totalCredit =
|
|
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
|
-
|
|
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
|
});
|
package/dist/db/schema/ledger.js
CHANGED
|
@@ -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
|
@@ -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
|
});
|
package/src/credits/ledger.ts
CHANGED
|
@@ -372,19 +372,25 @@ export class DrizzleLedger implements ILedger {
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
// Verify balance before hitting DB
|
|
375
|
-
let totalDebit =
|
|
376
|
-
let totalCredit =
|
|
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
|
-
|
|
386
|
-
|
|
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) => {
|
package/src/db/schema/ledger.ts
CHANGED
|
@@ -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
|
});
|