diff --git a/__tests__/csv.test.ts b/__tests__/csv.test.ts new file mode 100644 index 0000000..bb953aa --- /dev/null +++ b/__tests__/csv.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest' +import { escapeCSVCell, toCSV, csvContentDisposition, sanitizeFilename } from '@/lib/csv' + +describe('CSV utility', () => { + describe('escapeCSVCell', () => { + it('returns empty string for null/undefined', () => { + expect(escapeCSVCell(null)).toBe('') + expect(escapeCSVCell(undefined)).toBe('') + }) + + it('returns plain values unchanged', () => { + expect(escapeCSVCell('hello')).toBe('hello') + expect(escapeCSVCell(123)).toBe('123') + expect(escapeCSVCell(true)).toBe('true') + }) + + it('quotes values containing commas', () => { + expect(escapeCSVCell('a,b')).toBe('"a,b"') + }) + + it('quotes values containing double-quotes and escapes them', () => { + expect(escapeCSVCell('say "hi"')).toBe('"say ""hi"""') + }) + + it('quotes values containing newlines', () => { + expect(escapeCSVCell('line1\nline2')).toBe('"line1\nline2"') + expect(escapeCSVCell('line1\r\nline2')).toBe('"line1\r\nline2"') + }) + + it('serializes objects as JSON with escaped quotes', () => { + const result = escapeCSVCell({ key: 'value' }) + // JSON.stringify produces {"key":"value"}, then CSV escaping wraps in + // double quotes and doubles the inner quotes → "{""key"":""value""}" + expect(result).toBe('"{""key"":""value""}"') + }) + }) + + describe('toCSV', () => { + interface Row { + id: string + name: string + amount: number + } + + it('generates header row by default', () => { + const csv = toCSV([], { + columns: [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'amount', header: 'Amount' }, + ], + }) + expect(csv).toBe('ID,Name,Amount') + }) + + it('skips header when includeHeader=false', () => { + const csv = toCSV( + [{ id: '1', name: 'Alice', amount: 100 }], + { + columns: [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'amount', header: 'Amount' }, + ], + includeHeader: false, + } + ) + expect(csv).toBe('1,Alice,100') + }) + + it('serializes multiple rows', () => { + const csv = toCSV( + [ + { id: '1', name: 'Alice', amount: 100 }, + { id: '2', name: 'Bob, Jr.', amount: 200 }, + ], + { + columns: [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'amount', header: 'Amount' }, + ], + } + ) + expect(csv).toBe('ID,Name,Amount\r\n1,Alice,100\r\n2,"Bob, Jr.",200') + }) + + it('uses format function when provided', () => { + const csv = toCSV( + [{ id: '1', name: 'Alice', amount: 100.5 }], + { + columns: [ + { key: 'id', header: 'ID' }, + { + key: 'amount', + header: 'Amount', + format: (row) => `$${(row.amount).toFixed(2)}`, + }, + ], + } + ) + expect(csv).toBe('ID,Amount\r\n1,$100.50') + }) + }) + + describe('csvContentDisposition', () => { + it('returns attachment disposition with filename', () => { + expect(csvContentDisposition('report.csv')).toBe('attachment; filename="report.csv"') + }) + }) + + describe('sanitizeFilename', () => { + it('replaces unsafe characters with underscores', () => { + expect(sanitizeFilename('report 2024/10/17.csv')).toBe('report_2024_10_17.csv') + }) + + it('collapses consecutive underscores', () => { + expect(sanitizeFilename('a b')).toBe('a_b') + }) + + it('keeps dots and dashes', () => { + expect(sanitizeFilename('my-report.v2.csv')).toBe('my-report.v2.csv') + }) + }) +}) diff --git a/__tests__/payment-ledger/service.test.ts b/__tests__/payment-ledger/service.test.ts new file mode 100644 index 0000000..d24505d --- /dev/null +++ b/__tests__/payment-ledger/service.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// vi.mock is hoisted — use inline vi.fn() and import the mocked sql afterward +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) + +import { sql } from '@/lib/db' +import { + paymentLedgerService, + LedgerValidationError, + LedgerForbiddenError, + LedgerDuplicateEntryError, +} from '@/lib/payment-ledger' + +// sql is the mocked function — control it with mockResolvedValue / mockResolvedValueOnce +const sqlMock = sql as unknown as ReturnType + +describe('PaymentLedgerService', () => { + beforeEach(() => { + sqlMock.mockReset() + }) + + describe('listEntries', () => { + it('throws validation error for invalid contractId', async () => { + sqlMock.mockResolvedValueOnce([{ id: 'user-1', role: 'freelancer' }]) + await expect( + paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '0', + contractId: 'not-a-uuid', + }) + ).rejects.toThrow(LedgerValidationError) + }) + + it('throws validation error for invalid entryType', async () => { + sqlMock.mockResolvedValueOnce([{ id: 'user-1', role: 'freelancer' }]) + await expect( + paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '0', + entryType: 'invalid_type', + }) + ).rejects.toThrow(LedgerValidationError) + }) + + it('throws forbidden error when wallet not linked to user', async () => { + sqlMock.mockResolvedValueOnce([]) + await expect( + paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '0', + }) + ).rejects.toThrow(LedgerForbiddenError) + }) + + it('throws validation error for invalid limit', async () => { + await expect( + paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '0', + offsetParam: '0', + }) + ).rejects.toThrow(LedgerValidationError) + }) + + it('throws validation error for limit > 100', async () => { + await expect( + paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '101', + offsetParam: '0', + }) + ).rejects.toThrow(LedgerValidationError) + }) + + it('throws validation error for negative offset', async () => { + await expect( + paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '-1', + }) + ).rejects.toThrow(LedgerValidationError) + }) + + it('throws validation error for invalid status', async () => { + sqlMock.mockResolvedValueOnce([{ id: 'user-1', role: 'freelancer' }]) + await expect( + paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '0', + status: 'bogus', + }) + ).rejects.toThrow(LedgerValidationError) + }) + + it('returns paginated entries for valid request', async () => { + const mockEntry = { + id: 'entry-1', + contract_id: 'contract-1', + project_id: 'project-1', + milestone_id: null, + dispute_id: null, + actor_user_id: 'user-1', + counterparty_user_id: null, + transaction_type: 'deposit', + amount: '100.000000', + currency: 'USDC', + transaction_hash: 'tx-abc', + status: 'confirmed', + description: null, + metadata: {}, + created_at: '2026-10-18T12:00:00Z', + project_title: 'Test Project', + contract_status: 'active', + actor_wallet_address: 'GACTOR', + counterparty_wallet_address: null, + } + + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'freelancer' }]) + .mockResolvedValueOnce([{ total_count: 1 }]) + .mockResolvedValueOnce([mockEntry]) + + const result = await paymentLedgerService.listEntries({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '0', + }) + + expect(result.entries).toHaveLength(1) + expect(result.entries[0].id).toBe('entry-1') + expect(result.entries[0].entryType).toBe('deposit') + expect(result.entries[0].amount).toBe('100.000000') + expect(result.pagination.total).toBe(1) + expect(result.pagination.hasMore).toBe(false) + expect(result.pagination.nextOffset).toBeNull() + }) + }) + + describe('listPending', () => { + it('delegates to listEntries with status=pending', async () => { + const mockEntry = { + id: 'entry-2', + contract_id: 'contract-1', + project_id: 'project-1', + milestone_id: null, + dispute_id: null, + actor_user_id: 'user-1', + counterparty_user_id: null, + transaction_type: 'deposit', + amount: '50.000000', + currency: 'USDC', + transaction_hash: 'tx-pending', + status: 'pending', + description: null, + metadata: {}, + created_at: '2026-10-18T12:00:00Z', + project_title: null, + contract_status: 'active', + actor_wallet_address: 'GACTOR', + counterparty_wallet_address: null, + } + + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'freelancer' }]) + .mockResolvedValueOnce([{ total_count: 1 }]) + .mockResolvedValueOnce([mockEntry]) + + const result = await paymentLedgerService.listPending({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '0', + }) + + expect(result.entries).toHaveLength(1) + expect(result.entries[0].status).toBe('pending') + }) + }) + + describe('getBalances', () => { + it('returns balance per contract and currency', async () => { + const mockBalance = { + contract_id: 'contract-1', + currency: 'USDC', + funded: '1000.000000', + released: '300.000000', + refunded: '0', + pending: '50.000000', + entry_count: 5, + } + + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'admin' }]) + .mockResolvedValueOnce([{ total_count: 1 }]) + .mockResolvedValueOnce([mockBalance]) + + const result = await paymentLedgerService.getBalances({ + walletAddress: 'GWALLET', + limitParam: '10', + offsetParam: '0', + }) + + expect(result.balances).toHaveLength(1) + expect(result.balances[0].funded).toBe('1000.000000') + expect(result.balances[0].released).toBe('300.000000') + expect(result.balances[0].refunded).toBe('0') + expect(result.balances[0].remaining).toBe('700.000000') + }) + }) + + describe('getSummary', () => { + it('returns aggregated summary across currencies', async () => { + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'admin' }]) + .mockResolvedValueOnce([ + { + currency: 'USDC', + funded: '1000.000000', + released: '300.000000', + refunded: '0', + pending: '50.000000', + confirmed_count: 10, + pending_count: 2, + failed_count: 1, + }, + { + currency: 'XLM', + funded: '500.000000', + released: '100.000000', + refunded: '50.000000', + pending: '0', + confirmed_count: 5, + pending_count: 0, + failed_count: 0, + }, + ]) + + const result = await paymentLedgerService.getSummary({ + walletAddress: 'GWALLET', + }) + + expect(result.totalFunded).toBe('1500.000000') + expect(result.totalReleased).toBe('400.000000') + expect(result.totalRefunded).toBe('50.000000') + expect(result.totalRemaining).toBe('1050.000000') + expect(result.totalPending).toBe('50.000000') + expect(result.confirmedEntries).toBe(15) + expect(result.pendingEntries).toBe(2) + expect(result.failedEntries).toBe(1) + expect(result.currencies).toHaveLength(2) + }) + + it('returns zero values when no data', async () => { + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'admin' }]) + .mockResolvedValueOnce([]) + + const result = await paymentLedgerService.getSummary({ + walletAddress: 'GWALLET', + }) + + expect(result.totalFunded).toBe('0.000000') + expect(result.totalReleased).toBe('0.000000') + expect(result.totalRefunded).toBe('0.000000') + expect(result.totalRemaining).toBe('0.000000') + expect(result.currencies).toHaveLength(0) + }) + }) + + describe('recordEntry', () => { + it('throws duplicate error when entry already exists', async () => { + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'client' }]) + .mockResolvedValueOnce([{ id: 'c-1', project_id: 'p-1', client_id: 'user-1', freelancer_id: 'user-2', currency: 'USDC' }]) + .mockResolvedValueOnce([{ id: 'existing-entry' }]) + + await expect( + paymentLedgerService.recordEntry({ + walletAddress: 'GWALLET', + contractId: 'c-1', + entryType: 'deposit', + amount: '100.000000', + transactionHash: 'tx-dup', + }) + ).rejects.toThrow(LedgerDuplicateEntryError) + }) + + it('throws forbidden when non-participant tries to create entry', async () => { + sqlMock + .mockResolvedValueOnce([{ id: 'user-3', role: 'freelancer' }]) + .mockResolvedValueOnce([{ id: 'c-1', project_id: 'p-1', client_id: 'user-1', freelancer_id: 'user-2', currency: 'USDC' }]) + + await expect( + paymentLedgerService.recordEntry({ + walletAddress: 'GWALLET', + contractId: 'c-1', + entryType: 'deposit', + amount: '100.000000', + transactionHash: 'tx-new', + }) + ).rejects.toThrow(LedgerForbiddenError) + }) + + it('throws validation error when contract not found', async () => { + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'admin' }]) + .mockResolvedValueOnce([]) + + await expect( + paymentLedgerService.recordEntry({ + walletAddress: 'GWALLET', + contractId: '550e8400-e29b-41d4-a716-446655440000', + entryType: 'deposit', + amount: '100.000000', + }) + ).rejects.toThrow(LedgerValidationError) + }) + }) + + describe('exportCsv', () => { + it('generates CSV from ledger entries', async () => { + const mockEntry = { + id: 'entry-1', + contract_id: 'contract-1', + project_id: 'project-1', + milestone_id: null, + dispute_id: null, + actor_user_id: 'user-1', + counterparty_user_id: null, + transaction_type: 'deposit', + amount: '100.000000', + currency: 'USDC', + transaction_hash: 'tx-1', + status: 'confirmed', + description: 'Funding deposit', + metadata: {}, + created_at: '2026-10-18T12:00:00Z', + project_title: 'Test', + contract_status: 'active', + actor_wallet_address: 'GACTOR', + counterparty_wallet_address: null, + } + + sqlMock + .mockResolvedValueOnce([{ id: 'user-1', role: 'admin' }]) + .mockResolvedValueOnce([{ total_count: 1 }]) + .mockResolvedValueOnce([mockEntry]) + + const result = await paymentLedgerService.exportCsv({ + walletAddress: 'GWALLET', + }) + + expect(result.totalRows).toBe(1) + expect(result.filename).toContain('ledger_export_') + expect(result.csv).toContain('ID') + expect(result.csv).toContain('entry-1') + expect(result.csv).toContain('deposit') + expect(result.csv).toContain('100.000000') + }) + }) +}) diff --git a/__tests__/reconciliation/service.test.ts b/__tests__/reconciliation/service.test.ts new file mode 100644 index 0000000..8f4efe9 --- /dev/null +++ b/__tests__/reconciliation/service.test.ts @@ -0,0 +1,437 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) + +import { sql } from '@/lib/db' +import { + reconciliationService, + ReconciliationValidationError, + ReconciliationNotFoundError, +} from '@/lib/reconciliation' + +const sqlMock = sql as unknown as ReturnType + +describe('ReconciliationService', () => { + beforeEach(() => { + sqlMock.mockReset() + }) + + describe('runReconciliation', () => { + it('throws validation error for invalid contractId', async () => { + sqlMock.mockResolvedValueOnce([{ id: 'admin-1' }]) + + await expect( + reconciliationService.runReconciliation({ + walletAddress: 'GADMIN', + contractId: 'not-a-uuid', + }) + ).rejects.toThrow(ReconciliationValidationError) + }) + + it('throws validation error for invalid dateFrom', async () => { + await expect( + reconciliationService.runReconciliation({ + walletAddress: 'GADMIN', + dateFrom: 'not-a-date', + }) + ).rejects.toThrow(ReconciliationValidationError) + }) + + it('completes with status=completed when no discrepancies', async () => { + sqlMock + .mockResolvedValueOnce([{ id: 'admin-1' }]) + .mockResolvedValueOnce([{ id: 'report-1', status: 'pending' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ + id: 'report-1', + generated_by: 'admin-1', + status: 'completed', + total_funded: '0', + total_released: '0', + total_refunded: '0', + total_pending: '0', + discrepancy_count: 0, + report_metadata: {}, + error_message: null, + created_at: '2026-10-18T12:00:00Z', + completed_at: '2026-10-18T12:00:00Z', + }]) + + const result = await reconciliationService.runReconciliation({ + walletAddress: 'GADMIN', + }) + + expect(result.status).toBe('completed') + expect(result.discrepancyCount).toBe(0) + }) + + it('detects missing blockchain event when contract is funded but no deposit log', async () => { + const contract = { + id: 'c-1', + project_id: 'p-1', + client_id: 'user-1', + freelancer_id: 'user-2', + total_amount: '1000.000000', + currency: 'USDC', + escrow_status: 'funded', + status: 'active', + } + + sqlMock + .mockResolvedValueOnce([{ id: 'admin-1' }]) + .mockResolvedValueOnce([{ id: 'report-1', status: 'pending' }]) + .mockResolvedValueOnce([contract]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce([{ + id: 'report-1', + generated_by: 'admin-1', + status: 'discrepancies_found', + total_funded: '0', + total_released: '0', + total_refunded: '0', + total_pending: '0', + discrepancy_count: 1, + report_metadata: {}, + error_message: null, + created_at: '2026-10-18T12:00:00Z', + completed_at: '2026-10-18T12:00:00Z', + }]) + + const result = await reconciliationService.runReconciliation({ + walletAddress: 'GADMIN', + }) + + expect(result.status).toBe('discrepancies_found') + expect(result.discrepancyCount).toBe(1) + }) + + it('detects amount mismatch between contract total and ledger funded', async () => { + const contract = { + id: 'c-1', + project_id: 'p-1', + client_id: 'user-1', + freelancer_id: 'user-2', + total_amount: '1000.000000', + currency: 'USDC', + escrow_status: 'funded', + status: 'active', + } + + const ledgerSummary = { + contract_id: 'c-1', + currency: 'USDC', + funded: '900.000000', + released: '0', + refunded: '0', + pending: '0', + } + + sqlMock + .mockResolvedValueOnce([{ id: 'admin-1' }]) + .mockResolvedValueOnce([{ id: 'report-1', status: 'pending' }]) + .mockResolvedValueOnce([contract]) + .mockResolvedValueOnce([ledgerSummary]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce([{ + id: 'report-1', + generated_by: 'admin-1', + status: 'discrepancies_found', + total_funded: '900.000000', + total_released: '0', + total_refunded: '0', + total_pending: '0', + discrepancy_count: 1, + report_metadata: {}, + error_message: null, + created_at: '2026-10-18T12:00:00Z', + completed_at: '2026-10-18T12:00:00Z', + }]) + + const result = await reconciliationService.runReconciliation({ + walletAddress: 'GADMIN', + }) + + expect(result.status).toBe('discrepancies_found') + expect(result.totalFunded).toBe('900.000000') + }) + + it('detects duplicate entries', async () => { + const duplicate = { + transaction_hash: 'tx-dup', + contract_id: 'c-1', + cnt: 2, + ids: ['entry-1', 'entry-2'], + } + + sqlMock + .mockResolvedValueOnce([{ id: 'admin-1' }]) + .mockResolvedValueOnce([{ id: 'report-1', status: 'pending' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([duplicate]) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce([{ + id: 'report-1', + generated_by: 'admin-1', + status: 'discrepancies_found', + total_funded: '0', + total_released: '0', + total_refunded: '0', + total_pending: '0', + discrepancy_count: 1, + report_metadata: {}, + error_message: null, + created_at: '2026-10-18T12:00:00Z', + completed_at: '2026-10-18T12:00:00Z', + }]) + + const result = await reconciliationService.runReconciliation({ + walletAddress: 'GADMIN', + }) + + expect(result.status).toBe('discrepancies_found') + expect(result.discrepancyCount).toBe(1) + }) + }) + + describe('getReport', () => { + it('throws validation error for invalid reportId', async () => { + await expect( + reconciliationService.getReport('not-a-uuid') + ).rejects.toThrow(ReconciliationValidationError) + }) + + it('throws not found when report does not exist', async () => { + sqlMock.mockResolvedValueOnce([]) + + await expect( + reconciliationService.getReport('550e8400-e29b-41d4-a716-446655440000') + ).rejects.toThrow(ReconciliationNotFoundError) + }) + + it('returns report with discrepancies', async () => { + const mockReport = { + id: 'report-1', + generated_by: 'admin-1', + status: 'discrepancies_found', + contract_id: null, + token: null, + date_from: null, + date_to: null, + total_funded: '1000.000000', + total_released: '300.000000', + total_refunded: '0', + total_pending: '50.000000', + discrepancy_count: 1, + report_metadata: {}, + error_message: null, + created_at: '2026-10-18T12:00:00Z', + completed_at: '2026-10-18T12:00:00Z', + } + + const mockDiscrepancy = { + id: 'disc-1', + report_id: 'report-1', + contract_id: 'c-1', + milestone_id: null, + transaction_hash: null, + discrepancy_type: 'amount_mismatch', + severity: 'high', + description: 'Funded amount mismatch', + expected_amount: '1000.000000', + actual_amount: '900.000000', + blockchain_data: { ledgerFunded: '900.000000' }, + application_data: { contractTotal: '1000.000000' }, + status: 'open', + resolved_by: null, + resolution_notes: null, + resolved_at: null, + created_at: '2026-10-18T12:00:00Z', + updated_at: '2026-10-18T12:00:00Z', + } + + sqlMock + .mockResolvedValueOnce([mockReport]) + .mockResolvedValueOnce([mockDiscrepancy]) + + const result = await reconciliationService.getReport('550e8400-e29b-41d4-a716-446655440000') + + expect(result.report.id).toBe('report-1') + expect(result.discrepancies).toHaveLength(1) + expect(result.discrepancies[0].discrepancyType).toBe('amount_mismatch') + expect(result.discrepancies[0].severity).toBe('high') + }) + }) + + describe('listReports', () => { + it('throws validation error for invalid status filter', async () => { + await expect( + reconciliationService.listReports({ + limitParam: '10', + offsetParam: '0', + status: 'bogus', + }) + ).rejects.toThrow(ReconciliationValidationError) + }) + + it('returns paginated reports', async () => { + const mockReport = { + id: 'report-1', + generated_by: 'admin-1', + status: 'completed', + contract_id: null, + token: null, + date_from: null, + date_to: null, + total_funded: '0', + total_released: '0', + total_refunded: '0', + total_pending: '0', + discrepancy_count: 0, + report_metadata: {}, + error_message: null, + created_at: '2026-10-18T12:00:00Z', + completed_at: '2026-10-18T12:00:00Z', + } + + sqlMock + .mockResolvedValueOnce([{ total_count: 1 }]) + .mockResolvedValueOnce([mockReport]) + + const result = await reconciliationService.listReports({ + limitParam: '10', + offsetParam: '0', + }) + + expect(result.reports).toHaveLength(1) + expect(result.pagination.total).toBe(1) + }) + }) + + describe('listDiscrepancies', () => { + it('throws validation error for invalid discrepancyType', async () => { + await expect( + reconciliationService.listDiscrepancies({ + limitParam: '10', + offsetParam: '0', + discrepancyType: 'bogus', + }) + ).rejects.toThrow(ReconciliationValidationError) + }) + + it('throws validation error for invalid severity', async () => { + await expect( + reconciliationService.listDiscrepancies({ + limitParam: '10', + offsetParam: '0', + severity: 'ultra', + }) + ).rejects.toThrow(ReconciliationValidationError) + }) + + it('throws validation error for invalid discrepancy status', async () => { + await expect( + reconciliationService.listDiscrepancies({ + limitParam: '10', + offsetParam: '0', + status: 'bogus', + }) + ).rejects.toThrow(ReconciliationValidationError) + }) + }) + + describe('resolveDiscrepancy', () => { + it('throws validation error for short resolution notes', async () => { + sqlMock.mockResolvedValueOnce([{ id: 'admin-1' }]) + + await expect( + reconciliationService.resolveDiscrepancy( + '550e8400-e29b-41d4-a716-446655440000', + 'GADMIN', + 'ok' + ) + ).rejects.toThrow(ReconciliationValidationError) + }) + + it('throws not found when discrepancy does not exist', async () => { + sqlMock + .mockResolvedValueOnce([{ id: 'admin-1' }]) + .mockResolvedValueOnce([]) + + await expect( + reconciliationService.resolveDiscrepancy( + '550e8400-e29b-41d4-a716-446655440000', + 'GADMIN', + 'Resolved by adjusting the ledger entry' + ) + ).rejects.toThrow(ReconciliationNotFoundError) + }) + }) + + describe('exportReportCsv', () => { + it('generates CSV from report discrepancies', async () => { + const mockReport = { + id: 'report-1', + generated_by: 'admin-1', + status: 'discrepancies_found', + contract_id: null, + token: null, + date_from: null, + date_to: null, + total_funded: '0', + total_released: '0', + total_refunded: '0', + total_pending: '0', + discrepancy_count: 1, + report_metadata: {}, + error_message: null, + created_at: '2026-10-18T12:00:00Z', + completed_at: '2026-10-18T12:00:00Z', + } + + const mockDiscrepancy = { + id: 'disc-1', + report_id: 'report-1', + contract_id: 'c-1', + milestone_id: null, + transaction_hash: null, + discrepancy_type: 'duplicate_entry', + severity: 'critical', + description: 'Duplicate detected', + expected_amount: null, + actual_amount: null, + blockchain_data: {}, + application_data: null, + status: 'open', + resolved_by: null, + resolution_notes: null, + resolved_at: null, + created_at: '2026-10-18T12:00:00Z', + updated_at: '2026-10-18T12:00:00Z', + } + + sqlMock + .mockResolvedValueOnce([mockReport]) + .mockResolvedValueOnce([mockDiscrepancy]) + + const result = await reconciliationService.exportReportCsv( + '550e8400-e29b-41d4-a716-446655440000' + ) + + expect(result.totalRows).toBe(1) + expect(result.filename).toContain('reconciliation_report_') + expect(result.csv).toContain('Discrepancy ID') + expect(result.csv).toContain('duplicate_entry') + expect(result.csv).toContain('critical') + }) + }) +}) diff --git a/app/api/payment-ledger/balances/route.ts b/app/api/payment-ledger/balances/route.ts new file mode 100644 index 0000000..4d5460a --- /dev/null +++ b/app/api/payment-ledger/balances/route.ts @@ -0,0 +1,84 @@ +/** + * GET /api/payment-ledger/balances + * + * Returns funded, released, refunded, and remaining balances per + * contract and currency. Also supports a summary mode via ?summary=true + * which returns a single aggregated view across all currencies. + * + * Query params: + * summary — if 'true', returns a global balance summary instead of + * paginated per-contract balances + * limit/offset — pagination (ignored when summary=true) + * contractId — filter by contract UUID + * userId — filter by actor or counterparty UUID + * currency — filter by token/currency + * dateFrom — ISO date string (inclusive) + * dateTo — ISO date string (inclusive) + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { + paymentLedgerService, + LedgerError, + ledgerErrorToHttpStatus, + LedgerValidationError, + LedgerForbiddenError, +} from '@/lib/payment-ledger' + +export const GET = withAuth(async (request: NextRequest, auth) => { + const { searchParams } = request.nextUrl + const summary = searchParams.get('summary') === 'true' + + try { + if (summary) { + const summaryResult = await paymentLedgerService.getSummary({ + walletAddress: auth.walletAddress, + contractId: searchParams.get('contractId'), + currency: searchParams.get('currency'), + dateFrom: searchParams.get('dateFrom'), + dateTo: searchParams.get('dateTo'), + }) + return NextResponse.json({ summary: summaryResult }, { status: 200 }) + } + + const page = await paymentLedgerService.getBalances({ + walletAddress: auth.walletAddress, + limitParam: searchParams.get('limit'), + offsetParam: searchParams.get('offset'), + contractId: searchParams.get('contractId'), + userId: searchParams.get('userId'), + currency: searchParams.get('currency'), + dateFrom: searchParams.get('dateFrom'), + dateTo: searchParams.get('dateTo'), + }) + + return NextResponse.json(page, { status: 200 }) + } catch (err) { + if (err instanceof LedgerValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof LedgerForbiddenError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 403 } + ) + } + if (err instanceof LedgerError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: ledgerErrorToHttpStatus(err) } + ) + } + console.error('[payment-ledger/balances GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/payment-ledger/export/route.ts b/app/api/payment-ledger/export/route.ts new file mode 100644 index 0000000..34277d5 --- /dev/null +++ b/app/api/payment-ledger/export/route.ts @@ -0,0 +1,88 @@ +/** + * GET /api/payment-ledger/export + * + * Exports ledger entries as a CSV file download. Applies the same + * filters as the main ledger endpoint. + * + * Query params: + * contractId — filter by contract UUID + * userId — filter by actor or counterparty UUID + * currency — filter by token/currency + * entryType — filter by type + * status — filter by status + * dateFrom — ISO date string (inclusive) + * dateTo — ISO date string (inclusive) + * + * Response: + * Content-Type: text/csv + * Content-Disposition: attachment; filename="ledger_export_YYYY-MM-DD.csv" + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { csvContentDisposition } from '@/lib/csv' +import { + paymentLedgerService, + LedgerError, + ledgerErrorToHttpStatus, + LedgerValidationError, + LedgerForbiddenError, +} from '@/lib/payment-ledger' + +export const GET = withAuth(async (request: NextRequest, auth) => { + const { searchParams } = request.nextUrl + + try { + const result = await paymentLedgerService.exportCsv({ + walletAddress: auth.walletAddress, + contractId: searchParams.get('contractId'), + userId: searchParams.get('userId'), + currency: searchParams.get('currency'), + entryType: searchParams.get('entryType'), + status: searchParams.get('status'), + dateFrom: searchParams.get('dateFrom'), + dateTo: searchParams.get('dateTo'), + }) + + if (result.totalRows === 0) { + return NextResponse.json( + { error: 'No ledger entries found matching the specified filters', code: 'EMPTY_EXPORT' }, + { status: 200 } + ) + } + + return new NextResponse(result.csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': csvContentDisposition(result.filename), + }, + }) + } catch (err) { + if (err instanceof LedgerValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof LedgerForbiddenError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 403 } + ) + } + if (err instanceof LedgerError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: ledgerErrorToHttpStatus(err) } + ) + } + console.error('[payment-ledger/export GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/payment-ledger/pending/route.ts b/app/api/payment-ledger/pending/route.ts new file mode 100644 index 0000000..79ff8d1 --- /dev/null +++ b/app/api/payment-ledger/pending/route.ts @@ -0,0 +1,71 @@ +/** + * GET /api/payment-ledger/pending + * + * Lists pending transactions only — clearly separated from confirmed + * ledger entries. Same filters and pagination as the main ledger endpoint. + * + * Query params: + * limit/offset — pagination + * contractId — filter by contract UUID + * userId — filter by actor or counterparty UUID + * currency — filter by token/currency + * entryType — filter by type: deposit | milestone_release | refund | dispute + * dateFrom — ISO date string (inclusive) + * dateTo — ISO date string (inclusive) + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { + paymentLedgerService, + LedgerError, + ledgerErrorToHttpStatus, + LedgerValidationError, + LedgerForbiddenError, +} from '@/lib/payment-ledger' + +export const GET = withAuth(async (request: NextRequest, auth) => { + const { searchParams } = request.nextUrl + + try { + const page = await paymentLedgerService.listPending({ + walletAddress: auth.walletAddress, + limitParam: searchParams.get('limit'), + offsetParam: searchParams.get('offset'), + contractId: searchParams.get('contractId'), + userId: searchParams.get('userId'), + currency: searchParams.get('currency'), + entryType: searchParams.get('entryType'), + dateFrom: searchParams.get('dateFrom'), + dateTo: searchParams.get('dateTo'), + }) + + return NextResponse.json(page, { status: 200 }) + } catch (err) { + if (err instanceof LedgerValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof LedgerForbiddenError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 403 } + ) + } + if (err instanceof LedgerError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: ledgerErrorToHttpStatus(err) } + ) + } + console.error('[payment-ledger/pending GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/payment-ledger/route.ts b/app/api/payment-ledger/route.ts new file mode 100644 index 0000000..ea57593 --- /dev/null +++ b/app/api/payment-ledger/route.ts @@ -0,0 +1,75 @@ +/** + * GET /api/payment-ledger + * + * List ledger entries (confirmed, pending, and failed) with filtering and + * pagination. Access is scoped to contracts the authenticated user + * participates in (as client, freelancer, actor, or counterparty). + * + * Query params: + * limit — pagination page size (default 50, max 100) + * offset — pagination offset (default 0) + * contractId — filter by contract UUID + * userId — filter by actor or counterparty UUID + * currency — filter by token/currency (e.g. 'USDC') + * entryType — filter by type: deposit | milestone_release | refund | dispute + * status — filter by status: pending | confirmed | failed + * dateFrom — ISO date string (inclusive) + * dateTo — ISO date string (inclusive) + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { + paymentLedgerService, + LedgerError, + ledgerErrorToHttpStatus, + LedgerValidationError, + LedgerForbiddenError, +} from '@/lib/payment-ledger' + +export const GET = withAuth(async (request: NextRequest, auth) => { + const { searchParams } = request.nextUrl + + try { + const page = await paymentLedgerService.listEntries({ + walletAddress: auth.walletAddress, + limitParam: searchParams.get('limit'), + offsetParam: searchParams.get('offset'), + contractId: searchParams.get('contractId'), + userId: searchParams.get('userId'), + currency: searchParams.get('currency'), + entryType: searchParams.get('entryType'), + status: searchParams.get('status'), + dateFrom: searchParams.get('dateFrom'), + dateTo: searchParams.get('dateTo'), + }) + + return NextResponse.json(page, { status: 200 }) + } catch (err) { + if (err instanceof LedgerValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof LedgerForbiddenError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 403 } + ) + } + if (err instanceof LedgerError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: ledgerErrorToHttpStatus(err) } + ) + } + console.error('[payment-ledger GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/reconciliation/[id]/export/route.ts b/app/api/reconciliation/[id]/export/route.ts new file mode 100644 index 0000000..e00c217 --- /dev/null +++ b/app/api/reconciliation/[id]/export/route.ts @@ -0,0 +1,85 @@ +/** + * GET /api/reconciliation/[id]/export + * + * Exports a reconciliation report's discrepancies as a CSV file download. + * If the report has no discrepancies, returns a JSON message indicating + * the empty state. + * + * Access: requires 'reconciliation:view' (admin only). + * + * Response: + * Content-Type: text/csv + * Content-Disposition: attachment; filename="reconciliation_report__.csv" + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { csvContentDisposition } from '@/lib/csv' +import { + reconciliationService, + ReconciliationError, + reconciliationErrorToHttpStatus, + ReconciliationValidationError, + ReconciliationNotFoundError, +} from '@/lib/reconciliation' + +export const GET = withRbac('reconciliation:view', async (request: NextRequest, _auth: RbacContext) => { + const parts = request.nextUrl.pathname.split('/') + // /api/reconciliation/[id]/export → parts: ['', 'api', 'reconciliation', '', 'export'] + const id = parts[parts.length - 2] + + if (!id) { + return NextResponse.json( + { error: 'Report ID is required', code: 'MISSING_REPORT_ID' }, + { status: 400 } + ) + } + + try { + const result = await reconciliationService.exportReportCsv(id) + + if (result.totalRows === 0) { + return NextResponse.json( + { + error: 'No discrepancies found in this report', + code: 'EMPTY_EXPORT', + }, + { status: 200 } + ) + } + + return new NextResponse(result.csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': csvContentDisposition(result.filename), + }, + }) + } catch (err) { + if (err instanceof ReconciliationValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof ReconciliationNotFoundError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 404 } + ) + } + if (err instanceof ReconciliationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: reconciliationErrorToHttpStatus(err) } + ) + } + console.error('[reconciliation/[id]/export GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/reconciliation/[id]/route.ts b/app/api/reconciliation/[id]/route.ts new file mode 100644 index 0000000..621d23d --- /dev/null +++ b/app/api/reconciliation/[id]/route.ts @@ -0,0 +1,70 @@ +/** + * GET /api/reconciliation/[id] + * + * Returns a single reconciliation report with all its discrepancies. + * Access: requires 'reconciliation:view' (admin only). + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { + reconciliationService, + ReconciliationError, + reconciliationErrorToHttpStatus, + ReconciliationValidationError, + ReconciliationNotFoundError, +} from '@/lib/reconciliation' + +export const GET = withRbac('reconciliation:view', async (request: NextRequest, _auth: RbacContext) => { + const id = request.nextUrl.pathname.split('/').pop() + + if (!id) { + return NextResponse.json( + { error: 'Report ID is required', code: 'MISSING_REPORT_ID' }, + { status: 400 } + ) + } + + try { + const detail = await reconciliationService.getReport(id) + + if (detail.discrepancies.length === 0 && detail.report.status === 'completed') { + return NextResponse.json( + { + ...detail, + empty: true, + message: 'No discrepancies found. Ledger data is consistent.', + }, + { status: 200 } + ) + } + + return NextResponse.json(detail, { status: 200 }) + } catch (err) { + if (err instanceof ReconciliationValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof ReconciliationNotFoundError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 404 } + ) + } + if (err instanceof ReconciliationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: reconciliationErrorToHttpStatus(err) } + ) + } + console.error('[reconciliation/[id] GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/reconciliation/discrepancies/[id]/route.ts b/app/api/reconciliation/discrepancies/[id]/route.ts new file mode 100644 index 0000000..0c9fc65 --- /dev/null +++ b/app/api/reconciliation/discrepancies/[id]/route.ts @@ -0,0 +1,84 @@ +/** + * PATCH /api/reconciliation/discrepancies/[id] + * + * Resolve a reconciliation discrepancy by marking it as 'resolved' with + * resolution notes. Access: requires 'reconciliation:resolve' (admin only). + * + * Body (JSON): + * resolutionNotes — string (required, min 3 chars) explaining the + * resolution and any corrective actions taken. + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { + reconciliationService, + ReconciliationError, + reconciliationErrorToHttpStatus, + ReconciliationValidationError, + ReconciliationNotFoundError, +} from '@/lib/reconciliation' + +export const PATCH = withRbac('reconciliation:resolve', async (request: NextRequest, auth: RbacContext) => { + const id = request.nextUrl.pathname.split('/').pop() + + if (!id) { + return NextResponse.json( + { error: 'Discrepancy ID is required', code: 'MISSING_DISCREPANCY_ID' }, + { status: 400 } + ) + } + + let body: Record + try { + const parsed = await request.json() + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return NextResponse.json( + { error: 'Request body must be a JSON object', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + body = parsed as Record + } catch { + return NextResponse.json( + { error: 'Request body must be valid JSON', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + + try { + const discrepancy = await reconciliationService.resolveDiscrepancy( + id, + auth.walletAddress, + (body.resolutionNotes as string) ?? '' + ) + + return NextResponse.json({ discrepancy }, { status: 200 }) + } catch (err) { + if (err instanceof ReconciliationValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof ReconciliationNotFoundError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 404 } + ) + } + if (err instanceof ReconciliationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: reconciliationErrorToHttpStatus(err) } + ) + } + console.error('[reconciliation/discrepancies/[id] PATCH]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/reconciliation/discrepancies/route.ts b/app/api/reconciliation/discrepancies/route.ts new file mode 100644 index 0000000..aac9d8c --- /dev/null +++ b/app/api/reconciliation/discrepancies/route.ts @@ -0,0 +1,63 @@ +/** + * GET /api/reconciliation/discrepancies + * + * Lists discrepancies across all reconciliation reports with filtering + * and pagination. Access: requires 'reconciliation:view' (admin only). + * + * Query params: + * limit/offset — pagination + * reportId — filter by report UUID + * contractId — filter by contract UUID + * discrepancyType — amount_mismatch | missing_blockchain_event | + * missing_application_record | duplicate_entry | + * status_mismatch | other + * severity — low | medium | high | critical + * status — open | investigating | resolved + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { + reconciliationService, + ReconciliationError, + reconciliationErrorToHttpStatus, + ReconciliationValidationError, +} from '@/lib/reconciliation' + +export const GET = withRbac('reconciliation:view', async (request: NextRequest, _auth: RbacContext) => { + const { searchParams } = request.nextUrl + + try { + const page = await reconciliationService.listDiscrepancies({ + limitParam: searchParams.get('limit'), + offsetParam: searchParams.get('offset'), + reportId: searchParams.get('reportId'), + contractId: searchParams.get('contractId'), + discrepancyType: searchParams.get('discrepancyType'), + severity: searchParams.get('severity'), + status: searchParams.get('status'), + }) + + return NextResponse.json(page, { status: 200 }) + } catch (err) { + if (err instanceof ReconciliationValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof ReconciliationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: reconciliationErrorToHttpStatus(err) } + ) + } + console.error('[reconciliation/discrepancies GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/app/api/reconciliation/route.ts b/app/api/reconciliation/route.ts new file mode 100644 index 0000000..a7d1dc3 --- /dev/null +++ b/app/api/reconciliation/route.ts @@ -0,0 +1,127 @@ +/** + * GET /api/reconciliation — list reconciliation reports (admin only) + * POST /api/reconciliation — run a new reconciliation (admin only) + * + * GET query params: + * limit/offset — pagination + * status — pending | completed | failed | discrepancies_found + * contractId — filter by contract UUID + * + * POST body (JSON): + * contractId? — restrict reconciliation to a single contract + * token? — restrict to a specific currency/token + * dateFrom? — ISO date string (inclusive lower bound) + * dateTo? — ISO date string (inclusive upper bound) + * + * Access: requires 'reconciliation:view' (GET) or 'reconciliation:run' (POST). + * Both permissions are admin-only in the current RBAC model. + */ + +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { + reconciliationService, + ReconciliationError, + reconciliationErrorToHttpStatus, + ReconciliationValidationError, + ReconciliationNotFoundError, + ReconciliationExecutionError, +} from '@/lib/reconciliation' + +// --------------------------------------------------------------------------- +// GET — list reports +// --------------------------------------------------------------------------- + +export const GET = withRbac('reconciliation:view', async (request: NextRequest, _auth: RbacContext) => { + const { searchParams } = request.nextUrl + + try { + const page = await reconciliationService.listReports({ + limitParam: searchParams.get('limit'), + offsetParam: searchParams.get('offset'), + status: searchParams.get('status'), + contractId: searchParams.get('contractId'), + }) + + return NextResponse.json(page, { status: 200 }) + } catch (err) { + if (err instanceof ReconciliationValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof ReconciliationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: reconciliationErrorToHttpStatus(err) } + ) + } + console.error('[reconciliation GET]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) + +// --------------------------------------------------------------------------- +// POST — run reconciliation +// --------------------------------------------------------------------------- + +export const POST = withRbac('reconciliation:run', async (request: NextRequest, auth: RbacContext) => { + let body: Record + try { + const parsed = await request.json() + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return NextResponse.json( + { error: 'Request body must be a JSON object', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + body = parsed as Record + } catch { + return NextResponse.json( + { error: 'Request body must be valid JSON', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + + try { + const report = await reconciliationService.runReconciliation({ + walletAddress: auth.walletAddress, + contractId: (body.contractId as string) ?? null, + token: (body.token as string) ?? null, + dateFrom: (body.dateFrom as string) ?? null, + dateTo: (body.dateTo as string) ?? null, + }) + + return NextResponse.json({ report }, { status: 201 }) + } catch (err) { + if (err instanceof ReconciliationValidationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 422 } + ) + } + if (err instanceof ReconciliationExecutionError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 500 } + ) + } + if (err instanceof ReconciliationError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: reconciliationErrorToHttpStatus(err) } + ) + } + console.error('[reconciliation POST]', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) diff --git a/lib/auth/constants.ts b/lib/auth/constants.ts index e9e99e8..a0dd67d 100644 --- a/lib/auth/constants.ts +++ b/lib/auth/constants.ts @@ -39,6 +39,12 @@ export type Permission = | 'admin:system_oversight' // Activity log permissions | 'activity:view' + // Payment ledger & reconciliation permissions + | 'ledger:view' + | 'ledger:export' + | 'reconciliation:view' + | 'reconciliation:run' + | 'reconciliation:resolve' // General permissions | 'reputation:view' | 'reviews:create' @@ -55,6 +61,8 @@ export const ROLE_PERMISSIONS: Record = { 'dispute:create', 'dispute:view', 'activity:view', + 'ledger:view', + 'ledger:export', 'reputation:view', 'reviews:create', 'reviews:view', @@ -77,6 +85,8 @@ export const ROLE_PERMISSIONS: Record = { 'dispute:create', 'dispute:view', 'activity:view', + 'ledger:view', + 'ledger:export', 'reputation:view', 'reviews:create', 'reviews:view', @@ -105,6 +115,11 @@ export const ROLE_PERMISSIONS: Record = { 'admin:users_manage', 'admin:contracts_freeze', 'admin:system_oversight', + 'ledger:view', + 'ledger:export', + 'reconciliation:view', + 'reconciliation:run', + 'reconciliation:resolve', 'reputation:view', 'reviews:create', 'reviews:view', diff --git a/lib/csv.ts b/lib/csv.ts new file mode 100644 index 0000000..a203e86 --- /dev/null +++ b/lib/csv.ts @@ -0,0 +1,80 @@ +/** + * CSV Serialization Utility + * + * Minimal, dependency-free CSV serializer used by the ledger and + * reconciliation export endpoints. Handles: + * • Escaping values that contain commas, quotes, or newlines (RFC 4180). + * • Optional header row generation. + * • Consistent line endings (\r\n). + * + * Usage: + * const csv = toCSV(rows, { headers: ['id', 'amount'] }) + */ + +export interface CSVColumn { + /** Property key on the source object */ + key: keyof T | string + /** Column header in the CSV output */ + header: string + /** Optional transform applied before serialization */ + format?: (value: T[keyof T] | T, row: T) => string | number | null | undefined +} + +export interface CSVOption { + columns: CSVColumn[] + /** Include header row (default: true) */ + includeHeader?: boolean +} + +/** + * Escape a single cell value per RFC 4180: + * wrap in double-quotes if the value contains comma, quote, CR, or LF, + * and double any embedded quotes. + */ +export function escapeCSVCell(value: unknown): string { + if (value === null || value === undefined) return '' + const str = typeof value === 'object' ? JSON.stringify(value) : String(value) + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +/** + * Serialize an array of objects into a CSV string. + */ +export function toCSV(rows: T[], options: CSVOption): string { + const { columns, includeHeader = true } = options + const lines: string[] = [] + + if (includeHeader) { + lines.push(columns.map((c) => escapeCSVCell(c.header)).join(',')) + } + + for (const row of rows) { + const cells = columns.map((col) => { + if (col.format) { + return escapeCSVCell(col.format(row, row)) + } + const val = (row as Record)[col.key as string] + return escapeCSVCell(val) + }) + lines.push(cells.join(',')) + } + + return lines.join('\r\n') +} + +/** + * Build a Content-Disposition header value for a CSV download. + */ +export function csvContentDisposition(filename: string): string { + return `attachment; filename="${filename}"` +} + +/** + * Sanitize a filename by replacing unsafe characters. + */ +export function sanitizeFilename(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/_+/g, '_') +} diff --git a/lib/db/migrations/011_payment_ledger.sql b/lib/db/migrations/011_payment_ledger.sql new file mode 100644 index 0000000..5186e95 --- /dev/null +++ b/lib/db/migrations/011_payment_ledger.sql @@ -0,0 +1,149 @@ +-- ============================================================================ +-- 011_payment_ledger.sql +-- +-- Payment Ledger & Reconciliation Reports infrastructure. +-- +-- Adds: +-- 1. A unique index on escrow_transaction_logs to prevent duplicate ledger +-- entries caused by repeated blockchain events. +-- 2. reconciliation_reports table — metadata + summary of each reconciliation run. +-- 3. reconciliation_discrepancies table — surfaced discrepancies for investigation. +-- +-- The existing escrow_transaction_logs table remains the source of truth for +-- confirmed payment movements. This migration only adds read-side indexes +-- and new reconciliation tables without modifying the existing schema. +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 1. Deduplication: prevent duplicate ledger entries from repeated blockchain events +-- --------------------------------------------------------------------------- + +-- A unique index on (transaction_hash, transaction_type, contract_id) ensures +-- that a replayed blockchain event (same hash, same type, same contract) +-- cannot insert a second row. Rows with NULL transaction_hash (dispute logs) +-- are excluded from the index. +CREATE UNIQUE INDEX IF NOT EXISTS idx_escrow_tx_logs_dedup + ON escrow_transaction_logs (transaction_hash, transaction_type, contract_id) + WHERE transaction_hash IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 2. Reconciliation report status enum +-- --------------------------------------------------------------------------- +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'reconciliation_status') THEN + CREATE TYPE reconciliation_status AS ENUM ( + 'pending', + 'completed', + 'failed', + 'discrepancies_found' + ); + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- 3. Reconciliation reports +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS reconciliation_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + generated_by UUID REFERENCES users (id) ON DELETE SET NULL, + + status reconciliation_status NOT NULL DEFAULT 'pending', + + -- Optional scope filters applied during the run + contract_id UUID, -- null = all contracts + token TEXT, -- e.g. 'USDC' + date_from TIMESTAMPTZ, + date_to TIMESTAMPTZ, + + -- Aggregated balances discovered during reconciliation + total_funded NUMERIC(18,6) NOT NULL DEFAULT 0, + total_released NUMERIC(18,6) NOT NULL DEFAULT 0, + total_refunded NUMERIC(18,6) NOT NULL DEFAULT 0, + total_pending NUMERIC(18,6) NOT NULL DEFAULT 0, + + -- Number of discrepancies surfaced + discrepancy_count INTEGER NOT NULL DEFAULT 0, + + -- Flexible payload for run details (filters, timing, etc.) + report_metadata JSONB NOT NULL DEFAULT '{}', + + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_reconciliation_reports_status + ON reconciliation_reports (status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_reconciliation_reports_contract + ON reconciliation_reports (contract_id) + WHERE contract_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_reconciliation_reports_date + ON reconciliation_reports (created_at DESC); + +-- --------------------------------------------------------------------------- +-- 4. Reconciliation discrepancies +-- --------------------------------------------------------------------------- +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'discrepancy_type') THEN + CREATE TYPE discrepancy_type AS ENUM ( + 'amount_mismatch', + 'missing_blockchain_event', + 'missing_application_record', + 'duplicate_entry', + 'status_mismatch', + 'other' + ); + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'discrepancy_severity') THEN + CREATE TYPE discrepancy_severity AS ENUM ('low', 'medium', 'high', 'critical'); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS reconciliation_discrepancies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + report_id UUID NOT NULL REFERENCES reconciliation_reports (id) ON DELETE CASCADE, + + contract_id UUID, + milestone_id UUID, + transaction_hash TEXT, + + discrepancy_type discrepancy_type NOT NULL, + severity discrepancy_severity NOT NULL DEFAULT 'medium', + + description TEXT NOT NULL, + + expected_amount NUMERIC(18,6), + actual_amount NUMERIC(18,6), + + blockchain_data JSONB, + application_data JSONB, + + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'investigating', 'resolved')), + + resolved_by UUID REFERENCES users (id) ON DELETE SET NULL, + resolution_notes TEXT, + resolved_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_recon_discrepancies_report + ON reconciliation_discrepancies (report_id); + +CREATE INDEX IF NOT EXISTS idx_recon_discrepancies_status + ON reconciliation_discrepancies (status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recon_discrepancies_severity + ON reconciliation_discrepancies (severity) + WHERE status = 'open'; + +CREATE INDEX IF NOT EXISTS idx_recon_discrepancies_contract + ON reconciliation_discrepancies (contract_id) + WHERE contract_id IS NOT NULL; diff --git a/lib/payment-ledger/errors.ts b/lib/payment-ledger/errors.ts new file mode 100644 index 0000000..0af7827 --- /dev/null +++ b/lib/payment-ledger/errors.ts @@ -0,0 +1,98 @@ +/** + * Payment Ledger — Domain Errors + * + * Typed error classes that let API routes map to the correct HTTP status + * without leaking implementation details. Mirrors the pattern established + * by the escrow error hierarchy. + */ + +// --------------------------------------------------------------------------- +// Base class +// --------------------------------------------------------------------------- + +export class LedgerError extends Error { + readonly code: string + + constructor(message: string, code: string) { + super(message) + this.name = 'LedgerError' + this.code = code + } +} + +// --------------------------------------------------------------------------- +// 400 — Bad request / validation +// --------------------------------------------------------------------------- + +export class LedgerValidationError extends LedgerError { + constructor(message: string) { + super(message, 'LEDGER_VALIDATION_ERROR') + this.name = 'LedgerValidationError' + } +} + +// --------------------------------------------------------------------------- +// 403 — Authorisation +// --------------------------------------------------------------------------- + +export class LedgerForbiddenError extends LedgerError { + constructor(message: string) { + super(message, 'LEDGER_FORBIDDEN') + this.name = 'LedgerForbiddenError' + } +} + +// --------------------------------------------------------------------------- +// 404 — Not found +// --------------------------------------------------------------------------- + +export class LedgerNotFoundError extends LedgerError { + constructor(message: string) { + super(message, 'LEDGER_NOT_FOUND') + this.name = 'LedgerNotFoundError' + } +} + +// --------------------------------------------------------------------------- +// 409 — Conflict (duplicate entry) +// --------------------------------------------------------------------------- + +export class LedgerDuplicateEntryError extends LedgerError { + readonly existingEntryId: string + + constructor(existingEntryId: string) { + super( + 'A ledger entry with the same transaction hash, type, and contract already exists', + 'LEDGER_DUPLICATE_ENTRY' + ) + this.name = 'LedgerDuplicateEntryError' + this.existingEntryId = existingEntryId + } +} + +// --------------------------------------------------------------------------- +// 500 — Internal / unexpected +// --------------------------------------------------------------------------- + +export class LedgerQueryError extends LedgerError { + readonly cause: unknown + + constructor(message: string, cause?: unknown) { + super(message, 'LEDGER_QUERY_ERROR') + this.name = 'LedgerQueryError' + this.cause = cause + } +} + +// --------------------------------------------------------------------------- +// HTTP status mapper +// --------------------------------------------------------------------------- + +export function ledgerErrorToHttpStatus(err: LedgerError): number { + if (err instanceof LedgerValidationError) return 422 + if (err instanceof LedgerForbiddenError) return 403 + if (err instanceof LedgerNotFoundError) return 404 + if (err instanceof LedgerDuplicateEntryError) return 409 + if (err instanceof LedgerQueryError) return 500 + return 500 +} diff --git a/lib/payment-ledger/index.ts b/lib/payment-ledger/index.ts new file mode 100644 index 0000000..4c7d6d2 --- /dev/null +++ b/lib/payment-ledger/index.ts @@ -0,0 +1,30 @@ +/** + * Payment Ledger — Public API + * + * Import from this barrel in API routes and other consumers. + */ + +export { paymentLedgerService, PaymentLedgerService } from './service' + +export type { + LedgerEntry, + LedgerEntryType, + LedgerEntryStatus, + LedgerBalance, + LedgerSummary, + LedgerPage, + LedgerBalancePage, + LedgerQueryFilters, + LedgerBalanceQueryFilters, + LedgerCsvExport, +} from './types' + +export { + LedgerError, + LedgerValidationError, + LedgerForbiddenError, + LedgerNotFoundError, + LedgerDuplicateEntryError, + LedgerQueryError, + ledgerErrorToHttpStatus, +} from './errors' diff --git a/lib/payment-ledger/service.ts b/lib/payment-ledger/service.ts new file mode 100644 index 0000000..a3aba91 --- /dev/null +++ b/lib/payment-ledger/service.ts @@ -0,0 +1,643 @@ +/** + * Payment Ledger Service + * + * Provides a reliable accounting layer over the existing + * `escrow_transaction_logs` table. Separates confirmed entries from + * pending transactions, computes balance aggregates, and supports + * filtering by contract, user, token, and date range. + * + * Key design decisions: + * • Reads from `escrow_transaction_logs` — no duplicate financial data. + * • Access-scoped: non-admins only see entries on contracts they + * participate in (as client, freelancer, actor, or counterparty). + * • Immutable: entries are never modified; corrections use compensating + * entries (new rows with `metadata.reversesEntryId` pointing to the + * original). + * • Dedup-aware: the DB-level unique index prevents duplicate blockchain + * events from creating duplicate rows. The service also performs a + * pre-check to return a friendly 409 instead of a raw constraint error. + */ + +import { sql } from '@/lib/db' +import { z } from 'zod' +import { + LedgerValidationError, + LedgerForbiddenError, + LedgerDuplicateEntryError, + LedgerQueryError, +} from './errors' +import type { + LedgerEntry, + LedgerEntryType, + LedgerEntryStatus, + LedgerPage, + LedgerBalance, + LedgerBalancePage, + LedgerSummary, + LedgerQueryFilters, + LedgerBalanceQueryFilters, + LedgerCsvExport, +} from './types' +import { toCSV, sanitizeFilename, type CSVColumn } from '@/lib/csv' + +// --------------------------------------------------------------------------- +// Validation schemas & helpers +// --------------------------------------------------------------------------- + +const uuidSchema = z.string().uuid() + +const entryTypes: LedgerEntryType[] = ['deposit', 'milestone_release', 'refund', 'dispute'] +const entryStatuses: LedgerEntryStatus[] = ['pending', 'confirmed', 'failed'] + +interface AuthUser { + id: string + role: string +} + +async function getAuthUser(walletAddress: string): Promise { + const rows = await sql` + SELECT id, role::text AS role FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + ` + if (!rows[0]) { + throw new LedgerForbiddenError('Authenticated wallet is not linked to a user') + } + return rows[0] as AuthUser +} + +function normalizeLimit(value: string | null): number { + if (!value) return 50 + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + throw new LedgerValidationError('limit must be an integer between 1 and 100') + } + return parsed +} + +function normalizeOffset(value: string | null): number { + if (!value) return 0 + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 0) { + throw new LedgerValidationError('offset must be a non-negative integer') + } + return parsed +} + +function rowToEntry(row: Record): LedgerEntry { + return { + id: row.id as string, + contractId: row.contract_id as string, + projectId: row.project_id as string, + milestoneId: (row.milestone_id as string) ?? null, + disputeId: (row.dispute_id as string) ?? null, + actorUserId: row.actor_user_id as string, + counterpartyUserId: (row.counterparty_user_id as string) ?? null, + entryType: row.transaction_type as LedgerEntryType, + amount: row.amount != null ? String(row.amount) : null, + currency: row.currency as string, + transactionHash: (row.transaction_hash as string) ?? null, + status: row.status as LedgerEntryStatus, + description: (row.description as string) ?? null, + metadata: (row.metadata as Record) ?? {}, + createdAt: row.created_at as string, + projectTitle: (row.project_title as string) ?? null, + contractStatus: (row.contract_status as string) ?? null, + actorWalletAddress: (row.actor_wallet_address as string) ?? null, + counterpartyWalletAddress: (row.counterparty_wallet_address as string) ?? null, + } +} + +// --------------------------------------------------------------------------- +// Service class +// --------------------------------------------------------------------------- + +export class PaymentLedgerService { + // ========================================================================= + // listEntries — confirmed + pending + failed with filters & pagination + // ========================================================================= + + async listEntries( + params: { + walletAddress: string + limitParam: string | null + offsetParam: string | null + contractId?: string | null + userId?: string | null + currency?: string | null + entryType?: string | null + status?: string | null + dateFrom?: string | null + dateTo?: string | null + } + ): Promise { + const limit = normalizeLimit(params.limitParam) + const offset = normalizeOffset(params.offsetParam) + const user = await getAuthUser(params.walletAddress) + + this.validateFilters(params) + + // Count total + const countRows = await sql` + SELECT COUNT(*)::int AS total_count + FROM escrow_transaction_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id} + OR l.actor_user_id = ${user.id} + OR l.counterparty_user_id = ${user.id}) + AND (${params.contractId ?? null}::uuid IS NULL OR l.contract_id = ${params.contractId ?? null}::uuid) + AND (${params.userId ?? null}::uuid IS NULL + OR l.actor_user_id = ${params.userId ?? null}::uuid + OR l.counterparty_user_id = ${params.userId ?? null}::uuid) + AND (${params.currency ?? null}::text IS NULL OR l.currency = ${params.currency ?? null}::text) + AND (${params.entryType ?? null}::text IS NULL + OR l.transaction_type::text = ${params.entryType ?? null}::text) + AND (${params.status ?? null}::text IS NULL OR l.status = ${params.status ?? null}::text) + AND (${params.dateFrom ?? null}::timestamptz IS NULL OR l.created_at >= ${params.dateFrom ?? null}::timestamptz) + AND (${params.dateTo ?? null}::timestamptz IS NULL OR l.created_at <= ${params.dateTo ?? null}::timestamptz) + ` + const total = countRows[0]?.total_count ?? 0 + + const rows = total > offset + ? await sql` + SELECT l.*, p.title AS project_title, c.status AS contract_status, + ua.wallet_address AS actor_wallet_address, + uc.wallet_address AS counterparty_wallet_address + FROM escrow_transaction_logs l + JOIN contracts c ON c.id = l.contract_id + LEFT JOIN projects p ON p.id = l.project_id + LEFT JOIN users ua ON ua.id = l.actor_user_id + LEFT JOIN users uc ON uc.id = l.counterparty_user_id + WHERE (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id} + OR l.actor_user_id = ${user.id} + OR l.counterparty_user_id = ${user.id}) + AND (${params.contractId ?? null}::uuid IS NULL OR l.contract_id = ${params.contractId ?? null}::uuid) + AND (${params.userId ?? null}::uuid IS NULL + OR l.actor_user_id = ${params.userId ?? null}::uuid + OR l.counterparty_user_id = ${params.userId ?? null}::uuid) + AND (${params.currency ?? null}::text IS NULL OR l.currency = ${params.currency ?? null}::text) + AND (${params.entryType ?? null}::text IS NULL + OR l.transaction_type::text = ${params.entryType ?? null}::text) + AND (${params.status ?? null}::text IS NULL OR l.status = ${params.status ?? null}::text) + AND (${params.dateFrom ?? null}::timestamptz IS NULL OR l.created_at >= ${params.dateFrom ?? null}::timestamptz) + AND (${params.dateTo ?? null}::timestamptz IS NULL OR l.created_at <= ${params.dateTo ?? null}::timestamptz) + ORDER BY l.created_at DESC, l.id DESC + LIMIT ${limit} + OFFSET ${offset} + ` + : [] + + const entries = rows.map(rowToEntry) + + return { + entries, + pagination: { + limit, + offset, + total, + nextOffset: offset + limit < total ? offset + limit : null, + hasMore: offset + limit < total, + }, + } + } + + // ========================================================================= + // listPending — pending-only entries, clearly separated from confirmed + // ========================================================================= + + async listPending( + params: { + walletAddress: string + limitParam: string | null + offsetParam: string | null + contractId?: string | null + userId?: string | null + currency?: string | null + entryType?: string | null + dateFrom?: string | null + dateTo?: string | null + } + ): Promise { + return this.listEntries({ + ...params, + status: 'pending', + }) + } + + // ========================================================================= + // getBalances — funded / released / refunded / remaining per contract+currency + // ========================================================================= + + async getBalances( + params: { + walletAddress: string + limitParam: string | null + offsetParam: string | null + contractId?: string | null + userId?: string | null + currency?: string | null + dateFrom?: string | null + dateTo?: string | null + } + ): Promise { + const limit = normalizeLimit(params.limitParam) + const offset = normalizeOffset(params.offsetParam) + const user = await getAuthUser(params.walletAddress) + + this.validateFilters({ ...params, entryType: null, status: null }) + + const countRows = await sql` + SELECT COUNT(DISTINCT (l.contract_id, l.currency))::int AS total_count + FROM escrow_transaction_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE l.status = 'confirmed' + AND l.transaction_type IN ('deposit', 'milestone_release', 'refund') + AND (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id} + OR l.actor_user_id = ${user.id} + OR l.counterparty_user_id = ${user.id}) + AND (${params.contractId ?? null}::uuid IS NULL OR l.contract_id = ${params.contractId ?? null}::uuid) + AND (${params.userId ?? null}::uuid IS NULL + OR l.actor_user_id = ${params.userId ?? null}::uuid + OR l.counterparty_user_id = ${params.userId ?? null}::uuid) + AND (${params.currency ?? null}::text IS NULL OR l.currency = ${params.currency ?? null}::text) + AND (${params.dateFrom ?? null}::timestamptz IS NULL OR l.created_at >= ${params.dateFrom ?? null}::timestamptz) + AND (${params.dateTo ?? null}::timestamptz IS NULL OR l.created_at <= ${params.dateTo ?? null}::timestamptz) + ` + const total = countRows[0]?.total_count ?? 0 + + const rows = total > offset + ? await sql` + SELECT + l.contract_id, + l.currency, + COALESCE(SUM(CASE WHEN l.transaction_type = 'deposit' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS funded, + COALESCE(SUM(CASE WHEN l.transaction_type = 'milestone_release' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS released, + COALESCE(SUM(CASE WHEN l.transaction_type = 'refund' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS refunded, + COALESCE(SUM(CASE WHEN l.transaction_type IN ('deposit', 'milestone_release', 'refund') + AND l.status = 'pending' + THEN l.amount ELSE 0 END), 0)::text AS pending, + COUNT(*)::int AS entry_count + FROM escrow_transaction_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE l.transaction_type IN ('deposit', 'milestone_release', 'refund') + AND (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id} + OR l.actor_user_id = ${user.id} + OR l.counterparty_user_id = ${user.id}) + AND (${params.contractId ?? null}::uuid IS NULL OR l.contract_id = ${params.contractId ?? null}::uuid) + AND (${params.userId ?? null}::uuid IS NULL + OR l.actor_user_id = ${params.userId ?? null}::uuid + OR l.counterparty_user_id = ${params.userId ?? null}::uuid) + AND (${params.currency ?? null}::text IS NULL OR l.currency = ${params.currency ?? null}::text) + AND (${params.dateFrom ?? null}::timestamptz IS NULL OR l.created_at >= ${params.dateFrom ?? null}::timestamptz) + AND (${params.dateTo ?? null}::timestamptz IS NULL OR l.created_at <= ${params.dateTo ?? null}::timestamptz) + GROUP BY l.contract_id, l.currency + ORDER BY l.contract_id, l.currency + LIMIT ${limit} + OFFSET ${offset} + ` + : [] + + const balances: LedgerBalance[] = rows.map((row: Record) => { + const funded = row.funded ? String(row.funded) : '0' + const released = row.released ? String(row.released) : '0' + const refunded = row.refunded ? String(row.refunded) : '0' + const pending = row.pending ? String(row.pending) : '0' + return { + contractId: (row.contract_id as string) ?? null, + currency: row.currency as string, + funded, + released, + refunded, + pending, + remaining: (Number(funded) - Number(released) - Number(refunded)).toFixed(6), + entryCount: row.entry_count as number, + } + }) + + return { + balances, + pagination: { + limit, + offset, + total, + nextOffset: offset + limit < total ? offset + limit : null, + hasMore: offset + limit < total, + }, + } + } + + // ========================================================================= + // getSummary — global balance summary across all currencies + // ========================================================================= + + async getSummary( + params: { + walletAddress: string + contractId?: string | null + currency?: string | null + dateFrom?: string | null + dateTo?: string | null + } + ): Promise { + const user = await getAuthUser(params.walletAddress) + this.validateFilters({ ...params, entryType: null, status: null, userId: null }) + + const rows = await sql` + SELECT + l.currency, + COALESCE(SUM(CASE WHEN l.transaction_type = 'deposit' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS funded, + COALESCE(SUM(CASE WHEN l.transaction_type = 'milestone_release' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS released, + COALESCE(SUM(CASE WHEN l.transaction_type = 'refund' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS refunded, + COALESCE(SUM(CASE WHEN l.transaction_type IN ('deposit', 'milestone_release', 'refund') + AND l.status = 'pending' + THEN l.amount ELSE 0 END), 0)::text AS pending, + COUNT(CASE WHEN l.status = 'confirmed' THEN 1 END)::int AS confirmed_count, + COUNT(CASE WHEN l.status = 'pending' THEN 1 END)::int AS pending_count, + COUNT(CASE WHEN l.status = 'failed' THEN 1 END)::int AS failed_count + FROM escrow_transaction_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id} + OR l.actor_user_id = ${user.id} + OR l.counterparty_user_id = ${user.id}) + AND (${params.contractId ?? null}::uuid IS NULL OR l.contract_id = ${params.contractId ?? null}::uuid) + AND (${params.currency ?? null}::text IS NULL OR l.currency = ${params.currency ?? null}::text) + AND (${params.dateFrom ?? null}::timestamptz IS NULL OR l.created_at >= ${params.dateFrom ?? null}::timestamptz) + AND (${params.dateTo ?? null}::timestamptz IS NULL OR l.created_at <= ${params.dateTo ?? null}::timestamptz) + GROUP BY l.currency + ORDER BY l.currency + ` + + let totalFunded = 0 + let totalReleased = 0 + let totalRefunded = 0 + let totalPending = 0 + let confirmedEntries = 0 + let pendingEntries = 0 + let failedEntries = 0 + const currencies: LedgerSummary['currencies'] = [] + + for (const row of rows as Record[]) { + const funded = Number(row.funded ?? 0) + const released = Number(row.released ?? 0) + const refunded = Number(row.refunded ?? 0) + const pending = Number(row.pending ?? 0) + + totalFunded += funded + totalReleased += released + totalRefunded += refunded + totalPending += pending + confirmedEntries += row.confirmed_count as number + pendingEntries += row.pending_count as number + failedEntries += row.failed_count as number + + currencies.push({ + currency: row.currency as string, + funded: funded.toFixed(6), + released: released.toFixed(6), + refunded: refunded.toFixed(6), + remaining: (funded - released - refunded).toFixed(6), + pending: pending.toFixed(6), + }) + } + + return { + totalFunded: totalFunded.toFixed(6), + totalReleased: totalReleased.toFixed(6), + totalRefunded: totalRefunded.toFixed(6), + totalRemaining: (totalFunded - totalReleased - totalRefunded).toFixed(6), + totalPending: totalPending.toFixed(6), + confirmedEntries, + pendingEntries, + failedEntries, + currencies, + } + } + + // ========================================================================= + // exportCsv — generate CSV string of ledger entries + // ========================================================================= + + async exportCsv( + params: { + walletAddress: string + contractId?: string | null + userId?: string | null + currency?: string | null + entryType?: string | null + status?: string | null + dateFrom?: string | null + dateTo?: string | null + } + ): Promise { + // Reuse listEntries with a large page size to fetch all matching rows + const page = await this.listEntries({ + ...params, + limitParam: '100', + offsetParam: '0', + }) + + const allEntries: LedgerEntry[] = [...page.entries] + let currentOffset = 0 + while (page.pagination.hasMore) { + currentOffset += 100 + const nextPage = await this.listEntries({ + ...params, + limitParam: '100', + offsetParam: String(currentOffset), + }) + allEntries.push(...nextPage.entries) + if (!nextPage.pagination.hasMore) break + } + + const columns: CSVColumn[] = [ + { key: 'id', header: 'ID' }, + { key: 'contractId', header: 'Contract ID' }, + { key: 'projectId', header: 'Project ID' }, + { key: 'projectTitle', header: 'Project' }, + { key: 'entryType', header: 'Type' }, + { key: 'amount', header: 'Amount' }, + { key: 'currency', header: 'Currency' }, + { key: 'status', header: 'Status' }, + { key: 'transactionHash', header: 'Transaction Hash' }, + { key: 'actorWalletAddress', header: 'Actor Wallet' }, + { key: 'counterpartyWalletAddress', header: 'Counterparty Wallet' }, + { key: 'description', header: 'Description' }, + { key: 'createdAt', header: 'Created At' }, + ] + + const csv = toCSV(allEntries, { columns }) + const datePart = new Date().toISOString().slice(0, 10) + const filename = sanitizeFilename(`ledger_export_${datePart}.csv`) + + return { csv, filename, totalRows: allEntries.length } + } + + // ========================================================================= + // recordEntry — insert a new ledger entry with duplicate detection + // ========================================================================= + + async recordEntry(input: { + walletAddress: string + contractId: string + milestoneId?: string + disputeId?: string + actorUserId?: string + counterpartyUserId?: string + entryType: LedgerEntryType + amount?: string + currency?: string + transactionHash?: string + status?: LedgerEntryStatus + description?: string + metadata?: Record + }): Promise { + const user = await getAuthUser(input.walletAddress) + + // Verify contract exists and user has access + const contracts = await sql` + SELECT id, project_id, client_id, freelancer_id, currency + FROM contracts + WHERE id = ${input.contractId} + LIMIT 1 + ` + const contract = contracts[0] as + | { id: string; project_id: string; client_id: string; freelancer_id: string; currency: string } + | undefined + if (!contract) { + throw new LedgerValidationError('contractId does not reference an existing contract') + } + + if (user.role !== 'admin' && contract.client_id !== user.id && contract.freelancer_id !== user.id) { + throw new LedgerForbiddenError('Only contract participants or admins can create ledger entries') + } + + // --- Duplicate detection (pre-check before insert) --- + if (input.transactionHash) { + const existing = await sql` + SELECT id FROM escrow_transaction_logs + WHERE transaction_hash = ${input.transactionHash} + AND transaction_type = ${input.entryType}::escrow_transaction_type + AND contract_id = ${input.contractId} + LIMIT 1 + ` + if (existing[0]) { + throw new LedgerDuplicateEntryError(existing[0].id as string) + } + } + + const actorUserId = input.actorUserId ?? user.id + if (user.role !== 'admin' && actorUserId !== user.id) { + throw new LedgerForbiddenError('Only admins can create entries for another actor') + } + + try { + const rows = await sql` + INSERT INTO escrow_transaction_logs ( + contract_id, + project_id, + milestone_id, + dispute_id, + actor_user_id, + counterparty_user_id, + transaction_type, + amount, + currency, + transaction_hash, + status, + description, + metadata + ) + VALUES ( + ${input.contractId}, + ${contract.project_id}, + ${input.milestoneId ?? null}, + ${input.disputeId ?? null}, + ${actorUserId}, + ${input.counterpartyUserId ?? null}, + ${input.entryType}::escrow_transaction_type, + ${input.amount ?? null}::numeric, + ${input.currency ?? contract.currency}, + ${input.transactionHash ?? null}, + ${input.status ?? 'confirmed'}, + ${input.description ?? null}, + ${JSON.stringify(input.metadata ?? {})}::jsonb + ) + RETURNING * + ` + + // Fetch with joins for the full entry + const fullRows = await sql` + SELECT l.*, p.title AS project_title, c.status AS contract_status, + ua.wallet_address AS actor_wallet_address, + uc.wallet_address AS counterparty_wallet_address + FROM escrow_transaction_logs l + JOIN contracts c ON c.id = l.contract_id + LEFT JOIN projects p ON p.id = l.project_id + LEFT JOIN users ua ON ua.id = l.actor_user_id + LEFT JOIN users uc ON uc.id = l.counterparty_user_id + WHERE l.id = ${rows[0].id} + LIMIT 1 + ` + return rowToEntry(fullRows[0]) + } catch (err) { + // Catch unique constraint violation (23505) as a friendly duplicate error + if (err && typeof err === 'object' && 'code' in err && err.code === '23505') { + throw new LedgerDuplicateEntryError('unknown') + } + throw err + } + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private validateFilters(params: { + contractId?: string | null + userId?: string | null + currency?: string | null + entryType?: string | null + status?: string | null + dateFrom?: string | null + dateTo?: string | null + }): void { + if (params.contractId && !uuidSchema.safeParse(params.contractId).success) { + throw new LedgerValidationError('contractId must be a valid UUID') + } + if (params.userId && !uuidSchema.safeParse(params.userId).success) { + throw new LedgerValidationError('userId must be a valid UUID') + } + if (params.entryType && !entryTypes.includes(params.entryType as LedgerEntryType)) { + throw new LedgerValidationError('entryType is not a supported value') + } + if (params.status && !entryStatuses.includes(params.status as LedgerEntryStatus)) { + throw new LedgerValidationError('status is not a supported value') + } + if (params.dateFrom && Number.isNaN(new Date(params.dateFrom).getTime())) { + throw new LedgerValidationError('dateFrom must be a valid ISO date') + } + if (params.dateTo && Number.isNaN(new Date(params.dateTo).getTime())) { + throw new LedgerValidationError('dateTo must be a valid ISO date') + } + } +} + +// --------------------------------------------------------------------------- +// Exported singleton +// --------------------------------------------------------------------------- + +export const paymentLedgerService = new PaymentLedgerService() diff --git a/lib/payment-ledger/types.ts b/lib/payment-ledger/types.ts new file mode 100644 index 0000000..f6c0af7 --- /dev/null +++ b/lib/payment-ledger/types.ts @@ -0,0 +1,133 @@ +/** + * Payment Ledger — Domain Types + * + * Type definitions for the payment ledger module. The ledger reads from + * the existing `escrow_transaction_logs` table, separating confirmed + * entries from pending ones and computing balance aggregates. + */ + +// --------------------------------------------------------------------------- +// Ledger entry types +// --------------------------------------------------------------------------- + +export type LedgerEntryType = 'deposit' | 'milestone_release' | 'refund' | 'dispute' +export type LedgerEntryStatus = 'pending' | 'confirmed' | 'failed' + +export interface LedgerEntry { + id: string + contractId: string + projectId: string + milestoneId: string | null + disputeId: string | null + actorUserId: string + counterpartyUserId: string | null + entryType: LedgerEntryType + amount: string | null + currency: string + transactionHash: string | null + status: LedgerEntryStatus + description: string | null + metadata: Record + createdAt: string + // Joined fields for display + projectTitle: string | null + contractStatus: string | null + actorWalletAddress: string | null + counterpartyWalletAddress: string | null +} + +// --------------------------------------------------------------------------- +// Balance summary +// --------------------------------------------------------------------------- + +export interface LedgerBalance { + contractId: string | null + currency: string + funded: string + released: string + refunded: string + remaining: string + pending: string + entryCount: number +} + +export interface LedgerSummary { + totalFunded: string + totalReleased: string + totalRefunded: string + totalRemaining: string + totalPending: string + confirmedEntries: number + pendingEntries: number + failedEntries: number + currencies: Array<{ + currency: string + funded: string + released: string + refunded: string + remaining: string + pending: string + }> +} + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +export interface LedgerPage { + entries: LedgerEntry[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +export interface LedgerBalancePage { + balances: LedgerBalance[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +// --------------------------------------------------------------------------- +// Query filters +// --------------------------------------------------------------------------- + +export interface LedgerQueryFilters { + contractId?: string | null + userId?: string | null + currency?: string | null + entryType?: LedgerEntryType | null + status?: LedgerEntryStatus | null + dateFrom?: string | null + dateTo?: string | null + limit: number + offset: number +} + +export interface LedgerBalanceQueryFilters { + contractId?: string | null + userId?: string | null + currency?: string | null + dateFrom?: string | null + dateTo?: string | null + limit: number + offset: number +} + +// --------------------------------------------------------------------------- +// CSV export result +// --------------------------------------------------------------------------- + +export interface LedgerCsvExport { + csv: string + filename: string + totalRows: number +} diff --git a/lib/reconciliation/errors.ts b/lib/reconciliation/errors.ts new file mode 100644 index 0000000..da69bae --- /dev/null +++ b/lib/reconciliation/errors.ts @@ -0,0 +1,54 @@ +/** + * Reconciliation — Domain Errors + * + * Typed error classes for the reconciliation service. + */ + +export class ReconciliationError extends Error { + readonly code: string + + constructor(message: string, code: string) { + super(message) + this.name = 'ReconciliationError' + this.code = code + } +} + +export class ReconciliationValidationError extends ReconciliationError { + constructor(message: string) { + super(message, 'RECONCILIATION_VALIDATION_ERROR') + this.name = 'ReconciliationValidationError' + } +} + +export class ReconciliationForbiddenError extends ReconciliationError { + constructor(message: string) { + super(message, 'RECONCILIATION_FORBIDDEN') + this.name = 'ReconciliationForbiddenError' + } +} + +export class ReconciliationNotFoundError extends ReconciliationError { + constructor(message: string) { + super(message, 'RECONCILIATION_NOT_FOUND') + this.name = 'ReconciliationNotFoundError' + } +} + +export class ReconciliationExecutionError extends ReconciliationError { + readonly cause: unknown + + constructor(message: string, cause?: unknown) { + super(message, 'RECONCILIATION_EXECUTION_ERROR') + this.name = 'ReconciliationExecutionError' + this.cause = cause + } +} + +export function reconciliationErrorToHttpStatus(err: ReconciliationError): number { + if (err instanceof ReconciliationValidationError) return 422 + if (err instanceof ReconciliationForbiddenError) return 403 + if (err instanceof ReconciliationNotFoundError) return 404 + if (err instanceof ReconciliationExecutionError) return 500 + return 500 +} diff --git a/lib/reconciliation/index.ts b/lib/reconciliation/index.ts new file mode 100644 index 0000000..8fea1ae --- /dev/null +++ b/lib/reconciliation/index.ts @@ -0,0 +1,32 @@ +/** + * Reconciliation — Public API + * + * Import from this barrel in API routes and other consumers. + */ + +export { reconciliationService, ReconciliationService } from './service' + +export type { + ReconciliationStatus, + ReconciliationReport, + ReconciliationReportPage, + ReconciliationReportDetail, + ReconciliationDiscrepancy, + ReconciliationDiscrepancyPage, + DiscrepancyType, + DiscrepancySeverity, + DiscrepancyStatus, + ReconciliationQueryFilters, + DiscrepancyQueryFilters, + RunReconciliationInput, + ReconciliationCsvExport, +} from './types' + +export { + ReconciliationError, + ReconciliationValidationError, + ReconciliationForbiddenError, + ReconciliationNotFoundError, + ReconciliationExecutionError, + reconciliationErrorToHttpStatus, +} from './errors' diff --git a/lib/reconciliation/service.ts b/lib/reconciliation/service.ts new file mode 100644 index 0000000..8ece264 --- /dev/null +++ b/lib/reconciliation/service.ts @@ -0,0 +1,781 @@ +/** + * Reconciliation Service + * + * Compares blockchain-level payment data (from `escrow_transaction_logs`) + * against application-level records (from `contracts`, `milestones`) to + * detect and surface discrepancies. Each run produces a reconciliation + * report with aggregated balances and zero or more discrepancies. + * + * Discrepancy types detected: + * • amount_mismatch — ledger amount differs from contract/milestone total + * • missing_blockchain_event — contract is funded but no deposit log exists + * • missing_application_record — deposit/release log exists but contract/milestone + * is not in the expected state + * • duplicate_entry — same transaction_hash appears more than once + * • status_mismatch — log status doesn't match expected lifecycle state + * + * Access control: + * • Only admins can run reconciliation, view reports, and resolve discrepancies. + * • This is enforced at the API route level via `withRbac`. + */ + +import { sql } from '@/lib/db' +import { z } from 'zod' +import { + ReconciliationValidationError, + ReconciliationNotFoundError, + ReconciliationExecutionError, +} from './errors' +import type { + ReconciliationReport, + ReconciliationReportPage, + ReconciliationReportDetail, + ReconciliationDiscrepancy, + ReconciliationDiscrepancyPage, + ReconciliationStatus, + DiscrepancyType, + DiscrepancySeverity, + DiscrepancyStatus, + RunReconciliationInput, + ReconciliationCsvExport, +} from './types' +import { toCSV, sanitizeFilename, type CSVColumn } from '@/lib/csv' + +// --------------------------------------------------------------------------- +// Validation schemas & helpers +// --------------------------------------------------------------------------- + +const uuidSchema = z.string().uuid() + +const reportStatuses: ReconciliationStatus[] = [ + 'pending', 'completed', 'failed', 'discrepancies_found' +] +const discrepancyTypes: DiscrepancyType[] = [ + 'amount_mismatch', 'missing_blockchain_event', 'missing_application_record', + 'duplicate_entry', 'status_mismatch', 'other' +] +const severityLevels: DiscrepancySeverity[] = ['low', 'medium', 'high', 'critical'] +const discrepancyStatuses: DiscrepancyStatus[] = ['open', 'investigating', 'resolved'] + +function normalizeLimit(value: string | null): number { + if (!value) return 50 + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + throw new ReconciliationValidationError('limit must be an integer between 1 and 100') + } + return parsed +} + +function normalizeOffset(value: string | null): number { + if (!value) return 0 + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 0) { + throw new ReconciliationValidationError('offset must be a non-negative integer') + } + return parsed +} + +// --------------------------------------------------------------------------- +// Row mappers +// --------------------------------------------------------------------------- + +function rowToReport(row: Record): ReconciliationReport { + return { + id: row.id as string, + generatedBy: (row.generated_by as string) ?? null, + status: row.status as ReconciliationStatus, + contractId: (row.contract_id as string) ?? null, + token: (row.token as string) ?? null, + dateFrom: (row.date_from as string) ?? null, + dateTo: (row.date_to as string) ?? null, + totalFunded: String(row.total_funded ?? 0), + totalReleased: String(row.total_released ?? 0), + totalRefunded: String(row.total_refunded ?? 0), + totalPending: String(row.total_pending ?? 0), + discrepancyCount: row.discrepancy_count as number, + reportMetadata: (row.report_metadata as Record) ?? {}, + errorMessage: (row.error_message as string) ?? null, + createdAt: row.created_at as string, + completedAt: (row.completed_at as string) ?? null, + } +} + +function rowToDiscrepancy(row: Record): ReconciliationDiscrepancy { + return { + id: row.id as string, + reportId: row.report_id as string, + contractId: (row.contract_id as string) ?? null, + milestoneId: (row.milestone_id as string) ?? null, + transactionHash: (row.transaction_hash as string) ?? null, + discrepancyType: row.discrepancy_type as DiscrepancyType, + severity: row.severity as DiscrepancySeverity, + description: row.description as string, + expectedAmount: row.expected_amount != null ? String(row.expected_amount) : null, + actualAmount: row.actual_amount != null ? String(row.actual_amount) : null, + blockchainData: (row.blockchain_data as Record) ?? null, + applicationData: (row.application_data as Record) ?? null, + status: row.status as DiscrepancyStatus, + resolvedBy: (row.resolved_by as string) ?? null, + resolutionNotes: (row.resolution_notes as string) ?? null, + resolvedAt: (row.resolved_at as string) ?? null, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + } +} + +// --------------------------------------------------------------------------- +// Service class +// --------------------------------------------------------------------------- + +interface ContractRow { + id: string + project_id: string + client_id: string + freelancer_id: string + total_amount: string + currency: string + escrow_status: string + status: string +} + +interface LedgerSummaryRow { + contract_id: string + currency: string + funded: string + released: string + refunded: string + pending: string +} + +interface MilestoneRow { + id: string + contract_id: string + amount: string + status: string +} + +interface DuplicateRow { + transaction_hash: string + contract_id: string + cnt: number + ids: string[] +} + +export class ReconciliationService { + // ========================================================================= + // runReconciliation — create report, compare data, save discrepancies + // ========================================================================= + + async runReconciliation(input: RunReconciliationInput): Promise { + // Validate filters + if (input.contractId && !uuidSchema.safeParse(input.contractId).success) { + throw new ReconciliationValidationError('contractId must be a valid UUID') + } + if (input.dateFrom && Number.isNaN(new Date(input.dateFrom).getTime())) { + throw new ReconciliationValidationError('dateFrom must be a valid ISO date') + } + if (input.dateTo && Number.isNaN(new Date(input.dateTo).getTime())) { + throw new ReconciliationValidationError('dateTo must be a valid ISO date') + } + + // Resolve user ID for generated_by + const userRows = await sql` + SELECT id FROM users WHERE wallet_address = ${input.walletAddress} LIMIT 1 + ` + const generatedBy = (userRows[0]?.id as string) ?? null + + // Create a pending report + const reportRows = await sql` + INSERT INTO reconciliation_reports ( + generated_by, status, + contract_id, token, date_from, date_to + ) + VALUES ( + ${generatedBy}, + 'pending'::reconciliation_status, + ${input.contractId ?? null}::uuid, + ${input.token ?? null}, + ${input.dateFrom ?? null}::timestamptz, + ${input.dateTo ?? null}::timestamptz + ) + RETURNING * + ` + const reportId = reportRows[0].id as string + + try { + // --- 1. Fetch contracts in scope --- + const contracts = await sql` + SELECT id, project_id, client_id, freelancer_id, + total_amount::text, currency, escrow_status::text, status::text + FROM contracts + WHERE (${input.contractId ?? null}::uuid IS NULL OR id = ${input.contractId ?? null}::uuid) + AND escrow_status <> 'unfunded' + ` + + // --- 2. Fetch ledger summaries per contract --- + const ledgerSummaries = await sql` + SELECT + l.contract_id, + l.currency, + COALESCE(SUM(CASE WHEN l.transaction_type = 'deposit' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS funded, + COALESCE(SUM(CASE WHEN l.transaction_type = 'milestone_release' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS released, + COALESCE(SUM(CASE WHEN l.transaction_type = 'refund' AND l.status = 'confirmed' + THEN l.amount ELSE 0 END), 0)::text AS refunded, + COALESCE(SUM(CASE WHEN l.transaction_type IN ('deposit', 'milestone_release', 'refund') + AND l.status = 'pending' + THEN l.amount ELSE 0 END), 0)::text AS pending + FROM escrow_transaction_logs l + WHERE (${input.contractId ?? null}::uuid IS NULL OR l.contract_id = ${input.contractId ?? null}::uuid) + AND (${input.token ?? null}::text IS NULL OR l.currency = ${input.token ?? null}::text) + AND (${input.dateFrom ?? null}::timestamptz IS NULL OR l.created_at >= ${input.dateFrom ?? null}::timestamptz) + AND (${input.dateTo ?? null}::timestamptz IS NULL OR l.created_at <= ${input.dateTo ?? null}::timestamptz) + GROUP BY l.contract_id, l.currency + ` + + // --- 3. Fetch paid milestones --- + const milestones = await sql` + SELECT m.id, m.contract_id, m.amount::text, m.status::text + FROM milestones m + JOIN contracts c ON c.id = m.contract_id + WHERE m.status = 'paid' + AND (${input.contractId ?? null}::uuid IS NULL OR m.contract_id = ${input.contractId ?? null}::uuid) + ` + + // --- 4. Check for duplicate entries (same hash appearing more than once) --- + const duplicates = await sql` + SELECT transaction_hash, contract_id, + COUNT(*)::int AS cnt, + ARRAY_AGG(id::text) AS ids + FROM escrow_transaction_logs + WHERE transaction_hash IS NOT NULL + AND (${input.contractId ?? null}::uuid IS NULL OR contract_id = ${input.contractId ?? null}::uuid) + GROUP BY transaction_hash, contract_id + HAVING COUNT(*) > 1 + ` + + // --- 5. Build discrepancies --- + const discrepancies: Array<{ + contractId: string | null + milestoneId: string | null + transactionHash: string | null + discrepancyType: DiscrepancyType + severity: DiscrepancySeverity + description: string + expectedAmount: string | null + actualAmount: string | null + blockchainData: Record | null + applicationData: Record | null + }> = [] + + const contractMap = new Map() + for (const c of contracts as unknown as ContractRow[]) { + contractMap.set(c.id, c) + } + + const ledgerMap = new Map() + for (const l of ledgerSummaries as unknown as LedgerSummaryRow[]) { + ledgerMap.set(`${l.contract_id}:${l.currency}`, l) + } + + const milestoneMap = new Map() + for (const m of milestones as unknown as MilestoneRow[]) { + const existing = milestoneMap.get(m.contract_id) ?? [] + existing.push(m) + milestoneMap.set(m.contract_id, existing) + } + + // 5a. Check each contract: expected funded vs actual funded + for (const contract of contracts as unknown as ContractRow[]) { + const ledgerKey = `${contract.id}:${contract.currency}` + const ledger = ledgerMap.get(ledgerKey) + + // If contract is funded/partially_released/fully_released/refunded, + // there should be a deposit entry matching total_amount + if (['funded', 'partially_released', 'fully_released', 'refunded'].includes(contract.escrow_status)) { + const actualFunded = ledger ? Number(ledger.funded) : 0 + const expectedFunded = Number(contract.total_amount) + + if (actualFunded === 0) { + // Missing blockchain event: contract is funded but no deposit log + discrepancies.push({ + contractId: contract.id, + milestoneId: null, + transactionHash: null, + discrepancyType: 'missing_blockchain_event', + severity: 'high', + description: `Contract ${contract.id} has escrow_status='${contract.escrow_status}' but no confirmed deposit entry in the ledger`, + expectedAmount: contract.total_amount, + actualAmount: '0', + blockchainData: null, + applicationData: { + contractId: contract.id, + escrowStatus: contract.escrow_status, + totalAmount: contract.total_amount, + currency: contract.currency, + }, + }) + } else if (actualFunded !== expectedFunded) { + discrepancies.push({ + contractId: contract.id, + milestoneId: null, + transactionHash: null, + discrepancyType: 'amount_mismatch', + severity: 'high', + description: `Contract ${contract.id} funded amount mismatch: ledger shows ${actualFunded.toFixed(6)} ${contract.currency} but contract total is ${expectedFunded.toFixed(6)} ${contract.currency}`, + expectedAmount: expectedFunded.toFixed(6), + actualAmount: actualFunded.toFixed(6), + blockchainData: { + ledgerFunded: actualFunded.toFixed(6), + currency: contract.currency, + }, + applicationData: { + contractTotal: expectedFunded.toFixed(6), + currency: contract.currency, + }, + }) + } + } + + // 5b. Check released amounts: sum of paid milestone amounts vs ledger releases + const paidMilestones = milestoneMap.get(contract.id) ?? [] + if (paidMilestones.length > 0) { + const expectedReleased = paidMilestones.reduce( + (sum, m) => sum + Number(m.amount), 0 + ) + const actualReleased = ledger ? Number(ledger.released) : 0 + + if (actualReleased === 0 && expectedReleased > 0) { + discrepancies.push({ + contractId: contract.id, + milestoneId: null, + transactionHash: null, + discrepancyType: 'missing_blockchain_event', + severity: 'high', + description: `Contract ${contract.id} has ${paidMilestones.length} paid milestone(s) totaling ${expectedReleased.toFixed(6)} ${contract.currency} but no confirmed milestone_release entries in the ledger`, + expectedAmount: expectedReleased.toFixed(6), + actualAmount: '0', + blockchainData: null, + applicationData: { + paidMilestones: paidMilestones.length, + totalExpected: expectedReleased.toFixed(6), + currency: contract.currency, + }, + }) + } else if (Math.abs(actualReleased - expectedReleased) > 0.000001) { + discrepancies.push({ + contractId: contract.id, + milestoneId: null, + transactionHash: null, + discrepancyType: 'amount_mismatch', + severity: 'medium', + description: `Contract ${contract.id} released amount mismatch: ledger shows ${actualReleased.toFixed(6)} ${contract.currency} but paid milestones total ${expectedReleased.toFixed(6)} ${contract.currency}`, + expectedAmount: expectedReleased.toFixed(6), + actualAmount: actualReleased.toFixed(6), + blockchainData: { + ledgerReleased: actualReleased.toFixed(6), + currency: contract.currency, + }, + applicationData: { + paidMilestonesTotal: expectedReleased.toFixed(6), + currency: contract.currency, + }, + }) + } + } + + // 5c. Check for missing_application_record: deposit exists but contract not funded + if (ledger && Number(ledger.funded) > 0 && contract.escrow_status === 'unfunded') { + discrepancies.push({ + contractId: contract.id, + milestoneId: null, + transactionHash: null, + discrepancyType: 'missing_application_record', + severity: 'high', + description: `Contract ${contract.id} has confirmed deposit entries (${Number(ledger.funded).toFixed(6)} ${contract.currency}) but contract escrow_status is still 'unfunded'`, + expectedAmount: null, + actualAmount: ledger.funded, + blockchainData: { + ledgerFunded: ledger.funded, + currency: contract.currency, + }, + applicationData: { + escrowStatus: 'unfunded', + }, + }) + } + } + + // 5d. Check for duplicate entries + for (const dup of duplicates as unknown as DuplicateRow[]) { + discrepancies.push({ + contractId: dup.contract_id, + milestoneId: null, + transactionHash: dup.transaction_hash, + discrepancyType: 'duplicate_entry', + severity: 'critical', + description: `Duplicate ledger entries detected: transaction_hash '${dup.transaction_hash}' appears ${dup.cnt} times for contract ${dup.contract_id} (entry IDs: ${dup.ids.join(', ')})`, + expectedAmount: null, + actualAmount: null, + blockchainData: { + transactionHash: dup.transaction_hash, + duplicateCount: dup.cnt, + entryIds: dup.ids, + }, + applicationData: null, + }) + } + + // --- 6. Compute aggregated totals --- + let totalFunded = 0 + let totalReleased = 0 + let totalRefunded = 0 + let totalPending = 0 + for (const l of ledgerSummaries as unknown as LedgerSummaryRow[]) { + totalFunded += Number(l.funded) + totalReleased += Number(l.released) + totalRefunded += Number(l.refunded) + totalPending += Number(l.pending) + } + + // --- 7. Determine final status --- + const finalStatus: ReconciliationStatus = + discrepancies.length > 0 ? 'discrepancies_found' : 'completed' + + // --- 8. Save discrepancies and update report --- + if (discrepancies.length > 0) { + // Insert all discrepancies + for (const d of discrepancies) { + await sql` + INSERT INTO reconciliation_discrepancies ( + report_id, contract_id, milestone_id, transaction_hash, + discrepancy_type, severity, description, + expected_amount, actual_amount, + blockchain_data, application_data + ) + VALUES ( + ${reportId}::uuid, + ${d.contractId ?? null}::uuid, + ${d.milestoneId ?? null}::uuid, + ${d.transactionHash ?? null}, + ${d.discrepancyType}::discrepancy_type, + ${d.severity}::discrepancy_severity, + ${d.description}, + ${d.expectedAmount ?? null}::numeric, + ${d.actualAmount ?? null}::numeric, + ${d.blockchainData ? JSON.stringify(d.blockchainData) : null}::jsonb, + ${d.applicationData ? JSON.stringify(d.applicationData) : null}::jsonb + ) + ` + } + } + + // Update report with results + const updatedReports = await sql` + UPDATE reconciliation_reports + SET status = ${finalStatus}::reconciliation_status, + total_funded = ${totalFunded.toFixed(6)}::numeric, + total_released = ${totalReleased.toFixed(6)}::numeric, + total_refunded = ${totalRefunded.toFixed(6)}::numeric, + total_pending = ${totalPending.toFixed(6)}::numeric, + discrepancy_count = ${discrepancies.length}, + report_metadata = ${JSON.stringify({ + contractsChecked: contracts.length, + milestonesChecked: milestones.length, + duplicatesFound: duplicates.length, + filters: { + contractId: input.contractId ?? null, + token: input.token ?? null, + dateFrom: input.dateFrom ?? null, + dateTo: input.dateTo ?? null, + }, + })}::jsonb, + completed_at = NOW() + WHERE id = ${reportId}::uuid + RETURNING * + ` + + return rowToReport(updatedReports[0] as Record) + } catch (err) { + // Mark report as failed + const errorMessage = + err instanceof Error ? err.message : 'Unknown error during reconciliation' + + await sql` + UPDATE reconciliation_reports + SET status = 'failed'::reconciliation_status, + error_message = ${errorMessage}, + completed_at = NOW() + WHERE id = ${reportId}::uuid + ` + + // Re-throw as typed error + throw new ReconciliationExecutionError( + `Reconciliation run failed: ${errorMessage}`, + err + ) + } + } + + // ========================================================================= + // getReport — report detail with its discrepancies + // ========================================================================= + + async getReport(reportId: string): Promise { + if (!uuidSchema.safeParse(reportId).success) { + throw new ReconciliationValidationError('reportId must be a valid UUID') + } + + const reportRows = await sql` + SELECT * FROM reconciliation_reports WHERE id = ${reportId}::uuid LIMIT 1 + ` + if (!reportRows[0]) { + throw new ReconciliationNotFoundError(`Reconciliation report ${reportId} not found`) + } + + const discrepancyRows = await sql` + SELECT * FROM reconciliation_discrepancies + WHERE report_id = ${reportId}::uuid + ORDER BY severity DESC, created_at DESC + ` + + return { + report: rowToReport(reportRows[0] as Record), + discrepancies: discrepancyRows.map((r) => + rowToDiscrepancy(r as Record) + ), + } + } + + // ========================================================================= + // listReports — paginated list of reconciliation reports + // ========================================================================= + + async listReports(params: { + limitParam: string | null + offsetParam: string | null + status?: string | null + contractId?: string | null + }): Promise { + const limit = normalizeLimit(params.limitParam) + const offset = normalizeOffset(params.offsetParam) + + if (params.status && !reportStatuses.includes(params.status as ReconciliationStatus)) { + throw new ReconciliationValidationError('status is not a supported value') + } + if (params.contractId && !uuidSchema.safeParse(params.contractId).success) { + throw new ReconciliationValidationError('contractId must be a valid UUID') + } + + const countRows = await sql` + SELECT COUNT(*)::int AS total_count + FROM reconciliation_reports + WHERE (${params.status ?? null}::text IS NULL OR status::text = ${params.status ?? null}::text) + AND (${params.contractId ?? null}::uuid IS NULL OR contract_id = ${params.contractId ?? null}::uuid) + ` + const total = countRows[0]?.total_count ?? 0 + + const rows = total > offset + ? await sql` + SELECT * FROM reconciliation_reports + WHERE (${params.status ?? null}::text IS NULL OR status::text = ${params.status ?? null}::text) + AND (${params.contractId ?? null}::uuid IS NULL OR contract_id = ${params.contractId ?? null}::uuid) + ORDER BY created_at DESC, id DESC + LIMIT ${limit} + OFFSET ${offset} + ` + : [] + + const reports = rows.map((r) => rowToReport(r as Record)) + + return { + reports, + pagination: { + limit, + offset, + total, + nextOffset: offset + limit < total ? offset + limit : null, + hasMore: offset + limit < total, + }, + } + } + + // ========================================================================= + // listDiscrepancies — paginated list of discrepancies with filters + // ========================================================================= + + async listDiscrepancies(params: { + limitParam: string | null + offsetParam: string | null + reportId?: string | null + contractId?: string | null + discrepancyType?: string | null + severity?: string | null + status?: string | null + }): Promise { + const limit = normalizeLimit(params.limitParam) + const offset = normalizeOffset(params.offsetParam) + + if (params.reportId && !uuidSchema.safeParse(params.reportId).success) { + throw new ReconciliationValidationError('reportId must be a valid UUID') + } + if (params.contractId && !uuidSchema.safeParse(params.contractId).success) { + throw new ReconciliationValidationError('contractId must be a valid UUID') + } + if (params.discrepancyType && !discrepancyTypes.includes(params.discrepancyType as DiscrepancyType)) { + throw new ReconciliationValidationError('discrepancyType is not a supported value') + } + if (params.severity && !severityLevels.includes(params.severity as DiscrepancySeverity)) { + throw new ReconciliationValidationError('severity is not a supported value') + } + if (params.status && !discrepancyStatuses.includes(params.status as DiscrepancyStatus)) { + throw new ReconciliationValidationError('status is not a supported value') + } + + const countRows = await sql` + SELECT COUNT(*)::int AS total_count + FROM reconciliation_discrepancies + WHERE (${params.reportId ?? null}::uuid IS NULL OR report_id = ${params.reportId ?? null}::uuid) + AND (${params.contractId ?? null}::uuid IS NULL OR contract_id = ${params.contractId ?? null}::uuid) + AND (${params.discrepancyType ?? null}::text IS NULL + OR discrepancy_type::text = ${params.discrepancyType ?? null}::text) + AND (${params.severity ?? null}::text IS NULL + OR severity::text = ${params.severity ?? null}::text) + AND (${params.status ?? null}::text IS NULL OR status = ${params.status ?? null}::text) + ` + const total = countRows[0]?.total_count ?? 0 + + const rows = total > offset + ? await sql` + SELECT * FROM reconciliation_discrepancies + WHERE (${params.reportId ?? null}::uuid IS NULL OR report_id = ${params.reportId ?? null}::uuid) + AND (${params.contractId ?? null}::uuid IS NULL OR contract_id = ${params.contractId ?? null}::uuid) + AND (${params.discrepancyType ?? null}::text IS NULL + OR discrepancy_type::text = ${params.discrepancyType ?? null}::text) + AND (${params.severity ?? null}::text IS NULL + OR severity::text = ${params.severity ?? null}::text) + AND (${params.status ?? null}::text IS NULL OR status = ${params.status ?? null}::text) + ORDER BY + CASE severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + END, + created_at DESC + LIMIT ${limit} + OFFSET ${offset} + ` + : [] + + const discrepancies = rows.map((r) => rowToDiscrepancy(r as Record)) + + return { + discrepancies, + pagination: { + limit, + offset, + total, + nextOffset: offset + limit < total ? offset + limit : null, + hasMore: offset + limit < total, + }, + } + } + + // ========================================================================= + // resolveDiscrepancy — mark a discrepancy as resolved + // ========================================================================= + + async resolveDiscrepancy( + discrepancyId: string, + walletAddress: string, + resolutionNotes: string + ): Promise { + if (!uuidSchema.safeParse(discrepancyId).success) { + throw new ReconciliationValidationError('discrepancyId must be a valid UUID') + } + if (!resolutionNotes || resolutionNotes.trim().length < 3) { + throw new ReconciliationValidationError('resolutionNotes must be at least 3 characters') + } + + const userRows = await sql` + SELECT id FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + ` + const userId = userRows[0]?.id as string | undefined + if (!userId) { + throw new ReconciliationValidationError('Authenticated wallet is not linked to a user') + } + + const rows = await sql` + UPDATE reconciliation_discrepancies + SET status = 'resolved', + resolved_by = ${userId}::uuid, + resolution_notes = ${resolutionNotes.trim()}, + resolved_at = NOW(), + updated_at = NOW() + WHERE id = ${discrepancyId}::uuid + RETURNING * + ` + + if (!rows[0]) { + throw new ReconciliationNotFoundError(`Discrepancy ${discrepancyId} not found`) + } + + return rowToDiscrepancy(rows[0] as Record) + } + + // ========================================================================= + // exportReportCsv — CSV export of a reconciliation report + discrepancies + // ========================================================================= + + async exportReportCsv(reportId: string): Promise { + const detail = await this.getReport(reportId) + + const columns: CSVColumn[] = [ + { key: 'id', header: 'Discrepancy ID' }, + { key: 'reportId', header: 'Report ID' }, + { key: 'contractId', header: 'Contract ID' }, + { key: 'milestoneId', header: 'Milestone ID' }, + { key: 'transactionHash', header: 'Transaction Hash' }, + { key: 'discrepancyType', header: 'Type' }, + { key: 'severity', header: 'Severity' }, + { key: 'description', header: 'Description' }, + { key: 'expectedAmount', header: 'Expected Amount' }, + { key: 'actualAmount', header: 'Actual Amount' }, + { key: 'status', header: 'Status' }, + { + key: 'resolvedBy', + header: 'Resolved By', + format: (val) => val.resolvedBy ?? '', + }, + { + key: 'resolutionNotes', + header: 'Resolution Notes', + format: (val) => val.resolutionNotes ?? '', + }, + { key: 'createdAt', header: 'Created At' }, + { + key: 'resolvedAt', + header: 'Resolved At', + format: (val) => val.resolvedAt ?? '', + }, + ] + + const csv = toCSV(detail.discrepancies, { columns }) + const datePart = new Date().toISOString().slice(0, 10) + const filename = sanitizeFilename(`reconciliation_report_${reportId.slice(0, 8)}_${datePart}.csv`) + + return { + csv, + filename, + totalRows: detail.discrepancies.length, + } + } +} + +// --------------------------------------------------------------------------- +// Exported singleton +// --------------------------------------------------------------------------- + +export const reconciliationService = new ReconciliationService() diff --git a/lib/reconciliation/types.ts b/lib/reconciliation/types.ts new file mode 100644 index 0000000..76bdcc5 --- /dev/null +++ b/lib/reconciliation/types.ts @@ -0,0 +1,144 @@ +/** + * Reconciliation — Domain Types + * + * Type definitions for the reconciliation reports and discrepancies modules. + */ + +// --------------------------------------------------------------------------- +// Report types +// --------------------------------------------------------------------------- + +export type ReconciliationStatus = 'pending' | 'completed' | 'failed' | 'discrepancies_found' + +export interface ReconciliationReport { + id: string + generatedBy: string | null + status: ReconciliationStatus + contractId: string | null + token: string | null + dateFrom: string | null + dateTo: string | null + totalFunded: string + totalReleased: string + totalRefunded: string + totalPending: string + discrepancyCount: number + reportMetadata: Record + errorMessage: string | null + createdAt: string + completedAt: string | null +} + +export interface ReconciliationReportPage { + reports: ReconciliationReport[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +// --------------------------------------------------------------------------- +// Discrepancy types +// --------------------------------------------------------------------------- + +export type DiscrepancyType = + | 'amount_mismatch' + | 'missing_blockchain_event' + | 'missing_application_record' + | 'duplicate_entry' + | 'status_mismatch' + | 'other' + +export type DiscrepancySeverity = 'low' | 'medium' | 'high' | 'critical' + +export type DiscrepancyStatus = 'open' | 'investigating' | 'resolved' + +export interface ReconciliationDiscrepancy { + id: string + reportId: string + contractId: string | null + milestoneId: string | null + transactionHash: string | null + discrepancyType: DiscrepancyType + severity: DiscrepancySeverity + description: string + expectedAmount: string | null + actualAmount: string | null + blockchainData: Record | null + applicationData: Record | null + status: DiscrepancyStatus + resolvedBy: string | null + resolutionNotes: string | null + resolvedAt: string | null + createdAt: string + updatedAt: string +} + +export interface ReconciliationDiscrepancyPage { + discrepancies: ReconciliationDiscrepancy[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +// --------------------------------------------------------------------------- +// Report detail (report + its discrepancies) +// --------------------------------------------------------------------------- + +export interface ReconciliationReportDetail { + report: ReconciliationReport + discrepancies: ReconciliationDiscrepancy[] +} + +// --------------------------------------------------------------------------- +// Query filters +// --------------------------------------------------------------------------- + +export interface ReconciliationQueryFilters { + contractId?: string | null + token?: string | null + dateFrom?: string | null + dateTo?: string | null + status?: ReconciliationStatus | null + limit: number + offset: number +} + +export interface DiscrepancyQueryFilters { + reportId?: string | null + contractId?: string | null + discrepancyType?: DiscrepancyType | null + severity?: DiscrepancySeverity | null + status?: DiscrepancyStatus | null + limit: number + offset: number +} + +// --------------------------------------------------------------------------- +// Run reconciliation input +// --------------------------------------------------------------------------- + +export interface RunReconciliationInput { + walletAddress: string + contractId?: string | null + token?: string | null + dateFrom?: string | null + dateTo?: string | null +} + +// --------------------------------------------------------------------------- +// CSV export +// --------------------------------------------------------------------------- + +export interface ReconciliationCsvExport { + csv: string + filename: string + totalRows: number +}