Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions e2e/compliance-kyc-stats.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test, expect, APIRequestContext } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { createTestCredentials } from './test-wallet';

const API_URL = process.env.REACT_APP_API_URL! + '/v1';

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];
}

async function getAdminAuth(request: APIRequestContext): Promise<string> {
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 Stats Page - Smoke Test', () => {
let token: string;

test.beforeAll(async ({ request }) => {
token = await getAdminAuth(request);
});

test('page loads and shows table with API data', async ({ page }) => {
await page.goto(`/compliance/kyc-stats?session=${token}`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);

// Verify table is visible
await expect(page.getByText('As of 31.12.:')).toBeVisible();

// Verify row labels are present
await expect(page.getByText('KYC files managed on 01.01.xxxx')).toBeVisible();
await expect(page.getByText('KYC files managed on 31.12.20xx')).toBeVisible();

// Take screenshot
await page.screenshot({
path: 'e2e/test-results/kyc-stats-smoke-test.png',
fullPage: true,
});

console.log('Screenshot saved to e2e/test-results/kyc-stats-smoke-test.png');
});
});
77 changes: 77 additions & 0 deletions e2e/compliance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,81 @@ test.describe('Compliance Pages - Visual Regression Tests', () => {
// NOTE: Bank TX Return tests are skipped - no test transaction data available
// To add these tests later, create test data and uncomment:
// test.describe('Compliance Bank TX Return Page (/compliance/bank-tx/:id/return)', () => { ... });

test.describe('Compliance KYC Stats Page (/compliance/kyc-stats)', () => {
test('renders KYC stats table correctly', async ({ page }) => {
await page.goto(`/compliance/kyc-stats?session=${token}`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);

// Verify page loaded with table
await expect(page.getByText('As of 31.12.:')).toBeVisible();
await expect(page.getByText('2021')).toBeVisible();
await expect(page.getByText('2025')).toBeVisible();

// Verify row labels
await expect(page.getByText('KYC files managed on 01.01.xxxx')).toBeVisible();
await expect(page.getByText('KYC files managed on 31.12.20xx')).toBeVisible();

await expect(page).toHaveScreenshot('compliance-kyc-stats-01-table.png', {
fullPage: true,
maxDiffPixels: 5000,
});
});

test('displays correct data values', async ({ page }) => {
await page.goto(`/compliance/kyc-stats?session=${token}`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);

// Verify all year columns are present
const years = ['2021', '2022', '2023', '2024', '2025', '2026'];
for (const year of years) {
await expect(page.getByText(year)).toBeVisible();
}

// Verify all row labels
const rowLabels = [
'KYC files managed on 01.01.xxxx',
'Internal: Reopened KYC files',
'Internal: New KYC files',
'KYC files added between 01.01.20xx and 31.12.20xx',
'*KYC files managed during the year',
'KYC files closed between 01.01.20xx and 31.12.20xx',
'KYC files managed on 31.12.20xx',
];
for (const label of rowLabels) {
await expect(page.getByText(label)).toBeVisible();
}

// Verify specific data values using regex to handle different apostrophe characters
const table = page.locator('table');

// 2021 values
await expect(table.getByText('250').first()).toBeVisible();

// 2022 values (use regex to match apostrophe variants)
await expect(table.getByText(/1.947/).first()).toBeVisible();
await expect(table.getByText(/2.192/).first()).toBeVisible();
await expect(table.getByText(/2.197/).first()).toBeVisible();

// 2023 values
await expect(table.getByText('509').first()).toBeVisible();
await expect(table.getByText(/2.701/).first()).toBeVisible();
await expect(table.getByText(/2.127/).first()).toBeVisible();
await expect(table.getByText('574').first()).toBeVisible();

// 2024 values
await expect(table.getByText('87').first()).toBeVisible();
await expect(table.getByText('611').first()).toBeVisible();
await expect(table.getByText('698').first()).toBeVisible();
await expect(table.getByText(/1.272/).first()).toBeVisible();
await expect(table.getByText('253').first()).toBeVisible();
await expect(table.getByText(/1.019/).first()).toBeVisible();

// 2025 values
await expect(table.getByText(/2.006/).first()).toBeVisible();
await expect(table.getByText(/3.025/).first()).toBeVisible();
});
});
});
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ComplianceKycStatsScreen = lazy(() => import('./screens/compliance-kyc-stats.screen'));
const RealunitScreen = lazy(() => import('./screens/realunit.screen'));
const RealunitUserScreen = lazy(() => import('./screens/realunit-user.screen'));
const PersonalIbanScreen = lazy(() => import('./screens/personal-iban.screen'));
Expand Down Expand Up @@ -333,6 +334,10 @@ export const Routes = [
path: 'compliance/kyc-files',
element: withSuspense(<ComplianceKycFilesScreen />),
},
{
path: 'compliance/kyc-stats',
element: withSuspense(<ComplianceKycStatsScreen />),
},
{
path: 'realunit',
element: (
Expand Down
29 changes: 28 additions & 1 deletion src/hooks/compliance.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ export interface KycFileListEntry {
verifiedName?: string;
}

export interface KycFileYearlyStats {
year: number;
startCount: number;
reopened: number;
newFiles: number;
addedDuringYear: number;
activeDuringYear: number;
closedDuringYear: number;
endCount: number;
highestFileNr: number;
}

function normalizeSearchKey(key: string): string {
const normalized = electronicFormatIBAN(key);
if (normalized && isValidIBAN(normalized)) {
Expand Down Expand Up @@ -218,8 +230,23 @@ export function useCompliance() {
});
}

async function getKycFileStats(): Promise<KycFileYearlyStats[]> {
return call<KycFileYearlyStats[]>({
url: 'support/kycFileStats',
method: 'GET',
});
}

return useMemo(
() => ({ search, getUserData, downloadUserFiles, getTransactionRefundData, processTransactionRefund, getKycFileList }),
() => ({
search,
getUserData,
downloadUserFiles,
getTransactionRefundData,
processTransactionRefund,
getKycFileList,
getKycFileStats,
}),
[call],
);
}
99 changes: 99 additions & 0 deletions src/screens/compliance-kyc-stats.screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { 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';
import { KycFileYearlyStats, useCompliance } from 'src/hooks/compliance.hook';
import { useComplianceGuard } from 'src/hooks/guard.hook';
import { useLayoutOptions } from 'src/hooks/layout-config.hook';

type KycYearDataKey = keyof Omit<KycFileYearlyStats, 'year'>;

const rowDefinitions: { key: KycYearDataKey; label: string }[] = [
{ key: 'startCount', label: 'KYC files managed on 01.01.xxxx' },
{ key: 'reopened', label: 'Internal: Reopened KYC files' },
{ key: 'newFiles', label: 'Internal: New KYC files' },
{ key: 'addedDuringYear', label: 'KYC files added between 01.01.20xx and 31.12.20xx' },
{ key: 'activeDuringYear', label: '*KYC files managed during the year' },
{ key: 'closedDuringYear', label: 'KYC files closed between 01.01.20xx and 31.12.20xx' },
{ key: 'endCount', label: 'KYC files managed on 31.12.20xx' },
{ key: 'highestFileNr', label: 'Internal: Highest KYC file number' },
];

function formatNumber(num: number): string {
return num.toLocaleString('de-CH');
}

export default function ComplianceKycStatsScreen(): JSX.Element {
useComplianceGuard();

const { translate } = useSettingsContext();
const { getKycFileStats } = useCompliance();

const [stats, setStats] = useState<KycFileYearlyStats[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>();

useLayoutOptions({ title: translate('screens/compliance', 'KYC File Statistics') });

useEffect(() => {
getKycFileStats()
.then(setStats)
.catch((e) => setError(e.message))
.finally(() => setIsLoading(false));
}, []);

if (isLoading) {
return <StyledLoadingSpinner size={SpinnerSize.LG} />;
}

if (error) {
return <ErrorHint message={error} />;
}

const years = stats.map((s) => s.year);
const kycData = stats.reduce(
(acc, s) => {
acc[s.year] = s;
return acc;
},
{} as Record<number, KycFileYearlyStats>,
);

return (
<StyledVerticalStack gap={6} full>
<div className="w-full overflow-x-auto">
<table className="w-full border-collapse bg-white rounded-lg shadow-sm">
<thead>
<tr className="bg-dfxGray-300">
<th className="px-2 py-2 text-left text-sm font-semibold text-dfxBlue-800">
{translate('screens/compliance', 'As of 31.12.:')}
</th>
{years.map((year) => (
<th key={year} className="px-2 py-2 text-right text-sm font-semibold text-dfxBlue-800">
{year}
</th>
))}
</tr>
</thead>
<tbody>
{rowDefinitions.map((row, index) => (
<tr
key={row.key}
className={`border-b border-dfxGray-300 ${index % 2 === 1 ? 'bg-dfxGray-100' : ''}`}
>
<td className="px-2 py-2 text-left text-sm text-dfxBlue-800">
{translate('screens/compliance', row.label)}
</td>
{years.map((year) => (
<td key={year} className="px-2 py-2 text-right text-sm text-dfxBlue-800">
{formatNumber(kycData[year][row.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</StyledVerticalStack>
);
}
Loading