@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.
Files changed (238) hide show
  1. package/dist/api/routes/admin-credits.d.ts +2 -2
  2. package/dist/api/routes/admin-credits.js +9 -4
  3. package/dist/api/routes/quota.d.ts +2 -2
  4. package/dist/api/routes/verify-email.d.ts +3 -3
  5. package/dist/backup/on-demand-snapshot-service.d.ts +2 -2
  6. package/dist/billing/payram/webhook.d.ts +3 -3
  7. package/dist/billing/payram/webhook.js +5 -1
  8. package/dist/billing/payram/webhook.test.js +5 -4
  9. package/dist/billing/stripe/stripe-payment-processor.d.ts +2 -2
  10. package/dist/billing/stripe/stripe-payment-processor.test.js +7 -0
  11. package/dist/billing/stripe/tenant-store.d.ts +1 -1
  12. package/dist/billing/stripe/tenant-store.js +1 -1
  13. package/dist/credits/auto-topup-charge.d.ts +2 -2
  14. package/dist/credits/auto-topup-charge.js +5 -1
  15. package/dist/credits/auto-topup-charge.test.js +5 -4
  16. package/dist/credits/auto-topup-usage.d.ts +2 -2
  17. package/dist/credits/auto-topup-usage.test.js +53 -12
  18. package/dist/credits/credit-expiry-cron.d.ts +2 -2
  19. package/dist/credits/credit-expiry-cron.js +7 -4
  20. package/dist/credits/credit-expiry-cron.test.js +25 -8
  21. package/dist/credits/credit-ledger.d.ts +2 -2
  22. package/dist/credits/credit-ledger.js +1 -1
  23. package/dist/credits/dividend-cron.d.ts +4 -6
  24. package/dist/credits/dividend-cron.js +10 -16
  25. package/dist/credits/dividend-cron.test.js +31 -44
  26. package/dist/credits/dividend-repository.js +19 -22
  27. package/dist/credits/dividend-repository.test.js +4 -3
  28. package/dist/credits/index.d.ts +4 -2
  29. package/dist/credits/index.js +2 -1
  30. package/dist/credits/ledger.d.ts +195 -0
  31. package/dist/credits/ledger.js +561 -0
  32. package/dist/credits/ledger.test.js +418 -0
  33. package/dist/credits/signup-grant.d.ts +2 -2
  34. package/dist/credits/signup-grant.js +4 -4
  35. package/dist/credits/signup-grant.test.js +5 -3
  36. package/dist/credits/trial-balance-cron.d.ts +19 -0
  37. package/dist/credits/trial-balance-cron.js +30 -0
  38. package/dist/credits/trial-balance-cron.test.js +55 -0
  39. package/dist/db/schema/gateway-service-keys.d.ts +109 -0
  40. package/dist/db/schema/gateway-service-keys.js +18 -0
  41. package/dist/db/schema/index.d.ts +2 -0
  42. package/dist/db/schema/index.js +2 -0
  43. package/dist/db/schema/ledger.d.ts +442 -0
  44. package/dist/db/schema/ledger.js +76 -0
  45. package/dist/gateway/credit-gate.d.ts +2 -2
  46. package/dist/gateway/credit-gate.js +5 -1
  47. package/dist/gateway/credit-gate.test.js +35 -33
  48. package/dist/gateway/gateway-routes.test.js +1 -1
  49. package/dist/gateway/index.d.ts +2 -0
  50. package/dist/gateway/index.js +1 -0
  51. package/dist/gateway/protocol/anthropic.js +1 -1
  52. package/dist/gateway/protocol/deps.d.ts +5 -5
  53. package/dist/gateway/protocol/openai.js +1 -1
  54. package/dist/gateway/proxy.d.ts +4 -4
  55. package/dist/gateway/route-mounting.test.js +1 -1
  56. package/dist/gateway/service-key-auth.d.ts +1 -1
  57. package/dist/gateway/service-key-auth.js +1 -1
  58. package/dist/gateway/service-key-repository.d.ts +27 -0
  59. package/dist/gateway/service-key-repository.js +64 -0
  60. package/dist/gateway/types.d.ts +5 -5
  61. package/dist/metering/reconciliation-cron.test.js +9 -8
  62. package/dist/metering/reconciliation-repository.js +12 -10
  63. package/dist/metering/reconciliation-repository.test.js +9 -8
  64. package/dist/monetization/affiliate/affiliate-admin-repository.js +10 -8
  65. package/dist/monetization/affiliate/affiliate-admin-repository.test.js +32 -13
  66. package/dist/monetization/affiliate/credit-match.d.ts +2 -2
  67. package/dist/monetization/affiliate/credit-match.js +4 -1
  68. package/dist/monetization/affiliate/credit-match.test.js +58 -13
  69. package/dist/monetization/affiliate/new-user-bonus.d.ts +2 -2
  70. package/dist/monetization/affiliate/new-user-bonus.js +4 -1
  71. package/dist/monetization/affiliate/new-user-bonus.test.js +4 -3
  72. package/dist/monetization/credits/auto-topup-charge.d.ts +2 -2
  73. package/dist/monetization/credits/auto-topup-charge.js +5 -1
  74. package/dist/monetization/credits/auto-topup-charge.test.js +5 -4
  75. package/dist/monetization/credits/auto-topup-usage.d.ts +2 -2
  76. package/dist/monetization/credits/auto-topup-usage.test.js +53 -12
  77. package/dist/monetization/credits/bot-billing.d.ts +3 -3
  78. package/dist/monetization/credits/bot-billing.test.js +18 -5
  79. package/dist/monetization/credits/credit-expiry-cron.test.js +25 -8
  80. package/dist/monetization/credits/dividend-cron.d.ts +2 -4
  81. package/dist/monetization/credits/dividend-cron.js +7 -4
  82. package/dist/monetization/credits/dividend-cron.test.js +26 -46
  83. package/dist/monetization/credits/dividend-repository.js +15 -24
  84. package/dist/monetization/credits/dividend-repository.test.js +4 -3
  85. package/dist/monetization/credits/index.d.ts +2 -2
  86. package/dist/monetization/credits/index.js +1 -1
  87. package/dist/monetization/credits/member-usage.test.js +23 -10
  88. package/dist/monetization/credits/phone-billing.d.ts +2 -2
  89. package/dist/monetization/credits/phone-billing.js +5 -1
  90. package/dist/monetization/credits/phone-billing.test.js +9 -12
  91. package/dist/monetization/credits/runtime-cron.d.ts +2 -2
  92. package/dist/monetization/credits/runtime-cron.js +32 -8
  93. package/dist/monetization/credits/runtime-cron.test.js +28 -27
  94. package/dist/monetization/credits/runtime-scheduler.d.ts +2 -2
  95. package/dist/monetization/credits/runtime-scheduler.test.js +1 -1
  96. package/dist/monetization/credits/signup-grant.test.js +5 -3
  97. package/dist/monetization/credits/storage-tier-cron.test.js +3 -2
  98. package/dist/monetization/credits/trial-balance-cron.test.js +42 -0
  99. package/dist/monetization/feature-gate.d.ts +3 -3
  100. package/dist/monetization/index.d.ts +3 -3
  101. package/dist/monetization/index.js +1 -1
  102. package/dist/monetization/metering/reconciliation-cron.test.js +9 -8
  103. package/dist/monetization/metering/reconciliation-repository.js +11 -10
  104. package/dist/monetization/metering/reconciliation-repository.test.js +9 -8
  105. package/dist/monetization/payram/webhook.d.ts +2 -2
  106. package/dist/monetization/payram/webhook.js +5 -1
  107. package/dist/monetization/payram/webhook.test.js +5 -4
  108. package/dist/monetization/promotions/engine.d.ts +2 -2
  109. package/dist/monetization/promotions/engine.js +4 -1
  110. package/dist/monetization/promotions/engine.test.js +3 -1
  111. package/dist/monetization/repository-types.d.ts +1 -1
  112. package/dist/monetization/socket/socket.d.ts +3 -3
  113. package/dist/monetization/stripe/stripe-payment-processor.d.ts +2 -2
  114. package/dist/monetization/stripe/stripe-payment-processor.test.js +7 -0
  115. package/dist/monetization/stripe/webhook.d.ts +2 -2
  116. package/dist/monetization/stripe/webhook.js +70 -6
  117. package/dist/monetization/stripe/webhook.test.js +20 -15
  118. package/dist/onboarding/onboarding-service.d.ts +2 -2
  119. package/dist/onboarding/onboarding-service.js +6 -2
  120. package/drizzle/migrations/0002_gateway_service_keys.sql +14 -0
  121. package/drizzle/migrations/0003_double_entry_ledger.sql +82 -0
  122. package/drizzle/migrations/meta/_journal.json +14 -0
  123. package/package.json +1 -1
  124. package/src/api/routes/admin-credits.ts +11 -14
  125. package/src/api/routes/quota.ts +2 -2
  126. package/src/api/routes/verify-email.ts +4 -4
  127. package/src/backup/on-demand-snapshot-service.test.ts +3 -3
  128. package/src/backup/on-demand-snapshot-service.ts +3 -3
  129. package/src/billing/payram/webhook.test.ts +7 -5
  130. package/src/billing/payram/webhook.ts +8 -11
  131. package/src/billing/stripe/stripe-payment-processor.test.ts +10 -3
  132. package/src/billing/stripe/stripe-payment-processor.ts +3 -3
  133. package/src/billing/stripe/tenant-store.ts +1 -1
  134. package/src/credits/auto-topup-charge.test.ts +7 -5
  135. package/src/credits/auto-topup-charge.ts +7 -10
  136. package/src/credits/auto-topup-usage.test.ts +55 -13
  137. package/src/credits/auto-topup-usage.ts +2 -2
  138. package/src/credits/credit-expiry-cron.test.ts +26 -45
  139. package/src/credits/credit-expiry-cron.ts +9 -12
  140. package/src/credits/credit-ledger.ts +3 -3
  141. package/src/credits/dividend-cron.test.ts +38 -45
  142. package/src/credits/dividend-cron.ts +12 -26
  143. package/src/credits/dividend-repository.test.ts +4 -3
  144. package/src/credits/dividend-repository.ts +21 -23
  145. package/src/credits/index.ts +23 -4
  146. package/src/credits/ledger.test.ts +514 -0
  147. package/src/credits/ledger.ts +851 -0
  148. package/src/credits/signup-grant.test.ts +7 -4
  149. package/src/credits/signup-grant.ts +6 -12
  150. package/src/credits/trial-balance-cron.test.ts +68 -0
  151. package/src/credits/trial-balance-cron.ts +46 -0
  152. package/src/db/schema/gateway-service-keys.ts +23 -0
  153. package/src/db/schema/index.ts +2 -0
  154. package/src/db/schema/ledger.ts +94 -0
  155. package/src/gateway/credit-gate-wiring.test.ts +3 -3
  156. package/src/gateway/credit-gate.test.ts +35 -33
  157. package/src/gateway/credit-gate.ts +6 -10
  158. package/src/gateway/gateway-routes.test.ts +6 -6
  159. package/src/gateway/index.ts +2 -0
  160. package/src/gateway/protocol/anthropic.ts +2 -2
  161. package/src/gateway/protocol/deps.ts +5 -5
  162. package/src/gateway/protocol/openai.ts +2 -2
  163. package/src/gateway/proxy.ts +4 -4
  164. package/src/gateway/route-mounting.test.ts +3 -3
  165. package/src/gateway/service-key-auth.ts +4 -2
  166. package/src/gateway/service-key-repository.ts +87 -0
  167. package/src/gateway/types.ts +5 -5
  168. package/src/metering/reconciliation-cron.test.ts +10 -9
  169. package/src/metering/reconciliation-repository.test.ts +10 -9
  170. package/src/metering/reconciliation-repository.ts +14 -11
  171. package/src/monetization/affiliate/affiliate-admin-repository.test.ts +32 -19
  172. package/src/monetization/affiliate/affiliate-admin-repository.ts +16 -8
  173. package/src/monetization/affiliate/credit-match.test.ts +60 -14
  174. package/src/monetization/affiliate/credit-match.ts +6 -9
  175. package/src/monetization/affiliate/new-user-bonus.test.ts +6 -4
  176. package/src/monetization/affiliate/new-user-bonus.ts +6 -9
  177. package/src/monetization/credits/auto-topup-charge.test.ts +7 -5
  178. package/src/monetization/credits/auto-topup-charge.ts +7 -10
  179. package/src/monetization/credits/auto-topup-usage.test.ts +55 -13
  180. package/src/monetization/credits/auto-topup-usage.ts +2 -2
  181. package/src/monetization/credits/bot-billing.test.ts +20 -6
  182. package/src/monetization/credits/bot-billing.ts +3 -3
  183. package/src/monetization/credits/credit-expiry-cron.test.ts +26 -45
  184. package/src/monetization/credits/dividend-cron.test.ts +34 -48
  185. package/src/monetization/credits/dividend-cron.ts +9 -14
  186. package/src/monetization/credits/dividend-repository.test.ts +4 -3
  187. package/src/monetization/credits/dividend-repository.ts +19 -25
  188. package/src/monetization/credits/index.ts +4 -4
  189. package/src/monetization/credits/member-usage.test.ts +25 -11
  190. package/src/monetization/credits/phone-billing.test.ts +18 -26
  191. package/src/monetization/credits/phone-billing.ts +7 -10
  192. package/src/monetization/credits/runtime-cron.test.ts +29 -28
  193. package/src/monetization/credits/runtime-cron.ts +34 -58
  194. package/src/monetization/credits/runtime-scheduler.test.ts +1 -1
  195. package/src/monetization/credits/runtime-scheduler.ts +2 -2
  196. package/src/monetization/credits/signup-grant.test.ts +7 -4
  197. package/src/monetization/credits/storage-tier-cron.test.ts +5 -3
  198. package/src/monetization/credits/trial-balance-cron.test.ts +52 -0
  199. package/src/monetization/feature-gate.ts +3 -3
  200. package/src/monetization/index.ts +4 -4
  201. package/src/monetization/metering/reconciliation-cron.test.ts +10 -9
  202. package/src/monetization/metering/reconciliation-repository.test.ts +11 -9
  203. package/src/monetization/metering/reconciliation-repository.ts +13 -11
  204. package/src/monetization/payram/webhook.test.ts +7 -5
  205. package/src/monetization/payram/webhook.ts +7 -10
  206. package/src/monetization/promotions/engine.test.ts +6 -5
  207. package/src/monetization/promotions/engine.ts +6 -3
  208. package/src/monetization/repository-types.ts +1 -1
  209. package/src/monetization/socket/socket.ts +4 -4
  210. package/src/monetization/stripe/stripe-payment-processor.test.ts +10 -3
  211. package/src/monetization/stripe/stripe-payment-processor.ts +3 -3
  212. package/src/monetization/stripe/webhook.test.ts +22 -16
  213. package/src/monetization/stripe/webhook.ts +75 -50
  214. package/src/onboarding/onboarding-service.ts +8 -11
  215. package/dist/credits/credit-ledger-extra.test.js +0 -40
  216. package/dist/credits/credit-ledger.bench.js +0 -33
  217. package/dist/credits/credit-ledger.test.d.ts +0 -4
  218. package/dist/credits/credit-ledger.test.js +0 -203
  219. package/dist/credits/credit-transaction-repository.test.js +0 -232
  220. package/dist/monetization/credits/credit-ledger-extra.test.d.ts +0 -1
  221. package/dist/monetization/credits/credit-ledger-extra.test.js +0 -39
  222. package/dist/monetization/credits/credit-ledger.bench.d.ts +0 -1
  223. package/dist/monetization/credits/credit-ledger.bench.js +0 -32
  224. package/dist/monetization/credits/credit-ledger.test.d.ts +0 -4
  225. package/dist/monetization/credits/credit-ledger.test.js +0 -202
  226. package/dist/monetization/credits/credit-transaction-repository.test.d.ts +0 -1
  227. package/dist/monetization/credits/credit-transaction-repository.test.js +0 -232
  228. package/src/credits/credit-ledger-extra.test.ts +0 -57
  229. package/src/credits/credit-ledger.bench.ts +0 -56
  230. package/src/credits/credit-ledger.test.ts +0 -276
  231. package/src/credits/credit-transaction-repository.test.ts +0 -274
  232. package/src/monetization/credits/credit-ledger-extra.test.ts +0 -56
  233. package/src/monetization/credits/credit-ledger.bench.ts +0 -55
  234. package/src/monetization/credits/credit-ledger.test.ts +0 -275
  235. package/src/monetization/credits/credit-transaction-repository.test.ts +0 -274
  236. /package/dist/credits/{credit-ledger-extra.test.d.ts → ledger.test.d.ts} +0 -0
  237. /package/dist/credits/{credit-ledger.bench.d.ts → trial-balance-cron.test.d.ts} +0 -0
  238. /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
+ });