@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,514 @@
|
|
|
1
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { PlatformDb } from "../db/index.js";
|
|
4
|
+
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
5
|
+
import { Credit } from "./credit.js";
|
|
6
|
+
import { DrizzleLedger, InsufficientBalanceError } from "./ledger.js";
|
|
7
|
+
|
|
8
|
+
let pool: PGlite;
|
|
9
|
+
let db: PlatformDb;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
({ db, pool } = await createTestDb());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await pool.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("DrizzleLedger", () => {
|
|
20
|
+
let ledger: DrizzleLedger;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await truncateAllTables(pool);
|
|
24
|
+
ledger = new DrizzleLedger(db);
|
|
25
|
+
await ledger.seedSystemAccounts();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// -----------------------------------------------------------------------
|
|
29
|
+
// post() — the primitive
|
|
30
|
+
// -----------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe("post()", () => {
|
|
33
|
+
it("rejects entries with fewer than 2 lines", async () => {
|
|
34
|
+
await expect(
|
|
35
|
+
ledger.post({
|
|
36
|
+
entryType: "purchase",
|
|
37
|
+
tenantId: "t1",
|
|
38
|
+
lines: [{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" }],
|
|
39
|
+
}),
|
|
40
|
+
).rejects.toThrow("at least 2 lines");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects unbalanced entries", async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
ledger.post({
|
|
46
|
+
entryType: "purchase",
|
|
47
|
+
tenantId: "t1",
|
|
48
|
+
lines: [
|
|
49
|
+
{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" },
|
|
50
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(50), side: "credit" },
|
|
51
|
+
],
|
|
52
|
+
}),
|
|
53
|
+
).rejects.toThrow("Unbalanced");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects zero-amount lines", async () => {
|
|
57
|
+
await expect(
|
|
58
|
+
ledger.post({
|
|
59
|
+
entryType: "purchase",
|
|
60
|
+
tenantId: "t1",
|
|
61
|
+
lines: [
|
|
62
|
+
{ accountCode: "1000", amount: Credit.ZERO, side: "debit" },
|
|
63
|
+
{ accountCode: "2000:t1", amount: Credit.ZERO, side: "credit" },
|
|
64
|
+
],
|
|
65
|
+
}),
|
|
66
|
+
).rejects.toThrow("must be positive");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects negative-amount lines", async () => {
|
|
70
|
+
await expect(
|
|
71
|
+
ledger.post({
|
|
72
|
+
entryType: "purchase",
|
|
73
|
+
tenantId: "t1",
|
|
74
|
+
lines: [
|
|
75
|
+
{ accountCode: "1000", amount: Credit.fromRaw(-100), side: "debit" },
|
|
76
|
+
{ accountCode: "2000:t1", amount: Credit.fromRaw(-100), side: "credit" },
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
).rejects.toThrow("must be positive");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("posts a balanced entry and returns it", async () => {
|
|
83
|
+
const entry = await ledger.post({
|
|
84
|
+
entryType: "purchase",
|
|
85
|
+
tenantId: "t1",
|
|
86
|
+
description: "Stripe purchase",
|
|
87
|
+
referenceId: "pi_abc123",
|
|
88
|
+
metadata: { fundingSource: "stripe" },
|
|
89
|
+
createdBy: "system",
|
|
90
|
+
lines: [
|
|
91
|
+
{ accountCode: "1000", amount: Credit.fromCents(1000), side: "debit" },
|
|
92
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(1000), side: "credit" },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(entry.id).toBeTruthy();
|
|
97
|
+
expect(entry.entryType).toBe("purchase");
|
|
98
|
+
expect(entry.tenantId).toBe("t1");
|
|
99
|
+
expect(entry.description).toBe("Stripe purchase");
|
|
100
|
+
expect(entry.referenceId).toBe("pi_abc123");
|
|
101
|
+
expect(entry.lines).toHaveLength(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("enforces unique referenceId", async () => {
|
|
105
|
+
await ledger.post({
|
|
106
|
+
entryType: "purchase",
|
|
107
|
+
tenantId: "t1",
|
|
108
|
+
referenceId: "unique-ref",
|
|
109
|
+
lines: [
|
|
110
|
+
{ accountCode: "1000", amount: Credit.fromCents(100), side: "debit" },
|
|
111
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(100), side: "credit" },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
ledger.post({
|
|
117
|
+
entryType: "purchase",
|
|
118
|
+
tenantId: "t2",
|
|
119
|
+
referenceId: "unique-ref",
|
|
120
|
+
lines: [
|
|
121
|
+
{ accountCode: "1000", amount: Credit.fromCents(200), side: "debit" },
|
|
122
|
+
{ accountCode: "2000:t2", amount: Credit.fromCents(200), side: "credit" },
|
|
123
|
+
],
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toThrow();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("supports multi-line entries (3+ lines)", async () => {
|
|
129
|
+
// Split a $10 purchase: $7 to tenant, $3 to revenue (hypothetical split)
|
|
130
|
+
const entry = await ledger.post({
|
|
131
|
+
entryType: "split_purchase",
|
|
132
|
+
tenantId: "t1",
|
|
133
|
+
lines: [
|
|
134
|
+
{ accountCode: "1000", amount: Credit.fromCents(1000), side: "debit" },
|
|
135
|
+
{ accountCode: "2000:t1", amount: Credit.fromCents(700), side: "credit" },
|
|
136
|
+
{ accountCode: "4000", amount: Credit.fromCents(300), side: "credit" },
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(entry.lines).toHaveLength(3);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -----------------------------------------------------------------------
|
|
145
|
+
// credit() — convenience
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
describe("credit()", () => {
|
|
149
|
+
it("purchase: DR cash, CR unearned_revenue", async () => {
|
|
150
|
+
const entry = await ledger.credit("t1", Credit.fromCents(500), "purchase", {
|
|
151
|
+
description: "Stripe $5",
|
|
152
|
+
fundingSource: "stripe",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(entry.entryType).toBe("purchase");
|
|
156
|
+
expect(entry.lines).toHaveLength(2);
|
|
157
|
+
|
|
158
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
159
|
+
const debitLine = entry.lines.find((l) => l.side === "debit")!;
|
|
160
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
161
|
+
const creditLine = entry.lines.find((l) => l.side === "credit")!;
|
|
162
|
+
expect(debitLine.accountCode).toBe("1000"); // cash
|
|
163
|
+
expect(creditLine.accountCode).toBe("2000:t1"); // unearned revenue
|
|
164
|
+
expect(debitLine.amount.toCentsRounded()).toBe(500);
|
|
165
|
+
expect(creditLine.amount.toCentsRounded()).toBe(500);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("signup_grant: DR expense, CR unearned_revenue", async () => {
|
|
169
|
+
const entry = await ledger.credit("t1", Credit.fromCents(100), "signup_grant");
|
|
170
|
+
|
|
171
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
172
|
+
const debitLine = entry.lines.find((l) => l.side === "debit")!;
|
|
173
|
+
expect(debitLine.accountCode).toBe("5000"); // expense:signup_grant
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rejects zero amount", async () => {
|
|
177
|
+
await expect(ledger.credit("t1", Credit.ZERO, "purchase")).rejects.toThrow("must be positive");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("supports referenceId for idempotency", async () => {
|
|
181
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase", {
|
|
182
|
+
referenceId: "pi_abc",
|
|
183
|
+
});
|
|
184
|
+
expect(await ledger.hasReferenceId("pi_abc")).toBe(true);
|
|
185
|
+
expect(await ledger.hasReferenceId("pi_xyz")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// -----------------------------------------------------------------------
|
|
190
|
+
// debit() — convenience
|
|
191
|
+
// -----------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe("debit()", () => {
|
|
194
|
+
beforeEach(async () => {
|
|
195
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("bot_runtime: DR unearned_revenue, CR revenue", async () => {
|
|
199
|
+
const entry = await ledger.debit("t1", Credit.fromCents(200), "bot_runtime", {
|
|
200
|
+
description: "1hr compute",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(entry.entryType).toBe("bot_runtime");
|
|
204
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
205
|
+
const debitLine = entry.lines.find((l) => l.side === "debit")!;
|
|
206
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
207
|
+
const creditLine = entry.lines.find((l) => l.side === "credit")!;
|
|
208
|
+
expect(debitLine.accountCode).toBe("2000:t1"); // unearned revenue decreases
|
|
209
|
+
expect(creditLine.accountCode).toBe("4000"); // revenue recognized
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("throws InsufficientBalanceError when balance too low", async () => {
|
|
213
|
+
await expect(ledger.debit("t1", Credit.fromCents(2000), "bot_runtime")).rejects.toBeInstanceOf(
|
|
214
|
+
InsufficientBalanceError,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("allowNegative bypasses balance check", async () => {
|
|
219
|
+
const entry = await ledger.debit("t1", Credit.fromCents(2000), "bot_runtime", {
|
|
220
|
+
allowNegative: true,
|
|
221
|
+
});
|
|
222
|
+
expect(entry.entryType).toBe("bot_runtime");
|
|
223
|
+
|
|
224
|
+
const bal = await ledger.balance("t1");
|
|
225
|
+
expect(bal.toCentsRounded()).toBe(-1000);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("refund: DR unearned_revenue, CR cash", async () => {
|
|
229
|
+
const entry = await ledger.debit("t1", Credit.fromCents(300), "refund");
|
|
230
|
+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present in balanced entry
|
|
231
|
+
const creditLine = entry.lines.find((l) => l.side === "credit")!;
|
|
232
|
+
expect(creditLine.accountCode).toBe("1000"); // cash goes out
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("rejects zero amount", async () => {
|
|
236
|
+
await expect(ledger.debit("t1", Credit.ZERO, "bot_runtime")).rejects.toThrow("must be positive");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// -----------------------------------------------------------------------
|
|
241
|
+
// balance()
|
|
242
|
+
// -----------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
describe("balance()", () => {
|
|
245
|
+
it("returns ZERO for unknown tenant", async () => {
|
|
246
|
+
expect((await ledger.balance("unknown")).isZero()).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("reflects credits and debits", async () => {
|
|
250
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
251
|
+
expect((await ledger.balance("t1")).toCentsRounded()).toBe(1000);
|
|
252
|
+
|
|
253
|
+
await ledger.debit("t1", Credit.fromCents(300), "bot_runtime");
|
|
254
|
+
expect((await ledger.balance("t1")).toCentsRounded()).toBe(700);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("multiple tenants are independent", async () => {
|
|
258
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
259
|
+
await ledger.credit("t2", Credit.fromCents(200), "purchase");
|
|
260
|
+
|
|
261
|
+
expect((await ledger.balance("t1")).toCentsRounded()).toBe(500);
|
|
262
|
+
expect((await ledger.balance("t2")).toCentsRounded()).toBe(200);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// -----------------------------------------------------------------------
|
|
267
|
+
// accountBalance() — any account
|
|
268
|
+
// -----------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
describe("accountBalance()", () => {
|
|
271
|
+
it("tracks cash (asset) balance", async () => {
|
|
272
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase"); // DR cash
|
|
273
|
+
expect((await ledger.accountBalance("1000")).toCentsRounded()).toBe(1000);
|
|
274
|
+
|
|
275
|
+
await ledger.debit("t1", Credit.fromCents(300), "refund"); // CR cash
|
|
276
|
+
expect((await ledger.accountBalance("1000")).toCentsRounded()).toBe(700);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("tracks revenue balance", async () => {
|
|
280
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
281
|
+
await ledger.debit("t1", Credit.fromCents(400), "bot_runtime"); // CR revenue
|
|
282
|
+
expect((await ledger.accountBalance("4000")).toCentsRounded()).toBe(400);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("tracks expense balance", async () => {
|
|
286
|
+
await ledger.credit("t1", Credit.fromCents(100), "signup_grant"); // DR expense
|
|
287
|
+
expect((await ledger.accountBalance("5000")).toCentsRounded()).toBe(100);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// -----------------------------------------------------------------------
|
|
292
|
+
// trialBalance() — THE accounting invariant
|
|
293
|
+
// -----------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
describe("trialBalance()", () => {
|
|
296
|
+
it("empty ledger is balanced", async () => {
|
|
297
|
+
const tb = await ledger.trialBalance();
|
|
298
|
+
expect(tb.balanced).toBe(true);
|
|
299
|
+
expect(tb.difference.isZero()).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("balanced after multiple transactions", async () => {
|
|
303
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
304
|
+
await ledger.credit("t2", Credit.fromCents(500), "signup_grant");
|
|
305
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime");
|
|
306
|
+
await ledger.debit("t2", Credit.fromCents(100), "adapter_usage");
|
|
307
|
+
|
|
308
|
+
const tb = await ledger.trialBalance();
|
|
309
|
+
expect(tb.balanced).toBe(true);
|
|
310
|
+
expect(tb.totalDebits.equals(tb.totalCredits)).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// -----------------------------------------------------------------------
|
|
315
|
+
// history()
|
|
316
|
+
// -----------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe("history()", () => {
|
|
319
|
+
it("returns entries newest-first with lines", async () => {
|
|
320
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
321
|
+
await ledger.credit("t1", Credit.fromCents(200), "admin_grant");
|
|
322
|
+
await ledger.debit("t1", Credit.fromCents(50), "bot_runtime");
|
|
323
|
+
|
|
324
|
+
const entries = await ledger.history("t1");
|
|
325
|
+
expect(entries).toHaveLength(3);
|
|
326
|
+
expect(entries[0].entryType).toBe("bot_runtime"); // newest
|
|
327
|
+
expect(entries[2].entryType).toBe("purchase"); // oldest
|
|
328
|
+
// Each entry has lines
|
|
329
|
+
for (const e of entries) {
|
|
330
|
+
expect(e.lines.length).toBeGreaterThanOrEqual(2);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("filters by type", async () => {
|
|
335
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
336
|
+
await ledger.credit("t1", Credit.fromCents(200), "signup_grant");
|
|
337
|
+
|
|
338
|
+
const purchases = await ledger.history("t1", { type: "purchase" });
|
|
339
|
+
expect(purchases).toHaveLength(1);
|
|
340
|
+
expect(purchases[0].entryType).toBe("purchase");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("paginates with limit and offset", async () => {
|
|
344
|
+
for (let i = 0; i < 5; i++) {
|
|
345
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const page1 = await ledger.history("t1", { limit: 2, offset: 0 });
|
|
349
|
+
const page2 = await ledger.history("t1", { limit: 2, offset: 2 });
|
|
350
|
+
expect(page1).toHaveLength(2);
|
|
351
|
+
expect(page2).toHaveLength(2);
|
|
352
|
+
expect(page1[0].id).not.toBe(page2[0].id);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("isolates tenants", async () => {
|
|
356
|
+
await ledger.credit("t1", Credit.fromCents(100), "purchase");
|
|
357
|
+
await ledger.credit("t2", Credit.fromCents(200), "purchase");
|
|
358
|
+
|
|
359
|
+
expect(await ledger.history("t1")).toHaveLength(1);
|
|
360
|
+
expect(await ledger.history("t2")).toHaveLength(1);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// -----------------------------------------------------------------------
|
|
365
|
+
// tenantsWithBalance()
|
|
366
|
+
// -----------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
describe("tenantsWithBalance()", () => {
|
|
369
|
+
it("returns only tenants with positive balance", async () => {
|
|
370
|
+
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
371
|
+
await ledger.credit("t2", Credit.fromCents(300), "purchase");
|
|
372
|
+
await ledger.debit("t2", Credit.fromCents(300), "bot_runtime"); // zero balance
|
|
373
|
+
|
|
374
|
+
const result = await ledger.tenantsWithBalance();
|
|
375
|
+
expect(result).toHaveLength(1);
|
|
376
|
+
expect(result[0].tenantId).toBe("t1");
|
|
377
|
+
expect(result[0].balance.toCentsRounded()).toBe(500);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// -----------------------------------------------------------------------
|
|
382
|
+
// lifetimeSpend()
|
|
383
|
+
// -----------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
describe("lifetimeSpend()", () => {
|
|
386
|
+
it("sums all debits from tenant liability account", async () => {
|
|
387
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
388
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime");
|
|
389
|
+
await ledger.debit("t1", Credit.fromCents(300), "adapter_usage");
|
|
390
|
+
|
|
391
|
+
const spend = await ledger.lifetimeSpend("t1");
|
|
392
|
+
expect(spend.toCentsRounded()).toBe(500);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("returns zero for unknown tenant", async () => {
|
|
396
|
+
const spend = await ledger.lifetimeSpend("unknown");
|
|
397
|
+
expect(spend.isZero()).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// -----------------------------------------------------------------------
|
|
402
|
+
// lifetimeSpendBatch()
|
|
403
|
+
// -----------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
describe("lifetimeSpendBatch()", () => {
|
|
406
|
+
it("returns spend for multiple tenants", async () => {
|
|
407
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
408
|
+
await ledger.credit("t2", Credit.fromCents(500), "purchase");
|
|
409
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime");
|
|
410
|
+
await ledger.debit("t2", Credit.fromCents(100), "bot_runtime");
|
|
411
|
+
|
|
412
|
+
const result = await ledger.lifetimeSpendBatch(["t1", "t2", "t3"]);
|
|
413
|
+
// biome-ignore lint/style/noNonNullAssertion: keys guaranteed present per API contract
|
|
414
|
+
expect(result.get("t1")!.toCentsRounded()).toBe(200);
|
|
415
|
+
// biome-ignore lint/style/noNonNullAssertion: keys guaranteed present per API contract
|
|
416
|
+
expect(result.get("t2")!.toCentsRounded()).toBe(100);
|
|
417
|
+
// biome-ignore lint/style/noNonNullAssertion: keys guaranteed present per API contract
|
|
418
|
+
expect(result.get("t3")!.isZero()).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("returns empty map for empty input", async () => {
|
|
422
|
+
const result = await ledger.lifetimeSpendBatch([]);
|
|
423
|
+
expect(result.size).toBe(0);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// -----------------------------------------------------------------------
|
|
428
|
+
// memberUsage()
|
|
429
|
+
// -----------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
describe("memberUsage()", () => {
|
|
432
|
+
it("aggregates debit totals per attributed user", async () => {
|
|
433
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
434
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime", {
|
|
435
|
+
attributedUserId: "user-a",
|
|
436
|
+
});
|
|
437
|
+
await ledger.debit("t1", Credit.fromCents(300), "bot_runtime", {
|
|
438
|
+
attributedUserId: "user-a",
|
|
439
|
+
});
|
|
440
|
+
await ledger.debit("t1", Credit.fromCents(100), "bot_runtime", {
|
|
441
|
+
attributedUserId: "user-b",
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const usage = await ledger.memberUsage("t1");
|
|
445
|
+
expect(usage).toHaveLength(2);
|
|
446
|
+
|
|
447
|
+
// biome-ignore lint/style/noNonNullAssertion: seeded above, guaranteed present
|
|
448
|
+
const userA = usage.find((u) => u.userId === "user-a")!;
|
|
449
|
+
// biome-ignore lint/style/noNonNullAssertion: seeded above, guaranteed present
|
|
450
|
+
const userB = usage.find((u) => u.userId === "user-b")!;
|
|
451
|
+
expect(userA.totalDebit.toCentsRounded()).toBe(500);
|
|
452
|
+
expect(userA.transactionCount).toBe(2);
|
|
453
|
+
expect(userB.totalDebit.toCentsRounded()).toBe(100);
|
|
454
|
+
expect(userB.transactionCount).toBe(1);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("excludes entries without attributedUserId", async () => {
|
|
458
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
459
|
+
await ledger.debit("t1", Credit.fromCents(200), "bot_runtime"); // no user
|
|
460
|
+
|
|
461
|
+
const usage = await ledger.memberUsage("t1");
|
|
462
|
+
expect(usage).toHaveLength(0);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// -----------------------------------------------------------------------
|
|
467
|
+
// The accounting equation: Assets = Liabilities + Equity + Revenue - Expenses
|
|
468
|
+
// -----------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
describe("accounting equation", () => {
|
|
471
|
+
it("holds after a purchase + usage cycle", async () => {
|
|
472
|
+
// Tenant buys $10
|
|
473
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
474
|
+
// Tenant uses $3
|
|
475
|
+
await ledger.debit("t1", Credit.fromCents(300), "bot_runtime");
|
|
476
|
+
|
|
477
|
+
const cash = await ledger.accountBalance("1000"); // asset
|
|
478
|
+
const unearned = await ledger.balance("t1"); // liability
|
|
479
|
+
const revenue = await ledger.accountBalance("4000"); // revenue
|
|
480
|
+
|
|
481
|
+
// Assets ($10) = Liabilities ($7) + Revenue ($3)
|
|
482
|
+
expect(cash.toCentsRounded()).toBe(1000);
|
|
483
|
+
expect(unearned.toCentsRounded()).toBe(700);
|
|
484
|
+
expect(revenue.toCentsRounded()).toBe(300);
|
|
485
|
+
expect(cash.toCentsRounded()).toBe(unearned.toCentsRounded() + revenue.toCentsRounded());
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("holds after purchase + grant + usage + refund", async () => {
|
|
489
|
+
await ledger.credit("t1", Credit.fromCents(1000), "purchase");
|
|
490
|
+
await ledger.credit("t1", Credit.fromCents(100), "signup_grant");
|
|
491
|
+
await ledger.debit("t1", Credit.fromCents(400), "bot_runtime");
|
|
492
|
+
await ledger.debit("t1", Credit.fromCents(200), "refund");
|
|
493
|
+
|
|
494
|
+
// Assets = cash: $10 purchase - $2 refund = $8
|
|
495
|
+
// Liabilities = unearned: $10 + $1 grant - $4 usage - $2 refund = $5
|
|
496
|
+
// Revenue = $4
|
|
497
|
+
// Expense = $1 (signup grant)
|
|
498
|
+
// A = L + R - E → $8 = $5 + $4 - $1 = $8 ✓
|
|
499
|
+
const cash = await ledger.accountBalance("1000");
|
|
500
|
+
const unearned = await ledger.balance("t1");
|
|
501
|
+
const revenue = await ledger.accountBalance("4000");
|
|
502
|
+
const expense = await ledger.accountBalance("5000");
|
|
503
|
+
|
|
504
|
+
expect(cash.toCentsRounded()).toBe(800);
|
|
505
|
+
expect(unearned.toCentsRounded()).toBe(500);
|
|
506
|
+
expect(revenue.toCentsRounded()).toBe(400);
|
|
507
|
+
expect(expense.toCentsRounded()).toBe(100);
|
|
508
|
+
|
|
509
|
+
// Verify trial balance
|
|
510
|
+
const tb = await ledger.trialBalance();
|
|
511
|
+
expect(tb.balanced).toBe(true);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
});
|