From 7207b7aa5d27706971209eafd833af007824411d Mon Sep 17 00:00:00 2001 From: Abdulrasaq1515 Date: Sun, 18 Oct 2026 01:19:44 +0100 Subject: [PATCH] feat: payment ledger & reconciliation reports API (#246) Add payment ledger service that reads from escrow_transaction_logs, separating confirmed entries from pending transactions and computing funded/released/refunded/remaining balances with filtering by contract, user, token, and date range. Add reconciliation service that compares blockchain-level payment data against application-level records to surface discrepancies (amount mismatches, missing events, duplicate entries, status mismatches). Add CSV export endpoints for both ledger and reconciliation reports. Add DB migration with dedup unique index and reconciliation tables. Add RBAC permissions for ledger:view, ledger:export, reconciliation:view, reconciliation:run, reconciliation:resolve. All entries are immutable; corrections use compensating entries. Duplicate blockchain events are prevented by a DB-level unique index and application-level pre-checks. Failed reconciliation jobs are logged and marked as failed. Closes #246 --- __tests__/csv.test.ts | 125 +++ __tests__/payment-ledger/service.test.ts | 365 ++++++++ __tests__/reconciliation/service.test.ts | 437 ++++++++++ app/api/payment-ledger/balances/route.ts | 84 ++ app/api/payment-ledger/export/route.ts | 88 ++ app/api/payment-ledger/pending/route.ts | 71 ++ app/api/payment-ledger/route.ts | 75 ++ app/api/reconciliation/[id]/export/route.ts | 85 ++ app/api/reconciliation/[id]/route.ts | 70 ++ .../discrepancies/[id]/route.ts | 84 ++ app/api/reconciliation/discrepancies/route.ts | 63 ++ app/api/reconciliation/route.ts | 127 +++ lib/auth/constants.ts | 15 + lib/csv.ts | 80 ++ lib/db/migrations/011_payment_ledger.sql | 149 ++++ lib/payment-ledger/errors.ts | 98 +++ lib/payment-ledger/index.ts | 30 + lib/payment-ledger/service.ts | 643 ++++++++++++++ lib/payment-ledger/types.ts | 133 +++ lib/reconciliation/errors.ts | 54 ++ lib/reconciliation/index.ts | 32 + lib/reconciliation/service.ts | 781 ++++++++++++++++++ lib/reconciliation/types.ts | 144 ++++ 23 files changed, 3833 insertions(+) create mode 100644 __tests__/csv.test.ts create mode 100644 __tests__/payment-ledger/service.test.ts create mode 100644 __tests__/reconciliation/service.test.ts create mode 100644 app/api/payment-ledger/balances/route.ts create mode 100644 app/api/payment-ledger/export/route.ts create mode 100644 app/api/payment-ledger/pending/route.ts create mode 100644 app/api/payment-ledger/route.ts create mode 100644 app/api/reconciliation/[id]/export/route.ts create mode 100644 app/api/reconciliation/[id]/route.ts create mode 100644 app/api/reconciliation/discrepancies/[id]/route.ts create mode 100644 app/api/reconciliation/discrepancies/route.ts create mode 100644 app/api/reconciliation/route.ts create mode 100644 lib/csv.ts create mode 100644 lib/db/migrations/011_payment_ledger.sql create mode 100644 lib/payment-ledger/errors.ts create mode 100644 lib/payment-ledger/index.ts create mode 100644 lib/payment-ledger/service.ts create mode 100644 lib/payment-ledger/types.ts create mode 100644 lib/reconciliation/errors.ts create mode 100644 lib/reconciliation/index.ts create mode 100644 lib/reconciliation/service.ts create mode 100644 lib/reconciliation/types.ts 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 +}