@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.
- 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/dist/monetization/credits/runtime-cron.js +78 -58
- package/dist/monetization/credits/runtime-cron.test.js +106 -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
- package/src/monetization/credits/runtime-cron.test.ts +126 -0
- package/src/monetization/credits/runtime-cron.ts +81 -65
- package/vitest.config.ts +9 -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
|
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
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:
|
|
112
|
+
referenceId: tierRef,
|
|
91
113
|
});
|
|
92
114
|
}
|
|
115
|
+
didBillAnything = true;
|
|
93
116
|
}
|
|
94
117
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
144
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
@@ -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
|
});
|
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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:
|
|
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:
|
|
163
|
+
referenceId: tierRef,
|
|
144
164
|
});
|
|
145
165
|
}
|
|
166
|
+
didBillAnything = true;
|
|
146
167
|
}
|
|
147
168
|
}
|
|
169
|
+
}
|
|
148
170
|
|
|
149
|
-
|
|
171
|
+
const newBalance = await cfg.ledger.balance(tenantId);
|
|
150
172
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
205
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|