@wopr-network/platform-core 1.13.2 → 1.14.0
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/api/routes/admin-credits.d.ts +2 -2
- package/dist/api/routes/admin-credits.js +9 -4
- package/dist/api/routes/quota.d.ts +2 -2
- package/dist/api/routes/verify-email.d.ts +3 -3
- package/dist/backup/on-demand-snapshot-service.d.ts +2 -2
- package/dist/billing/payram/webhook.d.ts +3 -3
- package/dist/billing/payram/webhook.js +5 -1
- package/dist/billing/payram/webhook.test.js +5 -4
- package/dist/billing/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/billing/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/billing/stripe/tenant-store.d.ts +1 -1
- package/dist/billing/stripe/tenant-store.js +1 -1
- package/dist/credits/auto-topup-charge.d.ts +2 -2
- package/dist/credits/auto-topup-charge.js +5 -1
- package/dist/credits/auto-topup-charge.test.js +5 -4
- package/dist/credits/auto-topup-usage.d.ts +2 -2
- package/dist/credits/auto-topup-usage.test.js +53 -12
- package/dist/credits/credit-expiry-cron.d.ts +2 -2
- package/dist/credits/credit-expiry-cron.js +7 -4
- package/dist/credits/credit-expiry-cron.test.js +25 -8
- package/dist/credits/credit-ledger.d.ts +2 -2
- package/dist/credits/credit-ledger.js +1 -1
- package/dist/credits/dividend-cron.d.ts +4 -6
- package/dist/credits/dividend-cron.js +10 -16
- package/dist/credits/dividend-cron.test.js +31 -44
- package/dist/credits/dividend-repository.js +19 -22
- package/dist/credits/dividend-repository.test.js +4 -3
- package/dist/credits/index.d.ts +4 -2
- package/dist/credits/index.js +2 -1
- package/dist/credits/ledger.d.ts +195 -0
- package/dist/credits/ledger.js +561 -0
- package/dist/credits/ledger.test.js +418 -0
- package/dist/credits/signup-grant.d.ts +2 -2
- package/dist/credits/signup-grant.js +4 -4
- package/dist/credits/signup-grant.test.js +5 -3
- package/dist/credits/trial-balance-cron.d.ts +19 -0
- package/dist/credits/trial-balance-cron.js +30 -0
- package/dist/credits/trial-balance-cron.test.js +55 -0
- package/dist/db/schema/gateway-service-keys.d.ts +109 -0
- package/dist/db/schema/gateway-service-keys.js +18 -0
- package/dist/db/schema/index.d.ts +2 -0
- package/dist/db/schema/index.js +2 -0
- package/dist/db/schema/ledger.d.ts +442 -0
- package/dist/db/schema/ledger.js +76 -0
- package/dist/gateway/credit-gate.d.ts +2 -2
- package/dist/gateway/credit-gate.js +5 -1
- package/dist/gateway/credit-gate.test.js +35 -33
- package/dist/gateway/gateway-routes.test.js +1 -1
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +1 -0
- package/dist/gateway/protocol/anthropic.js +1 -1
- package/dist/gateway/protocol/deps.d.ts +5 -5
- package/dist/gateway/protocol/openai.js +1 -1
- package/dist/gateway/proxy.d.ts +4 -4
- package/dist/gateway/route-mounting.test.js +1 -1
- package/dist/gateway/service-key-auth.d.ts +1 -1
- package/dist/gateway/service-key-auth.js +1 -1
- package/dist/gateway/service-key-repository.d.ts +27 -0
- package/dist/gateway/service-key-repository.js +64 -0
- package/dist/gateway/types.d.ts +5 -5
- package/dist/metering/reconciliation-cron.test.js +9 -8
- package/dist/metering/reconciliation-repository.js +12 -10
- package/dist/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.js +10 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.test.js +32 -13
- package/dist/monetization/affiliate/credit-match.d.ts +2 -2
- package/dist/monetization/affiliate/credit-match.js +4 -1
- package/dist/monetization/affiliate/credit-match.test.js +58 -13
- package/dist/monetization/affiliate/new-user-bonus.d.ts +2 -2
- package/dist/monetization/affiliate/new-user-bonus.js +4 -1
- package/dist/monetization/affiliate/new-user-bonus.test.js +4 -3
- package/dist/monetization/credits/auto-topup-charge.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-charge.js +5 -1
- package/dist/monetization/credits/auto-topup-charge.test.js +5 -4
- package/dist/monetization/credits/auto-topup-usage.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-usage.test.js +53 -12
- package/dist/monetization/credits/bot-billing.d.ts +3 -3
- package/dist/monetization/credits/bot-billing.test.js +18 -5
- package/dist/monetization/credits/credit-expiry-cron.test.js +25 -8
- package/dist/monetization/credits/dividend-cron.d.ts +2 -4
- package/dist/monetization/credits/dividend-cron.js +7 -4
- package/dist/monetization/credits/dividend-cron.test.js +26 -46
- package/dist/monetization/credits/dividend-repository.js +15 -24
- package/dist/monetization/credits/dividend-repository.test.js +4 -3
- package/dist/monetization/credits/index.d.ts +2 -2
- package/dist/monetization/credits/index.js +1 -1
- package/dist/monetization/credits/member-usage.test.js +23 -10
- package/dist/monetization/credits/phone-billing.d.ts +2 -2
- package/dist/monetization/credits/phone-billing.js +5 -1
- package/dist/monetization/credits/phone-billing.test.js +9 -12
- package/dist/monetization/credits/runtime-cron.d.ts +2 -2
- package/dist/monetization/credits/runtime-cron.js +32 -8
- package/dist/monetization/credits/runtime-cron.test.js +28 -27
- package/dist/monetization/credits/runtime-scheduler.d.ts +2 -2
- package/dist/monetization/credits/runtime-scheduler.test.js +1 -1
- package/dist/monetization/credits/signup-grant.test.js +5 -3
- package/dist/monetization/credits/storage-tier-cron.test.js +3 -2
- package/dist/monetization/credits/trial-balance-cron.test.js +42 -0
- package/dist/monetization/feature-gate.d.ts +3 -3
- package/dist/monetization/index.d.ts +3 -3
- package/dist/monetization/index.js +1 -1
- package/dist/monetization/metering/reconciliation-cron.test.js +9 -8
- package/dist/monetization/metering/reconciliation-repository.js +11 -10
- package/dist/monetization/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/payram/webhook.d.ts +2 -2
- package/dist/monetization/payram/webhook.js +5 -1
- package/dist/monetization/payram/webhook.test.js +5 -4
- package/dist/monetization/promotions/engine.d.ts +2 -2
- package/dist/monetization/promotions/engine.js +4 -1
- package/dist/monetization/promotions/engine.test.js +3 -1
- package/dist/monetization/repository-types.d.ts +1 -1
- package/dist/monetization/socket/socket.d.ts +3 -3
- package/dist/monetization/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/monetization/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/monetization/stripe/webhook.d.ts +2 -2
- package/dist/monetization/stripe/webhook.js +70 -6
- package/dist/monetization/stripe/webhook.test.js +20 -15
- package/dist/onboarding/onboarding-service.d.ts +2 -2
- package/dist/onboarding/onboarding-service.js +6 -2
- package/drizzle/migrations/0002_gateway_service_keys.sql +14 -0
- package/drizzle/migrations/0003_double_entry_ledger.sql +82 -0
- package/drizzle/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/api/routes/admin-credits.ts +11 -14
- package/src/api/routes/quota.ts +2 -2
- package/src/api/routes/verify-email.ts +4 -4
- package/src/backup/on-demand-snapshot-service.test.ts +3 -3
- package/src/backup/on-demand-snapshot-service.ts +3 -3
- package/src/billing/payram/webhook.test.ts +7 -5
- package/src/billing/payram/webhook.ts +8 -11
- package/src/billing/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/billing/stripe/stripe-payment-processor.ts +3 -3
- package/src/billing/stripe/tenant-store.ts +1 -1
- package/src/credits/auto-topup-charge.test.ts +7 -5
- package/src/credits/auto-topup-charge.ts +7 -10
- package/src/credits/auto-topup-usage.test.ts +55 -13
- package/src/credits/auto-topup-usage.ts +2 -2
- package/src/credits/credit-expiry-cron.test.ts +26 -45
- package/src/credits/credit-expiry-cron.ts +9 -12
- package/src/credits/credit-ledger.ts +3 -3
- package/src/credits/dividend-cron.test.ts +38 -45
- package/src/credits/dividend-cron.ts +12 -26
- package/src/credits/dividend-repository.test.ts +4 -3
- package/src/credits/dividend-repository.ts +21 -23
- package/src/credits/index.ts +23 -4
- package/src/credits/ledger.test.ts +514 -0
- package/src/credits/ledger.ts +851 -0
- package/src/credits/signup-grant.test.ts +7 -4
- package/src/credits/signup-grant.ts +6 -12
- package/src/credits/trial-balance-cron.test.ts +68 -0
- package/src/credits/trial-balance-cron.ts +46 -0
- package/src/db/schema/gateway-service-keys.ts +23 -0
- package/src/db/schema/index.ts +2 -0
- package/src/db/schema/ledger.ts +94 -0
- package/src/gateway/credit-gate-wiring.test.ts +3 -3
- package/src/gateway/credit-gate.test.ts +35 -33
- package/src/gateway/credit-gate.ts +6 -10
- package/src/gateway/gateway-routes.test.ts +6 -6
- package/src/gateway/index.ts +2 -0
- package/src/gateway/protocol/anthropic.ts +2 -2
- package/src/gateway/protocol/deps.ts +5 -5
- package/src/gateway/protocol/openai.ts +2 -2
- package/src/gateway/proxy.ts +4 -4
- package/src/gateway/route-mounting.test.ts +3 -3
- package/src/gateway/service-key-auth.ts +4 -2
- package/src/gateway/service-key-repository.ts +87 -0
- package/src/gateway/types.ts +5 -5
- package/src/metering/reconciliation-cron.test.ts +10 -9
- package/src/metering/reconciliation-repository.test.ts +10 -9
- package/src/metering/reconciliation-repository.ts +14 -11
- package/src/monetization/affiliate/affiliate-admin-repository.test.ts +32 -19
- package/src/monetization/affiliate/affiliate-admin-repository.ts +16 -8
- package/src/monetization/affiliate/credit-match.test.ts +60 -14
- package/src/monetization/affiliate/credit-match.ts +6 -9
- package/src/monetization/affiliate/new-user-bonus.test.ts +6 -4
- package/src/monetization/affiliate/new-user-bonus.ts +6 -9
- package/src/monetization/credits/auto-topup-charge.test.ts +7 -5
- package/src/monetization/credits/auto-topup-charge.ts +7 -10
- package/src/monetization/credits/auto-topup-usage.test.ts +55 -13
- package/src/monetization/credits/auto-topup-usage.ts +2 -2
- package/src/monetization/credits/bot-billing.test.ts +20 -6
- package/src/monetization/credits/bot-billing.ts +3 -3
- package/src/monetization/credits/credit-expiry-cron.test.ts +26 -45
- package/src/monetization/credits/dividend-cron.test.ts +34 -48
- package/src/monetization/credits/dividend-cron.ts +9 -14
- package/src/monetization/credits/dividend-repository.test.ts +4 -3
- package/src/monetization/credits/dividend-repository.ts +19 -25
- package/src/monetization/credits/index.ts +4 -4
- package/src/monetization/credits/member-usage.test.ts +25 -11
- package/src/monetization/credits/phone-billing.test.ts +18 -26
- package/src/monetization/credits/phone-billing.ts +7 -10
- package/src/monetization/credits/runtime-cron.test.ts +29 -28
- package/src/monetization/credits/runtime-cron.ts +34 -58
- package/src/monetization/credits/runtime-scheduler.test.ts +1 -1
- package/src/monetization/credits/runtime-scheduler.ts +2 -2
- package/src/monetization/credits/signup-grant.test.ts +7 -4
- package/src/monetization/credits/storage-tier-cron.test.ts +5 -3
- package/src/monetization/credits/trial-balance-cron.test.ts +52 -0
- package/src/monetization/feature-gate.ts +3 -3
- package/src/monetization/index.ts +4 -4
- package/src/monetization/metering/reconciliation-cron.test.ts +10 -9
- package/src/monetization/metering/reconciliation-repository.test.ts +11 -9
- package/src/monetization/metering/reconciliation-repository.ts +13 -11
- package/src/monetization/payram/webhook.test.ts +7 -5
- package/src/monetization/payram/webhook.ts +7 -10
- package/src/monetization/promotions/engine.test.ts +6 -5
- package/src/monetization/promotions/engine.ts +6 -3
- package/src/monetization/repository-types.ts +1 -1
- package/src/monetization/socket/socket.ts +4 -4
- package/src/monetization/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/monetization/stripe/stripe-payment-processor.ts +3 -3
- package/src/monetization/stripe/webhook.test.ts +22 -16
- package/src/monetization/stripe/webhook.ts +75 -50
- package/src/onboarding/onboarding-service.ts +8 -11
- package/dist/credits/credit-ledger-extra.test.js +0 -40
- package/dist/credits/credit-ledger.bench.js +0 -33
- package/dist/credits/credit-ledger.test.d.ts +0 -4
- package/dist/credits/credit-ledger.test.js +0 -203
- package/dist/credits/credit-transaction-repository.test.js +0 -232
- package/dist/monetization/credits/credit-ledger-extra.test.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger-extra.test.js +0 -39
- package/dist/monetization/credits/credit-ledger.bench.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger.bench.js +0 -32
- package/dist/monetization/credits/credit-ledger.test.d.ts +0 -4
- package/dist/monetization/credits/credit-ledger.test.js +0 -202
- package/dist/monetization/credits/credit-transaction-repository.test.d.ts +0 -1
- package/dist/monetization/credits/credit-transaction-repository.test.js +0 -232
- package/src/credits/credit-ledger-extra.test.ts +0 -57
- package/src/credits/credit-ledger.bench.ts +0 -56
- package/src/credits/credit-ledger.test.ts +0 -276
- package/src/credits/credit-transaction-repository.test.ts +0 -274
- package/src/monetization/credits/credit-ledger-extra.test.ts +0 -56
- package/src/monetization/credits/credit-ledger.bench.ts +0 -55
- package/src/monetization/credits/credit-ledger.test.ts +0 -275
- package/src/monetization/credits/credit-transaction-repository.test.ts +0 -274
- /package/dist/credits/{credit-ledger-extra.test.d.ts → ledger.test.d.ts} +0 -0
- /package/dist/credits/{credit-ledger.bench.d.ts → trial-balance-cron.test.d.ts} +0 -0
- /package/dist/{credits/credit-transaction-repository.test.d.ts → monetization/credits/trial-balance-cron.test.d.ts} +0 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
3
|
+
import { Credit } from "./credit.js";
|
|
4
|
+
import { DrizzleLedger, InsufficientBalanceError } from "./ledger.js";
|
|
5
|
+
let pool;
|
|
6
|
+
let db;
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
({ db, pool } = await createTestDb());
|
|
9
|
+
});
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
await pool.close();
|
|
12
|
+
});
|
|
13
|
+
describe("DrizzleLedger", () => {
|
|
14
|
+
let ledger;
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
await truncateAllTables(pool);
|
|
17
|
+
ledger = new DrizzleLedger(db);
|
|
18
|
+
await ledger.seedSystemAccounts();
|
|
19
|
+
});
|
|
20
|
+
// -----------------------------------------------------------------------
|
|
21
|
+
// post() — the primitive
|
|
22
|
+
// -----------------------------------------------------------------------
|
|
23
|
+
describe("post()", () => {
|
|
24
|
+
it("rejects entries with fewer than 2 lines", async () => {
|
|
25
|
+
await expect(ledger.post({
|
|
26
|
+
entryType: "purchase",
|
|
27
|
+
tenantId: "t1",
|
|
28
|
+
lines: [{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" }],
|
|
29
|
+
})).rejects.toThrow("at least 2 lines");
|
|
30
|
+
});
|
|
31
|
+
it("rejects unbalanced entries", async () => {
|
|
32
|
+
await expect(ledger.post({
|
|
33
|
+
entryType: "purchase",
|
|
34
|
+
tenantId: "t1",
|
|
35
|
+
lines: [
|
|
36
|
+
{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" },
|
|
37
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(50), side: "credit" },
|
|
38
|
+
],
|
|
39
|
+
})).rejects.toThrow("Unbalanced");
|
|
40
|
+
});
|
|
41
|
+
it("rejects zero-amount lines", async () => {
|
|
42
|
+
await expect(ledger.post({
|
|
43
|
+
entryType: "purchase",
|
|
44
|
+
tenantId: "t1",
|
|
45
|
+
lines: [
|
|
46
|
+
{ accountCode: "1000", amount: Credit.ZERO, side: "debit" },
|
|
47
|
+
{ accountCode: "2000:t1", amount: Credit.ZERO, side: "credit" },
|
|
48
|
+
],
|
|
49
|
+
})).rejects.toThrow("must be positive");
|
|
50
|
+
});
|
|
51
|
+
it("rejects negative-amount lines", async () => {
|
|
52
|
+
await expect(ledger.post({
|
|
53
|
+
entryType: "purchase",
|
|
54
|
+
tenantId: "t1",
|
|
55
|
+
lines: [
|
|
56
|
+
{ accountCode: "1000", amount: Credit.fromRaw(-100), side: "debit" },
|
|
57
|
+
{ accountCode: "2000:t1", amount: Credit.fromRaw(-100), side: "credit" },
|
|
58
|
+
],
|
|
59
|
+
})).rejects.toThrow("must be positive");
|
|
60
|
+
});
|
|
61
|
+
it("posts a balanced entry and returns it", async () => {
|
|
62
|
+
const entry = await ledger.post({
|
|
63
|
+
entryType: "purchase",
|
|
64
|
+
tenantId: "t1",
|
|
65
|
+
description: "Stripe purchase",
|
|
66
|
+
referenceId: "pi_abc123",
|
|
67
|
+
metadata: { fundingSource: "stripe" },
|
|
68
|
+
createdBy: "system",
|
|
69
|
+
lines: [
|
|
70
|
+
{ accountCode: "1000", amount: Credit.fromCents(1000), side: "debit" },
|
|
71
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(1000), side: "credit" },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
expect(entry.id).toBeTruthy();
|
|
75
|
+
expect(entry.entryType).toBe("purchase");
|
|
76
|
+
expect(entry.tenantId).toBe("t1");
|
|
77
|
+
expect(entry.description).toBe("Stripe purchase");
|
|
78
|
+
expect(entry.referenceId).toBe("pi_abc123");
|
|
79
|
+
expect(entry.lines).toHaveLength(2);
|
|
80
|
+
});
|
|
81
|
+
it("enforces unique referenceId", async () => {
|
|
82
|
+
await ledger.post({
|
|
83
|
+
entryType: "purchase",
|
|
84
|
+
tenantId: "t1",
|
|
85
|
+
referenceId: "unique-ref",
|
|
86
|
+
lines: [
|
|
87
|
+
{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" },
|
|
88
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(100), side: "credit" },
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
await expect(ledger.post({
|
|
92
|
+
entryType: "purchase",
|
|
93
|
+
tenantId: "t2",
|
|
94
|
+
referenceId: "unique-ref",
|
|
95
|
+
lines: [
|
|
96
|
+
{ accountCode: "1000", amount: Credit.fromCents(200), side: "debit" },
|
|
97
|
+
{ accountCode: "2000:t2", amount: Credit.fromCents(200), side: "credit" },
|
|
98
|
+
],
|
|
99
|
+
})).rejects.toThrow();
|
|
100
|
+
});
|
|
101
|
+
it("supports multi-line entries (3+ lines)", async () => {
|
|
102
|
+
// Split a $10 purchase: $7 to tenant, $3 to revenue (hypothetical split)
|
|
103
|
+
const entry = await ledger.post({
|
|
104
|
+
entryType: "split_purchase",
|
|
105
|
+
tenantId: "t1",
|
|
106
|
+
lines: [
|
|
107
|
+
{ accountCode: "1000", amount: Credit.fromCents(1000), side: "debit" },
|
|
108
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(700), side: "credit" },
|
|
109
|
+
{ accountCode: "4000", amount: Credit.fromCents(300), side: "credit" },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
expect(entry.lines).toHaveLength(3);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// -----------------------------------------------------------------------
|
|
116
|
+
// credit() — convenience
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
describe("credit()", () => {
|
|
119
|
+
it("purchase: DR cash, CR unearned_revenue", async () => {
|
|
120
|
+
const entry = await ledger.credit("t1", Credit.fromCents(500), "purchase", {
|
|
121
|
+
description: "Stripe $5",
|
|
122
|
+
fundingSource: "stripe",
|
|
123
|
+
});
|
|
124
|
+
expect(entry.entryType).toBe("purchase");
|
|
125
|
+
expect(entry.lines).toHaveLength(2);
|
|
126
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
127
|
+
const debitLine = entry.lines.find((l) => l.side === "debit");
|
|
128
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
129
|
+
const creditLine = entry.lines.find((l) => l.side === "credit");
|
|
130
|
+
expect(debitLine.accountCode).toBe("1000"); // cash
|
|
131
|
+
expect(creditLine.accountCode).toBe("2000:t1"); // unearned revenue
|
|
132
|
+
expect(debitLine.amount.toCentsRounded()).toBe(500);
|
|
133
|
+
expect(creditLine.amount.toCentsRounded()).toBe(500);
|
|
134
|
+
});
|
|
135
|
+
it("signup_grant: DR expense, CR unearned_revenue", async () => {
|
|
136
|
+
const entry = await ledger.credit("t1", Credit.fromCents(100), "signup_grant");
|
|
137
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
138
|
+
const debitLine = entry.lines.find((l) => l.side === "debit");
|
|
139
|
+
expect(debitLine.accountCode).toBe("5000"); // expense:signup_grant
|
|
140
|
+
});
|
|
141
|
+
it("rejects zero amount", async () => {
|
|
142
|
+
await expect(ledger.credit("t1", Credit.ZERO, "purchase")).rejects.toThrow("must be positive");
|
|
143
|
+
});
|
|
144
|
+
it("supports referenceId for idempotency", async () => {
|
|
145
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase", {
|
|
146
|
+
referenceId: "pi_abc",
|
|
147
|
+
});
|
|
148
|
+
expect(await ledger.hasReferenceId("pi_abc")).toBe(true);
|
|
149
|
+
expect(await ledger.hasReferenceId("pi_xyz")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
// -----------------------------------------------------------------------
|
|
153
|
+
// debit() — convenience
|
|
154
|
+
// -----------------------------------------------------------------------
|
|
155
|
+
describe("debit()", () => {
|
|
156
|
+
beforeEach(async () => {
|
|
157
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
158
|
+
});
|
|
159
|
+
it("bot_runtime: DR unearned_revenue, CR revenue", async () => {
|
|
160
|
+
const entry = await ledger.debit("t1", Credit.fromCents(200), "bot_runtime", {
|
|
161
|
+
description: "1hr compute",
|
|
162
|
+
});
|
|
163
|
+
expect(entry.entryType).toBe("bot_runtime");
|
|
164
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
165
|
+
const debitLine = entry.lines.find((l) => l.side === "debit");
|
|
166
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
167
|
+
const creditLine = entry.lines.find((l) => l.side === "credit");
|
|
168
|
+
expect(debitLine.accountCode).toBe("2000:t1"); // unearned revenue decreases
|
|
169
|
+
expect(creditLine.accountCode).toBe("4000"); // revenue recognized
|
|
170
|
+
});
|
|
171
|
+
it("throws InsufficientBalanceError when balance too low", async () => {
|
|
172
|
+
await expect(ledger.debit("t1", Credit.fromCents(2000), "bot_runtime")).rejects.toBeInstanceOf(InsufficientBalanceError);
|
|
173
|
+
});
|
|
174
|
+
it("allowNegative bypasses balance check", async () => {
|
|
175
|
+
const entry = await ledger.debit("t1", Credit.fromCents(2000), "bot_runtime", {
|
|
176
|
+
allowNegative: true,
|
|
177
|
+
});
|
|
178
|
+
expect(entry.entryType).toBe("bot_runtime");
|
|
179
|
+
const bal = await ledger.balance("t1");
|
|
180
|
+
expect(bal.toCentsRounded()).toBe(-1000);
|
|
181
|
+
});
|
|
182
|
+
it("refund: DR unearned_revenue, CR cash", async () => {
|
|
183
|
+
const entry = await ledger.debit("t1", Credit.fromCents(300), "refund");
|
|
184
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
185
|
+
const creditLine = entry.lines.find((l) => l.side === "credit");
|
|
186
|
+
expect(creditLine.accountCode).toBe("1000"); // cash goes out
|
|
187
|
+
});
|
|
188
|
+
it("rejects zero amount", async () => {
|
|
189
|
+
await expect(ledger.debit("t1", Credit.ZERO, "bot_runtime")).rejects.toThrow("must be positive");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
// -----------------------------------------------------------------------
|
|
193
|
+
// balance()
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
describe("balance()", () => {
|
|
196
|
+
it("returns ZERO for unknown tenant", async () => {
|
|
197
|
+
expect((await ledger.balance("unknown")).isZero()).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
it("reflects credits and debits", async () => {
|
|
200
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
201
|
+
expect((await ledger.balance("t1")).toCentsRounded()).toBe(1000);
|
|
202
|
+
await ledger.debit("t1", Credit.fromCents(300), "bot_runtime");
|
|
203
|
+
expect((await ledger.balance("t1")).toCentsRounded()).toBe(700);
|
|
204
|
+
});
|
|
205
|
+
it("multiple tenants are independent", async () => {
|
|
206
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
207
|
+
await ledger.credit("t2", Credit.fromCents(200), "purchase");
|
|
208
|
+
expect((await ledger.balance("t1")).toCentsRounded()).toBe(500);
|
|
209
|
+
expect((await ledger.balance("t2")).toCentsRounded()).toBe(200);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
// -----------------------------------------------------------------------
|
|
213
|
+
// accountBalance() — any account
|
|
214
|
+
// -----------------------------------------------------------------------
|
|
215
|
+
describe("accountBalance()", () => {
|
|
216
|
+
it("tracks cash (asset) balance", async () => {
|
|
217
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase"); // DR cash
|
|
218
|
+
expect((await ledger.accountBalance("1000")).toCentsRounded()).toBe(1000);
|
|
219
|
+
await ledger.debit("t1", Credit.fromCents(300), "refund"); // CR cash
|
|
220
|
+
expect((await ledger.accountBalance("1000")).toCentsRounded()).toBe(700);
|
|
221
|
+
});
|
|
222
|
+
it("tracks revenue balance", async () => {
|
|
223
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
224
|
+
await ledger.debit("t1", Credit.fromCents(400), "bot_runtime"); // CR revenue
|
|
225
|
+
expect((await ledger.accountBalance("4000")).toCentsRounded()).toBe(400);
|
|
226
|
+
});
|
|
227
|
+
it("tracks expense balance", async () => {
|
|
228
|
+
await ledger.credit("t1", Credit.fromCents(100), "signup_grant"); // DR expense
|
|
229
|
+
expect((await ledger.accountBalance("5000")).toCentsRounded()).toBe(100);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
// trialBalance() — THE accounting invariant
|
|
234
|
+
// -----------------------------------------------------------------------
|
|
235
|
+
describe("trialBalance()", () => {
|
|
236
|
+
it("empty ledger is balanced", async () => {
|
|
237
|
+
const tb = await ledger.trialBalance();
|
|
238
|
+
expect(tb.balanced).toBe(true);
|
|
239
|
+
expect(tb.difference.isZero()).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
it("balanced after multiple transactions", async () => {
|
|
242
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
243
|
+
await ledger.credit("t2", Credit.fromCents(500), "signup_grant");
|
|
244
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime");
|
|
245
|
+
await ledger.debit("t2", Credit.fromCents(100), "adapter_usage");
|
|
246
|
+
const tb = await ledger.trialBalance();
|
|
247
|
+
expect(tb.balanced).toBe(true);
|
|
248
|
+
expect(tb.totalDebits.equals(tb.totalCredits)).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
// -----------------------------------------------------------------------
|
|
252
|
+
// history()
|
|
253
|
+
// -----------------------------------------------------------------------
|
|
254
|
+
describe("history()", () => {
|
|
255
|
+
it("returns entries newest-first with lines", async () => {
|
|
256
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
257
|
+
await ledger.credit("t1", Credit.fromCents(200), "admin_grant");
|
|
258
|
+
await ledger.debit("t1", Credit.fromCents(50), "bot_runtime");
|
|
259
|
+
const entries = await ledger.history("t1");
|
|
260
|
+
expect(entries).toHaveLength(3);
|
|
261
|
+
expect(entries[0].entryType).toBe("bot_runtime"); // newest
|
|
262
|
+
expect(entries[2].entryType).toBe("purchase"); // oldest
|
|
263
|
+
// Each entry has lines
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
expect(e.lines.length).toBeGreaterThanOrEqual(2);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
it("filters by type", async () => {
|
|
269
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
270
|
+
await ledger.credit("t1", Credit.fromCents(200), "signup_grant");
|
|
271
|
+
const purchases = await ledger.history("t1", { type: "purchase" });
|
|
272
|
+
expect(purchases).toHaveLength(1);
|
|
273
|
+
expect(purchases[0].entryType).toBe("purchase");
|
|
274
|
+
});
|
|
275
|
+
it("paginates with limit and offset", async () => {
|
|
276
|
+
for (let i = 0; i < 5; i++) {
|
|
277
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
278
|
+
}
|
|
279
|
+
const page1 = await ledger.history("t1", { limit: 2, offset: 0 });
|
|
280
|
+
const page2 = await ledger.history("t1", { limit: 2, offset: 2 });
|
|
281
|
+
expect(page1).toHaveLength(2);
|
|
282
|
+
expect(page2).toHaveLength(2);
|
|
283
|
+
expect(page1[0].id).not.toBe(page2[0].id);
|
|
284
|
+
});
|
|
285
|
+
it("isolates tenants", async () => {
|
|
286
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
287
|
+
await ledger.credit("t2", Credit.fromCents(200), "purchase");
|
|
288
|
+
expect(await ledger.history("t1")).toHaveLength(1);
|
|
289
|
+
expect(await ledger.history("t2")).toHaveLength(1);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
// -----------------------------------------------------------------------
|
|
293
|
+
// tenantsWithBalance()
|
|
294
|
+
// -----------------------------------------------------------------------
|
|
295
|
+
describe("tenantsWithBalance()", () => {
|
|
296
|
+
it("returns only tenants with positive balance", async () => {
|
|
297
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
298
|
+
await ledger.credit("t2", Credit.fromCents(300), "purchase");
|
|
299
|
+
await ledger.debit("t2", Credit.fromCents(300), "bot_runtime"); // zero balance
|
|
300
|
+
const result = await ledger.tenantsWithBalance();
|
|
301
|
+
expect(result).toHaveLength(1);
|
|
302
|
+
expect(result[0].tenantId).toBe("t1");
|
|
303
|
+
expect(result[0].balance.toCentsRounded()).toBe(500);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
// -----------------------------------------------------------------------
|
|
307
|
+
// lifetimeSpend()
|
|
308
|
+
// -----------------------------------------------------------------------
|
|
309
|
+
describe("lifetimeSpend()", () => {
|
|
310
|
+
it("sums all debits from tenant liability account", async () => {
|
|
311
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
312
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime");
|
|
313
|
+
await ledger.debit("t1", Credit.fromCents(300), "adapter_usage");
|
|
314
|
+
const spend = await ledger.lifetimeSpend("t1");
|
|
315
|
+
expect(spend.toCentsRounded()).toBe(500);
|
|
316
|
+
});
|
|
317
|
+
it("returns zero for unknown tenant", async () => {
|
|
318
|
+
const spend = await ledger.lifetimeSpend("unknown");
|
|
319
|
+
expect(spend.isZero()).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
// -----------------------------------------------------------------------
|
|
323
|
+
// lifetimeSpendBatch()
|
|
324
|
+
// -----------------------------------------------------------------------
|
|
325
|
+
describe("lifetimeSpendBatch()", () => {
|
|
326
|
+
it("returns spend for multiple tenants", async () => {
|
|
327
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
328
|
+
await ledger.credit("t2", Credit.fromCents(500), "purchase");
|
|
329
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime");
|
|
330
|
+
await ledger.debit("t2", Credit.fromCents(100), "bot_runtime");
|
|
331
|
+
const result = await ledger.lifetimeSpendBatch(["t1", "t2", "t3"]);
|
|
332
|
+
// biome-ignore lint/style/noNonNullAssertion: keys guaranteed present per API contract
|
|
333
|
+
expect(result.get("t1").toCentsRounded()).toBe(200);
|
|
334
|
+
// biome-ignore lint/style/noNonNullAssertion: keys guaranteed present per API contract
|
|
335
|
+
expect(result.get("t2").toCentsRounded()).toBe(100);
|
|
336
|
+
// biome-ignore lint/style/noNonNullAssertion: keys guaranteed present per API contract
|
|
337
|
+
expect(result.get("t3").isZero()).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
it("returns empty map for empty input", async () => {
|
|
340
|
+
const result = await ledger.lifetimeSpendBatch([]);
|
|
341
|
+
expect(result.size).toBe(0);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
// -----------------------------------------------------------------------
|
|
345
|
+
// memberUsage()
|
|
346
|
+
// -----------------------------------------------------------------------
|
|
347
|
+
describe("memberUsage()", () => {
|
|
348
|
+
it("aggregates debit totals per attributed user", async () => {
|
|
349
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
350
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime", {
|
|
351
|
+
attributedUserId: "user-a",
|
|
352
|
+
});
|
|
353
|
+
await ledger.debit("t1", Credit.fromCents(300), "bot_runtime", {
|
|
354
|
+
attributedUserId: "user-a",
|
|
355
|
+
});
|
|
356
|
+
await ledger.debit("t1", Credit.fromCents(100), "bot_runtime", {
|
|
357
|
+
attributedUserId: "user-b",
|
|
358
|
+
});
|
|
359
|
+
const usage = await ledger.memberUsage("t1");
|
|
360
|
+
expect(usage).toHaveLength(2);
|
|
361
|
+
// biome-ignore lint/style/noNonNullAssertion: seeded above, guaranteed present
|
|
362
|
+
const userA = usage.find((u) => u.userId === "user-a");
|
|
363
|
+
// biome-ignore lint/style/noNonNullAssertion: seeded above, guaranteed present
|
|
364
|
+
const userB = usage.find((u) => u.userId === "user-b");
|
|
365
|
+
expect(userA.totalDebit.toCentsRounded()).toBe(500);
|
|
366
|
+
expect(userA.transactionCount).toBe(2);
|
|
367
|
+
expect(userB.totalDebit.toCentsRounded()).toBe(100);
|
|
368
|
+
expect(userB.transactionCount).toBe(1);
|
|
369
|
+
});
|
|
370
|
+
it("excludes entries without attributedUserId", async () => {
|
|
371
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
372
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime"); // no user
|
|
373
|
+
const usage = await ledger.memberUsage("t1");
|
|
374
|
+
expect(usage).toHaveLength(0);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
// -----------------------------------------------------------------------
|
|
378
|
+
// The accounting equation: Assets = Liabilities + Equity + Revenue - Expenses
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
describe("accounting equation", () => {
|
|
381
|
+
it("holds after a purchase + usage cycle", async () => {
|
|
382
|
+
// Tenant buys $10
|
|
383
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
384
|
+
// Tenant uses $3
|
|
385
|
+
await ledger.debit("t1", Credit.fromCents(300), "bot_runtime");
|
|
386
|
+
const cash = await ledger.accountBalance("1000"); // asset
|
|
387
|
+
const unearned = await ledger.balance("t1"); // liability
|
|
388
|
+
const revenue = await ledger.accountBalance("4000"); // revenue
|
|
389
|
+
// Assets ($10) = Liabilities ($7) + Revenue ($3)
|
|
390
|
+
expect(cash.toCentsRounded()).toBe(1000);
|
|
391
|
+
expect(unearned.toCentsRounded()).toBe(700);
|
|
392
|
+
expect(revenue.toCentsRounded()).toBe(300);
|
|
393
|
+
expect(cash.toCentsRounded()).toBe(unearned.toCentsRounded() + revenue.toCentsRounded());
|
|
394
|
+
});
|
|
395
|
+
it("holds after purchase + grant + usage + refund", async () => {
|
|
396
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
397
|
+
await ledger.credit("t1", Credit.fromCents(100), "signup_grant");
|
|
398
|
+
await ledger.debit("t1", Credit.fromCents(400), "bot_runtime");
|
|
399
|
+
await ledger.debit("t1", Credit.fromCents(200), "refund");
|
|
400
|
+
// Assets = cash: $10 purchase - $2 refund = $8
|
|
401
|
+
// Liabilities = unearned: $10 + $1 grant - $4 usage - $2 refund = $5
|
|
402
|
+
// Revenue = $4
|
|
403
|
+
// Expense = $1 (signup grant)
|
|
404
|
+
// A = L + R - E → $8 = $5 + $4 - $1 = $8 ✓
|
|
405
|
+
const cash = await ledger.accountBalance("1000");
|
|
406
|
+
const unearned = await ledger.balance("t1");
|
|
407
|
+
const revenue = await ledger.accountBalance("4000");
|
|
408
|
+
const expense = await ledger.accountBalance("5000");
|
|
409
|
+
expect(cash.toCentsRounded()).toBe(800);
|
|
410
|
+
expect(unearned.toCentsRounded()).toBe(500);
|
|
411
|
+
expect(revenue.toCentsRounded()).toBe(400);
|
|
412
|
+
expect(expense.toCentsRounded()).toBe(100);
|
|
413
|
+
// Verify trial balance
|
|
414
|
+
const tb = await ledger.trialBalance();
|
|
415
|
+
expect(tb.balanced).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Credit } from "./credit.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ILedger } from "./ledger.js";
|
|
3
3
|
/** Signup grant amount: $5.00 */
|
|
4
4
|
export declare const SIGNUP_GRANT: Credit;
|
|
5
5
|
/**
|
|
@@ -9,4 +9,4 @@ export declare const SIGNUP_GRANT: Credit;
|
|
|
9
9
|
*
|
|
10
10
|
* @returns true if the grant was applied, false if already granted.
|
|
11
11
|
*/
|
|
12
|
-
export declare function grantSignupCredits(ledger:
|
|
12
|
+
export declare function grantSignupCredits(ledger: ILedger, tenantId: string): Promise<boolean>;
|
|
@@ -10,16 +10,16 @@ export const SIGNUP_GRANT = Credit.fromDollars(5);
|
|
|
10
10
|
*/
|
|
11
11
|
export async function grantSignupCredits(ledger, tenantId) {
|
|
12
12
|
const refId = `signup:${tenantId}`;
|
|
13
|
-
// Idempotency check
|
|
14
13
|
if (await ledger.hasReferenceId(refId)) {
|
|
15
14
|
return false;
|
|
16
15
|
}
|
|
17
16
|
try {
|
|
18
|
-
await ledger.credit(tenantId, SIGNUP_GRANT, "signup_grant",
|
|
17
|
+
await ledger.credit(tenantId, SIGNUP_GRANT, "signup_grant", {
|
|
18
|
+
description: "Welcome bonus — $5.00 credit on email verification",
|
|
19
|
+
referenceId: refId,
|
|
20
|
+
});
|
|
19
21
|
}
|
|
20
22
|
catch (err) {
|
|
21
|
-
// Concurrent verify-email request won the race and already inserted the same referenceId.
|
|
22
|
-
// Treat unique constraint violation as a no-op (idempotent).
|
|
23
23
|
if (isUniqueConstraintViolation(err))
|
|
24
24
|
return false;
|
|
25
25
|
throw err;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
3
|
-
import {
|
|
3
|
+
import { DrizzleLedger } from "./ledger.js";
|
|
4
4
|
import { grantSignupCredits, SIGNUP_GRANT } from "./signup-grant.js";
|
|
5
5
|
describe("grantSignupCredits", () => {
|
|
6
6
|
let pool;
|
|
@@ -14,7 +14,8 @@ describe("grantSignupCredits", () => {
|
|
|
14
14
|
});
|
|
15
15
|
beforeEach(async () => {
|
|
16
16
|
await truncateAllTables(pool);
|
|
17
|
-
ledger = new
|
|
17
|
+
ledger = new DrizzleLedger(db);
|
|
18
|
+
await ledger.seedSystemAccounts();
|
|
18
19
|
});
|
|
19
20
|
it("grants credits to a new tenant and returns true", async () => {
|
|
20
21
|
const result = await grantSignupCredits(ledger, "tenant-1");
|
|
@@ -42,7 +43,8 @@ describe("grantSignupCredits", () => {
|
|
|
42
43
|
const uniqueErr = Object.assign(new Error("duplicate key value violates unique constraint"), {
|
|
43
44
|
code: "23505",
|
|
44
45
|
});
|
|
45
|
-
const racingLedger = new
|
|
46
|
+
const racingLedger = new DrizzleLedger(db);
|
|
47
|
+
await racingLedger.seedSystemAccounts();
|
|
46
48
|
vi.spyOn(racingLedger, "hasReferenceId").mockResolvedValue(false);
|
|
47
49
|
vi.spyOn(racingLedger, "credit").mockRejectedValue(uniqueErr);
|
|
48
50
|
const result = await grantSignupCredits(racingLedger, "tenant-race");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ILedger } from "./ledger.js";
|
|
2
|
+
export interface TrialBalanceCronConfig {
|
|
3
|
+
ledger: ILedger;
|
|
4
|
+
}
|
|
5
|
+
export interface TrialBalanceCronResult {
|
|
6
|
+
balanced: boolean;
|
|
7
|
+
totalDebits: number;
|
|
8
|
+
totalCredits: number;
|
|
9
|
+
/** Absolute difference in raw units (nanodollars). Zero when balanced. */
|
|
10
|
+
differenceRaw: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run a trial balance check: assert that sum(debit lines) === sum(credit lines)
|
|
14
|
+
* across all journal entries.
|
|
15
|
+
*
|
|
16
|
+
* Designed to run hourly. Logs an error on imbalance but never throws —
|
|
17
|
+
* an imbalance is historical and requires human investigation, not automated action.
|
|
18
|
+
*/
|
|
19
|
+
export declare function runTrialBalanceCron(cfg: TrialBalanceCronConfig): Promise<TrialBalanceCronResult>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { logger } from "../config/logger.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run a trial balance check: assert that sum(debit lines) === sum(credit lines)
|
|
4
|
+
* across all journal entries.
|
|
5
|
+
*
|
|
6
|
+
* Designed to run hourly. Logs an error on imbalance but never throws —
|
|
7
|
+
* an imbalance is historical and requires human investigation, not automated action.
|
|
8
|
+
*/
|
|
9
|
+
export async function runTrialBalanceCron(cfg) {
|
|
10
|
+
const tb = await cfg.ledger.trialBalance();
|
|
11
|
+
const result = {
|
|
12
|
+
balanced: tb.balanced,
|
|
13
|
+
totalDebits: tb.totalDebits.toRaw(),
|
|
14
|
+
totalCredits: tb.totalCredits.toRaw(),
|
|
15
|
+
differenceRaw: tb.difference.toRaw(),
|
|
16
|
+
};
|
|
17
|
+
if (!tb.balanced) {
|
|
18
|
+
logger.error("LEDGER IMBALANCE DETECTED — books do not balance", {
|
|
19
|
+
totalDebits: tb.totalDebits.toDisplayString(),
|
|
20
|
+
totalCredits: tb.totalCredits.toDisplayString(),
|
|
21
|
+
difference: tb.difference.toDisplayString(),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
logger.info("Trial balance check passed", {
|
|
26
|
+
totalDebits: tb.totalDebits.toDisplayString(),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
3
|
+
import { Credit } from "./credit.js";
|
|
4
|
+
import { DrizzleLedger } from "./ledger.js";
|
|
5
|
+
import { runTrialBalanceCron } from "./trial-balance-cron.js";
|
|
6
|
+
describe("runTrialBalanceCron", () => {
|
|
7
|
+
let pool;
|
|
8
|
+
let ledger;
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
const { db, pool: p } = await createTestDb();
|
|
11
|
+
pool = p;
|
|
12
|
+
ledger = new DrizzleLedger(db);
|
|
13
|
+
});
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await pool.close();
|
|
16
|
+
});
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
await truncateAllTables(pool);
|
|
19
|
+
await ledger.seedSystemAccounts();
|
|
20
|
+
});
|
|
21
|
+
it("returns balanced when no entries exist", async () => {
|
|
22
|
+
const result = await runTrialBalanceCron({ ledger });
|
|
23
|
+
expect(result.balanced).toBe(true);
|
|
24
|
+
expect(result.differenceRaw).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
it("returns balanced after normal credit and debit", async () => {
|
|
27
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
28
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime");
|
|
29
|
+
const result = await runTrialBalanceCron({ ledger });
|
|
30
|
+
expect(result.balanced).toBe(true);
|
|
31
|
+
expect(result.differenceRaw).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
it("logs an error on imbalance", async () => {
|
|
34
|
+
// Inject an imbalance by mocking trialBalance to return unbalanced data
|
|
35
|
+
const errorSpy = vi.spyOn(ledger, "trialBalance").mockResolvedValueOnce({
|
|
36
|
+
totalDebits: Credit.fromCents(1000),
|
|
37
|
+
totalCredits: Credit.fromCents(900),
|
|
38
|
+
balanced: false,
|
|
39
|
+
difference: Credit.fromCents(100),
|
|
40
|
+
});
|
|
41
|
+
const result = await runTrialBalanceCron({ ledger });
|
|
42
|
+
expect(result.balanced).toBe(false);
|
|
43
|
+
expect(result.differenceRaw).toBe(Credit.fromCents(100).toRaw());
|
|
44
|
+
errorSpy.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
it("does not throw on imbalance", async () => {
|
|
47
|
+
vi.spyOn(ledger, "trialBalance").mockResolvedValueOnce({
|
|
48
|
+
totalDebits: Credit.fromCents(500),
|
|
49
|
+
totalCredits: Credit.fromCents(400),
|
|
50
|
+
balanced: false,
|
|
51
|
+
difference: Credit.fromCents(100),
|
|
52
|
+
});
|
|
53
|
+
await expect(runTrialBalanceCron({ ledger })).resolves.not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
});
|