From 1490ce1b30c2d05178750f09d678c031f0c90899 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:17:49 +0100 Subject: [PATCH] Add KYC files details page with extended columns (#944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add KYC files details page with extended columns - Add new /compliance/kyc-files/details route with additional columns - Add CSV export functionality to both KYC files screens - Add details navigation button (info icon) to KYC files list - Extend KycFileListEntry interface with new fields - Calculate status (open/closed) based on amlListExpiredDate - Calculate Sitzgesellschaft based on amlAccountType * Fix race condition in KYC files screens and add E2E test - Add isLoggedIn check before fetching data to prevent 401 errors - Add Playwright E2E test for KYC Files Details page * Fix KycFileListEntry interface field name to match backend * Format dates and volumes in Swiss locale for KYC files details * Use leading zeros in date format (08.01.2025) * Remove decimal places from volume display * Right-align numeric columns (Id, AccountId, Volume) * Add download button for files on each row * Add check button to verify files without downloading * Add Playwright E2E tests for KYC Files Details page - Test page load with correct headers and data display - Verify date format (dd.mm.yyyy) and volume format (Swiss) - Test check and download button visibility - Test CSV export functionality - Add screenshots for visual verification * Fix volume regex to accept Unicode apostrophe from toLocaleString * Add Check All Files button in header * Use SEARCH icon for check buttons * Add Download All Files button in header * Remove Download All button (too much data for API) * Add filter bar with Status and Date range filters - Status filter (All/Open/Closed) - Date range filter for Eröffnungsdatum (from/to) - Check All and CSV Export now work on filtered data - Shows count of filtered entries * Use amlListStatus for status filter (Active=open, Deactivated=closed) * Fix status filter: use amlListStatus with fallback to amlListExpiredDate * Simplify status filter to use only amlListExpiredDate Remove amlListStatus fallback logic as it's not applicable for the KYC file status determination. Status is now determined solely by whether amlListExpiredDate is set (closed) or not (open). * Move action buttons to filter bar and fix download icon - Move Check Filtered Files and CSV Export buttons from table header to filter bar for better UX - Change download icon from ARROW_DOWN to FILE for consistency * Use ARROW_DOWN icon for CSV export * Remove unused amlListStatus field from KycFileListEntry interface * Use ADMIN_SEED auth pattern instead of hardcoded credentials in E2E test - Replace hardcoded address/signature with ADMIN_SEED from api/.env - Use session token authentication matching other compliance tests - Add proper TypeScript types for Page parameter - Replace waitForTimeout with waitForLoadState('networkidle') * Add error handling for checkUserFiles and downloadUserFiles buttons - Wrap async button handlers in try-catch blocks - Display error message to user when API calls fail * Remove screenshots and fix icon inconsistency - Remove committed screenshots from e2e tests (should not be in repo) - Remove screenshot generation from E2E test code - Fix CSV export icon: use ARROW_DOWN consistently on both screens * chore: cleanup --------- Co-authored-by: David May --- e2e/kyc-files-details.spec.ts | 160 ++++++++ src/App.tsx | 5 + src/hooks/compliance.hook.ts | 21 + .../compliance-kyc-files-details.screen.tsx | 367 ++++++++++++++++++ src/screens/compliance-kyc-files.screen.tsx | 57 ++- 5 files changed, 605 insertions(+), 5 deletions(-) create mode 100644 e2e/kyc-files-details.spec.ts create mode 100644 src/screens/compliance-kyc-files-details.screen.tsx 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')}