diff --git a/docs/test-architecture.md b/docs/test-architecture.md index 82eb0acf7..2e938cda0 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -106,6 +106,16 @@ run does not prove for each one; the taxonomy and cross-repository entries live `**/v1/buy/paymentInfos` with static payloads, so a green run proves that the screen renders those payloads, not that the API produces them. Unit tests against the utility pin the payload shapes instead. +- **The RealUnit quotes and dashboard visual specs answer the admin list themselves.** + `e2e/realunit-quotes.spec.ts` and `e2e/realunit-dashboard.spec.ts` fulfil + `GET /v1/realunit/admin/quotes` (and, on the dashboard, holders, token info, price history, + transactions, and the three admin stats paths buy-volume, holders and registration) with + synthetic fixtures that include `userId`, `userName` and `deactivatedAt`. + They also fulfil staff/bootstrap GETs (`/v1/language`, `/v1/fiat`, `/v1/asset`, `/v1/bankAccount`, + `/v1/country`, `/v1/setting/infoBanner`, `/v2/user`) so a synthetic unsigned JWT does not 401. + A green run proves the quote list, pending-table and stats-chart fixtures render. It does not + prove that the API returns those payloads, that login or token verification works, or that those + staff/settings or stats endpoints return real data. - **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 @@ -131,6 +141,15 @@ run does not prove for each one; the taxonomy and cross-repository entries live fixture with `{ userData: { verifiedName } }`. A green run proves that the review screen accepts that name, not that the API returns the logged-in staff member's `verifiedName`. The spec stays on the AML reset path and does not assert the Editor label. +- **The call-queue outcome spec answers staff identity and the dossier itself.** + `e2e/compliance-call-queue-outcome.spec.ts` fulfils `GET /v1/support/issue/clerk` with + `{ clerk }`, a differently named fallback on `GET /v1/support/{staffAccount}`, + `GET /v1/support/{customer}` with a synthetic dossier, empty lookup lists, a null + info banner, and `GET /v2/user` with a synthetic account. A green run proves the + outcome form renders that clerk name as a read-only signature and does not request a + clerks list, not that the API returns those records or the logged-in staff member's + `verifiedName`. The session is a synthetic unsigned JWT, so a green run also does not + prove login or token verification. - **Full-stack guest assign/refund specs SQL-write `transaction.actionSecretHash`.** `e2e-stack/specs/transactions.spec.ts` (`seedActionSecret`) updates the hash directly. A green run does **not** prove that the mail/API path creates, hashes, or delivers the action secret. diff --git a/e2e-stack/specs/realunit.spec.ts b/e2e-stack/specs/realunit.spec.ts index 1aa69aef9..2353ce600 100644 --- a/e2e-stack/specs/realunit.spec.ts +++ b/e2e-stack/specs/realunit.spec.ts @@ -201,7 +201,8 @@ test.describe('RealUnit area', () => { await expect(page.getByRole('heading', { name: 'Pending Transactions' })).toBeVisible(); await expect(tableHeader(page, 'Type')).toBeVisible(); await expect(tableHeader(page, 'Amount')).toBeVisible(); - await expect(tableHeader(page, 'User')).toBeVisible(); + await expect(tableHeader(page, 'Address')).toBeVisible(); + await expect(tableHeader(page, 'Name')).toBeVisible(); await expect(tableHeader(page, 'Created')).toBeVisible(); // Empty success → "No pending transactions found"; fetch failure → ErrorHint with quotesError copy. @@ -379,7 +380,7 @@ test.describe('RealUnit area', () => { await expect(page.getByRole('heading', { name: 'Received Transactions' })).toBeVisible(); await expect(tableHeader(page, 'Type')).toBeVisible(); await expect(tableHeader(page, 'Amount CHF')).toBeVisible(); - await expect(tableHeader(page, 'User')).toBeVisible(); + await expect(tableHeader(page, 'Address')).toBeVisible(); await expect(tableHeader(page, 'Date')).toBeVisible(); const emptyOrError = page diff --git a/e2e/compliance-call-queue-outcome.spec.ts b/e2e/compliance-call-queue-outcome.spec.ts new file mode 100644 index 000000000..2154707b3 --- /dev/null +++ b/e2e/compliance-call-queue-outcome.spec.ts @@ -0,0 +1,201 @@ +import { expect, Page, Route, test } from '@playwright/test'; + +const USER_DATA_ID = 2001; +const STAFF_ACCOUNT = 9001; +const QUEUE = 'ManualCheckPhone'; +const SIGNATURE = 'Jane Clerk'; + +const userFixture = { + userData: { + id: USER_DATA_ID, + created: '2026-07-31T08:00:00.000Z', + status: 'Active', + riskStatus: 'Normal', + kycStatus: 'Completed', + kycLevel: 50, + accountType: 'Personal', + firstname: 'Test', + surname: 'Customer', + verifiedName: 'Test Customer', + mail: 'test.customer@example.com', + phone: '+41791234567', + language: { name: 'German', symbol: 'DE' }, + nationality: { name: 'Switzerland', symbol: 'CH' }, + country: { name: 'Switzerland', symbol: 'CH' }, + phoneCallStatus: 'Pending', + phoneCallCheckDate: '2026-07-31T09:00:00.000Z', + }, + kycFiles: [], + kycSteps: [], + kycLogs: [], + transactions: [ + { + id: 101, + uid: 'synthetic-buy-crypto-42', + buyCryptoId: 42, + type: 'Buy', + sourceType: 'BuyCrypto', + inputAmount: 500, + inputAsset: 'EUR', + outputAsset: 'BTC', + amountInChf: 480, + amlCheck: 'Pass', + amlReason: 'NA', + isCompleted: false, + buyCryptoIsComplete: false, + buyCryptoStatus: 'AMLPending', + buyCryptoHasBatch: false, + buyCryptoHasChargeback: false, + buyCryptoReviewResetBlocked: false, + created: '2026-07-31T08:30:00.000Z', + }, + ], + bankTxs: [], + cryptoInputs: [], + ipLogs: [], + supportIssues: [], + users: [ + { + id: 1, + address: '0x0000000000000000000000000000000000000001', + role: 'User', + status: 'Active', + walletName: 'Synthetic wallet', + created: '2026-07-31T08:00:00.000Z', + }, + ], + bankDatas: [], + buyRoutes: [], + sellRoutes: [], + swapRoutes: [], + virtualIbans: [], + refRewards: [], + notifications: [], + notes: [], + permissions: { + viewKycFiles: true, + viewKycLogs: true, + viewIpLogs: true, + viewSupportIssues: true, + canRequestLimit: true, + canPerformTransactionActions: true, + viewRecommendation: true, + }, +}; + +function jwt(): string { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode({ + account: STAFF_ACCOUNT, + user: STAFF_ACCOUNT, + role: 'Compliance', + exp: Math.floor(Date.now() / 1000) + 3600, + })}.synthetic`; +} + +async function fulfillJson(route: Route, body: unknown): Promise { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); +} + +async function installSyntheticApi(page: Page): Promise<{ unexpectedRequests: string[]; seenGets: string[] }> { + const unexpectedRequests: string[] = []; + const seenGets: string[] = []; + + await page.route('**/v1/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (request.method() === 'GET') seenGets.push(path); + + if (request.method() === 'GET' && path === `/v1/support/${USER_DATA_ID}`) { + await fulfillJson(route, userFixture); + return; + } + + if (request.method() === 'GET' && path === '/v1/support/issue/clerk') { + await fulfillJson(route, { clerk: SIGNATURE }); + return; + } + + if (request.method() === 'GET' && path === `/v1/support/${STAFF_ACCOUNT}`) { + await fulfillJson(route, { userData: { verifiedName: 'Fallback Name' } }); + return; + } + + if ( + request.method() === 'GET' && + ['/v1/language', '/v1/fiat', '/v1/asset', '/v1/bankAccount', '/v1/country'].includes(path) + ) { + await fulfillJson(route, []); + return; + } + + if (request.method() === 'GET' && path === '/v1/setting/infoBanner') { + await fulfillJson(route, null); + return; + } + + unexpectedRequests.push(`${request.method()} ${path}`); + await route.fulfill({ + status: 501, + contentType: 'application/json', + body: JSON.stringify({ error: 'Unexpected test request' }), + }); + }); + + await page.route('**/v2/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (request.method() === 'GET' && path === '/v2/user') { + await fulfillJson(route, { + id: STAFF_ACCOUNT, + activeAddress: { address: '0x0000000000000000000000000000000000000001' }, + addresses: [], + kyc: { level: 50, status: 'Completed' }, + language: { id: 1, name: 'German', symbol: 'DE' }, + }); + return; + } + + unexpectedRequests.push(`${request.method()} ${path}`); + await route.fulfill({ + status: 501, + contentType: 'application/json', + body: JSON.stringify({ error: 'Unexpected test request' }), + }); + }); + + return { unexpectedRequests, seenGets }; +} + +test.describe('Call-queue outcome form signature', () => { + // Fixture timestamps are UTC; the screen formats them in the browser locale without an + // explicit timeZone, so pin Zurich like the other fullPage compliance screenshots. + test.use({ timezoneId: 'Europe/Zurich' }); + + test('shows the logged-in staff verified name and no clerk select', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 1400 }); + const { unexpectedRequests, seenGets } = await installSyntheticApi(page); + + await page.goto(`/compliance/call-queues/${QUEUE}/${USER_DATA_ID}?session=${jwt()}&txId=101`); + + await expect(page.getByText('Signature', { exact: true })).toBeVisible(); + const signatureBlock = page.locator('label', { hasText: 'Signature' }).locator('..'); + await expect(signatureBlock.getByRole('combobox')).toHaveCount(0); + await expect(signatureBlock.getByText(SIGNATURE)).toBeVisible(); + await expect( + page.getByRole('combobox').filter({ has: page.locator('option', { hasText: SIGNATURE }) }), + ).toHaveCount(0); + + const form = signatureBlock.locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]'); + await form.scrollIntoViewIfNeeded(); + await expect(form).toHaveScreenshot('compliance-call-queue-outcome-signature.png', { + maxDiffPixels: 5000, + }); + + expect(seenGets).toContain('/v1/support/issue/clerk'); + expect(seenGets).not.toContain(`/v1/support/${STAFF_ACCOUNT}`); + expect(unexpectedRequests.filter((r) => r.includes('clerk'))).toEqual([]); + expect(unexpectedRequests).toEqual([]); + }); +}); diff --git a/e2e/realunit-dashboard.spec.ts b/e2e/realunit-dashboard.spec.ts new file mode 100644 index 000000000..daa04af94 --- /dev/null +++ b/e2e/realunit-dashboard.spec.ts @@ -0,0 +1,195 @@ +import { test, expect, Page, Route } from '@playwright/test'; + +/** + * Visual regression: RealUnit dashboard home (`/realunit`) pending-quotes table + * and monitoring charts (buy volume, holders over time, registration). + * + * Auth is a synthetic Admin JWT. Holders, token info, price history, quotes, + * transactions and admin stats are mocked. A green run does not prove the live + * API returns these fields. + */ + +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`; +} + +async function json(route: Route, body: unknown): Promise { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); +} + +const QUOTE = { + id: 8001, + uid: 'RQ8001FAKE', + type: 'Buy', + status: 'WaitingForPayment', + amount: 10000, + estimatedAmount: 9.87, + created: '2026-02-01T12:00:00.000Z', + userAddress: '0xabc0000000000000000000000000000000008001', + userId: 8001, + userName: 'Active Buyer', +}; + +const DEACTIVATED = { + ...QUOTE, + id: 8002, + uid: 'RQ8002FAKE', + amount: 2500, + deactivatedAt: '2026-02-02T12:00:00.000Z', + userId: 8002, + userName: 'Deactivated Buyer', +}; + +async function installDashboardRoutes(page: Page): Promise { + await page.route('**/v1/**', async (route: Route) => { + const request = route.request(); + const url = request.url(); + const path = new URL(url).pathname; + + if (/\/v1\/realunit\/admin\/quotes\/\d+\/(?:deactivate|confirm-payment)(?:\?|$)/.test(url)) { + return json(route, {}); + } + if (/\/v1\/realunit\/admin\/quotes(?:\?|$)/.test(url)) { + return json(route, [QUOTE, DEACTIVATED]); + } + if (/\/v1\/realunit\/admin\/transactions(?:\?|$)/.test(url)) { + return json(route, [ + { + id: 9001, + uid: 'RT9001FAKE', + type: 'BuyCrypto', + amountInChf: 500, + assets: 'REALU', + created: '2026-02-01T12:00:00.000Z', + userAddress: '0xabc0000000000000000000000000000000008001', + }, + ]); + } + if (path === '/v1/realunit/holders') { + return json(route, { + holders: [{ address: '0xabc0000000000000000000000000000000008001', balance: '100', percentage: 1.5 }], + pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: '', endCursor: '' }, + totalCount: 1, + }); + } + if (path === '/v1/realunit/tokenInfo') { + return json(route, { + totalShares: { total: '1000', timestamp: '2026-02-01T12:00:00.000Z', txHash: '0x1' }, + totalSupply: { value: '2000', timestamp: '2026-02-01T12:00:00.000Z' }, + }); + } + if (path === '/v1/realunit/price/history') { + return json(route, [{ timestamp: '2026-02-01T12:00:00.000Z', chf: 10, eur: 10, usd: 11 }]); + } + if (path === '/v1/realunit/price') { + return json(route, { timestamp: '2026-02-01T12:00:00.000Z', chf: 10, eur: 10, usd: 11 }); + } + if (path === '/v1/realunit/admin/stats/buy-volume') { + return json(route, [ + { timestamp: '2026-02-01T00:00:00.000Z', chf: 1000, shares: 700, priceChf: 1.4 }, + { timestamp: '2026-02-02T00:00:00.000Z', chf: 2500, shares: 1800, priceChf: 1.41 }, + ]); + } + if (path === '/v1/realunit/admin/stats/holders') { + return json(route, [ + { timestamp: '2026-02-01T00:00:00.000Z', holders: 10 }, + { timestamp: '2026-02-02T00:00:00.000Z', holders: 12 }, + ]); + } + if (path === '/v1/realunit/admin/stats/registration') { + return json(route, { + snapshot: { + completed: 194, + manualReview: 23, + confirmed: 111, + usersActive: 61, + usersNa: 402, + usersBlocked: 0, + usersDeleted: 2, + }, + series: [ + { timestamp: '2026-02-01T00:00:00.000Z', registered: 3, confirmed: 1 }, + { timestamp: '2026-02-02T00:00:00.000Z', registered: 5, confirmed: 2 }, + ], + }); + } + + 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); + } + + await route.continue(); + }); + + 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 dashboard - Visual Regression Tests', () => { + test('home pending table shows address and name and hides deactivated quotes', async ({ page }) => { + await installDashboardRoutes(page); + await page.goto(`/realunit?session=${encodeURIComponent(jwt())}&lang=en`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + await expect(page.getByRole('heading', { name: 'Pending Transactions' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Buy Volume' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Holders over time' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Registration' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'Active Buyer' }).first()).toBeVisible(); + await expect(page.getByText('Deactivated Buyer')).toHaveCount(0); + await expect(page.locator('.apexcharts-canvas').first()).toBeVisible(); + + const screenshotOpts = { maxDiffPixels: 5000 }; + const section = (heading: string) => page.getByRole('heading', { name: heading }).locator('xpath=..'); + + const pendingSection = section('Pending Transactions'); + await pendingSection.scrollIntoViewIfNeeded(); + await expect(pendingSection.getByRole('columnheader', { name: 'Address' })).toBeVisible(); + await expect(pendingSection.getByRole('columnheader', { name: 'User' })).toHaveCount(0); + await expect(pendingSection).toHaveScreenshot('realunit-dashboard-01-pending.png', screenshotOpts); + + const buyVolume = section('Buy Volume'); + await buyVolume.scrollIntoViewIfNeeded(); + await expect(buyVolume).toHaveScreenshot('realunit-dashboard-02-buy-volume-chf.png', screenshotOpts); + await buyVolume.getByRole('button', { name: 'Shares' }).click(); + await expect(buyVolume.locator('.apexcharts-canvas')).toBeVisible(); + await expect(buyVolume).toHaveScreenshot('realunit-dashboard-03-buy-volume-shares.png', screenshotOpts); + + const holders = section('Holders over time'); + await holders.scrollIntoViewIfNeeded(); + await expect(holders).toHaveScreenshot('realunit-dashboard-04-holders.png', screenshotOpts); + + const registration = section('Registration'); + await registration.scrollIntoViewIfNeeded(); + await expect(registration.getByText('Registered')).toBeVisible(); + await expect(registration).toHaveScreenshot('realunit-dashboard-05-registration.png', screenshotOpts); + }); +}); diff --git a/e2e/realunit-quotes.spec.ts b/e2e/realunit-quotes.spec.ts index d6cf21907..46f0c41be 100644 --- a/e2e/realunit-quotes.spec.ts +++ b/e2e/realunit-quotes.spec.ts @@ -1,7 +1,4 @@ -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 quote list + detail variants @@ -10,13 +7,12 @@ import { createTestCredentials } from './test-wallet'; * - /realunit/quotes (pending quote list) * - /realunit/quotes/:id (quote detail: active actions, deactivate overlay, deactivated) * - * Auth is REAL (same admin-token flow as realunit-compliance.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 can see both Confirm Payment - * and Deactivate on an active WaitingForPayment Buy. + * Auth is a synthetic Admin/Support JWT. Staff GETs and the quotes admin endpoints 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 quote 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 quotes admin endpoints are intercepted; everything else - * (auth/role/user/settings) is passed through via route.continue(). + * NO real production data. * * Intercepted endpoints (base `/v1/` is prepended by useApi): * - GET realunit/admin/quotes[?limit=&offset=] (list — detail screen also loads this and finds by id) @@ -26,44 +22,6 @@ import { createTestCredentials } from './test-wallet'; * Synthetic fixtures: fake ids (8000+), fixed ISO dates, fake addresses — no production data. */ -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; -} - // --------------------------------------------------------------------------- // Synthetic fixtures (mirror RealUnitQuote from the admin quotes list/detail screens). // --------------------------------------------------------------------------- @@ -77,6 +35,8 @@ interface RealUnitQuote { estimatedAmount: number; created: string; userAddress?: string; + userId?: number; + userName?: string; deactivatedAt?: string; } @@ -89,6 +49,8 @@ const ACTIVE_BUY: RealUnitQuote = { estimatedAmount: 9.87, created: '2026-02-01T12:00:00.000Z', userAddress: '0xabc0000000000000000000000000000000008001', + userId: 8001, + userName: 'Active Buyer', }; const DEACTIVATED_BUY: RealUnitQuote = { @@ -101,6 +63,8 @@ const DEACTIVATED_BUY: RealUnitQuote = { created: '2026-02-01T12:00:00.000Z', deactivatedAt: '2026-02-02T12:00:00.000Z', userAddress: '0xdef0000000000000000000000000000000008002', + userId: 8002, + userName: 'Deactivated Buyer', }; // Stable order for the GET list mock (detail screens also consume this list and find by id). @@ -129,20 +93,7 @@ function jwt(role: 'Admin' | 'Support'): string { })}.synthetic`; } -async function installQuotesRoutes(page: Page): Promise { - await page.route('**/v1/**', async (route: Route) => { - const url = route.request().url(); - - if (DEACTIVATE_RE.test(url)) return json(route, {}); - if (CONFIRM_PAYMENT_RE.test(url)) return json(route, {}); - if (LIST_RE.test(url)) return json(route, QUOTES); - - // everything else (auth, role, user, settings) hits the real api - await route.continue(); - }); -} - -/** Quotes mocks plus staff GETs so a synthetic Support JWT does not 401-clear the session. */ +/** Quotes mocks plus staff GETs so a synthetic JWT does not 401-clear the session. */ async function installQuotesRoutesWithStaffMocks(page: Page): Promise { await page.route('**/v1/**', async (route: Route) => { const request = route.request(); @@ -189,14 +140,10 @@ async function installQuotesRoutesWithStaffMocks(page: Page): Promise { } test.describe('RealUnit Quotes - Visual Regression Tests', () => { - let token: string; - - test.beforeAll(async ({ request }) => { - token = await getAdminAuth(request); - }); + const token = jwt('Admin'); - test('list shows active and deactivated WaitingForPayment Buy rows', async ({ page }) => { - await installQuotesRoutes(page); + test('list shows only pending WaitingForPayment Buy rows', async ({ page }) => { + await installQuotesRoutesWithStaffMocks(page); // lang=en: selectors and baselines are English; without it user.language decides the UI locale. await page.goto(`/realunit/quotes?session=${encodeURIComponent(token)}&lang=en`); @@ -204,9 +151,10 @@ test.describe('RealUnit Quotes - Visual Regression Tests', () => { await page.waitForTimeout(1000); await expect(page.getByRole('heading', { name: 'Pending Transactions' })).toBeVisible(); - // blanked addresses uniquely identify each fixture row (list does not render uid) - await expect(page.getByText(/0xabc00\.\.\.08001/)).toBeVisible(); - await expect(page.getByText(/0xdef00\.\.\.08002/)).toBeVisible(); + await expect(page.getByText('Active Buyer')).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Address' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'User' })).toHaveCount(0); + await expect(page.getByText('Deactivated Buyer')).toHaveCount(0); await expect(page).toHaveScreenshot('realunit-quotes-01-list.png', { fullPage: true, @@ -215,7 +163,7 @@ test.describe('RealUnit Quotes - Visual Regression Tests', () => { }); test('active WaitingForPayment Buy detail shows Confirm Payment and Deactivate', async ({ page }) => { - await installQuotesRoutes(page); + await installQuotesRoutesWithStaffMocks(page); await page.goto(`/realunit/quotes/${ACTIVE_BUY.id}?session=${encodeURIComponent(token)}&lang=en`); await page.waitForLoadState('networkidle'); @@ -231,7 +179,7 @@ test.describe('RealUnit Quotes - Visual Regression Tests', () => { }); test('Deactivate Quote opens the confirmation overlay', async ({ page }) => { - await installQuotesRoutes(page); + await installQuotesRoutesWithStaffMocks(page); await page.goto(`/realunit/quotes/${ACTIVE_BUY.id}?session=${encodeURIComponent(token)}&lang=en`); await page.waitForLoadState('networkidle'); @@ -249,7 +197,7 @@ test.describe('RealUnit Quotes - Visual Regression Tests', () => { }); test('deactivated quote detail shows Deactivated At and no action buttons', async ({ page }) => { - await installQuotesRoutes(page); + await installQuotesRoutesWithStaffMocks(page); await page.goto(`/realunit/quotes/${DEACTIVATED_BUY.id}?session=${encodeURIComponent(token)}&lang=en`); await page.waitForLoadState('networkidle'); diff --git a/e2e/screenshots/baseline/compliance-call-queue-outcome.spec.ts-compliance-call-queue-outcome-signature-chromium-darwin.png b/e2e/screenshots/baseline/compliance-call-queue-outcome.spec.ts-compliance-call-queue-outcome-signature-chromium-darwin.png new file mode 100644 index 000000000..f22622bbd Binary files /dev/null and b/e2e/screenshots/baseline/compliance-call-queue-outcome.spec.ts-compliance-call-queue-outcome-signature-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-01-pending-chromium-darwin.png b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-01-pending-chromium-darwin.png new file mode 100644 index 000000000..1538b62b2 Binary files /dev/null and b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-01-pending-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-02-buy-volume-chf-chromium-darwin.png b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-02-buy-volume-chf-chromium-darwin.png new file mode 100644 index 000000000..86bfbc509 Binary files /dev/null and b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-02-buy-volume-chf-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-03-buy-volume-shares-chromium-darwin.png b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-03-buy-volume-shares-chromium-darwin.png new file mode 100644 index 000000000..f60b95f9d Binary files /dev/null and b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-03-buy-volume-shares-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-04-holders-chromium-darwin.png b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-04-holders-chromium-darwin.png new file mode 100644 index 000000000..9744a5465 Binary files /dev/null and b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-04-holders-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-05-registration-chromium-darwin.png b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-05-registration-chromium-darwin.png new file mode 100644 index 000000000..8ecf9cc2a Binary files /dev/null and b/e2e/screenshots/baseline/realunit-dashboard.spec.ts-realunit-dashboard-05-registration-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-01-list-chromium-darwin.png b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-01-list-chromium-darwin.png index a0b18c92a..e790e3c6d 100644 Binary files a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-01-list-chromium-darwin.png and b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-01-list-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-02-detail-active-buy-chromium-darwin.png b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-02-detail-active-buy-chromium-darwin.png index f48a5779a..675e7f6fc 100644 Binary files a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-02-detail-active-buy-chromium-darwin.png and b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-02-detail-active-buy-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-03-detail-deactivate-overlay-chromium-darwin.png b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-03-detail-deactivate-overlay-chromium-darwin.png index ac3e0acff..a6ce7c1d8 100644 Binary files a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-03-detail-deactivate-overlay-chromium-darwin.png and b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-03-detail-deactivate-overlay-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-04-detail-deactivated-chromium-darwin.png b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-04-detail-deactivated-chromium-darwin.png index 63083deff..b39f1d785 100644 Binary files a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-04-detail-deactivated-chromium-darwin.png and b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-04-detail-deactivated-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-05-detail-support-active-buy-chromium-darwin.png b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-05-detail-support-active-buy-chromium-darwin.png index ab8ff625a..962c98e95 100644 Binary files a/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-05-detail-support-active-buy-chromium-darwin.png and b/e2e/screenshots/baseline/realunit-quotes.spec.ts-realunit-quotes-05-detail-support-active-buy-chromium-darwin.png differ diff --git a/scripts/handbook/metadata.json b/scripts/handbook/metadata.json index 93915dd43..8cd77857e 100644 --- a/scripts/handbook/metadata.json +++ b/scripts/handbook/metadata.json @@ -83,6 +83,10 @@ "title": "Compliance: Unzugeordnete Bankeingänge", "description": "Liste unzugeordneter Bankeingänge mit hervorgehobener Lastschrift-Zeile und das aufgeklappte Zuordnungsformular für die Admin-Rolle." }, + "compliance-call-queue-outcome": { + "title": "Compliance: Call-Queue-Outcome", + "description": "Outcome-Formular mit der Signatur als verified name des eingeloggten Bearbeiters, ohne Clerks-Auswahlliste." + }, "realunit-compliance": { "title": "RealUnit Compliance", "description": "RealUnit-spezifische Compliance-Suche und Dossier." @@ -95,6 +99,10 @@ "title": "RealUnit Quotes", "description": "RealUnit-Quote-Liste und -Detail: aktives WaitingForPayment-Buy mit Confirm Payment und Deactivate, Bestätigungs-Overlay, deaktiviertes Quote mit Deactivated At ohne Aktions-Buttons, und Support sieht bei aktivem WaitingForPayment-Buy nur Deactivate (kein Confirm Payment)." }, + "realunit-dashboard": { + "title": "RealUnit Dashboard", + "description": "RealUnit-Startseite: Pending-Tabelle mit Address und Kundenname ohne deaktivierte Quotes, Kaufvolumen (CHF und Shares), Holder im Zeitverlauf und Registrierungs-Funnel (Registered → Completed, Drop-off Manual review)." + }, "responsive": { "title": "Responsive Layout", "description": "Homepage, Buy und Login auf Mobile, Tablet und Desktop." diff --git a/src/__tests__/call-queue-outcome-form.test.tsx b/src/__tests__/call-queue-outcome-form.test.tsx index a396f6619..93de460ec 100644 --- a/src/__tests__/call-queue-outcome-form.test.tsx +++ b/src/__tests__/call-queue-outcome-form.test.tsx @@ -277,6 +277,35 @@ describe('CallQueueOutcomeForm AmlCheck action', () => { it('defaults an open-ended outcome to no change', async () => { renderForm(TX_CONTEXT); + fillAndSubmit(CallOutcome.SUSPICIOUS); + + await waitFor(() => expect(mockSaveCallOutcome).toHaveBeenCalledTimes(1)); + expect(submittedAmlAction()).toBeUndefined(); + }); + + // Repeat decides the transaction on its own: the hook releases this one transaction and the account + // stays in the queue for its next one. Asking for an AML action on top would only offer the plain + // reset, which re-runs the very check the still-missing check date is failing. + it('asks for no AML action on Repeat', () => { + renderForm(TX_CONTEXT); + + fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: CallOutcome.REPEAT } }); + + expect(screen.getAllByRole('combobox')).toHaveLength(1); + }); + + it('sends the automatic reset on Repeat for a recheck-blocked queue', async () => { + renderForm(TX_CONTEXT); + + fillAndSubmit(CallOutcome.REPEAT); + + await waitFor(() => expect(mockSaveCallOutcome).toHaveBeenCalledTimes(1)); + expect(submittedAmlAction()).toBe('Reset'); + }); + + it('sends no AML action on Repeat where the cron re-evaluates anyway', async () => { + renderForm(PLAIN_PHONE_TX_CONTEXT); + fillAndSubmit(CallOutcome.REPEAT); await waitFor(() => expect(mockSaveCallOutcome).toHaveBeenCalledTimes(1)); diff --git a/src/__tests__/compliance-call-outcome.hook.test.ts b/src/__tests__/compliance-call-outcome.hook.test.ts index 422387b82..3f2485961 100644 --- a/src/__tests__/compliance-call-outcome.hook.test.ts +++ b/src/__tests__/compliance-call-outcome.hook.test.ts @@ -88,6 +88,89 @@ describe('saveCallOutcome write order', () => { }); }); +// Repeat: let THIS payment through, call the customer again on the next one. The release is therefore +// written on the transaction while the account deliberately keeps its empty check date — which is what +// puts the next payment back into the queue. +describe('saveCallOutcome on a Repeat outcome', () => { + beforeEach(() => { + mockCalls.length = 0; + mockCall.mockImplementation(async (cfg: any) => { + mockCalls.push({ method: cfg.method, url: cfg.url, data: cfg.data }); + return {}; + }); + }); + + // The release is only accepted while the transaction still carries its queue reason, so it has to be + // written before the reset that clears amlCheck/amlReason. + it('releases the transaction before resetting it, and writes no check date', async () => { + const { result } = renderHook(() => useCompliance()); + + const res = await result.current.saveCallOutcome(TX_CONTEXT, CallOutcome.REPEAT, { + signature: 'JR', + comment: 'call again next time', + amlAction: 'Reset', + }); + + expect(res.success).toBe(true); + expect(mockCalls.map((c) => `${c.method} ${c.url}`)).toEqual([ + 'PUT userData/7', + 'PUT buyCrypto/42/phoneCallCleared', + 'PUT buyCrypto/42/amlCheck/reviewReset', + 'POST kyc/admin/log', + ]); + expect(mockCalls[0].data.phoneCallStatus).toBe('Repeat'); + expect(mockCalls[0].data.phoneCallIpCountryCheckDate).toBeUndefined(); + }); + + it('releases a sell transaction through its own endpoint', async () => { + const { result } = renderHook(() => useCompliance()); + + await result.current.saveCallOutcome( + { ...TX_CONTEXT, queue: 'ManualCheckPhone', sourceType: 'BuyFiat' }, + CallOutcome.REPEAT, + { signature: 'JR', comment: 'call again next time' }, + ); + + expect(mockCalls.map((c) => `${c.method} ${c.url}`)).toEqual([ + 'PUT userData/7', + 'PUT buyFiat/42/phoneCallCleared', + 'POST kyc/admin/log', + ]); + }); + + // The user queue has no transaction to release, and Repeat there deliberately writes nothing at all. + it('writes nothing but the log for a queue entry without a transaction', async () => { + const { result } = renderHook(() => useCompliance()); + + await result.current.saveCallOutcome({ queue: 'UnavailableSuspicious', userDataId: 7 } as any, CallOutcome.REPEAT, { + signature: 'JR', + comment: 'call again next time', + }); + + expect(mockCalls.map((c) => `${c.method} ${c.url}`)).toEqual(['POST kyc/admin/log']); + }); + + // A failed release must surface as a failed save: reporting success would tell the clerk the payment + // is on its way while it silently stays in the queue. + it('reports the transaction step as failed when the release is rejected', async () => { + mockCall.mockImplementation(async (cfg: any) => { + if (cfg.url.endsWith('/phoneCallCleared')) throw new Error('not waiting for a compliance phone call'); + mockCalls.push({ method: cfg.method, url: cfg.url, data: cfg.data }); + return {}; + }); + const { result } = renderHook(() => useCompliance()); + + const res = await result.current.saveCallOutcome({ ...TX_CONTEXT, queue: 'ManualCheckPhone' }, CallOutcome.REPEAT, { + signature: 'JR', + comment: 'call again next time', + }); + + expect(res.success).toBe(false); + expect(res.failedStep).toBe('transaction'); + expect(mockCalls.some((c) => c.url === 'kyc/admin/log')).toBe(false); + }); +}); + // Which queues need the outcome form to clear the transaction itself: exactly those whose AML reason // sits in the API's BlockAmlReasons, because the recheck cron never picks them up again. Everything // else must stay untouched so the API keeps deciding. diff --git a/src/__tests__/copyable-address.test.tsx b/src/__tests__/copyable-address.test.tsx new file mode 100644 index 000000000..e4a0941fa --- /dev/null +++ b/src/__tests__/copyable-address.test.tsx @@ -0,0 +1,79 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { CopyableAddress } from 'src/components/realunit/copyable-address'; + +const mockCopy = jest.fn(); + +jest.mock('src/hooks/clipboard.hook', () => ({ + useClipboard: () => ({ copy: mockCopy, isCopying: false }), +})); + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => ({ translate: (_ns: string, key: string) => key }), +})); + +jest.mock('@dfx.swiss/react-components', () => ({ + IconColor: { GRAY: 'gray' }, + CopyButton: ({ onCopy }: { onCopy?: () => void }) => ( + + ), +})); + +jest.mock('src/util/utils', () => ({ + blankedAddress: (address: string) => `blanked:${address}`, +})); + +const ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; + +describe('CopyableAddress', () => { + beforeEach(() => { + mockCopy.mockReset(); + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + it('renders a dash when the address is missing', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument(); + }); + + it('copies on address click and shows Copied until the timer elapses', () => { + render(); + fireEvent.click(screen.getByText(`blanked:${ADDRESS}`)); + expect(mockCopy).toHaveBeenCalledWith(ADDRESS); + expect(screen.getByText('Copied')).toBeInTheDocument(); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(screen.queryByText('Copied')).not.toBeInTheDocument(); + }); + + it('copies on the copy button and stops the click from bubbling', () => { + const onRowClick = jest.fn(); + render( +
+ +
, + ); + fireEvent.click(screen.getByTestId('copy-button')); + expect(mockCopy).toHaveBeenCalledWith(ADDRESS); + expect(screen.getByText('Copied')).toBeInTheDocument(); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('clears the copied timer on unmount', () => { + const { unmount } = render(); + fireEvent.click(screen.getByText(`blanked:${ADDRESS}`)); + unmount(); + jest.advanceTimersByTime(1000); + expect(mockCopy).toHaveBeenCalledWith(ADDRESS); + }); +}); diff --git a/src/__tests__/realunit-api.hook.test.ts b/src/__tests__/realunit-api.hook.test.ts index 6029981b9..65a9b5830 100644 --- a/src/__tests__/realunit-api.hook.test.ts +++ b/src/__tests__/realunit-api.hook.test.ts @@ -136,6 +136,33 @@ describe('useRealunitApi', () => { }); }); + it('getBuyVolume uppercases the timeframe query param', async () => { + const { result } = renderHook(() => useRealunitApi()); + await result.current.getBuyVolume(Timeframe.ALL); + expect(mockCall).toHaveBeenCalledWith({ + url: 'realunit/admin/stats/buy-volume?timeFrame=ALL', + method: 'GET', + }); + }); + + it('getRegistrationStats uppercases the timeframe query param', async () => { + const { result } = renderHook(() => useRealunitApi()); + await result.current.getRegistrationStats(Timeframe.MONTH); + expect(mockCall).toHaveBeenCalledWith({ + url: 'realunit/admin/stats/registration?timeFrame=1M', + method: 'GET', + }); + }); + + it('getHolderCount uppercases the timeframe query param', async () => { + const { result } = renderHook(() => useRealunitApi()); + await result.current.getHolderCount(Timeframe.WEEK); + expect(mockCall).toHaveBeenCalledWith({ + url: 'realunit/admin/stats/holders?timeFrame=1W', + method: 'GET', + }); + }); + it('deactivateQuote PUTs realunit/admin/quotes/:id/deactivate', async () => { const { result } = renderHook(() => useRealunitApi()); await result.current.deactivateQuote(7); diff --git a/src/__tests__/realunit-monitoring-charts.test.tsx b/src/__tests__/realunit-monitoring-charts.test.tsx new file mode 100644 index 000000000..85d35f308 --- /dev/null +++ b/src/__tests__/realunit-monitoring-charts.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { BuyVolumeChart } from 'src/components/realunit/buy-volume-chart'; +import { HolderCountChart } from 'src/components/realunit/holder-count-chart'; +import { RegistrationFunnel } from 'src/components/realunit/registration-funnel'; +import { Timeframe } from 'src/util/chart'; + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => ({ translate: (_ns: string, key: string) => key }), +})); + +jest.mock('react-apexcharts', () => ({ + __esModule: true, + default: () =>
, +})); + +const volume = [ + { timestamp: '2026-08-01T00:00:00.000Z', chf: 100, shares: 70, priceChf: 1.4 }, + { timestamp: '2026-08-02T00:00:00.000Z', chf: 0, shares: 0, priceChf: 1.41 }, +]; + +describe('RealUnit monitoring charts', () => { + it('toggles buy volume between CHF and shares and forwards timeframe clicks', () => { + const onTimeframeChange = jest.fn(); + render(); + fireEvent.click(screen.getByText('Shares')); + fireEvent.click(screen.getByText('1M')); + expect(onTimeframeChange).toHaveBeenCalledWith(Timeframe.MONTH); + expect(screen.getByTestId('chart')).toBeInTheDocument(); + }); + + it('renders holder count and a registration funnel with stage counts and conversion', () => { + const onTimeframeChange = jest.fn(); + const { unmount } = render( + , + ); + unmount(); + const holders = render( + , + ); + expect(holders.getByTestId('chart')).toBeInTheDocument(); + holders.unmount(); + + const funnel = render( + , + ); + expect(funnel.getByText('Registered')).toBeInTheDocument(); + expect(funnel.getByText('Completed')).toBeInTheDocument(); + expect(funnel.getByText('100')).toBeInTheDocument(); + expect(funnel.getByText('80')).toBeInTheDocument(); + expect(funnel.getByText('−20 (−20%)')).toBeInTheDocument(); + expect(funnel.getByText('Manual review')).toBeInTheDocument(); + expect(funnel.getByText('Confirmed')).toBeInTheDocument(); + expect(funnel.getByText('50')).toBeInTheDocument(); + expect(funnel.queryByText('Blocked users')).not.toBeInTheDocument(); + expect(funnel.queryByText('Active users')).not.toBeInTheDocument(); + expect(funnel.queryByTestId('chart')).not.toBeInTheDocument(); + funnel.unmount(); + + const noDrop = render( + , + ); + expect(noDrop.getAllByText('10')).toHaveLength(2); + expect(noDrop.getAllByText('100%')).toHaveLength(2); + expect(noDrop.queryByText('Manual review')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/realunit-quote-detail.screen.test.tsx b/src/__tests__/realunit-quote-detail.screen.test.tsx index 8e4ee5b4e..a58de8399 100644 --- a/src/__tests__/realunit-quote-detail.screen.test.tsx +++ b/src/__tests__/realunit-quote-detail.screen.test.tsx @@ -163,7 +163,8 @@ describe('RealunitQuoteDetailScreen', () => { expect(screen.getByText('WaitingForPayment')).toBeInTheDocument(); expect(screen.getByText(BASE_QUOTE.amount.toLocaleString())).toBeInTheDocument(); expect(screen.getByText(BASE_QUOTE.estimatedAmount.toLocaleString())).toBeInTheDocument(); - expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('Address')).toBeInTheDocument(); + expect(screen.queryByText('User')).not.toBeInTheDocument(); expect(screen.getByText('Created')).toBeInTheDocument(); }); diff --git a/src/__tests__/realunit-quotes.screen.test.tsx b/src/__tests__/realunit-quotes.screen.test.tsx index e0e86fc12..267d5ee57 100644 --- a/src/__tests__/realunit-quotes.screen.test.tsx +++ b/src/__tests__/realunit-quotes.screen.test.tsx @@ -20,8 +20,11 @@ jest.mock('@dfx.swiss/react', () => ({ }, })); +const mockCopy = jest.fn(); + jest.mock('@dfx.swiss/react-components', () => ({ SpinnerSize: { SM: 'sm', LG: 'lg' }, + IconColor: { GRAY: 'gray' }, StyledLoadingSpinner: ({ size }: { size?: string }) =>
, StyledButton: ({ label, onClick, disabled }: { label: string; onClick?: () => void; disabled?: boolean }) => ( ), StyledButtonWidth: { FULL: 'full' }, + CopyButton: ({ onCopy }: { onCopy?: () => void }) => ( + + ), +})); + +jest.mock('src/hooks/clipboard.hook', () => ({ + useClipboard: () => ({ copy: mockCopy, isCopying: false }), })); jest.mock('src/components/error-hint', () => ({ @@ -67,6 +79,8 @@ const QUOTE = { estimatedAmount: 25, created: '2026-01-15T10:00:00.000Z', userAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + userId: 42, + userName: 'Ada Lovelace', }; function setContext(overrides: Partial = {}) { @@ -129,10 +143,56 @@ describe('RealunitQuotesScreen', () => { expect(screen.getByText('Other')).toBeInTheDocument(); }); - it('shows "-" when userAddress is missing', () => { - setContext({ quotes: [{ ...QUOTE, userAddress: undefined }] }); + it('shows address and userName and the Address header', () => { + setContext({ quotes: [QUOTE] }); + render(); + expect(screen.getByText('Address')).toBeInTheDocument(); + expect(screen.queryByText('User')).not.toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('0xabcde...fabcd')).toBeInTheDocument(); + expect(screen.queryByText('42')).not.toBeInTheDocument(); + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + fireEvent.click(screen.getByText('0xabcde...fabcd')); + expect(mockCopy).toHaveBeenCalledWith(QUOTE.userAddress); + expect(screen.getByText('Copied')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('hides deactivated quotes from the pending list', () => { + setContext({ + quotes: [ + QUOTE, + { + ...QUOTE, + id: 99, + amount: 999, + userId: 99, + userName: 'Cancelled Person', + deactivatedAt: '2026-02-02T12:00:00.000Z', + }, + ], + }); + render(); + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + expect(screen.queryByText('Cancelled Person')).not.toBeInTheDocument(); + expect(screen.queryByText('999')).not.toBeInTheDocument(); + }); + + it('shows "-" when userAddress and userName are missing', () => { + setContext({ quotes: [{ ...QUOTE, userAddress: undefined, userName: undefined }] }); render(); - expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(2); + }); + + it('shows empty copy when the only quotes are deactivated', () => { + setContext({ + quotes: [{ ...QUOTE, deactivatedAt: '2026-02-02T12:00:00.000Z' }], + quotesLoading: false, + quotesError: false, + }); + render(); + expect(screen.getByText('No pending transactions found')).toBeInTheDocument(); + expect(screen.queryByText('Ada Lovelace')).not.toBeInTheDocument(); }); it('More button calls fetchQuotes and is disabled while loading', () => { diff --git a/src/__tests__/realunit.context.test.tsx b/src/__tests__/realunit.context.test.tsx index bdb4ea517..49329f13c 100644 --- a/src/__tests__/realunit.context.test.tsx +++ b/src/__tests__/realunit.context.test.tsx @@ -10,6 +10,9 @@ const mockGetAdminQuotes = jest.fn(); const mockGetAdminTransactions = jest.fn(); const mockConfirmPayment = jest.fn(); const mockDeactivateQuote = jest.fn(); +const mockGetBuyVolume = jest.fn(); +const mockGetRegistrationStats = jest.fn(); +const mockGetHolderCount = jest.fn(); jest.mock('src/hooks/realunit-api.hook', () => ({ useRealunitApi: () => ({ @@ -23,6 +26,9 @@ jest.mock('src/hooks/realunit-api.hook', () => ({ getAdminTransactions: mockGetAdminTransactions, confirmPayment: mockConfirmPayment, deactivateQuote: mockDeactivateQuote, + getBuyVolume: mockGetBuyVolume, + getRegistrationStats: mockGetRegistrationStats, + getHolderCount: mockGetHolderCount, }), })); @@ -36,6 +42,26 @@ function wrapper({ children }: PropsWithChildren) { return {children}; } +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: Error) => void; +} { + const controls = { + resolve: (_value: T) => { + throw new Error('deferred not initialized'); + }, + reject: (_reason: Error) => { + throw new Error('deferred not initialized'); + }, + }; + const promise = new Promise((resolve, reject) => { + controls.resolve = resolve; + controls.reject = reject; + }); + return { promise, resolve: controls.resolve, reject: controls.reject }; +} + const EMPTY_PAGE = { hasNextPage: false, hasPreviousPage: false, @@ -54,6 +80,20 @@ describe('RealunitContextProvider', () => { mockGetTokenPrice.mockResolvedValue(undefined); mockGetAdminQuotes.mockResolvedValue([]); mockGetAdminTransactions.mockResolvedValue([]); + mockGetBuyVolume.mockResolvedValue([]); + mockGetRegistrationStats.mockResolvedValue({ + snapshot: { + completed: 0, + manualReview: 0, + confirmed: 0, + usersActive: 0, + usersNa: 0, + usersBlocked: 0, + usersDeleted: 0, + }, + series: [], + }); + mockGetHolderCount.mockResolvedValue([]); }); it('fetchAccountSummary sets accountSummary on success and clears it on catch', async () => { @@ -283,4 +323,213 @@ describe('RealunitContextProvider', () => { expect(result.current.confirmPayment).toBe(mockConfirmPayment); expect(result.current.deactivateQuote).toBe(mockDeactivateQuote); }); + + it('fetchBuyVolume sets series on success and error on catch', async () => { + const series = [{ timestamp: '2026-08-01T00:00:00.000Z', chf: 10, shares: 7, priceChf: 1.4 }]; + mockGetBuyVolume.mockResolvedValueOnce(series); + const { result } = renderHook(() => useRealunitContext(), { wrapper }); + act(() => { + result.current.fetchBuyVolume(Timeframe.WEEK); + }); + await waitFor(() => { + expect(result.current.buyVolume).toEqual(series); + expect(result.current.buyVolumeLoading).toBe(false); + expect(result.current.buyVolumeTimeframe).toBe(Timeframe.WEEK); + }); + expect(mockGetBuyVolume).toHaveBeenCalledWith(Timeframe.WEEK); + + mockGetBuyVolume.mockRejectedValueOnce(new Error('fail')); + act(() => { + result.current.fetchBuyVolume(); + }); + await waitFor(() => { + expect(result.current.buyVolumeError).toBe(true); + expect(result.current.buyVolumeLoading).toBe(false); + }); + }); + + it('fetchHolderCount and fetchRegistrationStats set state and error flags', async () => { + const holders = [{ timestamp: '2026-08-01T00:00:00.000Z', holders: 9 }]; + const registration = { + snapshot: { + completed: 1, + manualReview: 2, + confirmed: 1, + usersActive: 3, + usersNa: 4, + usersBlocked: 0, + usersDeleted: 0, + }, + series: [{ timestamp: '2026-08-01T00:00:00.000Z', registered: 1, confirmed: 0 }], + }; + mockGetHolderCount.mockResolvedValueOnce(holders); + mockGetRegistrationStats.mockResolvedValueOnce(registration); + const { result } = renderHook(() => useRealunitContext(), { wrapper }); + act(() => { + result.current.fetchHolderCount(Timeframe.MONTH); + result.current.fetchRegistrationStats(Timeframe.YEAR); + }); + await waitFor(() => { + expect(result.current.holderCount).toEqual(holders); + expect(result.current.registrationStats).toEqual(registration); + }); + + mockGetHolderCount.mockRejectedValueOnce(new Error('fail')); + mockGetRegistrationStats.mockRejectedValueOnce(new Error('fail')); + act(() => { + result.current.fetchHolderCount(); + result.current.fetchRegistrationStats(); + }); + await waitFor(() => { + expect(result.current.holderCountError).toBe(true); + expect(result.current.registrationError).toBe(true); + }); + }); + + it('ignores stale stats success when a newer timeframe request is in flight', async () => { + const staleVolume = createDeferred<{ timestamp: string; chf: number; shares: number; priceChf: number }[]>(); + const staleHolders = createDeferred<{ timestamp: string; holders: number }[]>(); + const staleRegistration = createDeferred<{ + snapshot: { + completed: number; + manualReview: number; + confirmed: number; + usersActive: number; + usersNa: number; + usersBlocked: number; + usersDeleted: number; + }; + series: { timestamp: string; registered: number; confirmed: number }[]; + }>(); + const freshVolume = [{ timestamp: '2026-08-02T00:00:00.000Z', chf: 20, shares: 10, priceChf: 2 }]; + const freshHolders = [{ timestamp: '2026-08-02T00:00:00.000Z', holders: 4 }]; + const freshRegistration = { + snapshot: { + completed: 5, + manualReview: 0, + confirmed: 5, + usersActive: 5, + usersNa: 0, + usersBlocked: 0, + usersDeleted: 0, + }, + series: [{ timestamp: '2026-08-02T00:00:00.000Z', registered: 5, confirmed: 5 }], + }; + + mockGetBuyVolume.mockReturnValueOnce(staleVolume.promise).mockResolvedValueOnce(freshVolume); + mockGetHolderCount.mockReturnValueOnce(staleHolders.promise).mockResolvedValueOnce(freshHolders); + mockGetRegistrationStats.mockReturnValueOnce(staleRegistration.promise).mockResolvedValueOnce(freshRegistration); + + const { result } = renderHook(() => useRealunitContext(), { wrapper }); + act(() => { + result.current.fetchBuyVolume(Timeframe.YEAR); + result.current.fetchHolderCount(Timeframe.YEAR); + result.current.fetchRegistrationStats(Timeframe.YEAR); + }); + act(() => { + result.current.fetchBuyVolume(Timeframe.WEEK); + result.current.fetchHolderCount(Timeframe.WEEK); + result.current.fetchRegistrationStats(Timeframe.WEEK); + }); + await waitFor(() => { + expect(result.current.buyVolume).toEqual(freshVolume); + expect(result.current.holderCount).toEqual(freshHolders); + expect(result.current.registrationStats).toEqual(freshRegistration); + }); + + await act(async () => { + staleVolume.resolve([{ timestamp: '2026-01-01T00:00:00.000Z', chf: 1, shares: 1, priceChf: 1 }]); + staleHolders.resolve([{ timestamp: '2026-01-01T00:00:00.000Z', holders: 99 }]); + staleRegistration.resolve({ + snapshot: { + completed: 1, + manualReview: 1, + confirmed: 1, + usersActive: 1, + usersNa: 1, + usersBlocked: 1, + usersDeleted: 1, + }, + series: [{ timestamp: '2026-01-01T00:00:00.000Z', registered: 1, confirmed: 1 }], + }); + }); + + expect(result.current.buyVolume).toEqual(freshVolume); + expect(result.current.buyVolumeTimeframe).toBe(Timeframe.WEEK); + expect(result.current.buyVolumeLoading).toBe(false); + expect(result.current.holderCount).toEqual(freshHolders); + expect(result.current.holderCountTimeframe).toBe(Timeframe.WEEK); + expect(result.current.holderCountLoading).toBe(false); + expect(result.current.registrationStats).toEqual(freshRegistration); + expect(result.current.registrationTimeframe).toBe(Timeframe.WEEK); + expect(result.current.registrationLoading).toBe(false); + }); + + it('ignores stale stats errors when a newer timeframe request succeeds', async () => { + const staleVolume = createDeferred<{ timestamp: string; chf: number; shares: number; priceChf: number }[]>(); + const staleHolders = createDeferred<{ timestamp: string; holders: number }[]>(); + const staleRegistration = createDeferred<{ + snapshot: { + completed: number; + manualReview: number; + confirmed: number; + usersActive: number; + usersNa: number; + usersBlocked: number; + usersDeleted: number; + }; + series: { timestamp: string; registered: number; confirmed: number }[]; + }>(); + const freshVolume = [{ timestamp: '2026-08-03T00:00:00.000Z', chf: 8, shares: 4, priceChf: 2 }]; + const freshHolders = [{ timestamp: '2026-08-03T00:00:00.000Z', holders: 2 }]; + const freshRegistration = { + snapshot: { + completed: 2, + manualReview: 0, + confirmed: 2, + usersActive: 2, + usersNa: 0, + usersBlocked: 0, + usersDeleted: 0, + }, + series: [{ timestamp: '2026-08-03T00:00:00.000Z', registered: 2, confirmed: 2 }], + }; + + mockGetBuyVolume.mockReturnValueOnce(staleVolume.promise).mockResolvedValueOnce(freshVolume); + mockGetHolderCount.mockReturnValueOnce(staleHolders.promise).mockResolvedValueOnce(freshHolders); + mockGetRegistrationStats.mockReturnValueOnce(staleRegistration.promise).mockResolvedValueOnce(freshRegistration); + + const { result } = renderHook(() => useRealunitContext(), { wrapper }); + act(() => { + result.current.fetchBuyVolume(Timeframe.MONTH); + result.current.fetchHolderCount(Timeframe.MONTH); + result.current.fetchRegistrationStats(Timeframe.MONTH); + }); + act(() => { + result.current.fetchBuyVolume(Timeframe.WEEK); + result.current.fetchHolderCount(Timeframe.WEEK); + result.current.fetchRegistrationStats(Timeframe.WEEK); + }); + await waitFor(() => { + expect(result.current.buyVolume).toEqual(freshVolume); + expect(result.current.holderCount).toEqual(freshHolders); + expect(result.current.registrationStats).toEqual(freshRegistration); + }); + + await act(async () => { + staleVolume.reject(new Error('stale volume')); + staleHolders.reject(new Error('stale holders')); + staleRegistration.reject(new Error('stale registration')); + }); + + expect(result.current.buyVolumeError).toBe(false); + expect(result.current.holderCountError).toBe(false); + expect(result.current.registrationError).toBe(false); + expect(result.current.buyVolume).toEqual(freshVolume); + expect(result.current.holderCount).toEqual(freshHolders); + expect(result.current.registrationStats).toEqual(freshRegistration); + expect(result.current.buyVolumeLoading).toBe(false); + expect(result.current.holderCountLoading).toBe(false); + expect(result.current.registrationLoading).toBe(false); + }); }); diff --git a/src/__tests__/realunit.screen.test.tsx b/src/__tests__/realunit.screen.test.tsx new file mode 100644 index 000000000..ca6a30906 --- /dev/null +++ b/src/__tests__/realunit.screen.test.tsx @@ -0,0 +1,469 @@ +const mockNavigate = jest.fn(); +const mockCopy = jest.fn(); +const mockUseRealunitGuard = jest.fn(); +const mockFetchHolders = jest.fn(); +const mockFetchPriceHistory = jest.fn(); +const mockFetchTokenInfo = jest.fn(); +const mockFetchQuotes = jest.fn(); +const mockFetchTransactions = jest.fn(); +const mockFetchBuyVolume = jest.fn(); +const mockFetchHolderCount = jest.fn(); +const mockFetchRegistrationStats = jest.fn(); + +let mockContext: Record; + +jest.mock('@dfx.swiss/react-components', () => ({ + SpinnerSize: { SM: 'sm', MD: 'md', LG: 'lg' }, + IconColor: { GRAY: 'gray' }, + StyledLoadingSpinner: ({ size }: { size?: string }) =>
, + StyledButton: ({ label, onClick, disabled }: { label: string; onClick?: () => void; disabled?: boolean }) => ( + + ), + StyledButtonWidth: { MIN: 'min', FULL: 'full' }, + StyledButtonColor: { STURDY_WHITE: 'sturdy-white' }, + CopyButton: ({ onCopy }: { onCopy?: () => void }) => ( + + ), +})); + +jest.mock('src/components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); + +jest.mock('src/components/realunit/price-history-chart', () => ({ + PriceHistoryChart: ({ onTimeframeChange }: { onTimeframeChange?: () => void }) => ( + + ), +})); +jest.mock('src/components/realunit/buy-volume-chart', () => ({ + BuyVolumeChart: () =>
, +})); +jest.mock('src/components/realunit/holder-count-chart', () => ({ + HolderCountChart: () =>
, +})); +jest.mock('src/components/realunit/registration-funnel', () => ({ + RegistrationFunnel: () =>
, +})); + +jest.mock('src/hooks/guard.hook', () => ({ + useRealunitGuard: (...args: unknown[]) => mockUseRealunitGuard(...args), +})); + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => ({ translate: (_ns: string, key: string) => key }), +})); + +jest.mock('src/hooks/layout-config.hook', () => ({ + useLayoutOptions: () => undefined, +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('src/hooks/clipboard.hook', () => ({ + useClipboard: () => ({ copy: mockCopy }), +})); + +jest.mock('src/contexts/realunit.context', () => ({ + useRealunitContext: () => mockContext, +})); + +jest.mock('src/util/utils', () => ({ + blankedAddress: (address: string) => address, + formatSwissDateTimeWithSeconds: (value: string) => value, +})); + +import { StrictMode } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import RealunitScreen from 'src/screens/realunit.screen'; + +const HOLDER = { + address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + balance: '10', + percentage: 1.5, +}; + +const TOKEN_INFO = { + totalShares: { total: '1000', timestamp: '2026-01-01T00:00:00.000Z', txHash: '0x1' }, + totalSupply: { value: '2000', timestamp: '2026-01-02T00:00:00.000Z' }, +}; + +const QUOTE = { + id: 42, + uid: 'Q42', + type: 'Buy', + status: 'WaitingForPayment', + amount: 250, + estimatedAmount: 25, + created: '2026-01-15T10:00:00.000Z', + userAddress: '0x1234567890abcdef1234567890abcdef12345678', + userId: 42, + userName: 'Ada Lovelace', +}; + +const TX = { + id: 7, + uid: 'T7', + type: 'BuyCrypto', + amountInChf: 80, + assets: 'REALU', + created: '2026-01-16T10:00:00.000Z', + outputDate: '2026-01-17T10:00:00.000Z', + userAddress: '0x1234567890abcdef1234567890abcdef12345678', +}; + +function setContext(overrides: Record = {}) { + mockContext = { + holders: [HOLDER], + totalCount: 12, + tokenInfo: TOKEN_INFO, + isLoading: false, + priceHistory: [{ timestamp: '2026-01-01T00:00:00.000Z', chf: 1, eur: 1, usd: 1 }], + priceHistoryError: false, + timeframe: 'ALL', + quotes: [QUOTE], + transactions: [TX], + quotesLoading: false, + transactionsLoading: false, + fetchHolders: mockFetchHolders, + fetchPriceHistory: mockFetchPriceHistory, + fetchTokenInfo: mockFetchTokenInfo, + fetchQuotes: mockFetchQuotes, + fetchTransactions: mockFetchTransactions, + buyVolume: [], + buyVolumeLoading: false, + buyVolumeError: false, + holderCount: [], + holderCountLoading: false, + holderCountError: false, + registrationStats: undefined, + registrationLoading: false, + registrationError: false, + fetchBuyVolume: mockFetchBuyVolume, + fetchHolderCount: mockFetchHolderCount, + fetchRegistrationStats: mockFetchRegistrationStats, + buyVolumeTimeframe: 'All', + holderCountTimeframe: 'All', + registrationTimeframe: 'All', + ...overrides, + }; +} + +describe('RealunitScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + setContext(); + }); + + it('calls the realunit guard on render', () => { + render(); + expect(mockUseRealunitGuard).toHaveBeenCalledWith(); + }); + + it('shows a large spinner when holders and tokenInfo are empty', () => { + setContext({ holders: [], tokenInfo: undefined }); + render(); + expect(screen.getByTestId('loading-spinner')).toHaveAttribute('data-size', 'lg'); + }); + + it('fetches empty collections on mount and skips fetches when data already exists', () => { + setContext({ + holders: [], + tokenInfo: TOKEN_INFO, + priceHistory: [], + quotes: [], + transactions: [], + }); + const { unmount } = render(); + expect(mockFetchHolders).toHaveBeenCalled(); + expect(mockFetchTokenInfo).not.toHaveBeenCalled(); + expect(mockFetchPriceHistory).toHaveBeenCalled(); + expect(mockFetchQuotes).toHaveBeenCalled(); + expect(mockFetchTransactions).toHaveBeenCalled(); + expect(mockFetchBuyVolume).toHaveBeenCalledWith('All'); + expect(mockFetchHolderCount).toHaveBeenCalledWith('All'); + expect(mockFetchRegistrationStats).toHaveBeenCalledWith('All'); + unmount(); + + jest.clearAllMocks(); + setContext(); + render(); + expect(mockFetchHolders).not.toHaveBeenCalled(); + expect(mockFetchTokenInfo).not.toHaveBeenCalled(); + expect(mockFetchQuotes).not.toHaveBeenCalled(); + expect(mockFetchTransactions).not.toHaveBeenCalled(); + expect(mockFetchBuyVolume).toHaveBeenCalledWith('All'); + expect(mockFetchHolderCount).toHaveBeenCalledWith('All'); + expect(mockFetchRegistrationStats).toHaveBeenCalledWith('All'); + }); + + it('bootstraps lists and stats only once when StrictMode re-invokes effects', () => { + setContext({ + holders: [], + tokenInfo: undefined, + priceHistory: [], + quotes: [], + transactions: [], + }); + render( + + + , + ); + expect(mockFetchHolders).toHaveBeenCalledTimes(1); + expect(mockFetchTokenInfo).toHaveBeenCalledTimes(1); + expect(mockFetchPriceHistory).toHaveBeenCalledTimes(1); + expect(mockFetchQuotes).toHaveBeenCalledTimes(1); + expect(mockFetchTransactions).toHaveBeenCalledTimes(1); + expect(mockFetchBuyVolume).toHaveBeenCalledTimes(1); + expect(mockFetchHolderCount).toHaveBeenCalledTimes(1); + expect(mockFetchRegistrationStats).toHaveBeenCalledTimes(1); + }); + + it('shows stats error hints and loading spinners', () => { + setContext({ + buyVolumeError: true, + holderCountError: true, + registrationError: true, + }); + render(); + expect(screen.getByText('Failed to load buy volume.')).toBeInTheDocument(); + expect(screen.getByText('Failed to load holder count.')).toBeInTheDocument(); + expect(screen.getByText('Failed to load registration stats.')).toBeInTheDocument(); + }); + + it('shows medium spinners while stats are loading without data', () => { + setContext({ + buyVolumeLoading: true, + buyVolume: [], + holderCountLoading: true, + holderCount: [], + registrationLoading: true, + registrationStats: undefined, + }); + render(); + expect(screen.getAllByTestId('loading-spinner').some((el) => el.getAttribute('data-size') === 'md')).toBe(true); + expect(screen.queryByTestId('buy-volume-chart')).not.toBeInTheDocument(); + expect(screen.queryByTestId('holder-count-chart')).not.toBeInTheDocument(); + expect(screen.queryByTestId('registration-funnel')).not.toBeInTheDocument(); + }); + + it('keeps stats charts visible while a timeframe refetch is loading', () => { + setContext({ + buyVolumeLoading: true, + buyVolume: [{ timestamp: '2026-08-01T00:00:00.000Z', chf: 10, shares: 5, priceChf: 2 }], + holderCountLoading: true, + holderCount: [{ timestamp: '2026-08-01T00:00:00.000Z', holders: 3 }], + registrationLoading: true, + registrationStats: { + snapshot: { + completed: 1, + manualReview: 0, + confirmed: 1, + usersActive: 1, + usersNa: 0, + usersBlocked: 0, + usersDeleted: 0, + }, + series: [{ timestamp: '2026-08-01T00:00:00.000Z', registered: 1, confirmed: 1 }], + }, + }); + render(); + expect(screen.getByTestId('buy-volume-chart')).toBeInTheDocument(); + expect(screen.getByTestId('holder-count-chart')).toBeInTheDocument(); + expect(screen.getByTestId('registration-funnel')).toBeInTheDocument(); + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + }); + + it('does not render the registration funnel when stats failed without a snapshot', () => { + setContext({ + registrationError: true, + registrationStats: undefined, + registrationLoading: false, + }); + render(); + expect(screen.queryByTestId('registration-funnel')).not.toBeInTheDocument(); + expect(screen.getByText('Failed to load registration stats.')).toBeInTheDocument(); + }); + + it('shows token overview, totalCount fallback, price-history error, and support/compliance links', () => { + setContext({ totalCount: undefined, priceHistoryError: true }); + render(); + expect(screen.getByText('Holders')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getByText('1,000')).toBeInTheDocument(); + expect(screen.getByText(/2,000 REALU/)).toBeInTheDocument(); + expect(screen.getByTestId('error-hint')).toHaveTextContent('Failed to load price history.'); + fireEvent.click(screen.getByRole('button', { name: 'RealUnit Support' })); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/support'); + fireEvent.click(screen.getByRole('button', { name: 'RealUnit Compliance' })); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/compliance'); + fireEvent.click(screen.getByTestId('price-history-chart')); + expect(mockFetchPriceHistory).toHaveBeenCalled(); + }); + + it('shows the medium spinner while token info is loading', () => { + setContext({ isLoading: true, tokenInfo: undefined, holders: [HOLDER] }); + render(); + expect(screen.getByTestId('loading-spinner')).toHaveAttribute('data-size', 'md'); + expect(screen.queryByText('Overview')).not.toBeInTheDocument(); + }); + + it('navigates from a holder address and copies it, and shows More holders', () => { + setContext({ + holders: [ + HOLDER, + { address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', balance: '2', percentage: 0.2 }, + { address: '0xcccccccccccccccccccccccccccccccccccccccc', balance: '3', percentage: 0.3 }, + { address: '0xdddddddddddddddddddddddddddddddddddddddd', balance: '4', percentage: 0.4 }, + ], + }); + render(); + const holderButton = screen.getAllByRole('button').find((b) => b.textContent?.includes('0xabcd')); + if (!holderButton) { + throw new Error('holder address button missing'); + } + fireEvent.click(holderButton); + expect(mockNavigate).toHaveBeenCalledWith(`/realunit/user/${encodeURIComponent(HOLDER.address)}`); + fireEvent.click(screen.getAllByTestId('copy-button')[0]); + expect(mockCopy).toHaveBeenCalledWith(HOLDER.address); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/holders'); + }); + + it('shows address and userName on pending quotes and hides deactivated ones', () => { + setContext({ + quotes: [ + QUOTE, + { + ...QUOTE, + id: 99, + amount: 999, + userId: 99, + userName: 'Cancelled Person', + deactivatedAt: '2026-02-02T12:00:00.000Z', + }, + ], + }); + render(); + expect(screen.getAllByText('Address').length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText('User')).not.toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + expect(screen.getAllByText(QUOTE.userAddress as string).length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText('42')).not.toBeInTheDocument(); + expect(screen.queryByText('Cancelled Person')).not.toBeInTheDocument(); + fireEvent.click(screen.getAllByText(QUOTE.userAddress as string)[0]); + expect(mockCopy).toHaveBeenCalledWith(QUOTE.userAddress); + expect(screen.getByText('Copied')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('Ada Lovelace')); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/quotes/42'); + }); + + it('shows dashes when pending quote userAddress and userName are missing', () => { + setContext({ quotes: [{ ...QUOTE, userAddress: undefined, userName: undefined, amount: undefined }] }); + render(); + expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(2); + }); + + it('shows empty pending copy and a small spinner while quotes load', () => { + setContext({ quotes: [], quotesLoading: true }); + render(); + expect(screen.getByTestId('loading-spinner')).toHaveAttribute('data-size', 'sm'); + }); + + it('shows empty pending copy when only deactivated quotes exist', () => { + setContext({ + quotes: [{ ...QUOTE, deactivatedAt: '2026-02-02T12:00:00.000Z' }], + quotesLoading: false, + }); + render(); + expect(screen.getByText('No pending transactions found')).toBeInTheDocument(); + }); + + it('navigates to the full quotes list when more than three pending quotes exist', () => { + setContext({ + quotes: [ + { ...QUOTE, id: 1, userId: 1, userName: 'A' }, + { ...QUOTE, id: 2, userId: 2, userName: 'B' }, + { ...QUOTE, id: 3, userId: 3, userName: 'C' }, + { ...QUOTE, id: 4, userId: 4, userName: 'D' }, + ], + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/quotes'); + }); + + it('maps received transaction types, falls back to created date, and navigates to detail', () => { + setContext({ + quotes: [], + transactions: [ + { ...TX, id: 1, type: 'BuyFiat', userAddress: undefined, amountInChf: undefined, outputDate: undefined }, + { ...TX, id: 2, type: 'Other' }, + ], + }); + render(); + expect(screen.getByText('Sell')).toBeInTheDocument(); + expect(screen.getByText('Other')).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Other')); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/transactions/2'); + }); + + it('shows empty received copy and a small spinner while transactions load', () => { + setContext({ transactions: [], transactionsLoading: true, quotes: [QUOTE] }); + render(); + expect(screen.getByTestId('loading-spinner')).toHaveAttribute('data-size', 'sm'); + }); + + it('shows empty received copy when there are no transactions', () => { + setContext({ transactions: [], transactionsLoading: false }); + render(); + expect(screen.getByText('No received transactions found')).toBeInTheDocument(); + }); + + it('navigates to the full transactions list when more than three exist', () => { + setContext({ + quotes: [], + transactions: [ + { ...TX, id: 1 }, + { ...TX, id: 2 }, + { ...TX, id: 3 }, + { ...TX, id: 4 }, + ], + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'More' })); + expect(mockNavigate).toHaveBeenCalledWith('/realunit/transactions'); + }); + + it('maps pending quote displayType BuyCrypto, BuyFiat and passthrough', () => { + setContext({ + quotes: [ + { ...QUOTE, id: 1, type: 'BuyCrypto', userId: 1, userName: 'One' }, + { ...QUOTE, id: 2, type: 'BuyFiat', userId: 2, userName: 'Two' }, + { ...QUOTE, id: 3, type: 'Swap', userId: 3, userName: 'Three' }, + ], + transactions: [], + }); + render(); + expect(screen.getByText('Buy')).toBeInTheDocument(); + expect(screen.getByText('Sell')).toBeInTheDocument(); + expect(screen.getByText('Swap')).toBeInTheDocument(); + }); + + it('fetches tokenInfo on mount when it is missing and holders already exist', () => { + setContext({ tokenInfo: undefined, holders: [HOLDER], isLoading: false }); + render(); + expect(mockFetchTokenInfo).toHaveBeenCalled(); + }); +}); diff --git a/src/components/compliance/call-queue/call-queue-outcome-form.tsx b/src/components/compliance/call-queue/call-queue-outcome-form.tsx index dc15589ff..7045dadcd 100644 --- a/src/components/compliance/call-queue/call-queue-outcome-form.tsx +++ b/src/components/compliance/call-queue/call-queue-outcome-form.tsx @@ -15,10 +15,16 @@ import { useStaffVerifiedName } from 'src/hooks/staff-verified-name.hook'; import { canManuallySetAmlPass } from 'src/util/aml-pass.util'; import { STAFF_NAME_MISSING, staffNameLoadError } from '../staff-identity'; -// Completed and Failed leave nothing to decide: the phone-call status written in the same save -// already determines the transaction — the API passes it on the check date a completed call writes, -// and fails it on a failed one (`UserDataFailedCall`). -const OutcomesImplyingAmlAction: (CallOutcome | '')[] = [CallOutcome.COMPLETED, CallOutcome.FAILED]; +// These outcomes leave nothing to decide: the save itself already determines the transaction — the API +// passes it on the check date a completed call writes, fails it on a failed one (`UserDataFailedCall`), +// and on Repeat releases this one transaction while the account stays in the queue. Offering an AML +// action on top would only invite the combination that cannot work: a plain reset re-runs the very +// check the missing check date is still failing. +const OutcomesImplyingAmlAction: (CallOutcome | '')[] = [ + CallOutcome.COMPLETED, + CallOutcome.FAILED, + CallOutcome.REPEAT, +]; interface Props { context: CallOutcomeContext; @@ -58,10 +64,11 @@ export function CallQueueOutcomeForm({ const canSubmit = !!signature && !!outcome && !!comment.trim() && !isSaving && !isLoadingSignature && !impliedResetUnavailable; - // What the save sends for a Completed/Failed outcome. Only the queues whose AML reason the API - // excludes from the recheck cron need an explicit action: their transaction would otherwise stay + // What the save sends for an outcome that implies its action. Only the queues whose AML reason the + // API excludes from the recheck cron need an explicit action: their transaction would otherwise stay // Pending forever. Reset (never Pass/Fail) clears amlCheck + amlReason so the cron re-runs the FULL - // AML check on the state the call just produced — the API decides the outcome, not the tool. + // AML check on the state the call just produced — the API decides the outcome, not the tool. On + // Repeat the release is written before this reset, so the re-run no longer sees the call-queue error. function impliedAmlAction(): AmlAction | undefined { if (!needsExplicitAmlReset(context.queue) || buyCryptoResetUnavailable) return undefined; return 'Reset'; @@ -69,8 +76,8 @@ export function CallQueueOutcomeForm({ function handleOutcomeChange(value: CallOutcome | '') { setOutcome(value); - // Drop a selection made before the outcome was known; it is not submitted for Completed/Failed - // and must not reappear when the clerk switches back to another outcome. + // Drop a selection made before the outcome was known; it is not submitted for the outcomes that + // imply their action and must not reappear when the clerk switches back to another outcome. setAmlAction(''); } diff --git a/src/components/realunit/buy-volume-chart.tsx b/src/components/realunit/buy-volume-chart.tsx new file mode 100644 index 000000000..f9f351d1a --- /dev/null +++ b/src/components/realunit/buy-volume-chart.tsx @@ -0,0 +1,97 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo, useState } from 'react'; +import Chart from 'react-apexcharts'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RealUnitBuyVolumePoint } from 'src/dto/realunit.dto'; +import { Timeframe } from 'src/util/chart'; +import { ButtonGroup } from '../safe/button-group'; + +const SUPPORTED_TIMEFRAMES: Timeframe[] = [ + Timeframe.WEEK, + Timeframe.MONTH, + Timeframe.QUARTER, + Timeframe.YEAR, + Timeframe.ALL, +]; + +type VolumeMetric = 'chf' | 'shares'; + +interface BuyVolumeChartProps { + timeframe: Timeframe; + series: RealUnitBuyVolumePoint[]; + onTimeframeChange: (timeframe: Timeframe) => void; +} + +export const BuyVolumeChart = ({ timeframe, series, onTimeframeChange }: BuyVolumeChartProps) => { + const { translate } = useSettingsContext(); + const [metric, setMetric] = useState('chf'); + + const chartOptions = useMemo((): ApexOptions => { + return { + theme: { monochrome: { color: '#092f62', enabled: true } }, + chart: { + type: 'line', + dropShadow: { enabled: false }, + toolbar: { show: false }, + zoom: { enabled: false }, + background: '0', + }, + stroke: { width: metric === 'chf' ? [0, 3] : 0 }, + dataLabels: { enabled: false }, + grid: { show: false }, + xaxis: { + type: 'datetime', + labels: { show: false, datetimeUTC: false, format: 'dd MMM' }, + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + yaxis: + metric === 'chf' + ? [ + { show: false, min: 0 }, + { show: false, opposite: true, min: 0 }, + ] + : { show: false, min: 0 }, + tooltip: { x: { format: 'dd MMM yyyy' } }, + }; + }, [metric]); + + const chartSeries = useMemo(() => { + const volume = { + name: metric === 'chf' ? 'CHF' : translate('screens/realunit', 'Shares'), + type: 'column' as const, + data: series.map((point) => [new Date(point.timestamp).getTime(), metric === 'chf' ? point.chf : point.shares]), + }; + if (metric !== 'chf') return [volume]; + return [ + volume, + { + name: translate('screens/realunit', 'Price'), + type: 'line' as const, + data: series.map((point) => [new Date(point.timestamp).getTime(), Number(point.priceChf.toFixed(4))]), + }, + ]; + }, [series, metric, translate]); + + return ( +
+
+ + items={['chf', 'shares']} + selected={metric} + onClick={setMetric} + buttonLabel={(item) => (item === 'chf' ? 'CHF' : translate('screens/realunit', 'Shares'))} + /> +
+ +
+ + items={SUPPORTED_TIMEFRAMES} + selected={timeframe} + onClick={onTimeframeChange} + buttonLabel={(tf) => tf} + /> +
+
+ ); +}; diff --git a/src/components/realunit/copyable-address.tsx b/src/components/realunit/copyable-address.tsx new file mode 100644 index 000000000..7bc2b95f1 --- /dev/null +++ b/src/components/realunit/copyable-address.tsx @@ -0,0 +1,42 @@ +import { CopyButton, IconColor } from '@dfx.swiss/react-components'; +import { useEffect, useRef, useState } from 'react'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { useClipboard } from 'src/hooks/clipboard.hook'; +import { blankedAddress } from 'src/util/utils'; + +interface CopyableAddressProps { + address?: string; + displayLength?: number; +} + +export const CopyableAddress = ({ address, displayLength = 12 }: CopyableAddressProps) => { + const { copy } = useClipboard(); + const { translate } = useSettingsContext(); + const [copied, setCopied] = useState(false); + const resetTimer = useRef(); + + useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + if (!address) return <>-; + + const copyNow = () => { + copy(address); + setCopied(true); + window.clearTimeout(resetTimer.current); + resetTimer.current = window.setTimeout(() => setCopied(false), 1000); + }; + + return ( +
event.stopPropagation()}> + + + {copied && {translate('general/actions', 'Copied')}} +
+ ); +}; diff --git a/src/components/realunit/holder-count-chart.tsx b/src/components/realunit/holder-count-chart.tsx new file mode 100644 index 000000000..3fc6c685f --- /dev/null +++ b/src/components/realunit/holder-count-chart.tsx @@ -0,0 +1,80 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RealUnitHolderCountPoint } from 'src/dto/realunit.dto'; +import { Timeframe } from 'src/util/chart'; +import { ButtonGroup } from '../safe/button-group'; + +const SUPPORTED_TIMEFRAMES: Timeframe[] = [ + Timeframe.WEEK, + Timeframe.MONTH, + Timeframe.QUARTER, + Timeframe.YEAR, + Timeframe.ALL, +]; + +interface HolderCountChartProps { + timeframe: Timeframe; + series: RealUnitHolderCountPoint[]; + onTimeframeChange: (timeframe: Timeframe) => void; +} + +export const HolderCountChart = ({ timeframe, series, onTimeframeChange }: HolderCountChartProps) => { + const { translate } = useSettingsContext(); + + const maxHolders = useMemo(() => Math.max(...series.map((point) => point.holders), 0), [series]); + + const chartOptions = useMemo((): ApexOptions => { + return { + theme: { monochrome: { color: '#092f62', enabled: true } }, + chart: { + type: 'area', + dropShadow: { enabled: false }, + toolbar: { show: false }, + zoom: { enabled: false }, + background: '0', + }, + stroke: { width: 3 }, + dataLabels: { enabled: false }, + grid: { show: false }, + xaxis: { + type: 'datetime', + labels: { show: false, datetimeUTC: false, format: 'dd MMM' }, + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + yaxis: { show: false, min: 0, max: maxHolders * 1.5 || 1 }, + fill: { + colors: ['#5A81BB'], + type: 'gradient', + gradient: { type: 'vertical', opacityFrom: 1, opacityTo: 0.0 }, + }, + tooltip: { x: { format: 'dd MMM yyyy' } }, + }; + }, [maxHolders]); + + const chartSeries = useMemo( + () => [ + { + name: translate('screens/realunit', 'Holders'), + data: series.map((point) => [new Date(point.timestamp).getTime(), point.holders]), + }, + ], + [series, translate], + ); + + return ( +
+ +
+ + items={SUPPORTED_TIMEFRAMES} + selected={timeframe} + onClick={onTimeframeChange} + buttonLabel={(tf) => tf} + /> +
+
+ ); +}; diff --git a/src/components/realunit/registration-funnel.tsx b/src/components/realunit/registration-funnel.tsx new file mode 100644 index 000000000..2b2e0ab02 --- /dev/null +++ b/src/components/realunit/registration-funnel.tsx @@ -0,0 +1,82 @@ +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RealUnitRegistrationStats } from 'src/dto/realunit.dto'; + +interface RegistrationFunnelProps { + stats: RealUnitRegistrationStats; +} + +interface FunnelStage { + key: string; + value: number; +} + +function pct(part: number, whole: number): number { + return Math.round((part / whole) * 100); +} + +function FunnelConnector({ fromPct, toPct }: { fromPct: number; toPct: number }) { + const topLeft = (100 - fromPct) / 2; + const topRight = topLeft + fromPct; + const botLeft = (100 - toPct) / 2; + const botRight = botLeft + toPct; + + return ( + + + + ); +} + +export const RegistrationFunnel = ({ stats }: RegistrationFunnelProps) => { + const { translate } = useSettingsContext(); + const { completed, manualReview, confirmed } = stats.snapshot; + const registered = completed + manualReview; + const head = Math.max(registered, 1); + const stages: FunnelStage[] = [ + { key: 'Registered', value: registered }, + { key: 'Completed', value: completed }, + ]; + + return ( +
+ {stages.map((stage, index) => { + const previous = index > 0 ? stages[index - 1] : stage; + const drop = previous.value - stage.value; + const widthPct = Math.min(100, (stage.value / head) * 100); + const prevWidthPct = Math.min(100, (previous.value / head) * 100); + + return ( +
+ {index > 0 && ( +
+ + {drop > 0 && ( +
+
+
+ −{drop.toLocaleString()} (−{pct(drop, previous.value)}%) +
+
{translate('screens/realunit', 'Manual review')}
+
+
+ )} +
+ )} +
+
{translate('screens/realunit', stage.key)}
+
+ {stage.value.toLocaleString()} + {pct(stage.value, head)}% +
+
+
+ ); + })} +
+ {translate('screens/realunit', 'Confirmed')}{' '} + {confirmed.toLocaleString()} + {pct(confirmed, head)}% +
+
+ ); +}; diff --git a/src/contexts/realunit.context.tsx b/src/contexts/realunit.context.tsx index 6982436a4..27275c459 100644 --- a/src/contexts/realunit.context.tsx +++ b/src/contexts/realunit.context.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { AccountHistory, AccountSummary, @@ -6,7 +6,10 @@ import { PageInfo, PaginationDirection, PriceHistoryEntry, + RealUnitBuyVolumePoint, + RealUnitHolderCountPoint, RealUnitQuote, + RealUnitRegistrationStats, RealUnitTransaction, RealunitContextInterface, TokenInfo, @@ -44,6 +47,21 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El const [quotesError, setQuotesError] = useState(false); const [transactionsError, setTransactionsError] = useState(false); const [priceHistoryError, setPriceHistoryError] = useState(false); + const [buyVolume, setBuyVolume] = useState([]); + const [buyVolumeLoading, setBuyVolumeLoading] = useState(false); + const [buyVolumeError, setBuyVolumeError] = useState(false); + const [holderCount, setHolderCount] = useState([]); + const [holderCountLoading, setHolderCountLoading] = useState(false); + const [holderCountError, setHolderCountError] = useState(false); + const [registrationStats, setRegistrationStats] = useState(); + const [registrationLoading, setRegistrationLoading] = useState(false); + const [registrationError, setRegistrationError] = useState(false); + const [buyVolumeTimeframe, setBuyVolumeTimeframe] = useState(Timeframe.ALL); + const [holderCountTimeframe, setHolderCountTimeframe] = useState(Timeframe.ALL); + const [registrationTimeframe, setRegistrationTimeframe] = useState(Timeframe.ALL); + const buyVolumeRequest = useRef(0); + const holderCountRequest = useRef(0); + const registrationRequest = useRef(0); const { getAccountSummary, @@ -56,6 +74,9 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El getAdminTransactions, confirmPayment, deactivateQuote, + getBuyVolume, + getRegistrationStats, + getHolderCount, } = useRealunitApi(); const fetchAccountSummary = useCallback( @@ -149,6 +170,75 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El .finally(() => setTransactionsLoading(false)); }, [transactions.length]); + const fetchBuyVolume = useCallback( + (nextTimeframe = Timeframe.ALL) => { + const requestId = ++buyVolumeRequest.current; + setBuyVolumeLoading(true); + setBuyVolumeError(false); + getBuyVolume(nextTimeframe) + .then((data) => { + if (requestId !== buyVolumeRequest.current) return; + setBuyVolume(data); + setBuyVolumeTimeframe(nextTimeframe); + }) + .catch(() => { + if (requestId !== buyVolumeRequest.current) return; + setBuyVolumeError(true); + }) + .finally(() => { + if (requestId !== buyVolumeRequest.current) return; + setBuyVolumeLoading(false); + }); + }, + [getBuyVolume], + ); + + const fetchHolderCount = useCallback( + (nextTimeframe = Timeframe.ALL) => { + const requestId = ++holderCountRequest.current; + setHolderCountLoading(true); + setHolderCountError(false); + getHolderCount(nextTimeframe) + .then((data) => { + if (requestId !== holderCountRequest.current) return; + setHolderCount(data); + setHolderCountTimeframe(nextTimeframe); + }) + .catch(() => { + if (requestId !== holderCountRequest.current) return; + setHolderCountError(true); + }) + .finally(() => { + if (requestId !== holderCountRequest.current) return; + setHolderCountLoading(false); + }); + }, + [getHolderCount], + ); + + const fetchRegistrationStats = useCallback( + (nextTimeframe = Timeframe.ALL) => { + const requestId = ++registrationRequest.current; + setRegistrationLoading(true); + setRegistrationError(false); + getRegistrationStats(nextTimeframe) + .then((data) => { + if (requestId !== registrationRequest.current) return; + setRegistrationStats(data); + setRegistrationTimeframe(nextTimeframe); + }) + .catch(() => { + if (requestId !== registrationRequest.current) return; + setRegistrationError(true); + }) + .finally(() => { + if (requestId !== registrationRequest.current) return; + setRegistrationLoading(false); + }); + }, + [getRegistrationStats], + ); + const context = useMemo( () => ({ accountSummary, @@ -168,6 +258,15 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El quotesError, transactionsError, priceHistoryError, + buyVolume, + buyVolumeLoading, + buyVolumeError, + holderCount, + holderCountLoading, + holderCountError, + registrationStats, + registrationLoading, + registrationError, fetchAccountSummary, fetchAccountHistory, fetchHolders, @@ -179,6 +278,12 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El fetchTransactions, confirmPayment, deactivateQuote, + buyVolumeTimeframe, + holderCountTimeframe, + registrationTimeframe, + fetchBuyVolume, + fetchHolderCount, + fetchRegistrationStats, }), [ accountSummary, @@ -198,6 +303,18 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El quotesError, transactionsError, priceHistoryError, + buyVolume, + buyVolumeLoading, + buyVolumeError, + holderCount, + holderCountLoading, + holderCountError, + registrationStats, + registrationLoading, + registrationError, + buyVolumeTimeframe, + holderCountTimeframe, + registrationTimeframe, fetchAccountSummary, fetchAccountHistory, fetchHolders, @@ -209,6 +326,9 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El fetchTransactions, confirmPayment, deactivateQuote, + fetchBuyVolume, + fetchHolderCount, + fetchRegistrationStats, ], ); diff --git a/src/dto/realunit.dto.ts b/src/dto/realunit.dto.ts index 9a7dbd4b3..7a6cba781 100644 --- a/src/dto/realunit.dto.ts +++ b/src/dto/realunit.dto.ts @@ -103,6 +103,8 @@ export interface RealUnitQuote { estimatedAmount: number; created: string; userAddress?: string; + userId?: number; + userName?: string; deactivatedAt?: string; } @@ -121,6 +123,39 @@ export interface RealUnitTransaction { userAddress?: string; } +export interface RealUnitBuyVolumePoint { + timestamp: string; + chf: number; + shares: number; + priceChf: number; +} + +export interface RealUnitRegistrationSnapshot { + completed: number; + manualReview: number; + confirmed: number; + usersActive: number; + usersNa: number; + usersBlocked: number; + usersDeleted: number; +} + +export interface RealUnitRegistrationSeriesPoint { + timestamp: string; + registered: number; + confirmed: number; +} + +export interface RealUnitRegistrationStats { + snapshot: RealUnitRegistrationSnapshot; + series: RealUnitRegistrationSeriesPoint[]; +} + +export interface RealUnitHolderCountPoint { + timestamp: string; + holders: number; +} + export interface RealunitContextInterface { accountSummary?: AccountSummary; history?: AccountHistory; @@ -139,6 +174,18 @@ export interface RealunitContextInterface { quotesError: boolean; transactionsError: boolean; priceHistoryError: boolean; + buyVolume: RealUnitBuyVolumePoint[]; + buyVolumeLoading: boolean; + buyVolumeError: boolean; + holderCount: RealUnitHolderCountPoint[]; + holderCountLoading: boolean; + holderCountError: boolean; + registrationStats?: RealUnitRegistrationStats; + registrationLoading: boolean; + registrationError: boolean; + buyVolumeTimeframe: Timeframe; + holderCountTimeframe: Timeframe; + registrationTimeframe: Timeframe; fetchAccountSummary: (address: string) => void; fetchAccountHistory: (address: string, cursor?: string, direction?: PaginationDirection) => void; fetchHolders: (cursor?: string, direction?: PaginationDirection) => void; @@ -150,4 +197,7 @@ export interface RealunitContextInterface { fetchTransactions: () => void; confirmPayment: (id: number) => Promise; deactivateQuote: (id: number) => Promise; + fetchBuyVolume: (timeframe?: Timeframe) => void; + fetchHolderCount: (timeframe?: Timeframe) => void; + fetchRegistrationStats: (timeframe?: Timeframe) => void; } diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index 658b6906f..6d5074a0a 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -1059,6 +1059,20 @@ export function useCompliance() { // 2) Transaction update (if applicable) try { + // Repeat releases THIS transaction while the account stays in the queue: no check date was written + // above, so the AML re-run would otherwise hold it for the very same reason again. The API clears + // only the call-queue errors for this transaction and still decides the verdict itself. Runs before + // any reset, because it is only accepted while the transaction still carries its queue reason. + if (tx && outcome === CallOutcome.REPEAT) { + if (tx.sourceType === 'BuyCrypto') await clearBuyCryptoPhoneCall(tx.id); + else await clearBuyFiatPhoneCall(tx.id); + results.push({ + table: tx.sourceType === 'BuyCrypto' ? 'buyCrypto' : 'buyFiat', + column: 'phoneCallClearedDate', + value: 'released for this transaction', + }); + completedSteps.push('transaction'); + } if (tx && options.amlAction) { const signature = options.signature.trim(); const txTable = tx.sourceType === 'BuyCrypto' ? 'buyCrypto' : 'buyFiat'; @@ -1092,7 +1106,7 @@ export function useCompliance() { } else await resetBuyFiatAml(tx.id); results.push({ table: txTable, column: 'amlCheck', value: 'Reset' }); } - completedSteps.push('transaction'); + if (!completedSteps.includes('transaction')) completedSteps.push('transaction'); } } catch (e) { return fail('transaction', e); @@ -1436,6 +1450,22 @@ export function useCompliance() { }); } + // Releases a single transaction from its call queue without writing a check date on the account, so + // the account is called again on its next transaction. Sets no verdict — the API's AML run decides. + async function clearBuyCryptoPhoneCall(id: number): Promise { + return call({ + url: `buyCrypto/${id}/phoneCallCleared`, + method: 'PUT', + }); + } + + async function clearBuyFiatPhoneCall(id: number): Promise { + return call({ + url: `buyFiat/${id}/phoneCallCleared`, + method: 'PUT', + }); + } + async function updateBuyCryptoAmlCheck( id: number, data: { amlCheck: CheckStatus; amlReason?: AmlReason; responsible: string }, diff --git a/src/hooks/realunit-api.hook.ts b/src/hooks/realunit-api.hook.ts index c7e21a23c..ea39fc4a3 100644 --- a/src/hooks/realunit-api.hook.ts +++ b/src/hooks/realunit-api.hook.ts @@ -6,7 +6,10 @@ import { HoldersResponse, PaginationDirection, PriceHistoryEntry, + RealUnitBuyVolumePoint, + RealUnitHolderCountPoint, RealUnitQuote, + RealUnitRegistrationStats, RealUnitTransaction, TokenInfo, TokenPrice, @@ -108,6 +111,33 @@ export function useRealunitApi() { }); } + async function getBuyVolume(timeFrame: Timeframe): Promise { + const params = new URLSearchParams(); + params.set('timeFrame', timeFrame.toUpperCase()); + return call({ + url: apiUrl({ path: 'realunit/admin/stats/buy-volume', params }), + method: 'GET', + }); + } + + async function getRegistrationStats(timeFrame: Timeframe): Promise { + const params = new URLSearchParams(); + params.set('timeFrame', timeFrame.toUpperCase()); + return call({ + url: apiUrl({ path: 'realunit/admin/stats/registration', params }), + method: 'GET', + }); + } + + async function getHolderCount(timeFrame: Timeframe): Promise { + const params = new URLSearchParams(); + params.set('timeFrame', timeFrame.toUpperCase()); + return call({ + url: apiUrl({ path: 'realunit/admin/stats/holders', params }), + method: 'GET', + }); + } + return useMemo( () => ({ getAccountSummary, @@ -120,6 +150,9 @@ export function useRealunitApi() { getAdminTransactions, confirmPayment, deactivateQuote, + getBuyVolume, + getRegistrationStats, + getHolderCount, }), [call], ); diff --git a/src/screens/realunit-quote-detail.screen.tsx b/src/screens/realunit-quote-detail.screen.tsx index 6bc2e2067..4f79fe4d3 100644 --- a/src/screens/realunit-quote-detail.screen.tsx +++ b/src/screens/realunit-quote-detail.screen.tsx @@ -104,7 +104,7 @@ export default function RealunitQuoteDetailScreen(): JSX.Element { {quote.estimatedAmount?.toLocaleString()} - {translate('screens/realunit', 'User')} + {translate('screens/realunit', 'Address')} {userAddress ? (
+
+

{translate('screens/realunit', 'Buy Volume')}

+ {buyVolumeLoading && !buyVolume.length ? ( + + ) : ( + + )} + {buyVolumeError && ( +
+ +
+ )} +
+ +
+

{translate('screens/realunit', 'Holders over time')}

+ {holderCountLoading && !holderCount.length ? ( + + ) : ( + + )} + {holderCountError && ( +
+ +
+ )} +
+ +
+

{translate('screens/realunit', 'Registration')}

+ {registrationLoading && !registrationStats ? ( + + ) : ( + registrationStats && + )} + {registrationError && ( +
+ +
+ )} +
+ {isLoading ? (
@@ -232,7 +312,10 @@ export default function RealunitScreen(): JSX.Element { {translate('screens/realunit', 'Amount')} - {translate('screens/realunit', 'User')} + {translate('screens/realunit', 'Address')} + + + {translate('screens/realunit', 'Name')} {translate('screens/realunit', 'Created')} @@ -249,16 +332,19 @@ export default function RealunitScreen(): JSX.Element { {displayType(quote.type)} {quote.amount?.toLocaleString()} - {quote.userAddress ? blankedAddress(quote.userAddress, { displayLength: 12 }) : '-'} + + + + {quote.userName ? quote.userName : '-'} {formatSwissDateTimeWithSeconds(quote.created)} ))} - {!quotes.length && !quotesLoading && ( + {!pendingQuotes.length && !quotesLoading && ( - + {translate('screens/realunit', 'No pending transactions found')} @@ -272,7 +358,7 @@ export default function RealunitScreen(): JSX.Element { )}
- {quotes.length > 3 && ( + {pendingQuotes.length > 3 && (
- {translate('screens/realunit', 'User')} + {translate('screens/realunit', 'Address')} {translate('screens/realunit', 'Date')} @@ -310,9 +396,7 @@ export default function RealunitScreen(): JSX.Element { onClick={() => navigate(`/realunit/transactions/${tx.id}`)} > {displayType(tx.type)} - - {tx.amountInChf?.toLocaleString()} - + {tx.amountInChf?.toLocaleString()} {tx.userAddress ? blankedAddress(tx.userAddress, { displayLength: 12 }) : '-'} diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index 63ddd2a7e..8fcfd553c 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -12,6 +12,7 @@ "Cancel": "Abbrechen", "Finish": "Fertig", "Copy": "Kopieren", + "Copied": "Kopiert", "Save": "Speichern", "Delete": "Löschen", "Rename": "Umbenennen", @@ -1103,7 +1104,7 @@ "No received transactions found": "Keine erhaltenen Transaktionen gefunden", "Type": "Typ", "Amount": "Betrag", - "User": "Benutzer", + "Name": "Name", "Created": "Erstellt", "Amount CHF": "Betrag CHF", "Date": "Datum", @@ -1112,7 +1113,17 @@ "Payment confirmed successfully": "Zahlungseingang erfolgreich bestätigt", "Deactivate Quote": "Angebot deaktivieren", "Are you sure you want to deactivate this quote?": "Möchten Sie dieses Angebot wirklich deaktivieren?", - "Deactivated At": "Deaktiviert am" + "Deactivated At": "Deaktiviert am", + "Buy Volume": "Kaufvolumen", + "Holders over time": "Holder im Zeitverlauf", + "Completed": "Abgeschlossen", + "Registration": "Registrierung", + "Manual review": "Manuelle Prüfung", + "Confirmed": "Bestätigt", + "Registered": "Registriert", + "Failed to load buy volume.": "Kaufvolumen konnte nicht geladen werden.", + "Failed to load holder count.": "Holder-Anzahl konnte nicht geladen werden.", + "Failed to load registration stats.": "Registrierungsdaten konnten nicht geladen werden." }, "screens/blockchain": { "Transaction signing": "Transaktionssignierung", diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index 335ade57a..f8f7f471b 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -12,6 +12,7 @@ "Cancel": "Annuler", "Finish": "Terminé", "Copy": "Copier", + "Copied": "Copié", "Save": "Économiser", "Delete": "Supprimer", "Rename": "Renommer", @@ -1102,7 +1103,7 @@ "No received transactions found": "Aucune transaction reçue trouvée", "Type": "Type", "Amount": "Montant", - "User": "Utilisateur", + "Name": "Nom", "Created": "Créé", "Amount CHF": "Montant CHF", "Date": "Date", @@ -1111,7 +1112,17 @@ "Payment confirmed successfully": "Réception du paiement confirmée avec succès", "Deactivate Quote": "Désactiver l'offre", "Are you sure you want to deactivate this quote?": "Êtes-vous sûr de vouloir désactiver cette offre?", - "Deactivated At": "Désactivé le" + "Deactivated At": "Désactivé le", + "Buy Volume": "Volume d'achat", + "Holders over time": "Détenteurs dans le temps", + "Completed": "Terminé", + "Registration": "Inscription", + "Manual review": "Revue manuelle", + "Confirmed": "Confirmé", + "Registered": "Inscrits", + "Failed to load buy volume.": "Impossible de charger le volume d'achat.", + "Failed to load holder count.": "Impossible de charger le nombre de détenteurs.", + "Failed to load registration stats.": "Impossible de charger les inscriptions." }, "screens/blockchain": { "Transaction signing": "Signature de la transaction", diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index b8adaaed4..369ed8316 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -12,6 +12,7 @@ "Cancel": "Annullare", "Finish": "Finitura", "Copy": "Copia", + "Copied": "Copiato", "Save": "Salvare", "Delete": "Eliminare", "Rename": "Rinominare", @@ -1102,7 +1103,7 @@ "No received transactions found": "Nessuna transazione ricevuta trovata", "Type": "Tipo", "Amount": "Importo", - "User": "Utente", + "Name": "Nome", "Created": "Creato", "Amount CHF": "Importo CHF", "Date": "Data", @@ -1111,7 +1112,17 @@ "Payment confirmed successfully": "Ricezione del pagamento confermata con successo", "Deactivate Quote": "Disattiva offerta", "Are you sure you want to deactivate this quote?": "Sei sicuro di voler disattivare questa offerta?", - "Deactivated At": "Disattivato il" + "Deactivated At": "Disattivato il", + "Buy Volume": "Volume di acquisto", + "Holders over time": "Holder nel tempo", + "Completed": "Completato", + "Registration": "Registrazione", + "Manual review": "Revisione manuale", + "Confirmed": "Confermato", + "Registered": "Registrati", + "Failed to load buy volume.": "Impossibile caricare il volume di acquisto.", + "Failed to load holder count.": "Impossibile caricare il numero di holder.", + "Failed to load registration stats.": "Impossibile caricare le registrazioni." }, "screens/blockchain": { "Transaction signing": "Firma della transazione",