/** * Phoenix Ledger Service * Double-entry ledger with virtual accounts, holds, and multi-asset support */ import { getDb } from '../../db/index.js' import { logger } from '../../lib/logger.js' export interface JournalEntry { entryId: string timestamp: Date description: string correlationId: string lines: JournalLine[] } export interface JournalLine { accountRef: string debit: number credit: number asset: string } export interface VirtualAccount { subaccountId: string accountId: string currency: string asset: string labels: Record } export interface Hold { holdId: string amount: number asset: string expiry: Date | null status: 'ACTIVE' | 'RELEASED' | 'EXPIRED' } export interface Balance { accountId: string subaccountId: string | null asset: string balance: number } class LedgerService { /** * Create a journal entry (idempotent via correlation_id) */ async createJournalEntry( correlationId: string, description: string, lines: JournalLine[] ): Promise { const db = getDb() // Check idempotency const existing = await db.query( `SELECT * FROM journal_entries WHERE correlation_id = $1`, [correlationId] ) if (existing.rows.length > 0) { logger.info('Journal entry already exists', { correlationId }) return this.mapJournalEntry(existing.rows[0]) } // Validate double-entry balance const totalDebits = lines.reduce((sum, line) => sum + line.debit, 0) const totalCredits = lines.reduce((sum, line) => sum + line.credit, 0) if (Math.abs(totalDebits - totalCredits) > 0.01) { throw new Error('Journal entry is not balanced') } // Create entry const result = await db.query( `INSERT INTO journal_entries (correlation_id, description, timestamp) VALUES ($1, $2, NOW()) RETURNING *`, [correlationId, description] ) const entryId = result.rows[0].id // Create journal lines for (const line of lines) { await db.query( `INSERT INTO journal_lines (entry_id, account_ref, debit, credit, asset) VALUES ($1, $2, $3, $4, $5)`, [entryId, line.accountRef, line.debit, line.credit, line.asset] ) } logger.info('Journal entry created', { entryId, correlationId }) return this.mapJournalEntry(result.rows[0]) } /** * Create a hold (reserve) */ async createHold( accountId: string, amount: number, asset: string, expiry: Date | null = null ): Promise { const db = getDb() const result = await db.query( `INSERT INTO holds (account_id, amount, asset, expiry, status) VALUES ($1, $2, $3, $4, 'ACTIVE') RETURNING *`, [accountId, amount, asset, expiry] ) logger.info('Hold created', { holdId: result.rows[0].id }) return this.mapHold(result.rows[0]) } /** * Get balance for account/subaccount */ async getBalance(accountId: string, subaccountId?: string, asset?: string): Promise { const db = getDb() // This would query a materialized view or compute from journal_lines const query = ` SELECT account_ref as account_id, asset, SUM(debit - credit) as balance FROM journal_lines WHERE account_ref = $1 ${subaccountId ? 'AND account_ref LIKE $2' : ''} ${asset ? 'AND asset = $3' : ''} GROUP BY account_ref, asset ` const params: any[] = [accountId] if (subaccountId) params.push(`${accountId}:${subaccountId}`) if (asset) params.push(asset) const result = await db.query(query, params) return result.rows.map(row => ({ accountId: row.account_id, subaccountId: subaccountId || null, asset: row.asset, balance: parseFloat(row.balance) })) } private mapJournalEntry(row: any): JournalEntry { return { entryId: row.id, timestamp: row.timestamp, description: row.description, correlationId: row.correlation_id, lines: [] // Would be loaded separately } } private mapHold(row: any): Hold { return { holdId: row.id, amount: parseFloat(row.amount), asset: row.asset, expiry: row.expiry, status: row.status } } } export const ledgerService = new LedgerService()