@wopr-network/platform-core 1.39.4 → 1.39.6

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.
@@ -0,0 +1,343 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { Instance } from "./instance.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function makeProfile(overrides = {}) {
8
+ return {
9
+ id: "bot-1",
10
+ tenantId: "tenant-1",
11
+ name: "test-bot",
12
+ description: "A test bot",
13
+ image: "ghcr.io/wopr-network/test:latest",
14
+ env: {},
15
+ restartPolicy: "unless-stopped",
16
+ releaseChannel: "stable",
17
+ updatePolicy: "manual",
18
+ ...overrides,
19
+ };
20
+ }
21
+ /** Build a mock Docker.Container with sensible defaults */
22
+ function mockContainer(state = { Running: true, Status: "running" }) {
23
+ return {
24
+ start: vi.fn().mockResolvedValue(undefined),
25
+ stop: vi.fn().mockResolvedValue(undefined),
26
+ restart: vi.fn().mockResolvedValue(undefined),
27
+ remove: vi.fn().mockResolvedValue(undefined),
28
+ inspect: vi.fn().mockResolvedValue({
29
+ Id: "abc123",
30
+ Name: "/wopr-test-bot",
31
+ Created: "2026-01-01T00:00:00Z",
32
+ State: {
33
+ Running: state.Running ?? true,
34
+ Status: state.Status ?? "running",
35
+ StartedAt: "2026-01-01T00:00:00Z",
36
+ Health: { Status: "healthy" },
37
+ },
38
+ NetworkSettings: { Ports: {} },
39
+ }),
40
+ logs: vi.fn(),
41
+ stats: vi.fn().mockResolvedValue({
42
+ cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 1000, online_cpus: 2 },
43
+ precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 500 },
44
+ memory_stats: { usage: 100 * 1024 * 1024, limit: 512 * 1024 * 1024 },
45
+ }),
46
+ exec: vi.fn(),
47
+ };
48
+ }
49
+ function mockDocker(container) {
50
+ return {
51
+ getContainer: vi.fn().mockReturnValue(container),
52
+ pull: vi.fn(),
53
+ modem: {
54
+ followProgress: vi.fn((_stream, cb) => cb(null)),
55
+ demuxStream: vi.fn(),
56
+ },
57
+ };
58
+ }
59
+ function mockEventEmitter() {
60
+ return { emit: vi.fn() };
61
+ }
62
+ function mockMetricsTracker() {
63
+ return { reset: vi.fn(), getMetrics: vi.fn().mockReturnValue(null) };
64
+ }
65
+ function buildInstance(overrides = {}) {
66
+ const container = mockContainer();
67
+ const docker = mockDocker(container);
68
+ const emitter = mockEventEmitter();
69
+ const metrics = mockMetricsTracker();
70
+ const profile = makeProfile();
71
+ const instance = new Instance({
72
+ docker,
73
+ profile,
74
+ containerId: "abc123",
75
+ containerName: "wopr-test-bot",
76
+ url: "http://wopr-test-bot:7437",
77
+ eventEmitter: emitter,
78
+ botMetricsTracker: metrics,
79
+ ...overrides,
80
+ });
81
+ return { instance, container, docker, emitter, metrics };
82
+ }
83
+ function buildRemoteInstance() {
84
+ const container = mockContainer();
85
+ const docker = mockDocker(container);
86
+ return new Instance({
87
+ docker,
88
+ profile: makeProfile(),
89
+ containerId: "remote:node-3",
90
+ containerName: "wopr-test-bot",
91
+ url: "remote://node-3/wopr-test-bot",
92
+ });
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Tests
96
+ // ---------------------------------------------------------------------------
97
+ describe("Instance", () => {
98
+ beforeEach(() => {
99
+ vi.useFakeTimers();
100
+ });
101
+ afterEach(() => {
102
+ vi.useRealTimers();
103
+ vi.restoreAllMocks();
104
+ });
105
+ // -----------------------------------------------------------------------
106
+ // P0: Remote guard
107
+ // -----------------------------------------------------------------------
108
+ describe("remote instances", () => {
109
+ const ops = [
110
+ "start",
111
+ "stop",
112
+ "restart",
113
+ "remove",
114
+ "pullImage",
115
+ "logs",
116
+ "logStream",
117
+ "getVolumeUsage",
118
+ "status",
119
+ "containerState",
120
+ ];
121
+ for (const op of ops) {
122
+ it(`${op}() throws on remote instance`, async () => {
123
+ const remote = buildRemoteInstance();
124
+ const args = op === "logStream" ? [{}] : [];
125
+ await expect(remote[op](...args)).rejects.toThrow("not supported on remote instances");
126
+ });
127
+ }
128
+ it("emitCreated() works on remote instances (no Docker)", () => {
129
+ const emitter = mockEventEmitter();
130
+ const docker = mockDocker(mockContainer());
131
+ const remote = new Instance({
132
+ docker,
133
+ profile: makeProfile(),
134
+ containerId: "remote:node-5",
135
+ containerName: "wopr-test-bot",
136
+ url: "remote://node-5/wopr-test-bot",
137
+ eventEmitter: emitter,
138
+ });
139
+ expect(() => remote.emitCreated()).not.toThrow();
140
+ expect(emitter.emit.mock.calls[0][0]).toMatchObject({ type: "bot.created" });
141
+ });
142
+ });
143
+ // -----------------------------------------------------------------------
144
+ // restart()
145
+ // -----------------------------------------------------------------------
146
+ describe("restart()", () => {
147
+ it("restarts a running container and emits event", async () => {
148
+ const { instance, container, emitter } = buildInstance();
149
+ await instance.restart();
150
+ expect(container.inspect).toHaveBeenCalled();
151
+ expect(container.restart).toHaveBeenCalled();
152
+ expect(emitter.emit.mock.calls[0][0]).toMatchObject({ type: "bot.restarted" });
153
+ });
154
+ it("rejects when container is in paused state", async () => {
155
+ const container = mockContainer({ Running: false, Status: "paused" });
156
+ const docker = mockDocker(container);
157
+ const instance = new Instance({
158
+ docker,
159
+ profile: makeProfile(),
160
+ containerId: "abc123",
161
+ containerName: "wopr-test-bot",
162
+ url: "http://wopr-test-bot:7437",
163
+ });
164
+ await expect(instance.restart()).rejects.toThrow(/Cannot restart.*paused/);
165
+ });
166
+ it("accepts stopped/exited/dead states", async () => {
167
+ for (const status of ["stopped", "exited", "dead"]) {
168
+ const container = mockContainer({ Running: false, Status: status });
169
+ const docker = mockDocker(container);
170
+ const instance = new Instance({
171
+ docker,
172
+ profile: makeProfile(),
173
+ containerId: "abc123",
174
+ containerName: "wopr-test-bot",
175
+ url: "http://wopr-test-bot:7437",
176
+ });
177
+ await expect(instance.restart()).resolves.toBeUndefined();
178
+ }
179
+ });
180
+ it("resets metrics tracker on restart", async () => {
181
+ const { instance, metrics } = buildInstance();
182
+ await instance.restart();
183
+ expect(metrics.reset).toHaveBeenCalledWith("bot-1");
184
+ });
185
+ });
186
+ // -----------------------------------------------------------------------
187
+ // pullImage()
188
+ // -----------------------------------------------------------------------
189
+ describe("pullImage()", () => {
190
+ it("pulls image without auth when no env vars set", async () => {
191
+ const { instance, docker } = buildInstance();
192
+ const pullMock = docker.pull;
193
+ pullMock.mockResolvedValue("stream");
194
+ await instance.pullImage();
195
+ expect(pullMock).toHaveBeenCalledWith("ghcr.io/wopr-network/test:latest", {});
196
+ });
197
+ it("pulls image with auth when registry env vars are set", async () => {
198
+ process.env.REGISTRY_USERNAME = "user";
199
+ process.env.REGISTRY_PASSWORD = "pass";
200
+ process.env.REGISTRY_SERVER = "ghcr.io";
201
+ try {
202
+ const { instance, docker } = buildInstance();
203
+ const pullMock = docker.pull;
204
+ pullMock.mockResolvedValue("stream");
205
+ await instance.pullImage();
206
+ expect(pullMock).toHaveBeenCalledWith("ghcr.io/wopr-network/test:latest", {
207
+ authconfig: { username: "user", password: "pass", serveraddress: "ghcr.io" },
208
+ });
209
+ }
210
+ finally {
211
+ delete process.env.REGISTRY_USERNAME;
212
+ delete process.env.REGISTRY_PASSWORD;
213
+ delete process.env.REGISTRY_SERVER;
214
+ }
215
+ });
216
+ });
217
+ // -----------------------------------------------------------------------
218
+ // logs()
219
+ // -----------------------------------------------------------------------
220
+ describe("logs()", () => {
221
+ it("returns demuxed log output", async () => {
222
+ const { instance, container } = buildInstance();
223
+ // Build a Docker multiplexed frame: 8-byte header + payload
224
+ const payload = Buffer.from("hello from container\n");
225
+ const header = Buffer.alloc(8);
226
+ header.writeUInt8(1, 0); // stdout stream
227
+ header.writeUInt32BE(payload.length, 4);
228
+ const frame = Buffer.concat([header, payload]);
229
+ container.logs.mockResolvedValue(frame);
230
+ const result = await instance.logs(50);
231
+ expect(result).toBe("hello from container\n");
232
+ expect(container.logs).toHaveBeenCalledWith(expect.objectContaining({ stdout: true, stderr: true, tail: 50, timestamps: true }));
233
+ });
234
+ });
235
+ // -----------------------------------------------------------------------
236
+ // getVolumeUsage()
237
+ // -----------------------------------------------------------------------
238
+ describe("getVolumeUsage()", () => {
239
+ it("returns parsed df output for a running container", async () => {
240
+ const { instance, container } = buildInstance();
241
+ const dfOutput = "Filesystem 1B-blocks Used Available Use% Mounted on\n/dev/sda1 1073741824 536870912 536870912 50% /data\n";
242
+ const mockStream = new PassThrough();
243
+ const execObj = {
244
+ start: vi.fn((_opts, cb) => {
245
+ cb(null, mockStream);
246
+ mockStream.end(dfOutput);
247
+ }),
248
+ };
249
+ container.exec.mockResolvedValue(execObj);
250
+ const result = await instance.getVolumeUsage();
251
+ expect(result).toEqual({
252
+ totalBytes: 1073741824,
253
+ usedBytes: 536870912,
254
+ availableBytes: 536870912,
255
+ });
256
+ });
257
+ it("returns null when container is not running", async () => {
258
+ const container = mockContainer({ Running: false, Status: "stopped" });
259
+ const docker = mockDocker(container);
260
+ const instance = new Instance({
261
+ docker,
262
+ profile: makeProfile(),
263
+ containerId: "abc123",
264
+ containerName: "wopr-test-bot",
265
+ url: "http://wopr-test-bot:7437",
266
+ });
267
+ const result = await instance.getVolumeUsage();
268
+ expect(result).toBeNull();
269
+ });
270
+ });
271
+ // -----------------------------------------------------------------------
272
+ // status()
273
+ // -----------------------------------------------------------------------
274
+ describe("status()", () => {
275
+ it("returns BotStatus with stats for running container", async () => {
276
+ const { instance } = buildInstance();
277
+ const st = await instance.status();
278
+ expect(st.id).toBe("bot-1");
279
+ expect(st.state).toBe("running");
280
+ expect(st.containerId).toBe("abc123");
281
+ expect(st.stats).toBeDefined();
282
+ expect(st.stats?.cpuPercent).toBeGreaterThanOrEqual(0);
283
+ expect(st.stats?.memoryUsageMb).toBe(100);
284
+ expect(st.health).toBe("healthy");
285
+ });
286
+ it("returns offline status when container is gone", async () => {
287
+ const container = mockContainer();
288
+ container.inspect.mockRejectedValue(new Error("No such container"));
289
+ const docker = mockDocker(container);
290
+ const instance = new Instance({
291
+ docker,
292
+ profile: makeProfile(),
293
+ containerId: "abc123",
294
+ containerName: "wopr-test-bot",
295
+ url: "http://wopr-test-bot:7437",
296
+ });
297
+ const st = await instance.status();
298
+ expect(st.state).toBe("stopped");
299
+ expect(st.containerId).toBeNull();
300
+ expect(st.stats).toBeNull();
301
+ });
302
+ });
303
+ // -----------------------------------------------------------------------
304
+ // Concurrency lock
305
+ // -----------------------------------------------------------------------
306
+ describe("withLock serialization", () => {
307
+ it("serializes concurrent restart calls", async () => {
308
+ const callOrder = [];
309
+ const container = mockContainer();
310
+ const docker = mockDocker(container);
311
+ // Make restart take some time
312
+ container.inspect.mockImplementation(async () => {
313
+ callOrder.push("inspect-start");
314
+ await new Promise((r) => setTimeout(r, 50));
315
+ callOrder.push("inspect-end");
316
+ return {
317
+ Id: "abc123",
318
+ Name: "/wopr-test-bot",
319
+ Created: "2026-01-01T00:00:00Z",
320
+ State: { Running: true, Status: "running", StartedAt: "2026-01-01T00:00:00Z" },
321
+ };
322
+ });
323
+ container.restart.mockImplementation(async () => {
324
+ callOrder.push("restart");
325
+ });
326
+ const instance = new Instance({
327
+ docker,
328
+ profile: makeProfile(),
329
+ containerId: "abc123",
330
+ containerName: "wopr-test-bot",
331
+ url: "http://wopr-test-bot:7437",
332
+ });
333
+ // Fire two concurrent restarts
334
+ const p1 = instance.restart();
335
+ const p2 = instance.restart();
336
+ // Advance timers to let both complete
337
+ await vi.advanceTimersByTimeAsync(200);
338
+ await Promise.all([p1, p2]);
339
+ // Should see two full cycles without interleaving
340
+ expect(callOrder).toEqual(["inspect-start", "inspect-end", "restart", "inspect-start", "inspect-end", "restart"]);
341
+ });
342
+ });
343
+ });
@@ -46,9 +46,9 @@ export declare const commandSchema: z.ZodObject<{
46
46
  type: z.ZodEnum<{
47
47
  "bot.logs": "bot.logs";
48
48
  "bot.start": "bot.start";
49
- "bot.restart": "bot.restart";
50
49
  "bot.remove": "bot.remove";
51
50
  "bot.stop": "bot.stop";
51
+ "bot.restart": "bot.restart";
52
52
  "bot.update": "bot.update";
53
53
  "bot.export": "bot.export";
54
54
  "bot.import": "bot.import";
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.6",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,106 @@
1
+ # Double-Entry Credit Ledger
2
+
3
+ A production double-entry accounting system for prepaid credit management. Every mutation posts a balanced journal entry where `sum(debits) === sum(credits)`. A tenant's "credit balance" is the balance of their `unearned_revenue` liability account.
4
+
5
+ ## Accounting Model
6
+
7
+ ```
8
+ ASSETS (1000s) — Cash, Stripe Receivable
9
+ LIABILITIES (2000:tid) — Unearned Revenue per tenant (the "credit balance")
10
+ EQUITY (3000s) — Retained Earnings
11
+ REVENUE (4000s) — Bot Runtime, Adapter Usage, Addon, Storage, Onboarding, Expired Credits
12
+ EXPENSES (5000s) — Signup Grant, Admin Grant, Promo, Referral, Affiliate, Bounty, Dividend, Correction
13
+ ```
14
+
15
+ ### Transaction Flows
16
+
17
+ | Operation | Debit | Credit | Effect |
18
+ |-----------|-------|--------|--------|
19
+ | Purchase | Cash (1000) | Unearned Revenue (2000:tid) | Tenant buys credits |
20
+ | Usage | Unearned Revenue (2000:tid) | Revenue (4000-4050) | Credits consumed, revenue recognized |
21
+ | Grant | Expense (5000-5070) | Unearned Revenue (2000:tid) | Free credits issued |
22
+ | Refund | Unearned Revenue (2000:tid) | Cash (1000) | Money returned to tenant |
23
+ | Expiry | Unearned Revenue (2000:tid) | Revenue: Expired (4060) | Unused credits recognized as revenue |
24
+
25
+ The accounting equation holds at all times: `Assets = Liabilities + Equity + Revenue - Expenses`.
26
+
27
+ ## Safety Guarantees
28
+
29
+ ### Double-Entry Invariant
30
+ Every journal entry requires at least 2 lines. `sum(debits) === sum(credits)` is verified with **BigInt arithmetic** before the database transaction begins. The `trialBalance()` method independently verifies `total_debits === total_credits` across all journal lines using BigInt aggregation.
31
+
32
+ ### Transaction Isolation (TOCTOU-Safe)
33
+ All balance mutations use PostgreSQL `SELECT ... FOR UPDATE` row locks on both the `accounts` and `account_balances` rows. The balance check happens **inside** the transaction **after** acquiring locks, preventing time-of-check-to-time-of-use races under concurrent debit operations.
34
+
35
+ ### Deadlock Prevention
36
+ Journal lines are sorted by `accountCode` before lock acquisition, establishing a consistent global lock ordering. This eliminates deadlocks when concurrent transactions touch overlapping accounts.
37
+
38
+ ### Overflow Protection
39
+ - `Credit.fromRaw()` throws `RangeError` if the value exceeds `Number.MAX_SAFE_INTEGER` (~$9M in nanodollars)
40
+ - `Credit.add()`, `subtract()`, `multiply()` all throw `RangeError` on overflow
41
+ - Aggregate queries (`trialBalance`, `lifetimeSpend`, `sumPurchasesForPeriod`) use BigInt to prevent silent precision loss
42
+ - Tiered observability warnings at $10K / $100K / $1M balances
43
+
44
+ ### Idempotency
45
+ Journal entries support an optional `referenceId` with a unique constraint (partial index, NULL-safe). Callers use domain-specific prefixes to prevent double-posting:
46
+ - `pi_<stripe_id>` — Stripe purchases
47
+ - `runtime:<date>:<tenantId>` — Daily bot runtime billing
48
+ - `runtime-tier:<date>:<tenantId>` — Resource tier surcharges
49
+ - `runtime-storage:<date>:<tenantId>` — Storage tier surcharges
50
+ - `runtime-addon:<date>:<tenantId>` — Infrastructure addon charges
51
+ - `expiry:<entryId>` — Credit expiry clawbacks
52
+
53
+ ### Credit Expiry (Allowlist-Gated)
54
+ Only entry types listed in `EXPIRABLE_CREDIT_TYPES` can be returned by `expiredCredits()`. The constant is typed as `as const satisfies readonly CreditType[]` — adding a new `CreditType` without updating the allowlist produces a compile error.
55
+
56
+ ## Value Object: `Credit`
57
+
58
+ All amounts are stored as **nanodollars** (1 dollar = 1,000,000,000 raw units). The `Credit` class enforces integer-only arithmetic:
59
+
60
+ ```typescript
61
+ Credit.fromDollars(0.001) // 1,000,000 raw units
62
+ Credit.fromCents(500) // 5,000,000,000 raw units
63
+ Credit.fromRaw(n) // throws TypeError if not integer, RangeError if > MAX_SAFE_INTEGER
64
+
65
+ credit.toCentsRounded() // for display / API responses
66
+ credit.toCentsFloor() // for Stripe charges (never overcharge)
67
+ credit.toRaw() // for database storage
68
+ ```
69
+
70
+ No floating-point arithmetic in storage or ledger paths. `Math.round()` is used only at input boundaries (`fromDollars`, `fromCents`, `multiply`).
71
+
72
+ ## Key Files
73
+
74
+ | File | Purpose |
75
+ |------|---------|
76
+ | `ledger.ts` | `DrizzleLedger` — the core double-entry engine |
77
+ | `credit.ts` | `Credit` value object with overflow protection |
78
+ | `credit-expiry-cron.ts` | Sweeps expired grants, debits remaining balance |
79
+ | `../monetization/credits/runtime-cron.ts` | Daily bot billing with per-charge-type idempotency |
80
+ | `../gateway/credit-gate.ts` | Pre/post-call balance checks for the API gateway |
81
+ | `../db/schema/ledger.ts` | Drizzle schema: accounts, journal_entries, journal_lines, account_balances |
82
+
83
+ ## Interface: `ILedger`
84
+
85
+ ```typescript
86
+ post(input: PostEntryInput): Promise<JournalEntry> // The primitive — posts a balanced entry
87
+ credit(tenantId, amount, type, opts?): Promise<JournalEntry> // Add credits (DR source, CR liability)
88
+ debit(tenantId, amount, type, opts?): Promise<JournalEntry> // Deduct credits (DR liability, CR revenue)
89
+ debitCapped(tenantId, maxAmount, type, opts?) // Atomic balance-capped debit (single txn)
90
+ balance(tenantId): Promise<Credit> // Tenant's credit balance
91
+ trialBalance(): Promise<TrialBalance> // Verify the books balance
92
+ ```
93
+
94
+ ## Audit History
95
+
96
+ Audited 2026-03-16. Seven issues identified and resolved:
97
+
98
+ | # | Issue | Fix |
99
+ |---|-------|-----|
100
+ | #86 | Runtime cron `continue` skipped surcharges on crash retry | Per-charge-type independent idempotency |
101
+ | #87 | Credit expiry cron TOCTOU race on balance read | `debitCapped()` — atomic single-transaction balance read + debit |
102
+ | #88 | `Credit.add()/subtract()/multiply()` bypassed overflow checks | `Number.isSafeInteger()` guard on all arithmetic results |
103
+ | #89 | Potential deadlock from unsorted lock acquisition | Sort lines by `accountCode` before `FOR UPDATE` |
104
+ | #90 | `expiredCredits()` used denylist (fragile to new types) | Allowlist `EXPIRABLE_CREDIT_TYPES` with compile-time safety |
105
+ | #91 | Missing concurrency and edge-case tests | 6 new tests: race, corruption, deadlock, overflow, expiry |
106
+ | #92 | `post()` pre-check used JS number (overflow-prone) | BigInt accumulators + guarded error formatting |
@@ -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
  });