@wopr-network/platform-core 1.13.3 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/routes/admin-credits.d.ts +2 -2
- package/dist/api/routes/admin-credits.js +9 -4
- package/dist/api/routes/quota.d.ts +2 -2
- package/dist/api/routes/verify-email.d.ts +3 -3
- package/dist/backup/on-demand-snapshot-service.d.ts +2 -2
- package/dist/billing/payram/webhook.d.ts +3 -3
- package/dist/billing/payram/webhook.js +5 -1
- package/dist/billing/payram/webhook.test.js +5 -4
- package/dist/billing/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/billing/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/billing/stripe/tenant-store.d.ts +1 -1
- package/dist/billing/stripe/tenant-store.js +1 -1
- package/dist/credits/auto-topup-charge.d.ts +2 -2
- package/dist/credits/auto-topup-charge.js +5 -1
- package/dist/credits/auto-topup-charge.test.js +5 -4
- package/dist/credits/auto-topup-usage.d.ts +2 -2
- package/dist/credits/auto-topup-usage.test.js +53 -12
- package/dist/credits/credit-expiry-cron.d.ts +2 -2
- package/dist/credits/credit-expiry-cron.js +7 -4
- package/dist/credits/credit-expiry-cron.test.js +25 -8
- package/dist/credits/credit-ledger.d.ts +2 -2
- package/dist/credits/credit-ledger.js +1 -1
- package/dist/credits/dividend-cron.d.ts +4 -6
- package/dist/credits/dividend-cron.js +10 -16
- package/dist/credits/dividend-cron.test.js +31 -44
- package/dist/credits/dividend-repository.js +19 -22
- package/dist/credits/dividend-repository.test.js +4 -3
- package/dist/credits/index.d.ts +4 -2
- package/dist/credits/index.js +2 -1
- package/dist/credits/ledger.d.ts +195 -0
- package/dist/credits/ledger.js +561 -0
- package/dist/credits/ledger.test.js +418 -0
- package/dist/credits/signup-grant.d.ts +2 -2
- package/dist/credits/signup-grant.js +4 -4
- package/dist/credits/signup-grant.test.js +5 -3
- package/dist/credits/trial-balance-cron.d.ts +19 -0
- package/dist/credits/trial-balance-cron.js +30 -0
- package/dist/credits/trial-balance-cron.test.js +55 -0
- package/dist/db/schema/index.d.ts +1 -0
- package/dist/db/schema/index.js +1 -0
- package/dist/db/schema/ledger.d.ts +442 -0
- package/dist/db/schema/ledger.js +76 -0
- package/dist/gateway/credit-gate.d.ts +2 -2
- package/dist/gateway/credit-gate.js +5 -1
- package/dist/gateway/credit-gate.test.js +35 -33
- package/dist/gateway/protocol/deps.d.ts +2 -2
- package/dist/gateway/proxy.d.ts +2 -2
- package/dist/gateway/types.d.ts +2 -2
- package/dist/metering/reconciliation-cron.test.js +9 -8
- package/dist/metering/reconciliation-repository.js +12 -10
- package/dist/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.js +10 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.test.js +32 -13
- package/dist/monetization/affiliate/credit-match.d.ts +2 -2
- package/dist/monetization/affiliate/credit-match.js +4 -1
- package/dist/monetization/affiliate/credit-match.test.js +58 -13
- package/dist/monetization/affiliate/new-user-bonus.d.ts +2 -2
- package/dist/monetization/affiliate/new-user-bonus.js +4 -1
- package/dist/monetization/affiliate/new-user-bonus.test.js +4 -3
- package/dist/monetization/credits/auto-topup-charge.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-charge.js +5 -1
- package/dist/monetization/credits/auto-topup-charge.test.js +5 -4
- package/dist/monetization/credits/auto-topup-usage.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-usage.test.js +53 -12
- package/dist/monetization/credits/bot-billing.d.ts +3 -3
- package/dist/monetization/credits/bot-billing.test.js +18 -5
- package/dist/monetization/credits/credit-expiry-cron.test.js +25 -8
- package/dist/monetization/credits/dividend-cron.d.ts +2 -4
- package/dist/monetization/credits/dividend-cron.js +7 -4
- package/dist/monetization/credits/dividend-cron.test.js +26 -46
- package/dist/monetization/credits/dividend-repository.js +15 -24
- package/dist/monetization/credits/dividend-repository.test.js +4 -3
- package/dist/monetization/credits/index.d.ts +2 -2
- package/dist/monetization/credits/index.js +1 -1
- package/dist/monetization/credits/member-usage.test.js +23 -10
- package/dist/monetization/credits/phone-billing.d.ts +2 -2
- package/dist/monetization/credits/phone-billing.js +5 -1
- package/dist/monetization/credits/phone-billing.test.js +9 -12
- package/dist/monetization/credits/runtime-cron.d.ts +2 -2
- package/dist/monetization/credits/runtime-cron.js +32 -8
- package/dist/monetization/credits/runtime-cron.test.js +28 -27
- package/dist/monetization/credits/runtime-scheduler.d.ts +2 -2
- package/dist/monetization/credits/runtime-scheduler.test.js +1 -1
- package/dist/monetization/credits/signup-grant.test.js +5 -3
- package/dist/monetization/credits/storage-tier-cron.test.js +3 -2
- package/dist/monetization/credits/trial-balance-cron.test.js +42 -0
- package/dist/monetization/feature-gate.d.ts +3 -3
- package/dist/monetization/index.d.ts +3 -3
- package/dist/monetization/index.js +1 -1
- package/dist/monetization/metering/reconciliation-cron.test.js +9 -8
- package/dist/monetization/metering/reconciliation-repository.js +11 -10
- package/dist/monetization/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/payram/webhook.d.ts +2 -2
- package/dist/monetization/payram/webhook.js +5 -1
- package/dist/monetization/payram/webhook.test.js +5 -4
- package/dist/monetization/promotions/engine.d.ts +2 -2
- package/dist/monetization/promotions/engine.js +4 -1
- package/dist/monetization/promotions/engine.test.js +3 -1
- package/dist/monetization/repository-types.d.ts +1 -1
- package/dist/monetization/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/monetization/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/monetization/stripe/webhook.d.ts +2 -2
- package/dist/monetization/stripe/webhook.js +70 -6
- package/dist/monetization/stripe/webhook.test.js +20 -15
- package/dist/onboarding/onboarding-service.d.ts +2 -2
- package/dist/onboarding/onboarding-service.js +6 -2
- package/drizzle/migrations/0003_double_entry_ledger.sql +82 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/api/routes/admin-credits.ts +11 -14
- package/src/api/routes/quota.ts +2 -2
- package/src/api/routes/verify-email.ts +4 -4
- package/src/backup/on-demand-snapshot-service.test.ts +3 -3
- package/src/backup/on-demand-snapshot-service.ts +3 -3
- package/src/billing/payram/webhook.test.ts +7 -5
- package/src/billing/payram/webhook.ts +8 -11
- package/src/billing/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/billing/stripe/stripe-payment-processor.ts +3 -3
- package/src/billing/stripe/tenant-store.ts +1 -1
- package/src/credits/auto-topup-charge.test.ts +7 -5
- package/src/credits/auto-topup-charge.ts +7 -10
- package/src/credits/auto-topup-usage.test.ts +55 -13
- package/src/credits/auto-topup-usage.ts +2 -2
- package/src/credits/credit-expiry-cron.test.ts +26 -45
- package/src/credits/credit-expiry-cron.ts +9 -12
- package/src/credits/credit-ledger.ts +3 -3
- package/src/credits/dividend-cron.test.ts +38 -45
- package/src/credits/dividend-cron.ts +12 -26
- package/src/credits/dividend-repository.test.ts +4 -3
- package/src/credits/dividend-repository.ts +21 -23
- package/src/credits/index.ts +23 -4
- package/src/credits/ledger.test.ts +514 -0
- package/src/credits/ledger.ts +851 -0
- package/src/credits/signup-grant.test.ts +7 -4
- package/src/credits/signup-grant.ts +6 -12
- package/src/credits/trial-balance-cron.test.ts +68 -0
- package/src/credits/trial-balance-cron.ts +46 -0
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/ledger.ts +94 -0
- package/src/gateway/credit-gate-wiring.test.ts +3 -3
- package/src/gateway/credit-gate.test.ts +35 -33
- package/src/gateway/credit-gate.ts +6 -10
- package/src/gateway/gateway-routes.test.ts +5 -5
- package/src/gateway/protocol/deps.ts +2 -2
- package/src/gateway/proxy.ts +2 -2
- package/src/gateway/route-mounting.test.ts +2 -2
- package/src/gateway/types.ts +2 -2
- package/src/metering/reconciliation-cron.test.ts +10 -9
- package/src/metering/reconciliation-repository.test.ts +10 -9
- package/src/metering/reconciliation-repository.ts +14 -11
- package/src/monetization/affiliate/affiliate-admin-repository.test.ts +32 -19
- package/src/monetization/affiliate/affiliate-admin-repository.ts +16 -8
- package/src/monetization/affiliate/credit-match.test.ts +60 -14
- package/src/monetization/affiliate/credit-match.ts +6 -9
- package/src/monetization/affiliate/new-user-bonus.test.ts +6 -4
- package/src/monetization/affiliate/new-user-bonus.ts +6 -9
- package/src/monetization/credits/auto-topup-charge.test.ts +7 -5
- package/src/monetization/credits/auto-topup-charge.ts +7 -10
- package/src/monetization/credits/auto-topup-usage.test.ts +55 -13
- package/src/monetization/credits/auto-topup-usage.ts +2 -2
- package/src/monetization/credits/bot-billing.test.ts +20 -6
- package/src/monetization/credits/bot-billing.ts +3 -3
- package/src/monetization/credits/credit-expiry-cron.test.ts +26 -45
- package/src/monetization/credits/dividend-cron.test.ts +34 -48
- package/src/monetization/credits/dividend-cron.ts +9 -14
- package/src/monetization/credits/dividend-repository.test.ts +4 -3
- package/src/monetization/credits/dividend-repository.ts +19 -25
- package/src/monetization/credits/index.ts +4 -4
- package/src/monetization/credits/member-usage.test.ts +25 -11
- package/src/monetization/credits/phone-billing.test.ts +18 -26
- package/src/monetization/credits/phone-billing.ts +7 -10
- package/src/monetization/credits/runtime-cron.test.ts +29 -28
- package/src/monetization/credits/runtime-cron.ts +34 -58
- package/src/monetization/credits/runtime-scheduler.test.ts +1 -1
- package/src/monetization/credits/runtime-scheduler.ts +2 -2
- package/src/monetization/credits/signup-grant.test.ts +7 -4
- package/src/monetization/credits/storage-tier-cron.test.ts +5 -3
- package/src/monetization/credits/trial-balance-cron.test.ts +52 -0
- package/src/monetization/feature-gate.ts +3 -3
- package/src/monetization/index.ts +4 -4
- package/src/monetization/metering/reconciliation-cron.test.ts +10 -9
- package/src/monetization/metering/reconciliation-repository.test.ts +11 -9
- package/src/monetization/metering/reconciliation-repository.ts +13 -11
- package/src/monetization/payram/webhook.test.ts +7 -5
- package/src/monetization/payram/webhook.ts +7 -10
- package/src/monetization/promotions/engine.test.ts +6 -5
- package/src/monetization/promotions/engine.ts +6 -3
- package/src/monetization/repository-types.ts +1 -1
- package/src/monetization/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/monetization/stripe/stripe-payment-processor.ts +3 -3
- package/src/monetization/stripe/webhook.test.ts +22 -16
- package/src/monetization/stripe/webhook.ts +75 -50
- package/src/onboarding/onboarding-service.ts +8 -11
- package/dist/credits/credit-ledger-extra.test.js +0 -40
- package/dist/credits/credit-ledger.bench.js +0 -33
- package/dist/credits/credit-ledger.test.d.ts +0 -4
- package/dist/credits/credit-ledger.test.js +0 -203
- package/dist/credits/credit-transaction-repository.test.js +0 -232
- package/dist/monetization/credits/credit-ledger-extra.test.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger-extra.test.js +0 -39
- package/dist/monetization/credits/credit-ledger.bench.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger.bench.js +0 -32
- package/dist/monetization/credits/credit-ledger.test.d.ts +0 -4
- package/dist/monetization/credits/credit-ledger.test.js +0 -202
- package/dist/monetization/credits/credit-transaction-repository.test.d.ts +0 -1
- package/dist/monetization/credits/credit-transaction-repository.test.js +0 -232
- package/src/credits/credit-ledger-extra.test.ts +0 -57
- package/src/credits/credit-ledger.bench.ts +0 -56
- package/src/credits/credit-ledger.test.ts +0 -276
- package/src/credits/credit-transaction-repository.test.ts +0 -274
- package/src/monetization/credits/credit-ledger-extra.test.ts +0 -56
- package/src/monetization/credits/credit-ledger.bench.ts +0 -55
- package/src/monetization/credits/credit-ledger.test.ts +0 -275
- package/src/monetization/credits/credit-transaction-repository.test.ts +0 -274
- /package/dist/credits/{credit-ledger-extra.test.d.ts → ledger.test.d.ts} +0 -0
- /package/dist/credits/{credit-ledger.bench.d.ts → trial-balance-cron.test.d.ts} +0 -0
- /package/dist/{credits/credit-transaction-repository.test.d.ts → monetization/credits/trial-balance-cron.test.d.ts} +0 -0
|
@@ -0,0 +1,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 };
|