@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,851 @@
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
+
15
+ import crypto from "node:crypto";
16
+ import { and, eq, isNotNull, sql } from "drizzle-orm";
17
+ import type { PlatformDb } from "../db/index.js";
18
+ import { accountBalances, accounts, journalEntries, journalLines } from "../db/schema/ledger.js";
19
+ import { Credit } from "./credit.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export type CreditType =
26
+ | "signup_grant"
27
+ | "admin_grant"
28
+ | "purchase"
29
+ | "bounty"
30
+ | "referral"
31
+ | "promo"
32
+ | "community_dividend"
33
+ | "affiliate_bonus"
34
+ | "affiliate_match"
35
+ | "correction";
36
+
37
+ export type DebitType =
38
+ | "bot_runtime"
39
+ | "adapter_usage"
40
+ | "addon"
41
+ | "refund"
42
+ | "correction"
43
+ | "resource_upgrade"
44
+ | "storage_upgrade"
45
+ | "onboarding_llm"
46
+ | "credit_expiry";
47
+
48
+ export type TransactionType = CreditType | DebitType;
49
+
50
+ export type AccountType = "asset" | "liability" | "equity" | "revenue" | "expense";
51
+ export type Side = "debit" | "credit";
52
+
53
+ export interface JournalLine {
54
+ accountCode: string;
55
+ amount: Credit;
56
+ side: Side;
57
+ }
58
+
59
+ export interface PostEntryInput {
60
+ entryType: string;
61
+ tenantId: string;
62
+ description?: string;
63
+ referenceId?: string;
64
+ metadata?: Record<string, unknown>;
65
+ createdBy?: string;
66
+ /** Override the posted_at timestamp (useful in tests to backdate entries). */
67
+ postedAt?: string;
68
+ lines: JournalLine[];
69
+ /**
70
+ * When set, verifies inside the transaction (after acquiring row locks) that
71
+ * the tenant's balance >= amount. Throws InsufficientBalanceError otherwise.
72
+ * Use this instead of a pre-check outside the transaction (TOCTOU-safe).
73
+ */
74
+ balanceCheck?: { tenantId: string; amount: Credit };
75
+ }
76
+
77
+ export interface JournalEntry {
78
+ id: string;
79
+ postedAt: string;
80
+ entryType: string;
81
+ tenantId: string;
82
+ description: string | null;
83
+ referenceId: string | null;
84
+ metadata: Record<string, unknown> | null;
85
+ lines: Array<{
86
+ accountCode: string;
87
+ amount: Credit;
88
+ side: Side;
89
+ }>;
90
+ }
91
+
92
+ /** Thrown when a debit would exceed a tenant's credit balance. */
93
+ export class InsufficientBalanceError extends Error {
94
+ currentBalance: Credit;
95
+ requestedAmount: Credit;
96
+
97
+ constructor(currentBalance: Credit, requestedAmount: Credit) {
98
+ super(
99
+ `Insufficient balance: current ${currentBalance.toDisplayString()}, requested debit ${requestedAmount.toDisplayString()}`,
100
+ );
101
+ this.name = "InsufficientBalanceError";
102
+ this.currentBalance = currentBalance;
103
+ this.requestedAmount = requestedAmount;
104
+ }
105
+ }
106
+
107
+ export interface HistoryOptions {
108
+ limit?: number;
109
+ offset?: number;
110
+ type?: string;
111
+ }
112
+
113
+ export interface MemberUsageSummary {
114
+ userId: string;
115
+ totalDebit: Credit;
116
+ transactionCount: number;
117
+ }
118
+
119
+ export interface TrialBalance {
120
+ totalDebits: Credit;
121
+ totalCredits: Credit;
122
+ balanced: boolean;
123
+ difference: Credit;
124
+ }
125
+
126
+ export interface CreditOpts {
127
+ description?: string;
128
+ referenceId?: string;
129
+ fundingSource?: string;
130
+ stripeFingerprint?: string;
131
+ attributedUserId?: string;
132
+ expiresAt?: string;
133
+ createdBy?: string;
134
+ }
135
+
136
+ export interface DebitOpts {
137
+ description?: string;
138
+ referenceId?: string;
139
+ allowNegative?: boolean;
140
+ attributedUserId?: string;
141
+ createdBy?: string;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Account code mappings
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** Maps credit (money-in) types to the debit-side account code. */
149
+ export const CREDIT_TYPE_ACCOUNT: Record<CreditType, string> = {
150
+ purchase: "1000", // DR cash
151
+ signup_grant: "5000", // DR expense:signup_grant
152
+ admin_grant: "5010", // DR expense:admin_grant
153
+ promo: "5020", // DR expense:promo
154
+ referral: "5030", // DR expense:referral
155
+ affiliate_bonus: "5040", // DR expense:affiliate
156
+ affiliate_match: "5040", // DR expense:affiliate
157
+ bounty: "5050", // DR expense:bounty
158
+ community_dividend: "5060", // DR expense:dividend
159
+ correction: "5070", // DR expense:correction
160
+ };
161
+
162
+ /** Maps debit (money-out) types to the credit-side account code. */
163
+ export const DEBIT_TYPE_ACCOUNT: Record<DebitType, string> = {
164
+ bot_runtime: "4000", // CR revenue:bot_runtime
165
+ adapter_usage: "4010", // CR revenue:adapter_usage
166
+ addon: "4020", // CR revenue:addon
167
+ storage_upgrade: "4030", // CR revenue:storage_upgrade
168
+ resource_upgrade: "4040", // CR revenue:resource_upgrade
169
+ onboarding_llm: "4050", // CR revenue:onboarding_llm
170
+ credit_expiry: "4060", // CR revenue:expired
171
+ refund: "1000", // CR cash (money out)
172
+ correction: "5070", // CR expense:correction
173
+ };
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // System account seeds
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export interface SystemAccount {
180
+ code: string;
181
+ name: string;
182
+ type: AccountType;
183
+ normalSide: Side;
184
+ }
185
+
186
+ export const SYSTEM_ACCOUNTS: SystemAccount[] = [
187
+ // Assets
188
+ { code: "1000", name: "Cash", type: "asset", normalSide: "debit" },
189
+ { code: "1100", name: "Stripe Receivable", type: "asset", normalSide: "debit" },
190
+ // Equity
191
+ { code: "3000", name: "Retained Earnings", type: "equity", normalSide: "credit" },
192
+ // Revenue
193
+ { code: "4000", name: "Revenue: Bot Runtime", type: "revenue", normalSide: "credit" },
194
+ { code: "4010", name: "Revenue: Adapter Usage", type: "revenue", normalSide: "credit" },
195
+ { code: "4020", name: "Revenue: Addon", type: "revenue", normalSide: "credit" },
196
+ { code: "4030", name: "Revenue: Storage Upgrade", type: "revenue", normalSide: "credit" },
197
+ { code: "4040", name: "Revenue: Resource Upgrade", type: "revenue", normalSide: "credit" },
198
+ { code: "4050", name: "Revenue: Onboarding LLM", type: "revenue", normalSide: "credit" },
199
+ { code: "4060", name: "Revenue: Expired Credits", type: "revenue", normalSide: "credit" },
200
+ // Expenses
201
+ { code: "5000", name: "Expense: Signup Grant", type: "expense", normalSide: "debit" },
202
+ { code: "5010", name: "Expense: Admin Grant", type: "expense", normalSide: "debit" },
203
+ { code: "5020", name: "Expense: Promo", type: "expense", normalSide: "debit" },
204
+ { code: "5030", name: "Expense: Referral", type: "expense", normalSide: "debit" },
205
+ { code: "5040", name: "Expense: Affiliate", type: "expense", normalSide: "debit" },
206
+ { code: "5050", name: "Expense: Bounty", type: "expense", normalSide: "debit" },
207
+ { code: "5060", name: "Expense: Dividend", type: "expense", normalSide: "debit" },
208
+ { code: "5070", name: "Expense: Correction", type: "expense", normalSide: "debit" },
209
+ ];
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Interface
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export interface ILedger {
216
+ /** Post a balanced journal entry. The primitive. Everything else calls this. */
217
+ post(input: PostEntryInput): Promise<JournalEntry>;
218
+
219
+ /** Add credits to a tenant (posts balanced entry: DR source, CR unearned_revenue). */
220
+ credit(tenantId: string, amount: Credit, type: CreditType, opts?: CreditOpts): Promise<JournalEntry>;
221
+
222
+ /** Deduct credits from a tenant (posts balanced entry: DR unearned_revenue, CR revenue). */
223
+ debit(tenantId: string, amount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry>;
224
+
225
+ /** Tenant's credit balance (= their unearned_revenue liability account balance). */
226
+ balance(tenantId: string): Promise<Credit>;
227
+
228
+ /** Check if a reference ID has already been posted (idempotency). */
229
+ hasReferenceId(referenceId: string): Promise<boolean>;
230
+
231
+ /** Journal entries for a tenant, newest first. */
232
+ history(tenantId: string, opts?: HistoryOptions): Promise<JournalEntry[]>;
233
+
234
+ /** All tenants with positive credit balance. */
235
+ tenantsWithBalance(): Promise<Array<{ tenantId: string; balance: Credit }>>;
236
+
237
+ /** Per-member debit totals for a tenant. */
238
+ memberUsage(tenantId: string): Promise<MemberUsageSummary[]>;
239
+
240
+ /** Sum of all debits for a tenant (absolute value). */
241
+ lifetimeSpend(tenantId: string): Promise<Credit>;
242
+
243
+ /** Batch lifetimeSpend for multiple tenants. */
244
+ lifetimeSpendBatch(tenantIds: string[]): Promise<Map<string, Credit>>;
245
+
246
+ /** Expired credit grants not yet clawed back. */
247
+ expiredCredits(now: string): Promise<Array<{ entryId: string; tenantId: string; amount: Credit }>>;
248
+
249
+ /** Verify the books balance: total debits === total credits across all lines. */
250
+ trialBalance(): Promise<TrialBalance>;
251
+
252
+ /** Balance of any account by code. */
253
+ accountBalance(accountCode: string): Promise<Credit>;
254
+
255
+ /** Ensure system accounts exist (idempotent, called at startup). */
256
+ seedSystemAccounts(): Promise<void>;
257
+
258
+ /** Check if any journal entry has a referenceId matching a LIKE pattern (for dividend idempotency). */
259
+ existsByReferenceIdLike(pattern: string): Promise<boolean>;
260
+
261
+ /** Sum all purchase-type entry amounts credited to tenant accounts in [startTs, endTs). */
262
+ sumPurchasesForPeriod(startTs: string, endTs: string): Promise<Credit>;
263
+
264
+ /** Get distinct tenantIds with a purchase entry in [startTs, endTs). */
265
+ getActiveTenantIdsInWindow(startTs: string, endTs: string): Promise<string[]>;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Implementation
270
+ // ---------------------------------------------------------------------------
271
+
272
+ export class DrizzleLedger implements ILedger {
273
+ constructor(private readonly db: PlatformDb) {}
274
+
275
+ // -- Account management --------------------------------------------------
276
+
277
+ async seedSystemAccounts(): Promise<void> {
278
+ for (const acct of SYSTEM_ACCOUNTS) {
279
+ await this.db
280
+ .insert(accounts)
281
+ .values({
282
+ id: crypto.randomUUID(),
283
+ code: acct.code,
284
+ name: acct.name,
285
+ type: acct.type,
286
+ normalSide: acct.normalSide,
287
+ tenantId: null,
288
+ })
289
+ .onConflictDoNothing({ target: accounts.code });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Get or create the per-tenant unearned_revenue liability account, then lock
295
+ * it for the duration of the surrounding transaction.
296
+ * Code format: `2000:<tenantId>`
297
+ *
298
+ * Uses INSERT ON CONFLICT DO NOTHING so concurrent first-time calls for the
299
+ * same tenant are idempotent (no unique-constraint crash on the second writer).
300
+ */
301
+ private async ensureTenantAccountLocked(
302
+ tx: Parameters<Parameters<PlatformDb["transaction"]>[0]>[0],
303
+ tenantId: string,
304
+ ): Promise<string> {
305
+ const code = `2000:${tenantId}`;
306
+ // Idempotent upsert — safe under concurrent first-time creation.
307
+ await tx
308
+ .insert(accounts)
309
+ .values({
310
+ id: crypto.randomUUID(),
311
+ code,
312
+ name: `Unearned Revenue: ${tenantId}`,
313
+ type: "liability",
314
+ normalSide: "credit",
315
+ tenantId,
316
+ })
317
+ .onConflictDoNothing({ target: accounts.code });
318
+ // resolveAccountLocked acquires FOR UPDATE on the account row and ensures
319
+ // the account_balances row exists, serializing concurrent balance updates.
320
+ return this.resolveAccountLocked(tx, code);
321
+ }
322
+
323
+ /**
324
+ * Resolve account code → account id.
325
+ * Acquires FOR UPDATE locks on both the accounts row and the account_balances
326
+ * row so concurrent transactions are fully serialized on balance reads/writes.
327
+ */
328
+ private async resolveAccountLocked(
329
+ tx: Parameters<Parameters<PlatformDb["transaction"]>[0]>[0],
330
+ code: string,
331
+ ): Promise<string> {
332
+ const rows = (await tx.execute(sql`SELECT id FROM accounts WHERE code = ${code} FOR UPDATE`)) as unknown as {
333
+ rows: Array<{ id: string }>;
334
+ };
335
+
336
+ const id = rows.rows[0]?.id;
337
+ if (!id) throw new Error(`Account not found: ${code}`);
338
+
339
+ // Ensure balance row exists then lock it — serializes concurrent balance updates.
340
+ await tx
341
+ .insert(accountBalances)
342
+ .values({ accountId: id, balance: 0 })
343
+ .onConflictDoNothing({ target: accountBalances.accountId });
344
+ await tx.execute(sql`SELECT balance FROM account_balances WHERE account_id = ${id} FOR UPDATE`);
345
+
346
+ return id;
347
+ }
348
+
349
+ // -- The primitive: post() -----------------------------------------------
350
+
351
+ async post(input: PostEntryInput): Promise<JournalEntry> {
352
+ if (input.lines.length < 2) {
353
+ throw new Error("Journal entry must have at least 2 lines");
354
+ }
355
+
356
+ // Verify balance before hitting DB
357
+ let totalDebit = 0;
358
+ let totalCredit = 0;
359
+ for (const line of input.lines) {
360
+ if (line.amount.isZero() || line.amount.isNegative()) {
361
+ throw new Error("Journal line amounts must be positive");
362
+ }
363
+ if (line.side === "debit") totalDebit += line.amount.toRaw();
364
+ else totalCredit += line.amount.toRaw();
365
+ }
366
+ if (totalDebit !== totalCredit) {
367
+ throw new Error(
368
+ `Unbalanced entry: debits=${Credit.fromRaw(totalDebit).toDisplayString()}, credits=${Credit.fromRaw(totalCredit).toDisplayString()}`,
369
+ );
370
+ }
371
+
372
+ return this.db.transaction(async (tx) => {
373
+ const entryId = crypto.randomUUID();
374
+ const now = input.postedAt ?? new Date().toISOString();
375
+
376
+ // Insert journal entry header
377
+ await tx.insert(journalEntries).values({
378
+ id: entryId,
379
+ postedAt: now,
380
+ entryType: input.entryType,
381
+ description: input.description ?? null,
382
+ referenceId: input.referenceId ?? null,
383
+ tenantId: input.tenantId,
384
+ metadata: input.metadata ?? null,
385
+ createdBy: input.createdBy ?? null,
386
+ });
387
+
388
+ // Phase 1: resolve all account IDs with row locks so concurrent transactions
389
+ // are serialized before any balance check or update.
390
+ const resolvedLines: Array<JournalLine & { accountId: string }> = [];
391
+ for (const line of input.lines) {
392
+ let accountId: string;
393
+ if (line.accountCode.startsWith("2000:")) {
394
+ accountId = await this.ensureTenantAccountLocked(tx, line.accountCode.slice(5));
395
+ } else {
396
+ accountId = await this.resolveAccountLocked(tx, line.accountCode);
397
+ }
398
+ resolvedLines.push({ ...line, accountId });
399
+ }
400
+
401
+ // Phase 2: balance check inside the transaction (TOCTOU-safe).
402
+ // Locks are already held on both the account and account_balances rows.
403
+ if (input.balanceCheck) {
404
+ const { tenantId, amount } = input.balanceCheck;
405
+ const tenantAccountCode = `2000:${tenantId}`;
406
+ const balRows = (await tx.execute(
407
+ sql`SELECT ab.balance FROM account_balances ab
408
+ INNER JOIN accounts a ON a.id = ab.account_id
409
+ WHERE a.code = ${tenantAccountCode}`,
410
+ )) as unknown as { rows: Array<{ balance: number }> };
411
+ const currentBalance = Credit.fromRaw(Number(balRows.rows[0]?.balance ?? 0));
412
+ if (currentBalance.lessThan(amount)) {
413
+ throw new InsufficientBalanceError(currentBalance, amount);
414
+ }
415
+ }
416
+
417
+ // Phase 3: insert lines + update balances
418
+ const resultLines: JournalEntry["lines"] = [];
419
+ for (const line of resolvedLines) {
420
+ const { accountId } = line;
421
+
422
+ const lineId = crypto.randomUUID();
423
+ await tx.insert(journalLines).values({
424
+ id: lineId,
425
+ journalEntryId: entryId,
426
+ accountId,
427
+ amount: line.amount.toRaw(),
428
+ side: line.side,
429
+ });
430
+
431
+ // Update materialized balance
432
+ // For normal_side=debit accounts: balance += debit, balance -= credit
433
+ // For normal_side=credit accounts: balance += credit, balance -= debit
434
+ // We store balance in "normal" direction, so:
435
+ const acctRow = (await tx.execute(
436
+ sql`SELECT normal_side FROM accounts WHERE id = ${accountId}`,
437
+ )) as unknown as { rows: Array<{ normal_side: Side }> };
438
+ const normalSide = acctRow.rows[0]?.normal_side;
439
+ if (!normalSide) throw new Error(`Account ${accountId} missing normal_side`);
440
+
441
+ const delta = line.side === normalSide ? line.amount.toRaw() : -line.amount.toRaw();
442
+
443
+ await tx
444
+ .update(accountBalances)
445
+ .set({
446
+ balance: sql`${accountBalances.balance} + ${delta}`,
447
+ lastUpdated: sql`(now())`,
448
+ })
449
+ .where(eq(accountBalances.accountId, accountId));
450
+
451
+ resultLines.push({
452
+ accountCode: line.accountCode,
453
+ amount: line.amount,
454
+ side: line.side,
455
+ });
456
+ }
457
+
458
+ return {
459
+ id: entryId,
460
+ postedAt: now,
461
+ entryType: input.entryType,
462
+ tenantId: input.tenantId,
463
+ description: input.description ?? null,
464
+ referenceId: input.referenceId ?? null,
465
+ metadata: (input.metadata as Record<string, unknown>) ?? null,
466
+ lines: resultLines,
467
+ };
468
+ });
469
+ }
470
+
471
+ // -- Convenience: credit() / debit() ------------------------------------
472
+
473
+ async credit(tenantId: string, amount: Credit, type: CreditType, opts?: CreditOpts): Promise<JournalEntry> {
474
+ if (amount.isZero() || amount.isNegative()) {
475
+ throw new Error("amount must be positive for credits");
476
+ }
477
+
478
+ const debitAccount = CREDIT_TYPE_ACCOUNT[type];
479
+ const tenantAccount = `2000:${tenantId}`;
480
+
481
+ return this.post({
482
+ entryType: type,
483
+ tenantId,
484
+ description: opts?.description,
485
+ referenceId: opts?.referenceId,
486
+ metadata: {
487
+ fundingSource: opts?.fundingSource ?? null,
488
+ stripeFingerprint: opts?.stripeFingerprint ?? null,
489
+ attributedUserId: opts?.attributedUserId ?? null,
490
+ expiresAt: opts?.expiresAt ?? null,
491
+ },
492
+ createdBy: opts?.createdBy ?? "system",
493
+ lines: [
494
+ { accountCode: debitAccount, amount, side: "debit" },
495
+ { accountCode: tenantAccount, amount, side: "credit" },
496
+ ],
497
+ });
498
+ }
499
+
500
+ async debit(tenantId: string, amount: Credit, type: DebitType, opts?: DebitOpts): Promise<JournalEntry> {
501
+ if (amount.isZero() || amount.isNegative()) {
502
+ throw new Error("amount must be positive for debits");
503
+ }
504
+
505
+ const creditAccount = DEBIT_TYPE_ACCOUNT[type];
506
+ const tenantAccount = `2000:${tenantId}`;
507
+
508
+ return this.post({
509
+ entryType: type,
510
+ tenantId,
511
+ description: opts?.description,
512
+ referenceId: opts?.referenceId,
513
+ metadata: {
514
+ attributedUserId: opts?.attributedUserId ?? null,
515
+ },
516
+ createdBy: opts?.createdBy ?? "system",
517
+ // Balance check happens inside the transaction after acquiring row locks
518
+ // (TOCTOU-safe: prevents overdraft under concurrent debit operations).
519
+ balanceCheck: opts?.allowNegative ? undefined : { tenantId, amount },
520
+ lines: [
521
+ { accountCode: tenantAccount, amount, side: "debit" },
522
+ { accountCode: creditAccount, amount, side: "credit" },
523
+ ],
524
+ });
525
+ }
526
+
527
+ // -- Queries -------------------------------------------------------------
528
+
529
+ async balance(tenantId: string): Promise<Credit> {
530
+ const code = `2000:${tenantId}`;
531
+ const rows = await this.db
532
+ .select({ balance: accountBalances.balance })
533
+ .from(accountBalances)
534
+ .innerJoin(accounts, eq(accounts.id, accountBalances.accountId))
535
+ .where(eq(accounts.code, code));
536
+
537
+ return rows[0] ? Credit.fromRaw(rows[0].balance) : Credit.ZERO;
538
+ }
539
+
540
+ async accountBalance(accountCode: string): Promise<Credit> {
541
+ const rows = await this.db
542
+ .select({ balance: accountBalances.balance })
543
+ .from(accountBalances)
544
+ .innerJoin(accounts, eq(accounts.id, accountBalances.accountId))
545
+ .where(eq(accounts.code, accountCode));
546
+
547
+ return rows[0] ? Credit.fromRaw(rows[0].balance) : Credit.ZERO;
548
+ }
549
+
550
+ async hasReferenceId(referenceId: string): Promise<boolean> {
551
+ const rows = await this.db
552
+ .select({ id: journalEntries.id })
553
+ .from(journalEntries)
554
+ .where(eq(journalEntries.referenceId, referenceId))
555
+ .limit(1);
556
+ return rows.length > 0;
557
+ }
558
+
559
+ async history(tenantId: string, opts: HistoryOptions = {}): Promise<JournalEntry[]> {
560
+ const limit = Math.min(Math.max(1, opts.limit ?? 50), 250);
561
+ const offset = Math.max(0, opts.offset ?? 0);
562
+
563
+ const conditions = [eq(journalEntries.tenantId, tenantId)];
564
+ if (opts.type) {
565
+ conditions.push(eq(journalEntries.entryType, opts.type));
566
+ }
567
+
568
+ const entries = await this.db
569
+ .select()
570
+ .from(journalEntries)
571
+ .where(and(...conditions))
572
+ .orderBy(sql`${journalEntries.postedAt} DESC`)
573
+ .limit(limit)
574
+ .offset(offset);
575
+
576
+ // Batch-fetch lines for all entries
577
+ const entryIds = entries.map((e) => e.id);
578
+ if (entryIds.length === 0) return [];
579
+
580
+ const allLines = await this.db
581
+ .select({
582
+ journalEntryId: journalLines.journalEntryId,
583
+ accountCode: accounts.code,
584
+ amount: journalLines.amount,
585
+ side: journalLines.side,
586
+ })
587
+ .from(journalLines)
588
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
589
+ .where(
590
+ sql`${journalLines.journalEntryId} IN (${sql.join(
591
+ entryIds.map((id) => sql`${id}`),
592
+ sql`, `,
593
+ )})`,
594
+ );
595
+
596
+ const linesByEntry = new Map<string, JournalEntry["lines"]>();
597
+ for (const line of allLines) {
598
+ const arr = linesByEntry.get(line.journalEntryId) ?? [];
599
+ arr.push({
600
+ accountCode: line.accountCode,
601
+ amount: Credit.fromRaw(line.amount),
602
+ side: line.side,
603
+ });
604
+ linesByEntry.set(line.journalEntryId, arr);
605
+ }
606
+
607
+ return entries.map((e) => ({
608
+ id: e.id,
609
+ postedAt: e.postedAt,
610
+ entryType: e.entryType,
611
+ tenantId: e.tenantId,
612
+ description: e.description,
613
+ referenceId: e.referenceId,
614
+ metadata: e.metadata as Record<string, unknown> | null,
615
+ lines: linesByEntry.get(e.id) ?? [],
616
+ }));
617
+ }
618
+
619
+ async tenantsWithBalance(): Promise<Array<{ tenantId: string; balance: Credit }>> {
620
+ const rows = await this.db
621
+ .select({
622
+ tenantId: accounts.tenantId,
623
+ balance: accountBalances.balance,
624
+ })
625
+ .from(accountBalances)
626
+ .innerJoin(accounts, eq(accounts.id, accountBalances.accountId))
627
+ .where(and(isNotNull(accounts.tenantId), eq(accounts.type, "liability"), sql`${accountBalances.balance} > 0`));
628
+
629
+ return rows
630
+ .filter((r): r is typeof r & { tenantId: string } => r.tenantId != null)
631
+ .map((r) => ({
632
+ tenantId: r.tenantId,
633
+ balance: Credit.fromRaw(r.balance),
634
+ }));
635
+ }
636
+
637
+ async memberUsage(tenantId: string): Promise<MemberUsageSummary[]> {
638
+ // Sum debit-side lines on the tenant's liability account, grouped by attributed_user_id
639
+ const tenantAccount = `2000:${tenantId}`;
640
+ const rows = await this.db
641
+ .select({
642
+ userId: sql<string>`(${journalEntries.metadata}->>'attributedUserId')`,
643
+ totalDebitRaw: sql<number>`COALESCE(SUM(${journalLines.amount}), 0)`,
644
+ transactionCount: sql<number>`COUNT(*)`,
645
+ })
646
+ .from(journalLines)
647
+ .innerJoin(journalEntries, eq(journalEntries.id, journalLines.journalEntryId))
648
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
649
+ .where(
650
+ and(
651
+ eq(accounts.code, tenantAccount),
652
+ eq(journalLines.side, "debit"), // debits on liability = usage
653
+ sql`${journalEntries.metadata}->>'attributedUserId' IS NOT NULL`,
654
+ ),
655
+ )
656
+ .groupBy(sql`${journalEntries.metadata}->>'attributedUserId'`);
657
+
658
+ return rows
659
+ .filter((r) => r.userId != null)
660
+ .map((r) => ({
661
+ userId: r.userId,
662
+ totalDebit: Credit.fromRaw(Number(r.totalDebitRaw)),
663
+ // COUNT(*) returns bigint (serialized as string by the PG driver) — coerce to number.
664
+ transactionCount: Number(r.transactionCount),
665
+ }));
666
+ }
667
+
668
+ async lifetimeSpend(tenantId: string): Promise<Credit> {
669
+ const tenantAccount = `2000:${tenantId}`;
670
+ const rows = await this.db
671
+ .select({
672
+ totalRaw: sql<string>`COALESCE(SUM(${journalLines.amount}), 0)`,
673
+ })
674
+ .from(journalLines)
675
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
676
+ .where(
677
+ and(
678
+ eq(accounts.code, tenantAccount),
679
+ eq(journalLines.side, "debit"), // debits on liability = money out
680
+ ),
681
+ );
682
+
683
+ const raw = BigInt(String(rows[0]?.totalRaw ?? 0));
684
+ if (raw > BigInt(Number.MAX_SAFE_INTEGER)) {
685
+ throw new Error(`lifetimeSpend overflow: ${raw}`);
686
+ }
687
+ return Credit.fromRaw(Number(raw));
688
+ }
689
+
690
+ async lifetimeSpendBatch(tenantIds: string[]): Promise<Map<string, Credit>> {
691
+ if (tenantIds.length === 0) return new Map();
692
+
693
+ const codes = tenantIds.map((id) => `2000:${id}`);
694
+ const rows = await this.db
695
+ .select({
696
+ code: accounts.code,
697
+ totalRaw: sql<string>`COALESCE(SUM(${journalLines.amount}), 0)`,
698
+ })
699
+ .from(journalLines)
700
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
701
+ .where(
702
+ and(
703
+ sql`${accounts.code} IN (${sql.join(
704
+ codes.map((c) => sql`${c}`),
705
+ sql`, `,
706
+ )})`,
707
+ eq(journalLines.side, "debit"),
708
+ ),
709
+ )
710
+ .groupBy(accounts.code);
711
+
712
+ const result = new Map<string, Credit>();
713
+ for (const row of rows) {
714
+ const tenantId = row.code.slice(5); // strip '2000:'
715
+ const raw = BigInt(String(row.totalRaw));
716
+ if (raw > BigInt(Number.MAX_SAFE_INTEGER)) {
717
+ throw new Error(`lifetimeSpend overflow for ${tenantId}: ${raw}`);
718
+ }
719
+ result.set(tenantId, Credit.fromRaw(Number(raw)));
720
+ }
721
+ for (const id of tenantIds) {
722
+ if (!result.has(id)) result.set(id, Credit.ZERO);
723
+ }
724
+ return result;
725
+ }
726
+
727
+ async expiredCredits(now: string): Promise<Array<{ entryId: string; tenantId: string; amount: Credit }>> {
728
+ // Find credit entries with expiresAt <= now that don't yet have a corresponding expiry entry
729
+ const rows = await this.db
730
+ .select({
731
+ id: journalEntries.id,
732
+ tenantId: journalEntries.tenantId,
733
+ // The credit amount is on the tenant's liability line (credit side)
734
+ amount: sql<number>`(
735
+ SELECT jl.amount FROM journal_lines jl
736
+ INNER JOIN accounts a ON a.id = jl.account_id
737
+ WHERE jl.journal_entry_id = "journal_entries"."id"
738
+ AND a.type = 'liability'
739
+ AND jl.side = 'credit'
740
+ LIMIT 1
741
+ )`,
742
+ })
743
+ .from(journalEntries)
744
+ .where(
745
+ and(
746
+ isNotNull(sql`${journalEntries.metadata}->>'expiresAt'`),
747
+ sql`(${journalEntries.metadata}->>'expiresAt') <= ${now}`,
748
+ sql`${journalEntries.entryType} NOT IN ('credit_expiry', 'bot_runtime', 'adapter_usage', 'addon', 'refund')`,
749
+ ),
750
+ );
751
+
752
+ const result: Array<{ entryId: string; tenantId: string; amount: Credit }> = [];
753
+ for (const row of rows) {
754
+ if (!row.amount) continue;
755
+ // Check if already expired (idempotency)
756
+ if (await this.hasReferenceId(`expiry:${row.id}`)) continue;
757
+ result.push({
758
+ entryId: row.id,
759
+ tenantId: row.tenantId,
760
+ amount: Credit.fromRaw(row.amount),
761
+ });
762
+ }
763
+ return result;
764
+ }
765
+
766
+ async existsByReferenceIdLike(pattern: string): Promise<boolean> {
767
+ const rows = await this.db
768
+ .select({ id: journalEntries.id })
769
+ .from(journalEntries)
770
+ .where(sql`${journalEntries.referenceId} LIKE ${pattern}`)
771
+ .limit(1);
772
+ return rows.length > 0;
773
+ }
774
+
775
+ async sumPurchasesForPeriod(startTs: string, endTs: string): Promise<Credit> {
776
+ // Sum the credit-side amounts on tenant liability accounts for purchase entries in range.
777
+ const rows = await this.db
778
+ .select({
779
+ total: sql<string>`COALESCE(SUM(${journalLines.amount}), 0)`,
780
+ })
781
+ .from(journalLines)
782
+ .innerJoin(journalEntries, eq(journalEntries.id, journalLines.journalEntryId))
783
+ .innerJoin(accounts, eq(accounts.id, journalLines.accountId))
784
+ .where(
785
+ and(
786
+ eq(journalEntries.entryType, "purchase"),
787
+ eq(journalLines.side, "credit"),
788
+ eq(accounts.type, "liability"),
789
+ // Cast to timestamptz for correct chronological comparison regardless of format/TZ.
790
+ sql`${journalEntries.postedAt}::timestamptz >= ${startTs}::timestamptz`,
791
+ sql`${journalEntries.postedAt}::timestamptz < ${endTs}::timestamptz`,
792
+ ),
793
+ );
794
+ // Use BigInt to avoid silent precision loss for large totals (same pattern as lifetimeSpend).
795
+ const raw = BigInt(String(rows[0]?.total ?? 0));
796
+ if (raw > BigInt(Number.MAX_SAFE_INTEGER)) {
797
+ throw new Error(`sumPurchasesForPeriod overflow: ${raw}`);
798
+ }
799
+ return Credit.fromRaw(Number(raw));
800
+ }
801
+
802
+ async getActiveTenantIdsInWindow(startTs: string, endTs: string): Promise<string[]> {
803
+ const rows = await this.db
804
+ .selectDistinct({ tenantId: journalEntries.tenantId })
805
+ .from(journalEntries)
806
+ .where(
807
+ and(
808
+ eq(journalEntries.entryType, "purchase"),
809
+ // Cast to timestamptz for correct chronological comparison.
810
+ sql`${journalEntries.postedAt}::timestamptz >= ${startTs}::timestamptz`,
811
+ sql`${journalEntries.postedAt}::timestamptz < ${endTs}::timestamptz`,
812
+ ),
813
+ );
814
+ return rows.map((r) => r.tenantId);
815
+ }
816
+
817
+ // -- Audit ---------------------------------------------------------------
818
+
819
+ async trialBalance(): Promise<TrialBalance> {
820
+ const rows = await this.db
821
+ .select({
822
+ side: journalLines.side,
823
+ total: sql<string>`COALESCE(SUM(${journalLines.amount}), 0)`,
824
+ })
825
+ .from(journalLines)
826
+ .groupBy(journalLines.side);
827
+
828
+ let totalDebitsBig = 0n;
829
+ let totalCreditsBig = 0n;
830
+ for (const row of rows) {
831
+ if (row.side === "debit") totalDebitsBig = BigInt(String(row.total));
832
+ else totalCreditsBig = BigInt(String(row.total));
833
+ }
834
+
835
+ const diff = totalDebitsBig > totalCreditsBig ? totalDebitsBig - totalCreditsBig : totalCreditsBig - totalDebitsBig;
836
+
837
+ if (totalDebitsBig > BigInt(Number.MAX_SAFE_INTEGER) || totalCreditsBig > BigInt(Number.MAX_SAFE_INTEGER)) {
838
+ throw new Error(`trialBalance overflow: debits=${totalDebitsBig}, credits=${totalCreditsBig}`);
839
+ }
840
+
841
+ return {
842
+ totalDebits: Credit.fromRaw(Number(totalDebitsBig)),
843
+ totalCredits: Credit.fromRaw(Number(totalCreditsBig)),
844
+ balanced: totalDebitsBig === totalCreditsBig,
845
+ difference: Credit.fromRaw(Number(diff)),
846
+ };
847
+ }
848
+ }
849
+
850
+ // Backward-compat alias
851
+ export { DrizzleLedger as Ledger };