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
This commit is contained in:
175
api/src/services/sovereign-stack/ledger-service.ts
Normal file
175
api/src/services/sovereign-stack/ledger-service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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()
|
||||
Reference in New Issue
Block a user