@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,561 @@
1
+ /**
2
+ * Double-entry credit ledger.
3
+ *
4
+ * Every mutation posts a balanced journal entry: sum(debits) === sum(credits).
5
+ * A tenant's "credit balance" is the balance of their unearned_revenue liability account.
6
+ *
7
+ * Account model:
8
+ * ASSETS — cash, stripe_receivable
9
+ * LIABILITIES — unearned_revenue:<tenant_id> (the "credit balance")
10
+ * REVENUE — revenue:bot_runtime, revenue:adapter_usage, etc.
11
+ * EXPENSES — expense:signup_grant, expense:admin_grant, expense:promo, etc.
12
+ * EQUITY — retained_earnings
13
+ */
14
+ import crypto from "node:crypto";
15
+ import { and, eq, isNotNull, sql } from "drizzle-orm";
16
+ import { accountBalances, accounts, journalEntries, journalLines } from "../db/schema/ledger.js";
17
+ import { Credit } from "./credit.js";
18
+ /** Thrown when a debit would exceed a tenant's credit balance. */
19
+ export class InsufficientBalanceError extends Error {
20
+ currentBalance;
21
+ requestedAmount;
22
+ constructor(currentBalance, requestedAmount) {
23
+ super(`Insufficient balance: current ${currentBalance.toDisplayString()}, requested debit ${requestedAmount.toDisplayString()}`);
24
+ this.name = "InsufficientBalanceError";
25
+ this.currentBalance = currentBalance;
26
+ this.requestedAmount = requestedAmount;
27
+ }
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Account code mappings
31
+ // ---------------------------------------------------------------------------
32
+ /** Maps credit (money-in) types to the debit-side account code. */
33
+ export const CREDIT_TYPE_ACCOUNT = {
34
+ purchase: "1000", // DR cash
35
+ signup_grant: "5000", // DR expense:signup_grant
36
+ admin_grant: "5010", // DR expense:admin_grant
37
+ promo: "5020", // DR expense:promo
38
+ referral: "5030", // DR expense:referral
39
+ affiliate_bonus: "5040", // DR expense:affiliate
40
+ affiliate_match: "5040", // DR expense:affiliate
41
+ bounty: "5050", // DR expense:bounty
42
+ community_dividend: "5060", // DR expense:dividend
43
+ correction: "5070", // DR expense:correction
44
+ };
45
+ /** Maps debit (money-out) types to the credit-side account code. */
46
+ export const DEBIT_TYPE_ACCOUNT = {
47
+ bot_runtime: "4000", // CR revenue:bot_runtime
48
+ adapter_usage: "4010", // CR revenue:adapter_usage
49
+ addon: "4020", // CR revenue:addon
50
+ storage_upgrade: "4030", // CR revenue:storage_upgrade
51
+ resource_upgrade: "4040", // CR revenue:resource_upgrade
52
+ onboarding_llm: "4050", // CR revenue:onboarding_llm
53
+ credit_expiry: "4060", // CR revenue:expired
54
+ refund: "1000", // CR cash (money out)
55
+ correction: "5070", // CR expense:correction
56
+ };
57
+ export const SYSTEM_ACCOUNTS = [
58
+ // Assets
59
+ { code: "1000", name: "Cash", type: "asset", normalSide: "debit" },
60
+ { code: "1100", name: "Stripe Receivable", type: "asset", normalSide: "debit" },
61
+ // Equity
62
+ { code: "3000", name: "Retained Earnings", type: "equity", normalSide: "credit" },
63
+ // Revenue
64
+ { code: "4000", name: "Revenue: Bot Runtime", type: "revenue", normalSide: "credit" },
65
+ { code: "4010", name: "Revenue: Adapter Usage", type: "revenue", normalSide: "credit" },
66
+ { code: "4020", name: "Revenue: Addon", type: "revenue", normalSide: "credit" },
67
+ { code: "4030", name: "Revenue: Storage Upgrade", type: "revenue", normalSide: "credit" },
68
+ { code: "4040", name: "Revenue: Resource Upgrade", type: "revenue", normalSide: "credit" },
69
+ { code: "4050", name: "Revenue: Onboarding LLM", type: "revenue", normalSide: "credit" },
70
+ { code: "4060", name: "Revenue: Expired Credits", type: "revenue", normalSide: "credit" },
71
+ // Expenses
72
+ { code: "5000", name: "Expense: Signup Grant", type: "expense", normalSide: "debit" },
73
+ { code: "5010", name: "Expense: Admin Grant", type: "expense", normalSide: "debit" },
74
+ { code: "5020", name: "Expense: Promo", type: "expense", normalSide: "debit" },
75
+ { code: "5030", name: "Expense: Referral", type: "expense", normalSide: "debit" },
76
+ { code: "5040", name: "Expense: Affiliate", type: "expense", normalSide: "debit" },
77
+ { code: "5050", name: "Expense: Bounty", type: "expense", normalSide: "debit" },
78
+ { code: "5060", name: "Expense: Dividend", type: "expense", normalSide: "debit" },
79
+ { code: "5070", name: "Expense: Correction", type: "expense", normalSide: "debit" },
80
+ ];
81
+ // ---------------------------------------------------------------------------
82
+ // Implementation
83
+ // ---------------------------------------------------------------------------
84
+ export class DrizzleLedger {
85
+ db;
86
+ constructor(db) {
87
+ this.db = db;
88
+ }
89
+ // -- Account management --------------------------------------------------
90
+ async seedSystemAccounts() {
91
+ for (const acct of SYSTEM_ACCOUNTS) {
92
+ await this.db
93
+ .insert(accounts)
94
+ .values({
95
+ id: crypto.randomUUID(),
96
+ code: acct.code,
97
+ name: acct.name,
98
+ type: acct.type,
99
+ normalSide: acct.normalSide,
100
+ tenantId: null,
101
+ })
102
+ .onConflictDoNothing({ target: accounts.code });
103
+ }
104
+ }
105
+ /**
106
+ * Get or create the per-tenant unearned_revenue liability account, then lock
107
+ * it for the duration of the surrounding transaction.
108
+ * Code format: `2000:<tenantId>`
109
+ *
110
+ * Uses INSERT ON CONFLICT DO NOTHING so concurrent first-time calls for the
111
+ * same tenant are idempotent (no unique-constraint crash on the second writer).
112
+ */
113
+ async ensureTenantAccountLocked(tx, tenantId) {
114
+ const code = `2000:${tenantId}`;
115
+ // Idempotent upsert — safe under concurrent first-time creation.
116
+ await tx
117
+ .insert(accounts)
118
+ .values({
119
+ id: crypto.randomUUID(),
120
+ code,
121
+ name: `Unearned Revenue: ${tenantId}`,
122
+ type: "liability",
123
+ normalSide: "credit",
124
+ tenantId,
125
+ })
126
+ .onConflictDoNothing({ target: accounts.code });
127
+ // resolveAccountLocked acquires FOR UPDATE on the account row and ensures
128
+ // the account_balances row exists, serializing concurrent balance updates.
129
+ return this.resolveAccountLocked(tx, code);
130
+ }
131
+ /**
132
+ * Resolve account code → account id.
133
+ * Acquires FOR UPDATE locks on both the accounts row and the account_balances
134
+ * row so concurrent transactions are fully serialized on balance reads/writes.
135
+ */
136
+ async resolveAccountLocked(tx, code) {
137
+ const rows = (await tx.execute(sql `SELECT id FROM accounts WHERE code = ${code} FOR UPDATE`));
138
+ const id = rows.rows[0]?.id;
139
+ if (!id)
140
+ throw new Error(`Account not found: ${code}`);
141
+ // Ensure balance row exists then lock it — serializes concurrent balance updates.
142
+ await tx
143
+ .insert(accountBalances)
144
+ .values({ accountId: id, balance: 0 })
145
+ .onConflictDoNothing({ target: accountBalances.accountId });
146
+ await tx.execute(sql `SELECT balance FROM account_balances WHERE account_id = ${id} FOR UPDATE`);
147
+ return id;
148
+ }
149
+ // -- The primitive: post() -----------------------------------------------
150
+ async post(input) {
151
+ if (input.lines.length < 2) {
152
+ throw new Error("Journal entry must have at least 2 lines");
153
+ }
154
+ // Verify balance before hitting DB
155
+ let totalDebit = 0;
156
+ let totalCredit = 0;
157
+ for (const line of input.lines) {
158
+ if (line.amount.isZero() || line.amount.isNegative()) {
159
+ throw new Error("Journal line amounts must be positive");
160
+ }
161
+ if (line.side === "debit")
162
+ totalDebit += line.amount.toRaw();
163
+ else
164
+ totalCredit += line.amount.toRaw();
165
+ }
166
+ if (totalDebit !== totalCredit) {
167
+ throw new Error(`Unbalanced entry: debits=${Credit.fromRaw(totalDebit).toDisplayString()}, credits=${Credit.fromRaw(totalCredit).toDisplayString()}`);
168
+ }
169
+ return this.db.transaction(async (tx) => {
170
+ const entryId = crypto.randomUUID();
171
+ const now = input.postedAt ?? new Date().toISOString();
172
+ // Insert journal entry header
173
+ await tx.insert(journalEntries).values({
174
+ id: entryId,
175
+ postedAt: now,
176
+ entryType: input.entryType,
177
+ description: input.description ?? null,
178
+ referenceId: input.referenceId ?? null,
179
+ tenantId: input.tenantId,
180
+ metadata: input.metadata ?? null,
181
+ createdBy: input.createdBy ?? null,
182
+ });
183
+ // Phase 1: resolve all account IDs with row locks so concurrent transactions
184
+ // are serialized before any balance check or update.
185
+ const resolvedLines = [];
186
+ for (const line of input.lines) {
187
+ let accountId;
188
+ if (line.accountCode.startsWith("2000:")) {
189
+ accountId = await this.ensureTenantAccountLocked(tx, line.accountCode.slice(5));
190
+ }
191
+ else {
192
+ accountId = await this.resolveAccountLocked(tx, line.accountCode);
193
+ }
194
+ resolvedLines.push({ ...line, accountId });
195
+ }
196
+ // Phase 2: balance check inside the transaction (TOCTOU-safe).
197
+ // Locks are already held on both the account and account_balances rows.
198
+ if (input.balanceCheck) {
199
+ const { tenantId, amount } = input.balanceCheck;
200
+ const tenantAccountCode = `2000:${tenantId}`;
201
+ const balRows = (await tx.execute(sql `SELECT ab.balance FROM account_balances ab
202
+ INNER JOIN accounts a ON a.id = ab.account_id
203
+ WHERE a.code = ${tenantAccountCode}`));
204
+ const currentBalance = Credit.fromRaw(Number(balRows.rows[0]?.balance ?? 0));
205
+ if (currentBalance.lessThan(amount)) {
206
+ throw new InsufficientBalanceError(currentBalance, amount);
207
+ }
208
+ }
209
+ // Phase 3: insert lines + update balances
210
+ const resultLines = [];
211
+ for (const line of resolvedLines) {
212
+ const { accountId } = line;
213
+ const lineId = crypto.randomUUID();
214
+ await tx.insert(journalLines).values({
215
+ id: lineId,
216
+ journalEntryId: entryId,
217
+ accountId,
218
+ amount: line.amount.toRaw(),
219
+ side: line.side,
220
+ });
221
+ // Update materialized balance
222
+ // For normal_side=debit accounts: balance += debit, balance -= credit
223
+ // For normal_side=credit accounts: balance += credit, balance -= debit
224
+ // We store balance in "normal" direction, so:
225
+ const acctRow = (await tx.execute(sql `SELECT normal_side FROM accounts WHERE id = ${accountId}`));
226
+ const normalSide = acctRow.rows[0]?.normal_side;
227
+ if (!normalSide)
228
+ throw new Error(`Account ${accountId} missing normal_side`);
229
+ const delta = line.side === normalSide ? line.amount.toRaw() : -line.amount.toRaw();
230
+ await tx
231
+ .update(accountBalances)
232
+ .set({
233
+ balance: sql `${accountBalances.balance} + ${delta}`,
234
+ lastUpdated: sql `(now())`,
235
+ })
236
+ .where(eq(accountBalances.accountId, accountId));
237
+ resultLines.push({
238
+ accountCode: line.accountCode,
239
+ amount: line.amount,
240
+ side: line.side,
241
+ });
242
+ }
243
+ return {
244
+ id: entryId,
245
+ postedAt: now,
246
+ entryType: input.entryType,
247
+ tenantId: input.tenantId,
248
+ description: input.description ?? null,
249
+ referenceId: input.referenceId ?? null,
250
+ metadata: input.metadata ?? null,
251
+ lines: resultLines,
252
+ };
253
+ });
254
+ }
255
+ // -- Convenience: credit() / debit() ------------------------------------
256
+ async credit(tenantId, amount, type, opts) {
257
+ if (amount.isZero() || amount.isNegative()) {
258
+ throw new Error("amount must be positive for credits");
259
+ }
260
+ const debitAccount = CREDIT_TYPE_ACCOUNT[type];
261
+ const tenantAccount = `2000:${tenantId}`;
262
+ return this.post({
263
+ entryType: type,
264
+ tenantId,
265
+ description: opts?.description,
266
+ referenceId: opts?.referenceId,
267
+ metadata: {
268
+ fundingSource: opts?.fundingSource ?? null,
269
+ stripeFingerprint: opts?.stripeFingerprint ?? null,
270
+ attributedUserId: opts?.attributedUserId ?? null,
271
+ expiresAt: opts?.expiresAt ?? null,
272
+ },
273
+ createdBy: opts?.createdBy ?? "system",
274
+ lines: [
275
+ { accountCode: debitAccount, amount, side: "debit" },
276
+ { accountCode: tenantAccount, amount, side: "credit" },
277
+ ],
278
+ });
279
+ }
280
+ async debit(tenantId, amount, type, opts) {
281
+ if (amount.isZero() || amount.isNegative()) {
282
+ throw new Error("amount must be positive for debits");
283
+ }
284
+ const creditAccount = DEBIT_TYPE_ACCOUNT[type];
285
+ const tenantAccount = `2000:${tenantId}`;
286
+ return this.post({
287
+ entryType: type,
288
+ tenantId,
289
+ description: opts?.description,
290
+ referenceId: opts?.referenceId,
291
+ metadata: {
292
+ attributedUserId: opts?.attributedUserId ?? null,
293
+ },
294
+ createdBy: opts?.createdBy ?? "system",
295
+ // Balance check happens inside the transaction after acquiring row locks
296
+ // (TOCTOU-safe: prevents overdraft under concurrent debit operations).
297
+ balanceCheck: opts?.allowNegative ? undefined : { tenantId, amount },
298
+ lines: [
299
+ { accountCode: tenantAccount, amount, side: "debit" },
300
+ { accountCode: creditAccount, amount, side: "credit" },
301
+ ],
302
+ });
303
+ }
304
+ // -- Queries -------------------------------------------------------------
305
+ async balance(tenantId) {
306
+ const code = `2000:${tenantId}`;
307
+ const rows = await this.db
308
+ .select({ balance: accountBalances.balance })
309
+ .from(accountBalances)
310
+ .innerJoin(accounts, eq(accounts.id, accountBalances.accountId))
311
+ .where(eq(accounts.code, code));
312
+ return rows[0] ? Credit.fromRaw(rows[0].balance) : Credit.ZERO;
313
+ }
314
+ async accountBalance(accountCode) {
315
+ const rows = await this.db
316
+ .select({ balance: accountBalances.balance })
317
+ .from(accountBalances)
318
+ .innerJoin(accounts, eq(accounts.id, accountBalances.accountId))
319
+ .where(eq(accounts.code, accountCode));
320
+ return rows[0] ? Credit.fromRaw(rows[0].balance) : Credit.ZERO;
321
+ }
322
+ async hasReferenceId(referenceId) {
323
+ const rows = await this.db
324
+ .select({ id: journalEntries.id })
325
+ .from(journalEntries)
326
+ .where(eq(journalEntries.referenceId, referenceId))
327
+ .limit(1);
328
+ return rows.length > 0;
329
+ }
330
+ async history(tenantId, opts = {}) {
331
+ const limit = Math.min(Math.max(1, opts.limit ?? 50), 250);
332
+ const offset = Math.max(0, opts.offset ?? 0);
333
+ const conditions = [eq(journalEntries.tenantId, tenantId)];
334
+ if (opts.type) {
335
+ conditions.push(eq(journalEntries.entryType, opts.type));
336
+ }
337
+ const entries = await this.db
338
+ .select()
339
+ .from(journalEntries)
340
+ .where(and(...conditions))
341
+ .orderBy(sql `${journalEntries.postedAt} DESC`)
342
+ .limit(limit)
343
+ .offset(offset);
344
+ // Batch-fetch lines for all entries
345
+ const entryIds = entries.map((e) => e.id);
346
+ if (entryIds.length === 0)
347
+ return [];
348
+ const allLines = await this.db
349
+ .select({
350
+ journalEntryId: journalLines.journalEntryId,
351
+ accountCode: accounts.code,
352
+ amount: journalLines.amount,
353
+ side: journalLines.side,
354
+ })
355
+ .from(journalLines)
356
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
357
+ .where(sql `${journalLines.journalEntryId} IN (${sql.join(entryIds.map((id) => sql `${id}`), sql `, `)})`);
358
+ const linesByEntry = new Map();
359
+ for (const line of allLines) {
360
+ const arr = linesByEntry.get(line.journalEntryId) ?? [];
361
+ arr.push({
362
+ accountCode: line.accountCode,
363
+ amount: Credit.fromRaw(line.amount),
364
+ side: line.side,
365
+ });
366
+ linesByEntry.set(line.journalEntryId, arr);
367
+ }
368
+ return entries.map((e) => ({
369
+ id: e.id,
370
+ postedAt: e.postedAt,
371
+ entryType: e.entryType,
372
+ tenantId: e.tenantId,
373
+ description: e.description,
374
+ referenceId: e.referenceId,
375
+ metadata: e.metadata,
376
+ lines: linesByEntry.get(e.id) ?? [],
377
+ }));
378
+ }
379
+ async tenantsWithBalance() {
380
+ const rows = await this.db
381
+ .select({
382
+ tenantId: accounts.tenantId,
383
+ balance: accountBalances.balance,
384
+ })
385
+ .from(accountBalances)
386
+ .innerJoin(accounts, eq(accounts.id, accountBalances.accountId))
387
+ .where(and(isNotNull(accounts.tenantId), eq(accounts.type, "liability"), sql `${accountBalances.balance} > 0`));
388
+ return rows
389
+ .filter((r) => r.tenantId != null)
390
+ .map((r) => ({
391
+ tenantId: r.tenantId,
392
+ balance: Credit.fromRaw(r.balance),
393
+ }));
394
+ }
395
+ async memberUsage(tenantId) {
396
+ // Sum debit-side lines on the tenant's liability account, grouped by attributed_user_id
397
+ const tenantAccount = `2000:${tenantId}`;
398
+ const rows = await this.db
399
+ .select({
400
+ userId: sql `(${journalEntries.metadata}->>'attributedUserId')`,
401
+ totalDebitRaw: sql `COALESCE(SUM(${journalLines.amount}), 0)`,
402
+ transactionCount: sql `COUNT(*)`,
403
+ })
404
+ .from(journalLines)
405
+ .innerJoin(journalEntries, eq(journalEntries.id, journalLines.journalEntryId))
406
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
407
+ .where(and(eq(accounts.code, tenantAccount), eq(journalLines.side, "debit"), // debits on liability = usage
408
+ sql `${journalEntries.metadata}->>'attributedUserId' IS NOT NULL`))
409
+ .groupBy(sql `${journalEntries.metadata}->>'attributedUserId'`);
410
+ return rows
411
+ .filter((r) => r.userId != null)
412
+ .map((r) => ({
413
+ userId: r.userId,
414
+ totalDebit: Credit.fromRaw(Number(r.totalDebitRaw)),
415
+ // COUNT(*) returns bigint (serialized as string by the PG driver) — coerce to number.
416
+ transactionCount: Number(r.transactionCount),
417
+ }));
418
+ }
419
+ async lifetimeSpend(tenantId) {
420
+ const tenantAccount = `2000:${tenantId}`;
421
+ const rows = await this.db
422
+ .select({
423
+ totalRaw: sql `COALESCE(SUM(${journalLines.amount}), 0)`,
424
+ })
425
+ .from(journalLines)
426
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
427
+ .where(and(eq(accounts.code, tenantAccount), eq(journalLines.side, "debit")));
428
+ const raw = BigInt(String(rows[0]?.totalRaw ?? 0));
429
+ if (raw > BigInt(Number.MAX_SAFE_INTEGER)) {
430
+ throw new Error(`lifetimeSpend overflow: ${raw}`);
431
+ }
432
+ return Credit.fromRaw(Number(raw));
433
+ }
434
+ async lifetimeSpendBatch(tenantIds) {
435
+ if (tenantIds.length === 0)
436
+ return new Map();
437
+ const codes = tenantIds.map((id) => `2000:${id}`);
438
+ const rows = await this.db
439
+ .select({
440
+ code: accounts.code,
441
+ totalRaw: sql `COALESCE(SUM(${journalLines.amount}), 0)`,
442
+ })
443
+ .from(journalLines)
444
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
445
+ .where(and(sql `${accounts.code} IN (${sql.join(codes.map((c) => sql `${c}`), sql `, `)})`, eq(journalLines.side, "debit")))
446
+ .groupBy(accounts.code);
447
+ const result = new Map();
448
+ for (const row of rows) {
449
+ const tenantId = row.code.slice(5); // strip '2000:'
450
+ const raw = BigInt(String(row.totalRaw));
451
+ if (raw > BigInt(Number.MAX_SAFE_INTEGER)) {
452
+ throw new Error(`lifetimeSpend overflow for ${tenantId}: ${raw}`);
453
+ }
454
+ result.set(tenantId, Credit.fromRaw(Number(raw)));
455
+ }
456
+ for (const id of tenantIds) {
457
+ if (!result.has(id))
458
+ result.set(id, Credit.ZERO);
459
+ }
460
+ return result;
461
+ }
462
+ async expiredCredits(now) {
463
+ // Find credit entries with expiresAt <= now that don't yet have a corresponding expiry entry
464
+ const rows = await this.db
465
+ .select({
466
+ id: journalEntries.id,
467
+ tenantId: journalEntries.tenantId,
468
+ // The credit amount is on the tenant's liability line (credit side)
469
+ amount: sql `(
470
+ SELECT jl.amount FROM journal_lines jl
471
+ INNER JOIN accounts a ON a.id = jl.account_id
472
+ WHERE jl.journal_entry_id = "journal_entries"."id"
473
+ AND a.type = 'liability'
474
+ AND jl.side = 'credit'
475
+ LIMIT 1
476
+ )`,
477
+ })
478
+ .from(journalEntries)
479
+ .where(and(isNotNull(sql `${journalEntries.metadata}->>'expiresAt'`), sql `(${journalEntries.metadata}->>'expiresAt') <= ${now}`, sql `${journalEntries.entryType} NOT IN ('credit_expiry', 'bot_runtime', 'adapter_usage', 'addon', 'refund')`));
480
+ const result = [];
481
+ for (const row of rows) {
482
+ if (!row.amount)
483
+ continue;
484
+ // Check if already expired (idempotency)
485
+ if (await this.hasReferenceId(`expiry:${row.id}`))
486
+ continue;
487
+ result.push({
488
+ entryId: row.id,
489
+ tenantId: row.tenantId,
490
+ amount: Credit.fromRaw(row.amount),
491
+ });
492
+ }
493
+ return result;
494
+ }
495
+ async existsByReferenceIdLike(pattern) {
496
+ const rows = await this.db
497
+ .select({ id: journalEntries.id })
498
+ .from(journalEntries)
499
+ .where(sql `${journalEntries.referenceId} LIKE ${pattern}`)
500
+ .limit(1);
501
+ return rows.length > 0;
502
+ }
503
+ async sumPurchasesForPeriod(startTs, endTs) {
504
+ // Sum the credit-side amounts on tenant liability accounts for purchase entries in range.
505
+ const rows = await this.db
506
+ .select({
507
+ total: sql `COALESCE(SUM(${journalLines.amount}), 0)`,
508
+ })
509
+ .from(journalLines)
510
+ .innerJoin(journalEntries, eq(journalEntries.id, journalLines.journalEntryId))
511
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
512
+ .where(and(eq(journalEntries.entryType, "purchase"), eq(journalLines.side, "credit"), eq(accounts.type, "liability"),
513
+ // Cast to timestamptz for correct chronological comparison regardless of format/TZ.
514
+ sql `${journalEntries.postedAt}::timestamptz >= ${startTs}::timestamptz`, sql `${journalEntries.postedAt}::timestamptz < ${endTs}::timestamptz`));
515
+ // Use BigInt to avoid silent precision loss for large totals (same pattern as lifetimeSpend).
516
+ const raw = BigInt(String(rows[0]?.total ?? 0));
517
+ if (raw > BigInt(Number.MAX_SAFE_INTEGER)) {
518
+ throw new Error(`sumPurchasesForPeriod overflow: ${raw}`);
519
+ }
520
+ return Credit.fromRaw(Number(raw));
521
+ }
522
+ async getActiveTenantIdsInWindow(startTs, endTs) {
523
+ const rows = await this.db
524
+ .selectDistinct({ tenantId: journalEntries.tenantId })
525
+ .from(journalEntries)
526
+ .where(and(eq(journalEntries.entryType, "purchase"),
527
+ // Cast to timestamptz for correct chronological comparison.
528
+ sql `${journalEntries.postedAt}::timestamptz >= ${startTs}::timestamptz`, sql `${journalEntries.postedAt}::timestamptz < ${endTs}::timestamptz`));
529
+ return rows.map((r) => r.tenantId);
530
+ }
531
+ // -- Audit ---------------------------------------------------------------
532
+ async trialBalance() {
533
+ const rows = await this.db
534
+ .select({
535
+ side: journalLines.side,
536
+ total: sql `COALESCE(SUM(${journalLines.amount}), 0)`,
537
+ })
538
+ .from(journalLines)
539
+ .groupBy(journalLines.side);
540
+ let totalDebitsBig = 0n;
541
+ let totalCreditsBig = 0n;
542
+ for (const row of rows) {
543
+ if (row.side === "debit")
544
+ totalDebitsBig = BigInt(String(row.total));
545
+ else
546
+ totalCreditsBig = BigInt(String(row.total));
547
+ }
548
+ const diff = totalDebitsBig > totalCreditsBig ? totalDebitsBig - totalCreditsBig : totalCreditsBig - totalDebitsBig;
549
+ if (totalDebitsBig > BigInt(Number.MAX_SAFE_INTEGER) || totalCreditsBig > BigInt(Number.MAX_SAFE_INTEGER)) {
550
+ throw new Error(`trialBalance overflow: debits=${totalDebitsBig}, credits=${totalCreditsBig}`);
551
+ }
552
+ return {
553
+ totalDebits: Credit.fromRaw(Number(totalDebitsBig)),
554
+ totalCredits: Credit.fromRaw(Number(totalCreditsBig)),
555
+ balanced: totalDebitsBig === totalCreditsBig,
556
+ difference: Credit.fromRaw(Number(diff)),
557
+ };
558
+ }
559
+ }
560
+ // Backward-compat alias
561
+ export { DrizzleLedger as Ledger };