diff --git a/e2e/kyc-files-details.spec.ts b/e2e/kyc-files-details.spec.ts new file mode 100644 index 00000000..a2817182 --- /dev/null +++ b/e2e/kyc-files-details.spec.ts @@ -0,0 +1,160 @@ +import { test, expect, APIRequestContext, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createTestCredentials } from './test-wallet'; + +/** + * E2E tests for the KYC Files Details compliance page. + * + * Prerequisites: + * - Frontend running on localhost:3001 (yarn start) + * - Backend API running on localhost:3000 (yarn start:dev in api folder) + * - ADMIN_SEED configured in api/.env (run `npm run setup` in API directory) + * + * Run with: npx playwright test e2e/kyc-files-details.spec.ts + * + * Note: These tests require running services and will fail if the API is not available. + */ + +const API_URL = process.env.REACT_APP_API_URL! + '/v1'; + +/** + * Read ADMIN_SEED from the API .env file + */ +function getAdminSeed(): string { + const apiEnvPath = path.join(__dirname, '../../api/.env'); + if (!fs.existsSync(apiEnvPath)) { + throw new Error(`API .env file not found at ${apiEnvPath}. Run 'npm run setup' in the API directory first.`); + } + const content = fs.readFileSync(apiEnvPath, 'utf8'); + const match = content.match(/^ADMIN_SEED=(.*)$/m); + if (!match || !match[1]) { + throw new Error('ADMIN_SEED not found in API .env file. Run "npm run setup" in the API directory first.'); + } + return match[1]; +} + +/** + * Authenticate with admin credentials + */ +async function getAdminAuth(request: APIRequestContext): Promise { + const adminSeed = getAdminSeed(); + const credentials = await createTestCredentials(adminSeed); + + const response = await request.post(`${API_URL}/auth`, { + data: credentials, + }); + + if (!response.ok()) { + const body = await response.text().catch(() => 'unknown'); + throw new Error(`Admin auth failed: ${response.status()} - ${body}`); + } + + const data = await response.json(); + return data.accessToken; +} + +test.describe('KYC Files Details Page', () => { + let token: string; + + test.beforeAll(async ({ request }) => { + token = await getAdminAuth(request); + }); + + async function waitForPageLoad(page: Page) { + await page.goto(`/compliance/kyc-files/details?session=${token}`); + await page.waitForLoadState('networkidle'); + await expect(page.locator('text=KYC File Details')).toBeVisible({ timeout: 15000 }); + } + + test('page loads with data and displays correctly', async ({ page }) => { + await waitForPageLoad(page); + + // Check all table headers exist + const expectedHeaders = [ + 'Id', + 'AccountId', + 'Type', + 'Name', + 'Status', + 'Domizil Vertragspartei', + 'Domizil wB', + 'Sitzgesellschaft', + 'Eröffnungsdatum', + 'Schliessdatum', + 'Neueröffnung', + 'GmeR', + 'PEP', + 'Komplexe Struktur', + 'Volume', + ]; + + for (const header of expectedHeaders) { + await expect(page.getByRole('columnheader', { name: header, exact: true })).toBeVisible({ timeout: 5000 }); + } + + // Check data rows exist + const rowCount = await page.locator('tbody tr').count(); + console.log(`Found ${rowCount} rows in table`); + expect(rowCount).toBeGreaterThan(0); + + // Verify date format (dd.mm.yyyy) - check first row + const firstRowDate = page.locator('tbody tr').first().locator('td').nth(8); // Eröffnungsdatum column + const dateText = await firstRowDate.textContent(); + if (dateText && dateText !== '-') { + expect(dateText).toMatch(/^\d{2}\.\d{2}\.\d{4}$/); + console.log(`Date format verified: ${dateText}`); + } + + // Verify volume format (Swiss number format with apostrophe) - check first non-empty volume + const volumeCells = page.locator('tbody tr td:nth-child(15)'); + const volumeCount = await volumeCells.count(); + for (let i = 0; i < Math.min(volumeCount, 10); i++) { + const volumeText = await volumeCells.nth(i).textContent(); + if (volumeText && volumeText !== '-' && volumeText !== '0') { + // Swiss format uses apostrophe (Unicode RIGHT SINGLE QUOTATION MARK U+2019) as thousands separator + expect(volumeText).toMatch(/^[\d\u2019']+$/); + console.log(`Volume format verified: ${volumeText}`); + break; + } + } + + // Verify numeric columns are right-aligned + const idCell = page.locator('tbody tr').first().locator('td').first(); + await expect(idCell).toHaveClass(/text-right/); + }); + + test('check and download buttons are visible', async ({ page }) => { + await waitForPageLoad(page); + + // Check that action buttons exist in each row + const firstRow = page.locator('tbody tr').first(); + + // Check button (checkmark icon) + const checkButton = firstRow.locator('button[title="Check Files"]'); + await expect(checkButton).toBeVisible(); + + // Download button (arrow down icon) + const downloadButton = firstRow.locator('button[title="Download Files"]'); + await expect(downloadButton).toBeVisible(); + }); + + test('CSV export button works', async ({ page }) => { + await waitForPageLoad(page); + + // Find CSV export button in filter bar + const exportButton = page.locator('button[title="Export CSV"]'); + await expect(exportButton).toBeVisible(); + + // Test that clicking triggers download (we just verify no error) + const downloadPromise = page.waitForEvent('download', { timeout: 5000 }).catch(() => null); + await exportButton.click(); + const download = await downloadPromise; + + if (download) { + const filename = download.suggestedFilename(); + expect(filename).toMatch(/^kyc-files-details-\d{4}-\d{2}-\d{2}\.csv$/); + console.log(`CSV downloaded: ${filename}`); + } + }); +}); diff --git a/src/App.tsx b/src/App.tsx index eeca137e..bc5337c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,7 @@ const TelegramSupportScreen = lazy(() => import('./screens/telegram-support.scre const ComplianceScreen = lazy(() => import('./screens/compliance.screen')); const ComplianceBankTxReturnScreen = lazy(() => import('./screens/compliance-bank-tx-return.screen')); const ComplianceKycFilesScreen = lazy(() => import('./screens/compliance-kyc-files.screen')); +const ComplianceKycFilesDetailsScreen = lazy(() => import('./screens/compliance-kyc-files-details.screen')); const ComplianceKycStatsScreen = lazy(() => import('./screens/compliance-kyc-stats.screen')); const RealunitScreen = lazy(() => import('./screens/realunit.screen')); const RealunitUserScreen = lazy(() => import('./screens/realunit-user.screen')); @@ -334,6 +335,10 @@ export const Routes = [ path: 'compliance/kyc-files', element: withSuspense(), }, + { + path: 'compliance/kyc-files/details', + element: withSuspense(), + }, { path: 'compliance/kyc-stats', element: withSuspense(), diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index 898ca96a..4dad01b8 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -157,6 +157,15 @@ export interface KycFileListEntry { id: number; amlAccountType?: string; verifiedName?: string; + country?: { name: string }; + allBeneficialOwnersDomicile?: string; + amlListAddedDate?: string; + amlListExpiredDate?: string; + amlListReactivatedDate?: string; + highRisk?: boolean; + pep?: boolean; + complexOrgStructure?: boolean; + totalVolumeChfAuditPeriod?: number; } export interface KycFileYearlyStats { @@ -208,6 +217,17 @@ export function useCompliance() { downloadFile(data, headers, `DFX_export_${filenameDateFormat()}.zip`); } + async function checkUserFiles(userDataIds: number[]): Promise { + const { data, headers } = await call<{ data: Blob; headers: Record }>({ + url: 'userData/download', + method: 'POST', + data: { userDataIds, checkOnly: true }, + responseType: ResponseType.BLOB, + }); + + downloadFile(data, headers, `DFX_check_${filenameDateFormat()}.zip`); + } + async function getTransactionRefundData(transactionId: number): Promise { return call({ url: `support/transaction/${transactionId}/refund`, @@ -242,6 +262,7 @@ export function useCompliance() { search, getUserData, downloadUserFiles, + checkUserFiles, getTransactionRefundData, processTransactionRefund, getKycFileList, diff --git a/src/screens/compliance-kyc-files-details.screen.tsx b/src/screens/compliance-kyc-files-details.screen.tsx new file mode 100644 index 00000000..ea97a647 --- /dev/null +++ b/src/screens/compliance-kyc-files-details.screen.tsx @@ -0,0 +1,367 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { + DfxIcon, + IconColor, + IconSize, + IconVariant, + SpinnerSize, + StyledLoadingSpinner, + StyledVerticalStack, +} from '@dfx.swiss/react-components'; +import { useEffect, useMemo, useState } from 'react'; +import { ErrorHint } from 'src/components/error-hint'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { KycFileListEntry, useCompliance } from 'src/hooks/compliance.hook'; +import { useComplianceGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; + +type StatusFilter = 'all' | 'open' | 'closed'; + +export default function ComplianceKycFilesDetailsScreen(): JSX.Element { + useComplianceGuard(); + + const { translate } = useSettingsContext(); + const { getKycFileList, downloadUserFiles, checkUserFiles } = useCompliance(); + const { navigate } = useNavigation(); + const { isLoggedIn } = useSessionContext(); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [data, setData] = useState([]); + + // Filter state + const [statusFilter, setStatusFilter] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + function formatDate(dateString?: string): string { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('de-CH', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } + + function formatVolume(volume?: number): string { + if (volume == null) return '-'; + return Math.round(volume).toLocaleString('de-CH'); + } + + function getStatus(entry: KycFileListEntry): string { + return entry.amlListExpiredDate ? 'closed' : 'open'; + } + + function isShellCompany(entry: KycFileListEntry): boolean { + return entry.amlAccountType === 'Sitzgesellschaft'; + } + + const filteredData = useMemo(() => { + const fromDate = dateFrom ? new Date(dateFrom) : null; + const toDate = dateTo ? new Date(dateTo) : null; + if (toDate) toDate.setHours(23, 59, 59, 999); + + return data.filter((entry) => { + // Status filter + if (statusFilter !== 'all') { + const entryStatus = getStatus(entry); + if (entryStatus !== statusFilter) return false; + } + + // Date range filter (Eröffnungsdatum) + if (fromDate || toDate) { + const entryDate = entry.amlListAddedDate ? new Date(entry.amlListAddedDate) : null; + if (!entryDate) return false; + + if (fromDate && entryDate < fromDate) return false; + if (toDate && entryDate > toDate) return false; + } + + return true; + }); + }, [data, statusFilter, dateFrom, dateTo]); + + function exportCsv() { + const headers = [ + 'KycFileId', + 'AccountId', + 'Type', + 'Name', + 'Status', + 'Domizil Vertragspartei', + 'Domizil wB', + 'Sitzgesellschaft', + 'Eröffnungsdatum', + 'Schliessdatum', + 'Neueröffnung', + 'GmeR', + 'PEP', + 'Komplexe Struktur', + 'Volume', + ]; + const rows = filteredData.map((entry) => [ + entry.kycFileId, + entry.id, + entry.amlAccountType ?? '', + entry.verifiedName ?? '', + getStatus(entry), + entry.country?.name ?? '', + entry.allBeneficialOwnersDomicile ?? '', + isShellCompany(entry) ? 'Ja' : 'Nein', + formatDate(entry.amlListAddedDate), + formatDate(entry.amlListExpiredDate), + entry.amlListReactivatedDate ? 'Ja' : 'Nein', + entry.highRisk ? 'Ja' : 'Nein', + entry.pep ? 'Ja' : 'Nein', + entry.complexOrgStructure ? 'Ja' : 'Nein', + formatVolume(entry.totalVolumeChfAuditPeriod), + ]); + + const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `kyc-files-details-${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + URL.revokeObjectURL(url); + } + + useEffect(() => { + if (!isLoggedIn) return; + + getKycFileList() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setIsLoading(false)); + }, [isLoggedIn]); + + useLayoutOptions({ title: translate('screens/compliance', 'KYC File Details'), noMaxWidth: true }); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + + {/* Filter Bar */} +
+
+ + +
+ +
+ + setDateFrom(e.target.value)} + /> +
+ +
+ + setDateTo(e.target.value)} + /> +
+ +
+   + +
+ +
+ + {translate('screens/compliance', 'Showing')} {filteredData.length} {translate('screens/compliance', 'of')}{' '} + {data.length} {translate('screens/compliance', 'entries')} + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + {filteredData.length > 0 ? ( + filteredData.map((entry) => ( + navigate(`compliance/user/${entry.id}`)} + > + + + + + + + + + + + + + + + + + + )) + ) : ( + + + + )} + +
+ {translate('screens/compliance', 'Id')} + + {translate('screens/compliance', 'AccountId')} + + {translate('screens/compliance', 'Type')} + + {translate('screens/compliance', 'Name')} + + {translate('screens/compliance', 'Status')} + + {translate('screens/compliance', 'Domizil Vertragspartei')} + + {translate('screens/compliance', 'Domizil wB')} + + {translate('screens/compliance', 'Sitzgesellschaft')} + + {translate('screens/compliance', 'Eröffnungsdatum')} + + {translate('screens/compliance', 'Schliessdatum')} + + {translate('screens/compliance', 'Neueröffnung')} + + {translate('screens/compliance', 'GmeR')} + + {translate('screens/compliance', 'PEP')} + + {translate('screens/compliance', 'Komplexe Struktur')} + + {translate('screens/compliance', 'Volume')} +
{entry.kycFileId}{entry.id}{entry.amlAccountType ?? '-'}{entry.verifiedName ?? '-'}{getStatus(entry)}{entry.country?.name ?? '-'} + {entry.allBeneficialOwnersDomicile ?? '-'} + + {isShellCompany(entry) ? 'Ja' : 'Nein'} + {formatDate(entry.amlListAddedDate)} + {formatDate(entry.amlListExpiredDate)} + + {entry.amlListReactivatedDate ? 'Ja' : 'Nein'} + {entry.highRisk ? 'Ja' : 'Nein'}{entry.pep ? 'Ja' : 'Nein'} + {entry.complexOrgStructure ? 'Ja' : 'Nein'} + + {formatVolume(entry.totalVolumeChfAuditPeriod)} + + + +
+ {translate('screens/compliance', 'No entries found')} +
+
+
+ ); +} diff --git a/src/screens/compliance-kyc-files.screen.tsx b/src/screens/compliance-kyc-files.screen.tsx index f1fade70..5419b0fc 100644 --- a/src/screens/compliance-kyc-files.screen.tsx +++ b/src/screens/compliance-kyc-files.screen.tsx @@ -1,4 +1,13 @@ -import { SpinnerSize, StyledLoadingSpinner, StyledVerticalStack } from '@dfx.swiss/react-components'; +import { useSessionContext } from '@dfx.swiss/react'; +import { + DfxIcon, + IconColor, + IconSize, + IconVariant, + SpinnerSize, + StyledLoadingSpinner, + StyledVerticalStack, +} from '@dfx.swiss/react-components'; import { useEffect, useState } from 'react'; import { ErrorHint } from 'src/components/error-hint'; import { useSettingsContext } from 'src/contexts/settings.context'; @@ -13,17 +22,35 @@ export default function ComplianceKycFilesScreen(): JSX.Element { const { translate } = useSettingsContext(); const { getKycFileList } = useCompliance(); const { navigate } = useNavigation(); + const { isLoggedIn } = useSessionContext(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(); const [data, setData] = useState([]); + function exportCsv() { + const headers = ['KycFileId', 'AccountId', 'Type', 'Name']; + const rows = data.map((entry) => [entry.kycFileId, entry.id, entry.amlAccountType ?? '', entry.verifiedName ?? '']); + + const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `kyc-files-${new Date().toISOString().split('T')[0]}.csv`; + link.click(); + URL.revokeObjectURL(url); + } + useEffect(() => { + if (!isLoggedIn) return; + getKycFileList() .then(setData) .catch((e) => setError(e.message)) .finally(() => setIsLoading(false)); - }, []); + }, [isLoggedIn]); useLayoutOptions({ title: translate('screens/compliance', 'KYC File List') }); @@ -48,10 +75,29 @@ export default function ComplianceKycFilesScreen(): JSX.Element { {translate('screens/compliance', 'AccountId')} - {translate('screens/compliance', 'AML Account Type')} + {translate('screens/compliance', 'Type')} - {translate('screens/compliance', 'Verified Name')} + {translate('screens/compliance', 'Name')} + + +
+ + +
@@ -67,11 +113,12 @@ export default function ComplianceKycFilesScreen(): JSX.Element { {entry.id} {entry.amlAccountType ?? '-'} {entry.verifiedName ?? '-'} + )) ) : ( - + {translate('screens/compliance', 'No entries found')}