- Phoenix API Railing: proxy to PHOENIX_RAILING_URL, tenant me routes - Tenant-auth: X-API-Key support for /api/v1/* (api_keys table) - Migration 026: api_keys table; 025 sovereign stack marketplace - GET /graphql/schema, GET /graphql-playground, api/docs OpenAPI - Integration tests: phoenix-railing.test.ts - docs/api/API_VERSIONING: /api/v1/ railing alignment - docs/phoenix/PORTAL_RAILING_WIRING Made-with: Cursor
176 lines
4.3 KiB
TypeScript
176 lines
4.3 KiB
TypeScript
/**
|
|
* 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<string, string>
|
|
}
|
|
|
|
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<JournalEntry> {
|
|
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<Hold> {
|
|
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<Balance[]> {
|
|
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()
|