diff --git a/docs/test-architecture.md b/docs/test-architecture.md index e42294555..537838eb1 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -137,11 +137,16 @@ run does not prove for each one; the taxonomy and cross-repository entries live fixtures render. It does not prove that the live promo or relations API returns those payloads, that login or token verification works, or that create/deactivate succeed against the server. -- **The RealUnit compliance visual spec answers the customer list and dossier itself.** - `e2e/realunit-compliance.spec.ts` fulfils `GET /v1/realunit/compliance/customers` and - `GET /v1/realunit/compliance/customers/:id` with synthetic fixtures (including `addresses`). - A green run proves those fixtures render. It does not prove that the API returns that payload - or that the server filters to RealUnit wallets. +- **The RealUnit compliance visual spec answers the customer list, Dilisense actions, and dossier itself.** + `e2e/realunit-compliance.spec.ts` fulfils `GET /v1/realunit/compliance/customers`, + `GET /v1/realunit/compliance/customers/:id`, `GET /v1/realunit/compliance/name-check`, + `POST /v1/realunit/compliance/name-check` and `POST /v1/realunit/compliance/customers/:id/name-check` + with synthetic fixtures (including `addresses` and name-check results). Auth is a synthetic Admin + JWT plus staff bootstrap GETs. Unmatched `GET /v1/**` calls return `[]` and other unmatched + `/v1/**` methods return `{}`, so a green visual run does not prove which other staff endpoints the + screen calls. A green run proves those fixtures render, including the Screen / Screen-all confirm + dialogs and a running-batch state. It does not prove that the API returns that payload, that login + works, or that the server filters to RealUnit wallets. - **Two specs force KYC completeness.** Both collection-invoice cases — the refused QR and the stored-detail error — override `**/v2/user` so that `kyc.dataComplete` is read as `true`, because the invoice button is gated on that value. A green run therefore proves nothing about the gate for diff --git a/e2e-stack/specs/realunit.spec.ts b/e2e-stack/specs/realunit.spec.ts index 6f8466bb9..0d6722873 100644 --- a/e2e-stack/specs/realunit.spec.ts +++ b/e2e-stack/specs/realunit.spec.ts @@ -247,7 +247,7 @@ test.describe('RealUnit area', () => { `SELECT id FROM asset WHERE name = 'REALU' AND blockchain = 'Sepolia' AND type = 'Token' ORDER BY id ASC LIMIT 1`, ); if (realu?.id == null) { - throw new Error("seedWaitingForPaymentBuyQuote: no loc REALU token on Sepolia in seed data"); + throw new Error('seedWaitingForPaymentBuyQuote: no loc REALU token on Sepolia in seed data'); } const uid = `RQ${Date.now().toString(36)}${customer.userId}`.replace(/[^a-zA-Z0-9]/g, '').slice(0, 20); @@ -290,9 +290,7 @@ test.describe('RealUnit area', () => { ) .not.toBeNull(); - await expect - .poll(() => normPath(new URL(page.url()).pathname), { timeout: 15000 }) - .toBe('/realunit/quotes'); + await expect.poll(() => normPath(new URL(page.url()).pathname), { timeout: 15000 }).toBe('/realunit/quotes'); assertNoErrors(pageErrors, consoleErrors); }); @@ -331,9 +329,7 @@ test.describe('RealUnit area', () => { ) .not.toBeNull(); - await expect - .poll(() => normPath(new URL(page.url()).pathname), { timeout: 15000 }) - .toBe('/realunit/quotes'); + await expect.poll(() => normPath(new URL(page.url()).pathname), { timeout: 15000 }).toBe('/realunit/quotes'); assertNoErrors(pageErrors, consoleErrors); }); @@ -475,18 +471,20 @@ test.describe('RealUnit area', () => { await expect(page.getByPlaceholder('Search by ID, email, phone or name...')).toBeVisible(); await expect(page.getByRole('button', { name: 'Search' })).toBeVisible(); + // thead cells map to ARIA role "cell" in this app, not "columnheader". + await expect(page.getByText('Last Dilisense check')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Screen all' })).toBeVisible(); + await expect( + page.getByText('Something went wrong. Please try again. If the issue persists please reach out to our support.'), + ).toHaveCount(0); - // Loading resolves to customers count, empty filter copy, or handled ErrorHint — not a crash. - // Empty result can show "Customers: 0" and "No entries found" at once; .first() avoids strict mode. + // Loading resolves to customers count or empty filter copy — not a crash. GET name-check on + // mount must succeed (ErrorHint is not an accepted happy-path settle). Empty result can show + // "Customers: 0" and "No entries found" at once; .first() avoids strict mode. const settled = page .getByText(/^Customers:/) .or(page.getByText('No entries found')) - .or(page.getByText('All accounts are hidden by the filter above')) - .or( - page.getByText( - 'Something went wrong. Please try again. If the issue persists please reach out to our support.', - ), - ); + .or(page.getByText('All accounts are hidden by the filter above')); await expect(settled.first()).toBeVisible(); assertNoErrors(pageErrors, consoleErrors); diff --git a/e2e/realunit-compliance.spec.ts b/e2e/realunit-compliance.spec.ts index 9da526d2f..0abaef6e9 100644 --- a/e2e/realunit-compliance.spec.ts +++ b/e2e/realunit-compliance.spec.ts @@ -1,34 +1,41 @@ -import { test, expect, APIRequestContext, Page, Route } from '@playwright/test'; -import * as fs from 'fs'; -import * as path from 'path'; -import { createTestCredentials } from './test-wallet'; +import { test, expect, Page, Route } from '@playwright/test'; /** * E2E Visual Regression Tests: RealUnit staff Compliance dashboards * * Routes: - * - /realunit/compliance (customer search) + * - /realunit/compliance (customer search + Dilisense screen actions) * - /realunit/compliance/user/:id (reduced dossier — long vertical page) * - * Auth is REAL (same admin-token flow as compliance.spec.ts / compliance-recommendation-graph.spec.ts): the api must - * be reachable for `/v1/auth` and the frontend's own user/role fetch. The admin user has the ADMIN role, which - * `useRealunitGuard` accepts (ADMIN | REALUNIT). + * Auth is a synthetic Admin JWT. Feature data and staff bootstrap GETs are MOCKED, so the suite + * does not need a live API. A green run does not prove production auth or that the API returns + * these customer fields. * * Feature data is MOCKED with synthetic fixtures via page.route(...), so the baselines are deterministic AND contain - * NO real production data. Only the RealUnit-scoped compliance endpoints are intercepted; everything else - * (auth/role/user/settings) is passed through via route.continue(). + * NO real production data. * * The search screen has NO URL query support (unlike DFX /compliance): the query lives in a controlled input, so the * test fills the input and presses Enter (the screen's onKeyDown handler runs handleSearch). * * Intercepted endpoints (base `/v1/` is prepended by useApi): - * - GET realunit/compliance/customers[?key=...] (upfront list / search → RealUnitCustomerListDto[]) - * - GET realunit/compliance/customers/:id (dossier → RealUnitCustomerDetailDto) + * - GET realunit/compliance/customers[?key=...] (upfront list / search → RealUnitCustomerListDto[]) + * - GET realunit/compliance/customers/:id (dossier → RealUnitCustomerDetailDto) + * - GET realunit/compliance/name-check (batch status) + * - POST realunit/compliance/name-check (start batch) + * - POST realunit/compliance/customers/:id/name-check * * Synthetic fixtures: fake ids (7100+), fixed ISO dates, fake names/emails/IBANs — no production data. */ -const API_URL = process.env.REACT_APP_API_URL! + '/v1'; +function jwt(): string { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode({ + account: 1, + user: 1, + role: 'Admin', + exp: Math.floor(Date.now() / 1000) + 3600, + })}.synthetic`; +} // Author marker the backend stamps on customer messages (mirrors CustomerAuthor in src/util/support-stats.ts). const CUSTOMER_AUTHOR = 'Customer'; @@ -36,46 +43,10 @@ const CUSTOMER_AUTHOR = 'Customer'; // Numeric id of the customer whose dossier is screenshotted. const CUSTOMER_ID = 7101; -/** - * 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; -} - // --------------------------------------------------------------------------- // Synthetic fixtures (mirror the RealUnit reduced-compliance DTOs from src/dto/realunit-compliance.dto.ts). -// This is the REDUCED tenant view: NO DFX AML work products (no name-check, no amlCheck/amlReason, no notes, -// no limitRequest, no recommendation graph). +// This is the REDUCED tenant view: Dilisense last-check summary fields only — no DFX AML work products +// (no amlCheck/amlReason, no notes, no limitRequest, no recommendation graph). // --------------------------------------------------------------------------- interface RealUnitCustomerListDto { @@ -87,6 +58,10 @@ interface RealUnitCustomerListDto { name?: string; // mirrors the real DTO field since the Balance column was added balance?: number; + lastNameCheckDate?: string; + lastNameCheckStatus?: 'NoMatch' | 'MatchWithoutBirthday' | 'MatchWithBirthday'; + lastNameCheckEvaluation?: 'Confirmed' | 'Ignored' | 'NotMatching' | 'Canceled'; + canScreen: boolean; } // ~4 synthetic search results (one empty account exercises the default hide-empty toggle). @@ -99,6 +74,9 @@ const SEARCH_RESULTS: RealUnitCustomerListDto[] = [ mail: 'ops@acme-example.com', name: 'ACME Example AG', balance: 1250, + canScreen: true, + lastNameCheckDate: '2024-06-15T12:00:00.000Z', + lastNameCheckStatus: 'NoMatch', }, { id: 7102, @@ -108,6 +86,9 @@ const SEARCH_RESULTS: RealUnitCustomerListDto[] = [ mail: 'alice@example.com', name: 'Alice Muster', balance: 30.5, + canScreen: true, + lastNameCheckDate: '2024-03-01T12:00:00.000Z', + lastNameCheckStatus: 'MatchWithBirthday', }, // Bob stays visible despite balance 0 because name/mail are set — documents the filter semantics { @@ -118,6 +99,9 @@ const SEARCH_RESULTS: RealUnitCustomerListDto[] = [ mail: 'bob@example.com', name: 'Bob Beispiel', balance: 0, + canScreen: true, + lastNameCheckDate: '2024-01-20T12:00:00.000Z', + lastNameCheckStatus: 'MatchWithoutBirthday', }, // intentionally no name/mail/accountType — the only empty account; hidden by the toggle in the default // view, shown in search because an active search bypasses the filter @@ -126,6 +110,7 @@ const SEARCH_RESULTS: RealUnitCustomerListDto[] = [ kycStatus: 'NA', kycLevel: '0', balance: 0, + canScreen: false, }, ]; @@ -173,12 +158,38 @@ const DOSSIER = { // Customer-scoped slices (reduced) kycFiles: [ { uid: 'file-7101-1', type: 'Identification', name: 'passport.pdf', created: '2024-01-02T00:00:00.000Z' }, - { uid: 'file-7101-2', type: 'AdditionalDocuments', name: 'commercial-register.pdf', created: '2024-01-03T00:00:00.000Z' }, + { + uid: 'file-7101-2', + type: 'AdditionalDocuments', + name: 'commercial-register.pdf', + created: '2024-01-03T00:00:00.000Z', + }, ], kycSteps: [ - { id: 7201, name: 'Contract', type: 'Contract', status: 'Completed', sequenceNumber: 1, created: '2024-01-02T00:00:00.000Z' }, - { id: 7202, name: 'Ident', type: 'Auto', status: 'Completed', sequenceNumber: 2, created: '2024-01-03T00:00:00.000Z' }, - { id: 7203, name: 'LegalEntity', type: 'Manual', status: 'InProgress', sequenceNumber: 3, created: '2024-01-04T00:00:00.000Z' }, + { + id: 7201, + name: 'Contract', + type: 'Contract', + status: 'Completed', + sequenceNumber: 1, + created: '2024-01-02T00:00:00.000Z', + }, + { + id: 7202, + name: 'Ident', + type: 'Auto', + status: 'Completed', + sequenceNumber: 2, + created: '2024-01-03T00:00:00.000Z', + }, + { + id: 7203, + name: 'LegalEntity', + type: 'Manual', + status: 'InProgress', + sequenceNumber: 3, + created: '2024-01-04T00:00:00.000Z', + }, ], transactions: [ { @@ -298,8 +309,16 @@ const DOSSIER = { information: 'Customer reported a missing incoming transfer; resolved after bank reconciliation.', transaction: { id: 7301, uid: 'TX-7301', type: 'Buy', sourceType: 'BuyCrypto', amountInChf: 10000 }, messages: [ - { author: CUSTOMER_AUTHOR, message: 'I sent 10000 CHF but do not see the tokens yet.', created: '2024-01-05T08:00:00.000Z' }, - { author: 'Rita Clerk', message: 'We located the payment, the tokens have now been credited.', created: '2024-01-05T11:00:00.000Z' }, + { + author: CUSTOMER_AUTHOR, + message: 'I sent 10000 CHF but do not see the tokens yet.', + created: '2024-01-05T08:00:00.000Z', + }, + { + author: 'Rita Clerk', + message: 'We located the payment, the tokens have now been credited.', + created: '2024-01-05T11:00:00.000Z', + }, ], }, { @@ -313,48 +332,99 @@ const DOSSIER = { department: 'Compliance', information: 'Follow-up on the beneficial ownership declaration for the organization.', messages: [ - { author: CUSTOMER_AUTHOR, message: 'Please find the updated ownership declaration attached.', created: '2024-01-07T09:30:00.000Z' }, - { author: 'Tom Support', message: 'Thank you, we are reviewing the document.', created: '2024-01-07T14:15:00.000Z' }, + { + author: CUSTOMER_AUTHOR, + message: 'Please find the updated ownership declaration attached.', + created: '2024-01-07T09:30:00.000Z', + }, + { + author: 'Tom Support', + message: 'Thank you, we are reviewing the document.', + created: '2024-01-07T14:15:00.000Z', + }, ], }, ], }; // --------------------------------------------------------------------------- -// Routing: intercept ONLY the RealUnit compliance endpoints; pass everything else through. +// Routing: named RealUnit compliance endpoints plus staff bootstrap GETs. Unmatched GET /v1/** +// returns [] and other unmatched /v1/** methods return {} so a synthetic JWT does not 401. // The list endpoint is `.../customers?key=...`; the detail endpoint is `.../customers/:id` — match detail first. // --------------------------------------------------------------------------- const DETAIL_RE = /\/v1\/realunit\/compliance\/customers\/(\d+)(?:\?|$)/; const SEARCH_RE = /\/v1\/realunit\/compliance\/customers(?:\?|$)/; +const NAME_CHECK_BATCH_RE = /\/v1\/realunit\/compliance\/name-check(?:\?|$)/; +const NAME_CHECK_CUSTOMER_RE = /\/v1\/realunit\/compliance\/customers\/\d+\/name-check(?:\?|$)/; + +const IDLE_BATCH = { status: 'Idle', total: 0, done: 0, failed: 0, skipped: 0 }; +const RUNNING_BATCH = { status: 'Running', total: 3, done: 1, failed: 0, skipped: 1, startedBy: 1 }; async function json(route: Route, body: unknown): Promise { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); } -async function installComplianceRoutes(page: Page): Promise { +/** Compliance mocks plus staff GETs so a synthetic JWT does not 401-clear the session. */ +async function installComplianceRoutes( + page: Page, + batch: { status: string; total: number; done: number; failed: number; skipped: number } = IDLE_BATCH, +): Promise { await page.route('**/v1/**', async (route: Route) => { - const url = route.request().url(); - + const request = route.request(); + const url = request.url(); + const path = new URL(url).pathname; + + if (NAME_CHECK_CUSTOMER_RE.test(url)) { + return json(route, { id: CUSTOMER_ID, riskStatus: 'NoMatch', date: '2024-06-15T12:00:00.000Z' }); + } + if (NAME_CHECK_BATCH_RE.test(url)) return json(route, batch); if (DETAIL_RE.test(url)) return json(route, DOSSIER); if (SEARCH_RE.test(url)) return json(route, SEARCH_RESULTS); - // everything else (auth, role, user, settings) hits the real api + if ( + request.method() === 'GET' && + ['/v1/language', '/v1/fiat', '/v1/asset', '/v1/bankAccount', '/v1/country'].includes(path) + ) { + return json(route, []); + } + + if (request.method() === 'GET' && path === '/v1/setting/infoBanner') { + return json(route, null); + } + + if (request.method() === 'GET') return json(route, []); + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + }); + + await page.route('**/v2/**', async (route: Route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (request.method() === 'GET' && path === '/v2/user') { + return json(route, { + id: 1, + activeAddress: { + address: '0x0000000000000000000000000000000000000001', + wallet: 'DFX', + }, + addresses: [], + kyc: { level: 50, status: 'Completed' }, + language: { id: 1, name: 'English', symbol: 'EN' }, + }); + } + await route.continue(); }); } test.describe('RealUnit Compliance dashboards - Visual Regression Tests', () => { - let token: string; - - test.beforeAll(async ({ request }) => { - token = await getAdminAuth(request); - }); + const token = jwt(); test('search screen renders customer results', async ({ page }) => { await installComplianceRoutes(page); - await page.goto(`/realunit/compliance?session=${token}`); + await page.goto(`/realunit/compliance?session=${encodeURIComponent(token)}&lang=en`); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1000); @@ -381,6 +451,12 @@ test.describe('RealUnit Compliance dashboards - Visual Regression Tests', () => await expect(page.getByText('bob@example.com')).toBeVisible(); // active search bypasses the empty filter — the empty account (id 7104) is visible too await expect(page.getByText('7104')).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Last Dilisense check' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible(); + await expect(page.getByText('No match')).toBeVisible(); + await expect(page.getByText('Match without Birthday')).toBeVisible(); + await expect(page.getByText('Match with Birthday (Open)')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Screen all' })).toBeVisible(); await page.waitForTimeout(500); await expect(page).toHaveScreenshot('realunit-compliance-01-search.png', { @@ -389,10 +465,65 @@ test.describe('RealUnit Compliance dashboards - Visual Regression Tests', () => }); }); + test('Screen on a named row opens the Dilisense confirm dialog', async ({ page }) => { + await installComplianceRoutes(page); + + await page.goto(`/realunit/compliance?session=${encodeURIComponent(token)}&lang=en`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + await expect(page.getByText('ACME Example AG')).toBeVisible(); + await page.getByRole('button', { name: 'Screen', exact: true }).first().click(); + await expect( + page.getByText('A Dilisense screening consumes provider quota and costs money – continue?'), + ).toBeVisible(); + await page.waitForTimeout(500); + + await expect(page).toHaveScreenshot('realunit-compliance-03-screen-confirm.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); + + test('Screen all opens the Dilisense confirm dialog', async ({ page }) => { + await installComplianceRoutes(page); + + await page.goto(`/realunit/compliance?session=${encodeURIComponent(token)}&lang=en`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + await expect(page.getByRole('button', { name: 'Screen all' })).toBeVisible(); + await page.getByRole('button', { name: 'Screen all' }).click(); + await expect(page.getByText('Screening all named shareholders consumes Dilisense quota – continue?')).toBeVisible(); + await page.waitForTimeout(500); + + await expect(page).toHaveScreenshot('realunit-compliance-04-screen-all-confirm.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); + + test('running batch disables Screen all and shows progress', async ({ page }) => { + await installComplianceRoutes(page, RUNNING_BATCH); + + await page.goto(`/realunit/compliance?session=${encodeURIComponent(token)}&lang=en`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + await expect(page.getByRole('button', { name: /Screening 1 \/ 3/ })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Screen', exact: true }).first()).toBeDisabled(); + await page.waitForTimeout(500); + + await expect(page).toHaveScreenshot('realunit-compliance-05-batch-running.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); + test('dossier screen renders the full reduced customer view', async ({ page }) => { await installComplianceRoutes(page); - await page.goto(`/realunit/compliance/user/${CUSTOMER_ID}?session=${token}`); + await page.goto(`/realunit/compliance/user/${CUSTOMER_ID}?session=${encodeURIComponent(token)}&lang=en`); await page.waitForLoadState('networkidle'); await page.waitForTimeout(1500); diff --git a/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-01-search-chromium-darwin.png b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-01-search-chromium-darwin.png index 67e01569e..4ffbfb74d 100644 Binary files a/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-01-search-chromium-darwin.png and b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-01-search-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-02-dossier-chromium-darwin.png b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-02-dossier-chromium-darwin.png index e3eba6e8d..ff712d72f 100644 Binary files a/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-02-dossier-chromium-darwin.png and b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-02-dossier-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-03-screen-confirm-chromium-darwin.png b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-03-screen-confirm-chromium-darwin.png new file mode 100644 index 000000000..9c4fea2f9 Binary files /dev/null and b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-03-screen-confirm-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-04-screen-all-confirm-chromium-darwin.png b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-04-screen-all-confirm-chromium-darwin.png new file mode 100644 index 000000000..19f8d0075 Binary files /dev/null and b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-04-screen-all-confirm-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-05-batch-running-chromium-darwin.png b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-05-batch-running-chromium-darwin.png new file mode 100644 index 000000000..400234a63 Binary files /dev/null and b/e2e/screenshots/baseline/realunit-compliance.spec.ts-realunit-compliance-05-batch-running-chromium-darwin.png differ diff --git a/scripts/handbook/metadata.json b/scripts/handbook/metadata.json index 2d614b03d..de14df8c2 100644 --- a/scripts/handbook/metadata.json +++ b/scripts/handbook/metadata.json @@ -89,7 +89,7 @@ }, "realunit-compliance": { "title": "RealUnit Compliance", - "description": "RealUnit-spezifische Compliance-Suche und Dossier." + "description": "RealUnit-Compliance: Kundensuche mit letztem Dilisense-Datum und -Ergebnis, Screen pro Zeile und Screen all (jeweils mit Kosten-Bestätigung), laufender Batch mit Fortschritt, und reduziertes Kundendossier." }, "realunit-support": { "title": "RealUnit Support", diff --git a/src/__tests__/realunit-compliance-screen.test.tsx b/src/__tests__/realunit-compliance-screen.test.tsx index f6554ab0c..b973fb7bb 100644 --- a/src/__tests__/realunit-compliance-screen.test.tsx +++ b/src/__tests__/realunit-compliance-screen.test.tsx @@ -1,13 +1,42 @@ // Component tests for the RealUnit compliance customer list screen: default empty-account filter, -// toggle, search bypass, and empty-state messages. Heavy transitive deps are mocked so the screen -// can render under @testing-library/react without the full app shell. +// toggle, search bypass, empty-state messages, and Dilisense name-check actions. Heavy transitive deps +// are mocked so the screen can render under @testing-library/react without the full app shell. jest.mock('@dfx.swiss/react', () => ({})); jest.mock('@dfx.swiss/react-components', () => ({ SpinnerSize: { SM: 'sm', LG: 'lg' }, StyledLoadingSpinner: () => null, })); -jest.mock('src/components/error-hint', () => ({ ErrorHint: () => null })); +jest.mock('src/components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); +jest.mock('src/components/confirm-dialog', () => ({ + ConfirmDialog: ({ + isOpen, + title, + message, + onConfirm, + onCancel, + }: { + isOpen: boolean; + title?: string; + message: string; + onConfirm: () => void; + onCancel: () => void; + }) => + isOpen ? ( +
+

{title}

+

{message}

+ + +
+ ) : null, +})); jest.mock('src/hooks/guard.hook', () => ({ useRealunitGuard: () => undefined, })); @@ -24,12 +53,24 @@ jest.mock('src/hooks/navigation.hook', () => ({ })); const mockSearchCustomers = jest.fn(); +const mockScreenCustomer = jest.fn(); +const mockStartNameCheckBatch = jest.fn(); +const mockGetNameCheckBatch = jest.fn(); jest.mock('src/hooks/realunit-compliance.hook', () => ({ - useRealunitCompliance: () => ({ searchCustomers: mockSearchCustomers }), + useRealunitCompliance: () => ({ + searchCustomers: mockSearchCustomers, + screenCustomer: mockScreenCustomer, + startNameCheckBatch: mockStartNameCheckBatch, + getNameCheckBatch: mockGetNameCheckBatch, + }), })); -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { RealUnitNameCheckBatchDto } from 'src/dto/realunit-compliance.dto'; import RealunitComplianceScreen from 'src/screens/realunit-compliance.screen'; +import { formatDate } from 'src/util/compliance-helpers'; + +const IDLE_BATCH = { status: 'Idle' as const, total: 0, done: 0, failed: 0, skipped: 0 }; const FULL = { id: 1, @@ -38,6 +79,9 @@ const FULL = { name: 'Alice Muster', mail: 'a@b.ch', balance: 3, + canScreen: true, + lastNameCheckDate: '2024-06-15T12:00:00.000Z', + lastNameCheckStatus: 'NoMatch' as const, }; const EMPTY = { @@ -45,11 +89,13 @@ const EMPTY = { kycStatus: 'NA', kycLevel: '0', balance: 0, + canScreen: false, }; describe('RealunitComplianceScreen empty-account filter', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetNameCheckBatch.mockResolvedValue(IDLE_BATCH); }); it('filters empty accounts in the default view and shows the toggle with the empty count', async () => { @@ -152,3 +198,911 @@ describe('RealunitComplianceScreen empty-account filter', () => { }); }); }); + +describe('RealunitComplianceScreen name-check', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetNameCheckBatch.mockResolvedValue(IDLE_BATCH); + }); + + it('does not navigate when the Screen button is clicked', async () => { + mockSearchCustomers.mockResolvedValue([FULL, EMPTY]); + render(); + + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('opens a confirm dialog when Screen all is clicked', async () => { + mockSearchCustomers.mockResolvedValue([FULL, EMPTY]); + render(); + + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + + expect( + screen.getByText('Screening all named shareholders consumes Dilisense quota – continue?'), + ).toBeInTheDocument(); + }); + + it('disables Screen when canScreen is false', async () => { + mockSearchCustomers.mockResolvedValue([FULL, EMPTY]); + render(); + + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('checkbox')); + + const emptyRow = screen.getByRole('cell', { name: '2' }).closest('tr'); + if (emptyRow == null) throw new Error('expected empty-account row'); + const screenButton = within(emptyRow).getByRole('button', { name: /^Screen$/ }); + expect(screenButton).toBeDisabled(); + expect(screenButton).toHaveAttribute('title', 'Cannot screen without a name'); + }); + + it('renders the last Dilisense check date and translated status', async () => { + mockSearchCustomers.mockResolvedValue([FULL, EMPTY]); + render(); + + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + expect(screen.getByText('Last Dilisense check')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(screen.getByText(formatDate(FULL.lastNameCheckDate))).toBeInTheDocument(); + expect(screen.getByText('No match')).toBeInTheDocument(); + }); + + it('renders Match without Birthday and open vs evaluated Match with Birthday', async () => { + mockSearchCustomers.mockResolvedValue([{ ...FULL, lastNameCheckStatus: 'MatchWithoutBirthday' as const }]); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByText('Match without Birthday')).toBeInTheDocument(); + }); + unmount(); + + mockSearchCustomers.mockResolvedValue([ + { ...FULL, lastNameCheckStatus: 'MatchWithBirthday' as const, lastNameCheckEvaluation: undefined }, + ]); + const second = render(); + await waitFor(() => { + expect(screen.getByText('Match with Birthday (Open)')).toBeInTheDocument(); + }); + second.unmount(); + + mockSearchCustomers.mockResolvedValue([ + { ...FULL, lastNameCheckStatus: 'MatchWithBirthday' as const, lastNameCheckEvaluation: 'Ignored' as const }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText('Match with Birthday')).toBeInTheDocument(); + }); + expect(screen.queryByText('Match with Birthday (Open)')).not.toBeInTheDocument(); + }); + + it('confirms a row screen and reloads the list', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockResolvedValue({ + id: 1, + riskStatus: 'NoMatch', + date: '2024-06-16T12:00:00.000Z', + }); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(mockScreenCustomer).toHaveBeenCalledWith(1); + }); + expect(mockSearchCustomers).toHaveBeenCalledTimes(2); + }); + + it('does not apply a stale list after a newer loadCustomers', async () => { + jest.useFakeTimers(); + let resolveSearch: (value: (typeof FULL)[]) => void = () => undefined; + mockSearchCustomers + .mockResolvedValueOnce([FULL]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSearch = resolve; + }), + ) + .mockResolvedValue([ + { ...FULL, lastNameCheckStatus: 'MatchWithBirthday' as const, lastNameCheckEvaluation: 'Ignored' as const }, + ]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }) + .mockResolvedValue({ status: 'Completed', total: 1, done: 1, failed: 0, skipped: 0 }); + + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + + fireEvent.change(screen.getByPlaceholderText('Search by ID, email, phone or name...'), { + target: { value: 'Alice' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + jest.advanceTimersByTime(2000); + + await waitFor(() => { + expect(screen.getByText('Match with Birthday')).toBeInTheDocument(); + }); + + resolveSearch([{ ...FULL, name: 'Stale Name' }]); + await waitFor(() => { + expect(screen.queryByText('Stale Name')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Match with Birthday')).toBeInTheDocument(); + jest.useRealTimers(); + }); + + it('does not apply a stale list error after a newer loadCustomers', async () => { + jest.useFakeTimers(); + let rejectSearch: (reason: Error) => void = () => undefined; + mockSearchCustomers + .mockResolvedValueOnce([FULL]) + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectSearch = reject; + }), + ) + .mockResolvedValue([ + { ...FULL, lastNameCheckStatus: 'MatchWithBirthday' as const, lastNameCheckEvaluation: 'Ignored' as const }, + ]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }) + .mockResolvedValue({ status: 'Completed', total: 1, done: 1, failed: 0, skipped: 0 }); + + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + + fireEvent.change(screen.getByPlaceholderText('Search by ID, email, phone or name...'), { + target: { value: 'Alice' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + jest.advanceTimersByTime(2000); + + await waitFor(() => { + expect(screen.getByText('Match with Birthday')).toBeInTheDocument(); + }); + + rejectSearch(new Error('stale search down')); + await waitFor(() => { + expect(screen.queryByText('stale search down')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Match with Birthday')).toBeInTheDocument(); + jest.useRealTimers(); + }); + + it('confirms Screen all and polls while the batch is running', async () => { + jest.useFakeTimers(); + mockSearchCustomers.mockResolvedValue([FULL]); + mockStartNameCheckBatch.mockResolvedValue({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }); + mockGetNameCheckBatch + .mockResolvedValueOnce(IDLE_BATCH) + .mockResolvedValueOnce({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }) + .mockResolvedValue({ status: 'Completed', total: 1, done: 1, failed: 0, skipped: 0 }); + + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(mockStartNameCheckBatch).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + expect(screen.queryByRole('button', { name: 'Confirm' })).not.toBeInTheDocument(); + + jest.advanceTimersByTime(2000); + await waitFor(() => { + expect(mockGetNameCheckBatch.mock.calls.length).toBeGreaterThan(1); + }); + + jest.useRealTimers(); + }); + + it('shows an error when the customer list fails to load', async () => { + mockSearchCustomers.mockRejectedValue(new Error('list down')); + render(); + + await waitFor(() => { + expect(screen.getByText('list down')).toBeInTheDocument(); + }); + }); + + it('shows an error when the batch status fails to load', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockRejectedValue(new Error('batch down')); + render(); + + await waitFor(() => { + expect(screen.getByText('batch down')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /^Screen$/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Screen all' })).toBeDisabled(); + }); + + it('shows an error when a row screen fails', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockRejectedValue(new Error('dilisense down')); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(screen.getByText('dilisense down')).toBeInTheDocument(); + }); + }); + + it('clears a previous row-screen error when confirm starts again', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockRejectedValueOnce(new Error('dilisense down')).mockResolvedValue(undefined); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => { + expect(screen.getByText('dilisense down')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => { + expect(screen.queryByText('dilisense down')).not.toBeInTheDocument(); + }); + }); + + it('closes the confirm dialog on Cancel', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + expect( + screen.getByText('Screening all named shareholders consumes Dilisense quota – continue?'), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect( + screen.queryByText('Screening all named shareholders consumes Dilisense quota – continue?'), + ).not.toBeInTheDocument(); + }); + + it('starts polling when a batch is already running on mount', async () => { + jest.useFakeTimers(); + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 3, done: 1, failed: 0, skipped: 0 }) + .mockResolvedValue({ status: 'Completed', total: 3, done: 3, failed: 0, skipped: 0 }); + + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + + jest.advanceTimersByTime(2000); + await waitFor(() => { + expect(mockSearchCustomers.mock.calls.length).toBeGreaterThan(1); + }); + jest.useRealTimers(); + }); + + it('keeps screening locked when polling errors', async () => { + jest.useFakeTimers(); + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 3, done: 1, failed: 0, skipped: 0 }) + .mockRejectedValue(new Error('poll down')); + + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + + jest.advanceTimersByTime(2000); + await waitFor(() => { + expect(screen.getByText('poll down')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + jest.useRealTimers(); + }); + + it('does not start a second screen while confirm is in flight', async () => { + let resolveScreen: () => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockImplementation( + () => + new Promise((resolve) => { + resolveScreen = () => resolve(undefined); + }), + ); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + expect(mockScreenCustomer).toHaveBeenCalledTimes(1); + resolveScreen(); + await waitFor(() => { + expect(mockSearchCustomers.mock.calls.length).toBeGreaterThan(1); + }); + }); + + it('navigates to the customer dossier when a row is clicked', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Alice Muster')); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/compliance/user/1'); + }); + + it('reloads the list when Screen all finishes immediately', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockStartNameCheckBatch.mockResolvedValue({ + status: 'Completed', + total: 0, + done: 0, + failed: 0, + skipped: 1, + }); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(mockStartNameCheckBatch).toHaveBeenCalled(); + expect(mockSearchCustomers).toHaveBeenCalledTimes(2); + }); + }); + + it('shows an error when Screen all fails to start', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockStartNameCheckBatch.mockRejectedValue(new Error('quota')); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + await waitFor(() => { + expect(screen.getByText('quota')).toBeInTheDocument(); + }); + }); + + it('renders a dash for an unknown result and a missing date', async () => { + mockSearchCustomers.mockResolvedValue([ + { + ...FULL, + lastNameCheckDate: undefined, + lastNameCheckStatus: undefined, + balance: undefined, + accountType: undefined, + mail: undefined, + kycLevel: undefined, + }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + const row = screen.getByText('Alice Muster').closest('tr'); + if (row == null) throw new Error('expected customer row'); + expect(within(row).getAllByText('-').length).toBeGreaterThan(0); + }); + + it('does not navigate when the Screen cell is clicked outside the button', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + const cell = screen.getByRole('button', { name: /^Screen$/ }).closest('td'); + if (cell == null) throw new Error('expected Screen cell'); + fireEvent.click(cell); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('shows Unknown error when a list load fails without a message', async () => { + mockSearchCustomers.mockRejectedValue({ message: undefined }); + render(); + + await waitFor(() => { + expect(screen.getByText('Unknown error')).toBeInTheDocument(); + }); + }); + + it('keeps the confirm dialog open while a screen request is in flight', async () => { + let resolveScreen: () => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockImplementation( + () => + new Promise((resolve) => { + resolveScreen = () => resolve(undefined); + }), + ); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect( + screen.getByText('A Dilisense screening consumes provider quota and costs money – continue?'), + ).toBeInTheDocument(); + + resolveScreen(); + await waitFor(() => { + expect( + screen.queryByText('A Dilisense screening consumes provider quota and costs money – continue?'), + ).not.toBeInTheDocument(); + }); + }); + + it('disables Search while a row screen is in flight and still closes the dialog', async () => { + let resolveScreen: () => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockImplementation( + () => + new Promise((resolve) => { + resolveScreen = () => resolve(undefined); + }), + ); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + expect(screen.getByRole('button', { name: 'Search' })).toBeDisabled(); + fireEvent.keyDown(screen.getByPlaceholderText('Search by ID, email, phone or name...'), { key: 'Enter' }); + expect(mockSearchCustomers).toHaveBeenCalledTimes(1); + resolveScreen(); + + await waitFor(() => { + expect( + screen.queryByText('A Dilisense screening consumes provider quota and costs money – continue?'), + ).not.toBeInTheDocument(); + }); + }); + + it('ignores a late row-screen result after unmount', async () => { + let resolveScreen: () => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockImplementation( + () => + new Promise((resolve) => { + resolveScreen = () => resolve(undefined); + }), + ); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + unmount(); + resolveScreen(); + }); + + it('ignores a late row-screen error after unmount', async () => { + let rejectScreen: (reason: Error) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockImplementation( + () => + new Promise((_, reject) => { + rejectScreen = reject; + }), + ); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + unmount(); + rejectScreen(new Error('late')); + }); + + it('ignores a late Screen-all error after unmount', async () => { + let rejectBatch: (reason: Error) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockResolvedValue(IDLE_BATCH); + mockStartNameCheckBatch.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectBatch = reject; + }), + ); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + unmount(); + rejectBatch(new Error('late')); + }); + + it('skips overlapping poll ticks while a request is in flight', async () => { + jest.useFakeTimers(); + let resolvePoll: (value: RealUnitNameCheckBatchDto) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 2, done: 0, failed: 0, skipped: 0 }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePoll = resolve; + }), + ) + .mockResolvedValue({ status: 'Completed', total: 2, done: 2, failed: 0, skipped: 0 }); + + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + + jest.advanceTimersByTime(2000); + jest.advanceTimersByTime(2000); + expect(mockGetNameCheckBatch).toHaveBeenCalledTimes(2); + resolvePoll({ status: 'Running', total: 2, done: 1, failed: 0, skipped: 0 }); + jest.advanceTimersByTime(2000); + await waitFor(() => { + expect(mockGetNameCheckBatch.mock.calls.length).toBeGreaterThan(2); + }); + jest.useRealTimers(); + }); + + it('ignores a poll result after the screen unmounts', async () => { + let resolvePoll: (value: RealUnitNameCheckBatchDto) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePoll = resolve; + }), + ); + + jest.useFakeTimers(); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + jest.advanceTimersByTime(2000); + unmount(); + resolvePoll({ status: 'Completed', total: 1, done: 1, failed: 0, skipped: 0 }); + jest.useRealTimers(); + }); + + it('shows an ellipsis on Search while the list is loading', async () => { + let resolveSearch: (value: (typeof FULL)[]) => void = () => undefined; + mockSearchCustomers.mockImplementation( + () => + new Promise((resolve) => { + resolveSearch = resolve; + }), + ); + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: '…' })).toBeDisabled(); + }); + fireEvent.keyDown(screen.getByPlaceholderText('Search by ID, email, phone or name...'), { key: 'Enter' }); + expect(mockSearchCustomers).toHaveBeenCalledTimes(1); + resolveSearch([FULL]); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + }); + + it('does not search when a non-Enter key is pressed', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText('Search by ID, email, phone or name...'); + fireEvent.change(input, { target: { value: 'x' } }); + fireEvent.keyDown(input, { key: 'a' }); + expect(mockSearchCustomers).toHaveBeenCalledTimes(1); + }); + + it('shows Unknown error when polling fails without a message', async () => { + jest.useFakeTimers(); + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }) + .mockRejectedValue({}); + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + jest.advanceTimersByTime(2000); + await waitFor(() => { + expect(screen.getByText('Unknown error')).toBeInTheDocument(); + }); + jest.useRealTimers(); + }); + + it('shows Unknown error when the batch status fails without a message', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockRejectedValue({}); + render(); + await waitFor(() => { + expect(screen.getByText('Unknown error')).toBeInTheDocument(); + }); + }); + + it('shows Unknown error when a row screen fails without a message', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockScreenCustomer.mockRejectedValue({}); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /^Screen$/ })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => { + expect(screen.getByText('Unknown error')).toBeInTheDocument(); + }); + }); + + it('shows Unknown error when Screen all fails without a message', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockStartNameCheckBatch.mockRejectedValue({}); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => { + expect(screen.getByText('Unknown error')).toBeInTheDocument(); + }); + }); + + it('ignores a poll error after the screen unmounts', async () => { + let rejectPoll: (reason: Error) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }) + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectPoll = reject; + }), + ); + jest.useFakeTimers(); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + jest.advanceTimersByTime(2000); + unmount(); + rejectPoll(new Error('late')); + jest.useRealTimers(); + }); + + it('ignores a late mount batch response after unmount', async () => { + let resolveBatch: (value: RealUnitNameCheckBatchDto) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBatch = resolve; + }), + ); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + unmount(); + resolveBatch({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }); + }); + + it('ignores a late Screen-all response after unmount', async () => { + let resolveBatch: (value: RealUnitNameCheckBatchDto) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockResolvedValue(IDLE_BATCH); + mockStartNameCheckBatch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBatch = resolve; + }), + ); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + unmount(); + resolveBatch({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }); + }); + + it('disables Screen until the mount batch status has loaded', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockImplementation(() => new Promise(() => undefined)); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /^Screen$/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Screen all' })).toBeDisabled(); + }); + + it('shows a failed mount batch status without reloading the list', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockResolvedValue({ + status: 'Failed', + total: 2, + done: 0, + failed: 2, + skipped: 0, + error: 'quota', + }); + render(); + await waitFor(() => { + expect(screen.getByText('quota')).toBeInTheDocument(); + }); + expect(mockSearchCustomers).toHaveBeenCalledTimes(1); + }); + + it('shows a failed Screen-all response without treating it as success', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockStartNameCheckBatch.mockResolvedValue({ + status: 'Failed', + total: 1, + done: 0, + failed: 1, + skipped: 0, + error: 'quota', + }); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => { + expect(screen.getByText('quota')).toBeInTheDocument(); + }); + expect(mockSearchCustomers).toHaveBeenCalledTimes(1); + }); + + it('clears a Failed batch error when a new run starts', async () => { + mockSearchCustomers.mockResolvedValue([FULL]); + mockStartNameCheckBatch + .mockResolvedValueOnce({ + status: 'Failed', + total: 1, + done: 0, + failed: 1, + skipped: 0, + error: 'quota', + }) + .mockResolvedValue({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }); + render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => { + expect(screen.getByText('quota')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Screen all' })); + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })); + await waitFor(() => { + expect(screen.queryByText('quota')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + }); + + it('keeps a failed poll status error instead of reloading the list', async () => { + jest.useFakeTimers(); + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 2, done: 0, failed: 0, skipped: 0 }) + .mockResolvedValue({ status: 'Failed', total: 2, done: 0, failed: 2, skipped: 0, error: 'quota' }); + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + const callsBeforePoll = mockSearchCustomers.mock.calls.length; + jest.advanceTimersByTime(2000); + await waitFor(() => { + expect(screen.getByText('quota')).toBeInTheDocument(); + }); + expect(mockSearchCustomers.mock.calls.length).toBe(callsBeforePoll); + jest.useRealTimers(); + }); + + it('ignores a late mount batch error after unmount', async () => { + let rejectBatch: (reason: Error) => void = () => undefined; + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectBatch = reject; + }), + ); + const { unmount } = render(); + await waitFor(() => { + expect(screen.getByText('Alice Muster')).toBeInTheDocument(); + }); + unmount(); + rejectBatch(new Error('late batch')); + }); + + it('shows Unknown error when a failed poll status has no error', async () => { + jest.useFakeTimers(); + mockSearchCustomers.mockResolvedValue([FULL]); + mockGetNameCheckBatch + .mockResolvedValueOnce({ status: 'Running', total: 1, done: 0, failed: 0, skipped: 0 }) + .mockResolvedValue({ status: 'Failed', total: 1, done: 0, failed: 1, skipped: 0 }); + render(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Screening {{done}} / {{total}}' })).toBeDisabled(); + }); + jest.advanceTimersByTime(2000); + await waitFor(() => { + expect(screen.getByText('Unknown error')).toBeInTheDocument(); + }); + jest.useRealTimers(); + }); +}); diff --git a/src/__tests__/realunit-customer-filter.test.ts b/src/__tests__/realunit-customer-filter.test.ts index 7ce9c068e..0cc950506 100644 --- a/src/__tests__/realunit-customer-filter.test.ts +++ b/src/__tests__/realunit-customer-filter.test.ts @@ -4,6 +4,7 @@ import { isEmptyAccount } from 'src/util/realunit-customer-filter'; const customer = (overrides: Partial): RealUnitCustomerListDto => ({ id: 1, kycStatus: 'NA', + canScreen: false, ...overrides, }); diff --git a/src/__tests__/realunit-dashboard.hook.test.ts b/src/__tests__/realunit-dashboard.hook.test.ts index 8485dcfab..e6655c1ce 100644 --- a/src/__tests__/realunit-dashboard.hook.test.ts +++ b/src/__tests__/realunit-dashboard.hook.test.ts @@ -163,4 +163,31 @@ describe('useRealunitCompliance', () => { responseType: ResponseType.BLOB, }); }); + + it('posts a per-customer Dilisense name-check', async () => { + const { result } = renderHook(() => useRealunitCompliance()); + + await result.current.screenCustomer(9); + + expect(mockCall).toHaveBeenCalledWith({ + url: 'realunit/compliance/customers/9/name-check', + method: 'POST', + }); + }); + + it('posts the name-check batch', async () => { + const { result } = renderHook(() => useRealunitCompliance()); + + await result.current.startNameCheckBatch(); + + expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/compliance/name-check', method: 'POST' }); + }); + + it('gets the name-check batch status', async () => { + const { result } = renderHook(() => useRealunitCompliance()); + + await result.current.getNameCheckBatch(); + + expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/compliance/name-check', method: 'GET' }); + }); }); diff --git a/src/dto/realunit-compliance.dto.ts b/src/dto/realunit-compliance.dto.ts index afc3a8148..a39a6c6aa 100644 --- a/src/dto/realunit-compliance.dto.ts +++ b/src/dto/realunit-compliance.dto.ts @@ -107,6 +107,29 @@ export interface RealUnitCustomerListDto { accountType?: string; mail?: string; name?: string; + lastNameCheckDate?: string; + lastNameCheckStatus?: 'NoMatch' | 'MatchWithoutBirthday' | 'MatchWithBirthday'; + lastNameCheckEvaluation?: 'Confirmed' | 'Ignored' | 'NotMatching' | 'Canceled'; + canScreen: boolean; +} + +export interface RealUnitNameCheckBatchDto { + status: 'Idle' | 'Running' | 'Completed' | 'Failed'; + total: number; + done: number; + failed: number; + skipped: number; + startedAt?: string; + finishedAt?: string; + startedBy?: string; + currentId?: number; + error?: string; +} + +export interface RealUnitNameCheckResultDto { + id: number; + riskStatus: string; + date: string; } export interface RealUnitKycFileDto { diff --git a/src/hooks/realunit-compliance.hook.ts b/src/hooks/realunit-compliance.hook.ts index 6f5427edb..53ccf0000 100644 --- a/src/hooks/realunit-compliance.hook.ts +++ b/src/hooks/realunit-compliance.hook.ts @@ -4,13 +4,15 @@ import { RealUnitCustomerDetailDto, RealUnitCustomerListDto, RealUnitKycFileDownloadDto, + RealUnitNameCheckBatchDto, + RealUnitNameCheckResultDto, } from 'src/dto/realunit-compliance.dto'; import { downloadFile as saveFile, filenameDateFormat } from 'src/util/utils'; import { useGuardedApi } from './guarded-api.hook'; -// RealUnit tenant compliance hook. READ-ONLY, strictly customer-scoped `/v1/realunit/compliance/*` endpoints over -// ONLY the tenant's own customers. Returns the REDUCED dossier (no DFX AML work products). `call` MUST come from -// useGuardedApi so the staff 2FA (TFA_REQUIRED) redirect works. +// RealUnit tenant compliance hook. Customer-scoped `/v1/realunit/compliance/*` endpoints over ONLY the tenant's own +// customers. Returns the REDUCED dossier (no DFX AML work products). Manual Dilisense name-check (single + batch) is +// the write exception. `call` MUST come from useGuardedApi so the staff 2FA (TFA_REQUIRED) redirect works. export function useRealunitCompliance() { const { call } = useGuardedApi(); @@ -47,12 +49,36 @@ export function useRealunitCompliance() { saveFile(data, headers, `RealUnit_dossier_${id}_${filenameDateFormat()}.zip`); } + async function screenCustomer(id: number): Promise { + return call({ + url: `realunit/compliance/customers/${id}/name-check`, + method: 'POST', + }); + } + + async function startNameCheckBatch(): Promise { + return call({ + url: 'realunit/compliance/name-check', + method: 'POST', + }); + } + + async function getNameCheckBatch(): Promise { + return call({ + url: 'realunit/compliance/name-check', + method: 'GET', + }); + } + return useMemo( () => ({ searchCustomers, getCustomer, downloadFile, downloadDossier, + screenCustomer, + startNameCheckBatch, + getNameCheckBatch, }), [call], ); diff --git a/src/screens/realunit-compliance.screen.tsx b/src/screens/realunit-compliance.screen.tsx index b7b74b6c4..1f3e320c5 100644 --- a/src/screens/realunit-compliance.screen.tsx +++ b/src/screens/realunit-compliance.screen.tsx @@ -1,19 +1,23 @@ import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ConfirmDialog } from 'src/components/confirm-dialog'; import { ErrorHint } from 'src/components/error-hint'; import { useSettingsContext } from 'src/contexts/settings.context'; -import { RealUnitCustomerListDto } from 'src/dto/realunit-compliance.dto'; +import { RealUnitCustomerListDto, RealUnitNameCheckBatchDto } from 'src/dto/realunit-compliance.dto'; import { useRealunitGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; import { useRealunitCompliance } from 'src/hooks/realunit-compliance.hook'; +import { formatDate } from 'src/util/compliance-helpers'; import { isEmptyAccount } from 'src/util/realunit-customer-filter'; +type PendingConfirm = { type: 'row'; id: number } | { type: 'all' }; + export default function RealunitComplianceScreen(): JSX.Element { useRealunitGuard(); const { translate } = useSettingsContext(); - const { searchCustomers } = useRealunitCompliance(); + const { searchCustomers, screenCustomer, startNameCheckBatch, getNameCheckBatch } = useRealunitCompliance(); const { navigate } = useNavigation(); const [searchKey, setSearchKey] = useState(''); @@ -24,6 +28,14 @@ export default function RealunitComplianceScreen(): JSX.Element { const [hideEmpty, setHideEmpty] = useState(true); // whether the current results were loaded with a search key (searchKey is just the live input value) const [isSearchActive, setIsSearchActive] = useState(false); + const [batch, setBatch] = useState(); + const [pendingConfirm, setPendingConfirm] = useState(); + const [isConfirming, setIsConfirming] = useState(false); + const lastSearchKeyRef = useRef(); + const listLoadGenerationRef = useRef(0); + const pollRef = useRef>(); + const pollInFlightRef = useRef(false); + const pollGenerationRef = useRef(0); useLayoutOptions({ title: translate('screens/compliance', 'RealUnit Compliance'), @@ -31,26 +43,152 @@ export default function RealunitComplianceScreen(): JSX.Element { noMaxWidth: true, }); - // Load the complete customer list upfront; a search key narrows it down, an empty search returns to the - // unsearched view. The hide-empty toggle state deliberately persists across searches (user choice wins); - // "re-engaged" only means the search bypass ends. - useEffect(() => loadCustomers(), []); + function clearPoll(): void { + pollGenerationRef.current++; + if (pollRef.current !== undefined) { + clearInterval(pollRef.current); + pollRef.current = undefined; + } + } function loadCustomers(key?: string): void { + const generation = ++listLoadGenerationRef.current; + lastSearchKeyRef.current = key; setIsLoading(true); setError(undefined); setResults(undefined); setIsSearchActive(!!key); searchCustomers(key) - .then((res) => setResults(res)) - .catch((e: Error) => setError(e.message ?? 'Unknown error')) - .finally(() => setIsLoading(false)); + .then((res) => { + if (generation !== listLoadGenerationRef.current) return; + setResults(res); + }) + .catch((e: Error) => { + if (generation !== listLoadGenerationRef.current) return; + setError(e.message ?? 'Unknown error'); + }) + .finally(() => { + if (generation !== listLoadGenerationRef.current) return; + setIsLoading(false); + }); } + function startPolling(): void { + clearPoll(); + const generation = pollGenerationRef.current; + pollRef.current = setInterval(() => { + if (pollInFlightRef.current) return; + pollInFlightRef.current = true; + getNameCheckBatch() + .then((status) => { + if (generation !== pollGenerationRef.current) return; + settleBatch(status, true); + if (status.status === 'Running') return; + clearPoll(); + }) + .catch((e: Error) => { + if (generation !== pollGenerationRef.current) return; + setError(e.message ?? 'Unknown error'); + }) + .finally(() => { + pollInFlightRef.current = false; + }); + }, 2000); + } + + function settleBatch(status: RealUnitNameCheckBatchDto, reload: boolean): void { + setBatch(status); + if (status.status === 'Failed') { + setError(status.error ?? 'Unknown error'); + return; + } + if (reload && status.status !== 'Running') loadCustomers(lastSearchKeyRef.current); + } + + // Load the complete customer list upfront; a search key narrows it down, an empty search returns to the + // unsearched view. The hide-empty toggle state deliberately persists across searches (user choice wins); + // "re-engaged" only means the search bypass ends. One GET of the name-check batch on mount; poll while running. + useEffect(() => { + const generation = pollGenerationRef.current; + loadCustomers(); + getNameCheckBatch() + .then((status) => { + if (generation !== pollGenerationRef.current) return; + settleBatch(status, false); + if (status.status === 'Running') startPolling(); + }) + .catch((e: Error) => { + if (generation !== pollGenerationRef.current) return; + setError(e.message ?? 'Unknown error'); + }); + return () => { + listLoadGenerationRef.current++; + clearPoll(); + }; + }, []); + function handleSearch(): void { loadCustomers(searchKey.trim() || undefined); } + function handleConfirmScreen(): void { + if (!pendingConfirm || isConfirming) return; + const action = pendingConfirm; + setIsConfirming(true); + setError(undefined); + const done = (): void => { + setIsConfirming(false); + setPendingConfirm(undefined); + }; + if (action.type === 'row') { + const listGeneration = listLoadGenerationRef.current; + screenCustomer(action.id) + .then(() => { + if (listGeneration !== listLoadGenerationRef.current) return; + loadCustomers(lastSearchKeyRef.current); + }) + .catch((e: Error) => { + if (listGeneration !== listLoadGenerationRef.current) return; + setError(e.message ?? 'Unknown error'); + }) + .finally(() => { + done(); + }); + return; + } + const generation = pollGenerationRef.current; + startNameCheckBatch() + .then((status) => { + if (generation !== pollGenerationRef.current) return; + settleBatch(status, true); + done(); + if (status.status === 'Running') startPolling(); + }) + .catch((e: Error) => { + if (generation !== pollGenerationRef.current) return; + setError(e.message ?? 'Unknown error'); + }) + .finally(() => { + if (generation !== pollGenerationRef.current) return; + done(); + }); + } + + function formatNameCheckResult(customer: RealUnitCustomerListDto): string { + switch (customer.lastNameCheckStatus) { + case 'NoMatch': + return translate('screens/compliance', 'No match'); + case 'MatchWithoutBirthday': + return translate('screens/compliance', 'Match without Birthday'); + case 'MatchWithBirthday': { + const label = translate('screens/compliance', 'Match with Birthday'); + return customer.lastNameCheckEvaluation ? label : `${label} (${translate('screens/compliance', 'Open')})`; + } + default: + return '-'; + } + } + // An active search always shows every match: whoever searches for a specific customer must find them, // hidden rows included (that blind spot is exactly what this screen once suffered from). Filtering never // reorders: rows keep their API position. @@ -61,6 +199,8 @@ export default function RealunitComplianceScreen(): JSX.Element { const hiddenCount = results && displayedResults ? results.length - displayedResults.length : 0; const emptyCount = useMemo(() => (results ?? []).filter(isEmptyAccount).length, [results]); + const isBatchRunning = batch?.status === 'Running'; + const screeningLocked = batch == null || isBatchRunning || isConfirming; return (
@@ -71,17 +211,29 @@ export default function RealunitComplianceScreen(): JSX.Element { value={searchKey} onChange={(e) => setSearchKey(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && !isLoading) handleSearch(); + if (e.key === 'Enter' && !isLoading && !isConfirming) handleSearch(); }} placeholder={translate('screens/compliance', 'Search by ID, email, phone or name...')} /> +
{error && } @@ -133,6 +285,13 @@ export default function RealunitComplianceScreen(): JSX.Element { {translate('screens/compliance', 'Balance (REALU)')} + + {translate('screens/compliance', 'Last Dilisense check')} + + + {translate('screens/compliance', 'Result')} + + @@ -151,6 +310,26 @@ export default function RealunitComplianceScreen(): JSX.Element { {u.balance != null ? u.balance.toLocaleString('de-CH') : '-'} + + {u.lastNameCheckDate ? formatDate(u.lastNameCheckDate) : '-'} + + {formatNameCheckResult(u)} + e.stopPropagation()}> + + ))} @@ -158,6 +337,22 @@ export default function RealunitComplianceScreen(): JSX.Element { )} )} + + { + if (!isConfirming) setPendingConfirm(undefined); + }} + /> ); } diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index 5d5f601e0..75e09dbec 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -376,6 +376,18 @@ "blocked": "gesperrt", "Pass only allowed when all errors are phone-related": "Pass nur erlaubt, wenn alle Errors Phone-bezogen sind", "Hide empty accounts": "Leere Accounts ausblenden", + "Last Dilisense check": "Letzter Dilisense-Check", + "Result": "Ergebnis", + "Screen": "Screenen", + "Screen all": "Alle screenen", + "Screening {{done}} / {{total}}": "Screening {{done}} / {{total}}", + "A Dilisense screening consumes provider quota and costs money – continue?": "Ein Dilisense-Screening verbraucht Provider-Kontingent und kostet Geld – fortfahren?", + "Screening all named shareholders consumes Dilisense quota – continue?": "Alle namentlich bekannten Aktionäre screenen verbraucht Dilisense-Kontingent – fortfahren?", + "Cannot screen without a name": "Ohne Namen nicht screenbar", + "No match": "Kein Treffer", + "Match without Birthday": "Treffer ohne Geburtsdatum", + "Match with Birthday": "Treffer mit Geburtsdatum", + "Open": "offen", "All accounts are hidden by the filter above": "Alle Accounts sind durch den Filter oben ausgeblendet", "Addresses": "Adressen", "No addresses": "Keine Adressen", diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index 183cc706c..d564ae853 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -376,6 +376,18 @@ "blocked": "bloqué", "Pass only allowed when all errors are phone-related": "Pass autorisé uniquement si toutes les erreurs sont liées au téléphone", "Hide empty accounts": "Masquer les comptes vides", + "Last Dilisense check": "Dernier contrôle Dilisense", + "Result": "Résultat", + "Screen": "Contrôler", + "Screen all": "Tout contrôler", + "Screening {{done}} / {{total}}": "Screening {{done}} / {{total}}", + "A Dilisense screening consumes provider quota and costs money – continue?": "Un contrôle Dilisense consomme le quota du fournisseur et coûte de l'argent – continuer ?", + "Screening all named shareholders consumes Dilisense quota – continue?": "Contrôler tous les actionnaires nommés consomme le quota Dilisense – continuer ?", + "Cannot screen without a name": "Impossible sans nom", + "No match": "Aucune correspondance", + "Match without Birthday": "Correspondance sans date de naissance", + "Match with Birthday": "Correspondance avec date de naissance", + "Open": "ouvert", "All accounts are hidden by the filter above": "Tous les comptes sont masqués par le filtre ci-dessus", "Addresses": "Adresses", "No addresses": "Aucune adresse", diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index 33b636b88..ff9890e8f 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -376,6 +376,18 @@ "blocked": "bloccato", "Pass only allowed when all errors are phone-related": "Pass consentito solo se tutti gli errori sono relativi al telefono", "Hide empty accounts": "Nascondi conti vuoti", + "Last Dilisense check": "Ultimo controllo Dilisense", + "Result": "Risultato", + "Screen": "Controlla", + "Screen all": "Controlla tutti", + "Screening {{done}} / {{total}}": "Screening {{done}} / {{total}}", + "A Dilisense screening consumes provider quota and costs money – continue?": "Uno screening Dilisense consuma il contingente del provider e costa — continuare?", + "Screening all named shareholders consumes Dilisense quota – continue?": "Controllare tutti gli azionisti nominati consuma il contingente Dilisense — continuare?", + "Cannot screen without a name": "Impossibile senza nome", + "No match": "Nessuna corrispondenza", + "Match without Birthday": "Corrispondenza senza data di nascita", + "Match with Birthday": "Corrispondenza con data di nascita", + "Open": "aperto", "All accounts are hidden by the filter above": "Tutti i conti sono nascosti dal filtro sopra", "Addresses": "Indirizzi", "No addresses": "Nessun indirizzo",