@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,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 { ICreditLedger } from "./credit-ledger.js";
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: ICreditLedger, tenantId: string): Promise<boolean>;
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", "Welcome bonus — $5.00 credit on email verification", refId);
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 { CreditLedger } from "./credit-ledger.js";
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 CreditLedger(db);
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 CreditLedger(db);
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
+ });