Files
Sankofa/api/src/services/sovereign-stack/ledger-service.ts
defiQUG 8436e22f4c API: Phoenix railing proxy, API key auth for /api/v1/*, schema export, docs, migrations, tests
- 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
2026-03-11 12:57:41 -07:00

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()