diff --git a/apps/web/e2e/infakt-connection.mjs b/apps/web/e2e/infakt-connection.mjs new file mode 100644 index 000000000..000e3e34c --- /dev/null +++ b/apps/web/e2e/infakt-connection.mjs @@ -0,0 +1,106 @@ +/** + * inFakt setup-guide screenshot capture — connection walkthrough. + * + * Drives the running web app (:4173): opens /connections/new, selects the + * inFakt card, fills out the guided setup form with the real sandbox API + * key, submits, and runs "Test connection". Captures libs/integrations/infakt/docs/assets/. + * + * Presentation requirement (these screenshots are reused verbatim in the + * operator-facing setup guide): the API-key input stays `type="password"` + * throughout — this script never switches it to reveal the raw key, and + * never logs the key to stdout. Use a realistic connection name, not + * placeholder junk. + * + * Usage: node apps/web/e2e/infakt-connection.mjs + * Env: + * WEB_BASE web preview base (default http://localhost:4173) + * INFAKT_SANDBOX_API_KEY required — real inFakt sandbox API key + * INFAKT_CONN_NAME connection name (default "inFakt Sandbox") + */ +import { chromium } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SHOTS = resolve(__dirname, '../../../libs/integrations/infakt/docs/assets'); +const BASE = process.env.WEB_BASE ?? 'http://localhost:4173'; +const API_KEY = process.env.INFAKT_SANDBOX_API_KEY ?? ''; +const CONN_NAME = process.env.INFAKT_CONN_NAME ?? 'inFakt Sandbox'; +// inFakt's adapter defaults to the production base URL — sandbox key holders +// must override it, or every call (including the connection-test probe) 401s. +const INFAKT_BASE_URL = process.env.INFAKT_BASE_URL ?? 'https://api.sandbox-infakt.pl/api/v3'; + +if (!API_KEY) { + console.error('INFAKT_CONNECTION_ERROR: INFAKT_SANDBOX_API_KEY is required (not committed, not logged).'); + process.exit(1); +} + +async function forceLightTheme(page) { + // These screenshots are reused in the tutorial — always capture OpenLinker's + // light theme regardless of the OS/browser color-scheme preference. + await page.addInitScript(() => { + window.localStorage.setItem('openlinker.theme', 'light'); + }); +} + +async function login(page) { + await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); + await page.getByPlaceholder('Enter your username').fill('operator'); + await page.getByPlaceholder('Enter your password').fill('infakt-e2e-pw'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 15000 }); +} + +async function shot(page, name) { + await page.waitForTimeout(700); + await page.screenshot({ path: resolve(SHOTS, `${name}.png`), fullPage: true }); + console.log('captured', name); +} + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +try { + await forceLightTheme(page); + await login(page); + + // 1. Platform picker — inFakt card visible. + await page.goto(`${BASE}/connections/new`, { waitUntil: 'networkidle' }); + await shot(page, '00-platform-picker'); + + // 2. Guided setup form (empty). + const infaktCard = page.locator('.platform-picker__card', { hasText: 'inFakt' }); + await infaktCard.click(); + await page.waitForURL(/\/connections\/new\/infakt/, { timeout: 10000 }); + await shot(page, '01-infakt-wizard-empty'); + + // 3. Filled form — realistic name, real sandbox key (masked on screen). + await page.getByLabel('Connection name').fill(CONN_NAME); + await page.getByLabel('API key').fill(API_KEY); + await page.getByLabel(/Base URL/i).fill(INFAKT_BASE_URL); + await shot(page, '02-infakt-wizard-filled'); + + // 4. Submit — connection created. + await page.getByRole('button', { name: 'Connect inFakt' }).click(); + await page.waitForTimeout(1500); + await shot(page, '03-infakt-connection-created'); + + // 5. Test connection. + const testBtn = page.getByRole('button', { name: 'Test connection' }); + if (await testBtn.count()) { + await testBtn.click(); + await page.waitForTimeout(3000); + await shot(page, '04-infakt-connection-test-ok'); + } + + // 6. Connections list — the new inFakt connection visible. + await page.goto(`${BASE}/connections`, { waitUntil: 'networkidle' }); + await shot(page, '05-connections-list-with-infakt'); + + console.log('DONE'); +} catch (err) { + console.error('INFAKT_CONNECTION_ERROR', err.message); + await page.screenshot({ path: resolve(SHOTS, 'error-connection.png'), fullPage: true }).catch(() => {}); + process.exitCode = 1; +} finally { + await browser.close(); +} diff --git a/apps/web/e2e/infakt-invoice.mjs b/apps/web/e2e/infakt-invoice.mjs new file mode 100644 index 000000000..1faef7aed --- /dev/null +++ b/apps/web/e2e/infakt-invoice.mjs @@ -0,0 +1,176 @@ +/** + * inFakt setup-guide screenshot capture — issue an invoice through inFakt. + * + * Drives the running web app (:4173): opens a real ingested order, issues an + * invoice through the order-detail Invoice panel, waits for the real + * OL -> InfaktInvoicingAdapter -> inFakt sandbox -> KSeF round-trip, then + * captures the submitted (pending) and accepted (cleared) reg-card states, + * and drives one KOR correction. Captures into libs/integrations/infakt/docs/assets/. + * + * The document is created end-to-end against the real inFakt sandbox — this + * script only drives + screenshots the OL browser side. + * + * Presentation requirement: use a real seeded order, let every toast/alert + * settle before each shot, and re-take any frame that shows a dev artifact + * (raw JSON, console error toast, stray localhost URL) rather than shipping + * it — these screenshots are reused verbatim in the operator setup guide. + * + * Usage: node apps/web/e2e/infakt-invoice.mjs + * Env: + * WEB_BASE web preview base (default http://localhost:4173) + * ORDER_ID OL order id to issue against — required (no order picked automatically, + * to avoid accidentally issuing against the wrong seeded fixture) + * INFAKT_CONN_NAME connection label to pick when >1 invoicing connection + * CLEARANCE_POLL_MS max time to poll for KSeF acceptance (default 120000 — sandbox clears + * in ~90s per the feasibility POC) + */ +import { chromium } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SHOTS = resolve(__dirname, '../../../libs/integrations/infakt/docs/assets'); +const BASE = process.env.WEB_BASE ?? 'http://localhost:4173'; +const ORDER_ID = process.env.ORDER_ID ?? ''; +const CONN_NAME = process.env.INFAKT_CONN_NAME ?? 'inFakt'; +const CONNECTION_ID = process.env.INFAKT_CONNECTION_ID ?? ''; +const CLEARANCE_POLL_MS = Number(process.env.CLEARANCE_POLL_MS ?? 120000); + +if (!ORDER_ID) { + console.error('INFAKT_INVOICE_ERROR: ORDER_ID is required — pick a real seeded order id.'); + process.exit(1); +} + +async function forceLightTheme(page) { + // These screenshots are reused in the tutorial — always capture OpenLinker's + // light theme regardless of the OS/browser color-scheme preference. + await page.addInitScript(() => { + window.localStorage.setItem('openlinker.theme', 'light'); + }); +} + +async function login(page) { + await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); + await page.getByPlaceholder('Enter your username').fill('operator'); + await page.getByPlaceholder('Enter your password').fill('infakt-e2e-pw'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 15000 }); +} + +async function shot(page, name) { + await page.waitForTimeout(700); + await page.screenshot({ path: resolve(SHOTS, `${name}.png`), fullPage: true }); + console.log('captured', name); +} + +async function pickConnectionIfNeeded(page) { + const picker = page.getByLabel(/Invoicing connection/i).first(); + if (await picker.count()) { + // Select by connection id (option value) — the fragile label-text/regex + // match previously fell through to selectOption({index:1}), silently + // picking the WRONG provider (Subiekt) instead of inFakt. + if (CONNECTION_ID) { + await picker.selectOption({ value: CONNECTION_ID }); + } else { + await picker.selectOption({ label: CONN_NAME }).catch(() => picker.selectOption({ index: 1 })); + } + await page.waitForTimeout(800); + } +} + +async function waitForAccepted(page, deadlineMs) { + const start = Date.now(); + while (Date.now() - start < deadlineMs) { + // A full reload drops the client-side connection-picker selection — + // re-pick it every time or the panel falls back to "Select a + // connection…" and the reg-card never renders at all (looked like a + // false "rejected" before this fix). + await page.reload({ waitUntil: 'networkidle' }); + await pickConnectionIfNeeded(page); + const accepted = await page.locator('.reg-card--success').count(); + if (accepted > 0) return true; + const rejected = await page.locator('.reg-card--error').count(); + if (rejected > 0) return false; + await page.waitForTimeout(5000); + } + return false; +} + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +try { + await forceLightTheme(page); + await login(page); + + // 1. Order detail — not-issued state. + await page.goto(`${BASE}/orders/${ORDER_ID}`, { waitUntil: 'networkidle' }); + await pickConnectionIfNeeded(page); + await shot(page, '10-order-detail-not-issued'); + + // 2. Issue the invoice. Shoot BEFORE any reload — the mutation's own + // query-invalidation updates the panel in place, and a reload here would + // just drop the connection-picker selection for no benefit. + const issueBtn = page.getByRole('button', { name: /Issue invoice/i }).first(); + if (await issueBtn.count()) { + await issueBtn.click(); + await page.waitForTimeout(3000); + } + await shot(page, '11-invoice-issued-submitted'); + const pendingCard = await page.locator('.reg-card--info').count(); + console.log('pending reg-card visible:', pendingCard > 0); + + // 3. Poll until KSeF clears (accepted) or rejects. + const accepted = await waitForAccepted(page, CLEARANCE_POLL_MS); + await shot(page, accepted ? '12-invoice-accepted-cleared' : '12-invoice-rejected'); + if (!accepted) { + console.log('KSeF did not reach accepted within the poll window — skipping correction step.'); + } + + // 4. Issue a KOR correction (only if accepted) — still on the order page, + // where the "Issue correction" button actually lives (no PDF link exists + // for inFakt today, so there is no separate "view invoice" affordance to + // detour through first). + if (accepted) { + const correctionBtn = page.getByRole('button', { name: /Issue correction/i }).first(); + if (await correctionBtn.count()) { + await correctionBtn.click(); + await page.waitForTimeout(500); + await shot(page, '14-correction-modal-empty'); + + await page.getByLabel(/Line number 1/i).fill('1'); + await page.getByLabel(/New qty, line 1/i).fill('1'); + await page.getByLabel(/New price, line 1/i).fill('99.99'); + await shot(page, '15-correction-modal-filled'); + + await page.getByRole('button', { name: /Issue KOR/i }).click(); + await page.waitForTimeout(4000); + await shot(page, '16-correction-issued'); + } + } + + // 5. Invoices list — both the original and (if issued) the correction visible. + // Filtered to our connection so the shared dev DB's unrelated rows (other + // providers' test fixtures) don't clutter a screenshot meant for the tutorial. + const invoicesListUrl = CONNECTION_ID + ? `${BASE}/invoices?connectionId=${CONNECTION_ID}` + : `${BASE}/invoices`; + await page.goto(invoicesListUrl, { waitUntil: 'networkidle' }); + await shot(page, '17-invoices-list'); + + // 6. Full invoice detail page (screen 06 parity) — reached by clicking the + // row for our order, the only navigation path (no PDF link on the panel). + const invoiceRow = page.getByText(ORDER_ID.slice(0, 24)).first(); + if (await invoiceRow.count()) { + await invoiceRow.click(); + await page.waitForTimeout(1200); + await shot(page, '13-invoice-detail-page'); + } + + console.log('DONE'); +} catch (err) { + console.error('INFAKT_INVOICE_ERROR', err.message); + await page.screenshot({ path: resolve(SHOTS, 'error-invoice.png'), fullPage: true }).catch(() => {}); + process.exitCode = 1; +} finally { + await browser.close(); +} diff --git a/apps/web/src/app/routes/route-lazy.test.ts b/apps/web/src/app/routes/route-lazy.test.ts index 481e71fd8..3a94f1466 100644 --- a/apps/web/src/app/routes/route-lazy.test.ts +++ b/apps/web/src/app/routes/route-lazy.test.ts @@ -50,20 +50,21 @@ const lazyRoutes = collectLazyRoutes([ * route reverted to eager `element:` form, which is exactly the regression * the parameterized test below is meant to catch. * - * Today's breakdown (46 total): + * Today's breakdown (49 total): * - 36 authenticated children (under `coreChildren`, counting per-children-node * because grouped routes like orders/customers/inventory expose multiple * lazy nodes — includes `/dev/ui` design-system page (#775), `/shipments` (#770), * `/users` user-management page (#1125), and `/invoices/:invoiceId` detail (#1240)) * - 3 guest routes (forgot-password, reset-password, register — login stays eager) - * - 9 plugin routes (allegro callback + setup, prestashop setup, dpd setup, - * woocommerce setup, erli setup, subiekt setup (#1199), ksef setup, inpost setup) + * - 10 plugin routes (allegro callback + setup, prestashop setup, dpd setup, + * woocommerce setup, erli setup, subiekt setup (#1199), ksef setup, inpost setup, + * infakt setup (#1282)) * * Routes that are intentionally eager (no page module to defer): * - login (first-paint optimization — see `login.route.tsx`) * - prompt-templates-legacy-redirects (inline `` element) */ -const EXPECTED_LAZY_ROUTE_COUNT = 48; +const EXPECTED_LAZY_ROUTE_COUNT = 49; describe('route lazy contract', () => { it(`the registered route tree contains exactly ${EXPECTED_LAZY_ROUTE_COUNT} lazy routes`, () => { diff --git a/apps/web/src/features/connections/components/EditConnectionForm.tsx b/apps/web/src/features/connections/components/EditConnectionForm.tsx index 9e386dbbb..344e90719 100644 --- a/apps/web/src/features/connections/components/EditConnectionForm.tsx +++ b/apps/web/src/features/connections/components/EditConnectionForm.tsx @@ -50,7 +50,8 @@ type StructuredField = | 'sellerCountryIso2' | 'contextIdentifier' | 'inpostEnvironment' - | 'inpostOrganizationId'; + | 'inpostOrganizationId' + | 'infaktPaymentMethod'; function readString(config: Record, key: string): string { const value = config[key]; @@ -104,6 +105,16 @@ function readInpostEnvironment(config: Record): '' | 'sandbox' return value === 'sandbox' || value === 'production' ? value : ''; } +/** + * Read the Infakt default payment method out of `config.defaultPaymentMethod` + * (#1303). Absent keys hydrate to `'cash'` — the adapter's own fallback — so + * the select (and the disclosure summary reading it) always reflects what + * will actually be sent, never a blank/unset state. + */ +function readInfaktPaymentMethod(config: Record): 'cash' | 'transfer' { + return config.defaultPaymentMethod === 'transfer' ? 'transfer' : 'cash'; +} + /** * Read the InPost sender address out of `config.senderAddress` (#771). Returns * a fully-populated form shape — empty-string fields where the operator hasn't @@ -333,6 +344,8 @@ export function EditConnectionForm({ connection }: EditConnectionFormProps): Rea inpostEnvironment: readInpostEnvironment(connection.config), inpostOrganizationId: readString(connection.config, 'organizationId'), inpostSenderAddress: readInpostSenderAddress(connection.config), + // Infakt default payment method (#1303) — `config.defaultPaymentMethod`. + infaktPaymentMethod: readInfaktPaymentMethod(connection.config), }, resolver: zodResolver(editConnectionSchema), }); diff --git a/apps/web/src/features/connections/components/edit-connection.schema.ts b/apps/web/src/features/connections/components/edit-connection.schema.ts index e31b336cd..e3b04f7b2 100644 --- a/apps/web/src/features/connections/components/edit-connection.schema.ts +++ b/apps/web/src/features/connections/components/edit-connection.schema.ts @@ -236,6 +236,9 @@ export const editConnectionSchema = z // flat `config.sellerNip`). All optional client-side so the operator can save // incremental progress; the server is the strict gate. ksefEnvironment: z.union([z.enum(['test', 'demo', 'prod']), z.literal('')]).optional(), + // Infakt-only structured field surfacing `config.defaultPaymentMethod` + // (#1303). Empty allowed for unset — the adapter falls back to `'cash'`. + infaktPaymentMethod: z.union([z.enum(['cash', 'transfer']), z.literal('')]).optional(), // NIP normalization mirrors the setup wizard (`ksef-setup.schema.ts`): strip // dashes/spaces the operator may paste, then enforce 10 digits. Without this // parity a value saved with separators on create fails the edit-schema check. @@ -364,6 +367,11 @@ export interface StructuredConfigPatch { subiektCapabilities?: Record; /** KSeF environment — `config.env` (#1152). Empty string clears the key. */ ksefEnvironment?: string; + /** + * Infakt default payment method — `config.defaultPaymentMethod` (#1303). + * Empty string clears the key (adapter falls back to `'cash'`). + */ + infaktPaymentMethod?: string; /** * KSeF seller-profile sub-fields (#1223). Each maps onto a leaf of the nested * `config.seller.{nip,name,address}` shape the adapter's `resolveSeller` @@ -589,6 +597,16 @@ export function mergeStructuredIntoConfig( } } } + // Infakt default payment method (#1303) — `config.defaultPaymentMethod`. + // The form field is named `infaktPaymentMethod` to avoid colliding with a + // future generic `paymentMethod` field on another platform. + if (structured.infaktPaymentMethod !== undefined) { + if (structured.infaktPaymentMethod.length === 0) { + delete next.defaultPaymentMethod; + } else { + next.defaultPaymentMethod = structured.infaktPaymentMethod; + } + } return next; } diff --git a/apps/web/src/features/connections/components/infakt-setup-form.test.tsx b/apps/web/src/features/connections/components/infakt-setup-form.test.tsx new file mode 100644 index 000000000..211f159ee --- /dev/null +++ b/apps/web/src/features/connections/components/infakt-setup-form.test.tsx @@ -0,0 +1,254 @@ +/** + * InfaktSetupForm Tests + * + * Coverage for the single-step inFakt setup wizard. Tests form validation, + * submission via the generic create-connection mutation, and the post-create + * "Test connection" flow that surfaces a ConnectionTestResult. Mirrors + * `erli-setup-form.test.tsx`. + */ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createMockApiClient, + findToastTitle, + renderWithProviders, +} from '../../../test/test-utils'; +import { InfaktSetupForm } from './infakt-setup-form'; + +describe('InfaktSetupForm', () => { + afterEach(cleanup); + + it('renders the required form fields', () => { + renderWithProviders(); + expect(screen.getByLabelText('Connection name')).toBeInTheDocument(); + expect(screen.getByLabelText('API key')).toBeInTheDocument(); + expect(screen.getByLabelText('Base URL (optional)')).toBeInTheDocument(); + expect(screen.getByLabelText('Default payment method')).toBeInTheDocument(); + }); + + it('defaults the payment method to cash', () => { + renderWithProviders(); + expect(screen.getByLabelText('Default payment method')).toHaveValue('cash'); + }); + + it('requires connection name to be non-empty', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(screen.getAllByText('Connection name is required')[0]).toBeInTheDocument(); + }); + }); + + it('requires the API key to be non-empty', async () => { + renderWithProviders(); + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(screen.getAllByText('API key is required')[0]).toBeInTheDocument(); + }); + }); + + it('rejects a non-HTTPS base URL override', async () => { + renderWithProviders(); + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.change(screen.getByLabelText('Base URL (optional)'), { + target: { value: 'http://api.infakt.pl' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(screen.getAllByText('Base URL must use HTTPS')[0]).toBeInTheDocument(); + }); + }); + + it('submits the API key and a cash-default config when no base URL is given', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const apiClient = createMockApiClient({ connections: { create } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My inFakt Account', + platformType: 'infakt', + adapterKey: 'infakt.accounting.v1', + config: { defaultPaymentMethod: 'cash' }, + credentials: { apiKey: 'sk_test_123' }, + }), + ); + }); + expect(await findToastTitle('Connection created')).toBeInTheDocument(); + }); + + it('includes baseUrl in config when supplied', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const apiClient = createMockApiClient({ connections: { create } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.change(screen.getByLabelText('Base URL (optional)'), { + target: { value: 'https://sandbox.infakt.pl' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + config: { defaultPaymentMethod: 'cash', baseUrl: 'https://sandbox.infakt.pl' }, + }), + ); + }); + }); + + it('submits transfer when selected in the payment method field', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const apiClient = createMockApiClient({ connections: { create } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.change(screen.getByLabelText('Default payment method'), { + target: { value: 'transfer' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + config: { defaultPaymentMethod: 'transfer' }, + }), + ); + }); + }); + + it('surfaces the test-connection result after a successful create', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const test = vi + .fn() + .mockResolvedValue({ success: true, status: 200, message: 'OK', latencyMs: 42 }); + const apiClient = createMockApiClient({ connections: { create, test } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + // After create, the test affordance replaces the connect button. + const testButton = await screen.findByRole('button', { name: 'Test connection' }); + fireEvent.click(testButton); + + await waitFor(() => { + expect(test).toHaveBeenCalledWith('conn-1'); + }); + expect(await screen.findByText('Connection test passed')).toBeInTheDocument(); + }); + + it('surfaces a failing test-connection result', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const test = vi + .fn() + .mockResolvedValue({ success: false, status: 401, message: 'Unauthorized', latencyMs: 10 }); + const apiClient = createMockApiClient({ connections: { create, test } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + const testButton = await screen.findByRole('button', { name: 'Test connection' }); + fireEvent.click(testButton); + + expect(await screen.findByText('Connection test failed')).toBeInTheDocument(); + expect(screen.getByText(/Unauthorized/)).toBeInTheDocument(); + }); + + it('surfaces the "Unable to test connection" alert when the test request rejects', async () => { + const create = vi.fn().mockResolvedValue({ id: 'conn-1', name: 'My inFakt Account' }); + const test = vi.fn().mockRejectedValue(new Error('Network unreachable')); + const apiClient = createMockApiClient({ connections: { create, test } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + const testButton = await screen.findByRole('button', { name: 'Test connection' }); + fireEvent.click(testButton); + + expect(await screen.findByText('Unable to test connection')).toBeInTheDocument(); + expect(screen.getByText(/Network unreachable/)).toBeInTheDocument(); + expect(screen.queryByText('Connection test passed')).not.toBeInTheDocument(); + expect(screen.queryByText('Connection test failed')).not.toBeInTheDocument(); + }); + + it('disables the submit button during the create mutation', async () => { + const create = vi + .fn() + .mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve({ id: 'conn-1', name: 'My inFakt Account' }), 100), + ), + ); + const apiClient = createMockApiClient({ connections: { create } }); + + renderWithProviders(, { apiClient }); + + fireEvent.change(screen.getByLabelText('Connection name'), { + target: { value: 'My inFakt Account' }, + }); + fireEvent.change(screen.getByLabelText('API key'), { + target: { value: 'sk_test_123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect inFakt' })); + + await waitFor(() => { + const button = screen.getByRole('button', { name: /Connecting|Connect inFakt/ }); + expect(button).toBeDisabled(); + }); + }); +}); diff --git a/apps/web/src/features/connections/components/infakt-setup-form.tsx b/apps/web/src/features/connections/components/infakt-setup-form.tsx new file mode 100644 index 000000000..62d4e5e8b --- /dev/null +++ b/apps/web/src/features/connections/components/infakt-setup-form.tsx @@ -0,0 +1,214 @@ +/** + * Infakt Setup Form + * + * Single-step wizard for creating an inFakt connection. Collects: + * - Connection name + * - inFakt API key (the only credential) + * - Optional advanced base URL override (config) — sandbox vs. production + * - Default payment method sent on every issued invoice/correction (#1303) + * + * Mirrors `ErliSetupForm`: one credential, no capabilities step (capabilities + * default silently to the adapter manifest's supported set on the omitted + * path). After a successful create the form surfaces a "Test connection" + * affordance that calls the generic `/connections/:id/test` endpoint and + * renders the `ConnectionTestResult`. Abandon-prevention triggers a native + * confirm dialog when the form is dirty and the tab is closed. + * + * @module features/connections/components + */ +import { useEffect, useState, type ReactElement } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { useCreateConnectionMutation } from '../hooks/use-create-connection-mutation'; +import { useTestConnectionMutation } from '../hooks/use-test-connection-mutation'; +import type { ConnectionTestResult } from '../api/connections.types'; +import { + INFAKT_SETUP_DEFAULT_VALUES, + infaktSetupSchema, + toCreateConnectionInput, + type InfaktSetupFormSubmission, + type InfaktSetupFormValues, +} from './infakt-setup.schema'; +import { Alert } from '../../../shared/ui/alert'; +import { BackLink } from '../../../shared/ui/back-link'; +import { Button } from '../../../shared/ui/button'; +import { FormErrorSummary } from '../../../shared/ui/form-error-summary'; +import { FormField } from '../../../shared/ui/form-field'; +import { Input } from '../../../shared/ui/input'; +import { Select } from '../../../shared/ui/select'; +import { useToast } from '../../../shared/ui/toast-provider'; + +export function InfaktSetupForm(): ReactElement { + const createConnection = useCreateConnectionMutation(); + const testConnection = useTestConnectionMutation(); + const { showToast } = useToast(); + const navigate = useNavigate(); + const [createdConnectionId, setCreatedConnectionId] = useState(null); + const [testResult, setTestResult] = useState(null); + + const form = useForm({ + defaultValues: INFAKT_SETUP_DEFAULT_VALUES, + resolver: zodResolver(infaktSetupSchema), + mode: 'onBlur', + }); + + // Abandon-prevention. + useEffect(() => { + function handleBeforeUnload(event: BeforeUnloadEvent): void { + if (!form.formState.isDirty || createdConnectionId !== null) return; + event.preventDefault(); + event.returnValue = ''; + } + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [form.formState.isDirty, createdConnectionId]); + + const validationMessages = Object.values(form.formState.errors).flatMap((error) => + error?.message ? [String(error.message)] : [], + ); + + const onSubmit = form.handleSubmit(async (values) => { + try { + const created = await createConnection.mutateAsync(toCreateConnectionInput(values)); + form.reset(values, { keepValues: true, keepDirty: false }); + setCreatedConnectionId(created.id); + showToast({ + tone: 'success', + title: 'Connection created', + description: `inFakt connection "${created.name}" was created.`, + }); + } catch { + return; + } + }); + + const onTest = async (): Promise => { + if (!createdConnectionId) return; + // Clear any prior result first: otherwise a re-test that rejects would render + // a stale resolved-result Alert alongside the "unable to test" error Alert. + setTestResult(null); + try { + const result = await testConnection.mutateAsync(createdConnectionId); + setTestResult(result); + } catch { + // surfaced via testConnection.error + } + }; + + return ( +
void onSubmit(event)} noValidate> + + + {form.formState.submitCount > 0 && validationMessages.length > 0 ? ( + + ) : null} + {createConnection.error ? ( + + {createConnection.error.message} + + ) : null} + + + In your inFakt account settings, generate an API key. OpenLinker uses + it to issue invoices and read KSeF clearance status through inFakt's native + integration. The key is stored securely on the server and only shown once. + + + + + + + + + + + + + + + + + + + {createdConnectionId ? ( + <> + {testResult ? ( + + {testResult.message} + {typeof testResult.latencyMs === 'number' ? ` (${testResult.latencyMs}ms)` : null} + + ) : null} + {testConnection.error ? ( + + {testConnection.error.message} + + ) : null} +
+ + +
+ + ) : ( +
+ +
+ )} + + ); +} diff --git a/apps/web/src/features/connections/components/infakt-setup.schema.ts b/apps/web/src/features/connections/components/infakt-setup.schema.ts new file mode 100644 index 000000000..ee1f94deb --- /dev/null +++ b/apps/web/src/features/connections/components/infakt-setup.schema.ts @@ -0,0 +1,72 @@ +/** + * Infakt Setup Form Schema + * + * Zod schema + form → API payload mapping for the guided inFakt connection + * wizard. inFakt is a Polish accounting platform authenticated with a single + * API key (#1280/#1282). The form collects a connection name, the required + * `apiKey` credential, and an optional advanced `baseUrl` config override + * (sandbox vs. production). Mirrors `erli-setup.schema.ts`. + * + * @module features/connections/components + */ +import { z } from 'zod'; +import type { CreateConnectionInput } from '../api/connections.types'; + +export const INFAKT_ADAPTER_KEY = 'infakt.accounting.v1'; + +// Mirrors the BE config DTO posture (optional https-only base URL override). +const isHttps = (value: string): boolean => value.startsWith('https://'); + +// Mirrors the BE `InfaktPaymentMethodValues` enum (#1303) — kept as a plain +// literal here rather than a cross-package import since FE and BE are +// separate deployables (see other structured sections, e.g. dpd-setup-form's +// `environment` select). +export const INFAKT_PAYMENT_METHOD_VALUES = ['cash', 'transfer'] as const; + +export const infaktSetupSchema = z.object({ + name: z.string().trim().min(1, 'Connection name is required'), + apiKey: z.string().trim().min(1, 'API key is required'), + baseUrl: z + .union([ + z + .string() + .trim() + .url('Base URL must be a valid URL (e.g. https://api.infakt.pl)') + .refine(isHttps, 'Base URL must use HTTPS'), + z.literal(''), + ]) + .optional(), + defaultPaymentMethod: z.enum(INFAKT_PAYMENT_METHOD_VALUES), +}); + +export type InfaktSetupFormValues = z.input; +export type InfaktSetupFormSubmission = z.output; + +export const INFAKT_SETUP_DEFAULT_VALUES: InfaktSetupFormValues = { + name: '', + apiKey: '', + baseUrl: '', + // Cash is fiscal-safe by default — transfer 422s on inFakt unless a bank + // account is configured (see the help copy below and on the edit section). + // Matches the adapter's own fallback in infakt-invoicing.adapter.ts. + defaultPaymentMethod: 'cash', +}; + +export function toCreateConnectionInput(values: InfaktSetupFormSubmission): CreateConnectionInput { + const config: Record = { defaultPaymentMethod: values.defaultPaymentMethod }; + if (values.baseUrl && values.baseUrl.length > 0) { + config.baseUrl = values.baseUrl; + } + + return { + name: values.name, + platformType: 'infakt', + adapterKey: INFAKT_ADAPTER_KEY, + credentials: { apiKey: values.apiKey }, + config, + // enabledCapabilities is OMITTED on purpose: on the omitted path + // `ConnectionService.create` defaults to the adapter manifest's supported + // set, so the inFakt connection lands with the capabilities the + // registered `infakt.accounting.v1` adapter actually delivers (Invoicing). + }; +} diff --git a/apps/web/src/features/invoicing/hooks/use-invoice-rendered-document-download.ts b/apps/web/src/features/invoicing/hooks/use-invoice-rendered-document-download.ts new file mode 100644 index 000000000..19f723e6f --- /dev/null +++ b/apps/web/src/features/invoicing/hooks/use-invoice-rendered-document-download.ts @@ -0,0 +1,82 @@ +/** + * useInvoiceRenderedDocumentDownload + * + * One-shot action (NOT a TanStack mutation) that fetches the provider's + * server-rendered document (`kind=rendered` — e.g. Infakt's PDF, #1321) via + * `apiClient.invoicing.downloadDocument(id, 'rendered')` and triggers a + * browser download. Mirrors `useKsefUpoDownload`'s shape exactly; kept as a + * separate hook (rather than generalizing `useKsefUpoDownload`) because that + * hook is scoped to the dedicated `/upo` route, while this one goes through + * the neutral `/document?kind=rendered` route any provider can serve. + * + * Neutral: keyed on the internal `invoice.id`, never on platform type (ADR-026). + * Lives in `features/invoicing/hooks/` so it can use `useApiClient` freely; + * per-provider `invoiceDetailSection` slots import it from the + * `features/invoicing` barrel (dep direction: plugins/* → features/invoicing + * is explicitly allowed). + * + * @module features/invoicing/hooks + */ +import { useCallback, useState } from 'react'; +import { useApiClient } from '../../../app/api/api-client-provider'; + +const EXTENSION_BY_MIME: Readonly> = { + 'application/pdf': 'pdf', + 'text/html': 'html', +}; + +function extensionForBlob(blob: Blob): string { + const mime = blob.type.toLowerCase().split(';', 1)[0]?.trim() ?? ''; + return EXTENSION_BY_MIME[mime] ?? 'bin'; +} + +/** + * Trigger a browser download for an already-fetched blob via an in-memory + * object URL + a programmatic `` click. Defers `revokeObjectURL` + * past the current tick — some engines cancel the in-flight download if the + * URL is revoked synchronously after `click()`. + */ +function triggerBlobDownload(blob: Blob, filename: string): void { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + +interface UseInvoiceRenderedDocumentDownload { + /** Fetch + trigger the browser download. Resolves `true` on success, `false` + * when the fetch failed (the error is also exposed via `error`). */ + download: (invoiceId: string) => Promise; + isDownloading: boolean; + error: Error | null; +} + +export function useInvoiceRenderedDocumentDownload(): UseInvoiceRenderedDocumentDownload { + const apiClient = useApiClient(); + const [isDownloading, setIsDownloading] = useState(false); + const [error, setError] = useState(null); + + const download = useCallback( + async (invoiceId: string): Promise => { + setIsDownloading(true); + setError(null); + try { + const blob = await apiClient.invoicing.downloadDocument(invoiceId, 'rendered'); + triggerBlobDownload(blob, `ol-invoice-${invoiceId}.${extensionForBlob(blob)}`); + return true; + } catch (caught) { + setError(caught instanceof Error ? caught : new Error(String(caught))); + return false; + } finally { + setIsDownloading(false); + } + }, + [apiClient], + ); + + return { download, isDownloading, error }; +} diff --git a/apps/web/src/features/invoicing/index.ts b/apps/web/src/features/invoicing/index.ts index ca5afc3fc..73bac2952 100644 --- a/apps/web/src/features/invoicing/index.ts +++ b/apps/web/src/features/invoicing/index.ts @@ -10,9 +10,10 @@ * `resolveIssueErrorMessage`, `DocumentTypeSelect`, and `DOCUMENT_TYPE_LABEL_FALLBACK` * stay internal (only used by the panel itself or tests that deep-import them). * - * Exception: `RegulatoryStatusBadge` is exported so per-provider - * `invoiceDetailSection` slot components (e.g. `plugins/ksef`) can reuse - * the neutral badge without duplicating the tone/label mapping. + * Exception: `RegulatoryStatusBadge` and `regCardToneFor` are exported so + * per-provider `invoiceDetailSection` slot components (KSeF, Subiekt, + * inFakt) can reuse the neutral badge and `.reg-card` tone mapping without + * duplicating either. * * @module apps/web/src/features/invoicing */ @@ -21,6 +22,7 @@ export { InvoiceTimeline } from './components/invoice-timeline'; export { InvoiceStatusBadge } from './components/invoice-status-badge'; export type { InvoiceDisplayStatus } from './components/invoice-status-badge'; export { RegulatoryStatusBadge } from './components/regulatory-status-badge'; +export { regCardToneFor, type RegCardTone } from './lib/derive-invoice-display'; export { InvoicePdfLink } from './components/invoice-pdf-link'; export { useOrderInvoiceQuery } from './hooks/use-order-invoice-query'; export { useInvoiceQuery } from './hooks/use-invoice-query'; @@ -35,6 +37,7 @@ export { useKsefUpoPreview } from './hooks/use-ksef-upo-preview'; export type { UpoPreviewKind } from './hooks/use-ksef-upo-preview'; export { useKsefUpoDownload } from './hooks/use-ksef-upo-download'; export { useKsefFa3 } from './hooks/use-ksef-fa3'; +export { useInvoiceRenderedDocumentDownload } from './hooks/use-invoice-rendered-document-download'; export { invoicingQueryKeys } from './api/invoicing.query-keys'; export { InvoiceStatusValues, RegulatoryStatusValues } from './api/invoicing.types'; export type { diff --git a/apps/web/src/features/invoicing/lib/derive-invoice-display.ts b/apps/web/src/features/invoicing/lib/derive-invoice-display.ts index 553755f87..59f7b842f 100644 --- a/apps/web/src/features/invoicing/lib/derive-invoice-display.ts +++ b/apps/web/src/features/invoicing/lib/derive-invoice-display.ts @@ -18,11 +18,25 @@ * * @module apps/web/src/features/invoicing/lib */ -import type { FailureCode, InvoiceRecord } from '../api/invoicing.types'; +import type { FailureCode, InvoiceRecord, RegulatoryStatus } from '../api/invoicing.types'; import type { InvoiceDisplayStatus } from '../components/invoice-status-badge'; type Translate = (key: string, fallback: string) => string; +export type RegCardTone = 'reg-card--info' | 'reg-card--success' | 'reg-card--error' | ''; + +/** + * Severity-stripe tone for the shared `.reg-card` treatment (#1282). Shared + * by every per-provider `invoiceDetailSection` slot (KSeF, Subiekt, inFakt) + * so the status→tone mapping lives in exactly one place. + */ +export function regCardToneFor(status: RegulatoryStatus): RegCardTone { + if (status === 'submitted') return 'reg-card--info'; + if (status === 'accepted') return 'reg-card--success'; + if (status === 'rejected') return 'reg-card--error'; + return ''; +} + /** * Maps an `InvoiceRecord | null` (null = invoice-absent) to the FE display * status. A `failed` row is split into `failed` (rejected) vs `in-doubt` off diff --git a/apps/web/src/index.css b/apps/web/src/index.css index bde5d7b4f..2aa4ad0c8 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -810,7 +810,8 @@ textarea::placeholder { color: var(--text-disabled); } -textarea { +textarea, +.textarea { height: auto; min-height: 5rem; padding: var(--space-2) var(--space-3); @@ -9774,6 +9775,72 @@ html[data-density='compact'] .status-badge { align-self: stretch; } +/* ── Regulatory section card (#1282) ────────────────────────────────── + Tone wrapper a provider's `invoiceDetailSection` root class adopts + (KSeF, Subiekt, inFakt) so the region reads at a glance — severity + stripe reusing the existing --status-* tokens, no new colors. Additive + alongside each section's own root class (e.g. `invoice-detail-section`), + never a replacement, so pre-existing DOM-shape assertions keep passing. */ +.reg-card { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); + border-left: 3px solid var(--border-subtle); + background: var(--bg-surface-muted); +} +.reg-card--info { + border-left-color: var(--status-info); + background: var(--status-info-soft); +} +.reg-card--success { + border-left-color: var(--status-success); + background: var(--status-success-soft); +} +.reg-card--error { + border-left-color: var(--status-error); + background: var(--status-error-soft); +} +.reg-card__header { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); +} +.reg-card__progress { + height: 3px; + border-radius: 2px; + background: linear-gradient( + 100deg, + var(--status-info-soft) 0%, + var(--status-info) 50%, + var(--status-info-soft) 100% + ); + background-size: 200% 100%; + animation: invoice-skeleton-shimmer 1.4s ease-in-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .reg-card__progress { + animation: none; + background: var(--status-info); + } +} +.reg-card__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} +.reg-card__note { + font-size: 0.75rem; + margin: 0; +} + /* ─ Invoice Timeline ─ */ .invoice-timeline { display: flex; @@ -9985,6 +10052,31 @@ html[data-density='compact'] .status-badge { margin-top: var(--space-3); } +/* ─ Infakt invoice correction dialog (#1282) ─ */ +.infakt-correction__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + border: 0; + padding: 0 0 var(--space-3); +} +.infakt-correction__reason { + margin-bottom: var(--space-4); +} +.infakt-correction__table-scroll { + overflow-x: auto; +} +.infakt-correction__col-qty { + min-width: 80px; +} +.infakt-correction__col-price { + min-width: 100px; +} +.infakt-correction__note { + margin-top: var(--space-3); +} + /* ─ Correction trigger in order invoice panel (issued state) ─ */ .order-invoice-panel__correction { margin-top: var(--space-3); @@ -10128,6 +10220,58 @@ html[data-density='compact'] .status-badge { } .ksef-fa3-view__lineitems tbody tr:last-child td { border-bottom: none; } +/* ── Inline Disclosure (#1303) ─────────────────────────────────────── + * Native
/ "collapsed value + Change affordance" + * pattern. First use: Infakt's default-payment-method field, which + * should read as an inline fact ("Payment method for invoice: Transfer") + * rather than a permanently-open form control most operators never touch. + */ +.inline-disclosure { + border: 1px dashed var(--accent-primary-border); + background: var(--accent-primary-soft); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); +} + +.inline-disclosure__summary { + cursor: pointer; + list-style: none; + font-size: 0.85rem; + line-height: 1.5; + color: var(--text-primary); +} + +.inline-disclosure__summary::-webkit-details-marker { + display: none; +} + +.inline-disclosure__summary:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); + border-radius: var(--radius-sm); +} + +.inline-disclosure__label { + color: var(--text-secondary); +} + +.inline-disclosure__value { + font-family: var(--font-mono); + font-weight: 600; + color: var(--text-primary); +} + +.inline-disclosure__cta { + color: var(--accent-primary); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + font-weight: 600; +} + +.inline-disclosure__panel { + margin-top: var(--space-3); +} /* ── Demo Mode (#1127) ─────────────────────────────────────────── */ diff --git a/apps/web/src/pages/connections/infakt-setup-page.tsx b/apps/web/src/pages/connections/infakt-setup-page.tsx new file mode 100644 index 000000000..670d0c165 --- /dev/null +++ b/apps/web/src/pages/connections/infakt-setup-page.tsx @@ -0,0 +1,26 @@ +/** + * Infakt Setup Page + * + * Page wrapper for the guided inFakt connection wizard. + */ +import type { ReactElement } from 'react'; +import { InfaktSetupForm } from '../../features/connections/components/infakt-setup-form'; +import { PageLayout } from '../../shared/ui/page-layout'; + +export function InfaktSetupPage(): ReactElement { + return ( + + API key + Guided setup + + } + > + + + ); +} diff --git a/apps/web/src/plugins/index.ts b/apps/web/src/plugins/index.ts index 13e50f807..1184d82bc 100644 --- a/apps/web/src/plugins/index.ts +++ b/apps/web/src/plugins/index.ts @@ -44,6 +44,7 @@ import { allegroPlugin } from './allegro'; import { assertUniquePluginInvariants } from './assert-unique-plugin-invariants'; import { dpdPlugin } from './dpd'; import { erliPlugin } from './erli'; +import { infaktPlugin } from './infakt'; import { inpostPlugin } from './inpost'; import { ksefPlugin } from './ksef'; import { prestashopPlugin } from './prestashop'; @@ -59,6 +60,7 @@ export const plugins: readonly OpenLinkerPlugin[] = [ erliPlugin, subiektPlugin, ksefPlugin, + infaktPlugin, ]; assertUniquePluginInvariants(plugins); diff --git a/apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx new file mode 100644 index 000000000..dac952906 --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.test.tsx @@ -0,0 +1,81 @@ +/** + * InfaktCredentialsPanel Tests + * + * Coverage for the rotate-API-key flow for inFakt connections. inFakt carries + * a single `apiKey` credential, so the panel rotates one field. Mirrors + * `erli-credentials-panel.test.tsx`. + */ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createMockApiClient, + findToastTitle, + renderWithProviders, + sampleConnection, +} from '../../../test/test-utils'; +import { InfaktCredentialsPanel } from './infakt-credentials-panel'; + +const infaktConnection = { ...sampleConnection, platformType: 'infakt' }; + +describe('InfaktCredentialsPanel', () => { + afterEach(cleanup); + + it('renders the rotate affordance for a credentials-backed connection', () => { + renderWithProviders(); + expect(screen.getByText('Rotate API key')).toBeInTheDocument(); + }); + + it('falls back to the env-var disabled input when credentialsBacked=false', () => { + const envBacked = { ...infaktConnection, credentialsBacked: false }; + renderWithProviders(); + expect( + screen.getByDisplayValue('Environment variable (not editable via UI)'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /rotate/i })).not.toBeInTheDocument(); + }); + + it('sends the new apiKey and surfaces a success toast', async () => { + const updateCredentials = vi.fn().mockResolvedValue(undefined); + const apiClient = createMockApiClient({ connections: { updateCredentials } }); + renderWithProviders(, { apiClient }); + + fireEvent.click(screen.getByText('Rotate API key')); + fireEvent.change(screen.getByPlaceholderText('New API key'), { + target: { value: 'sk_new_456' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save new API key' })); + + await waitFor(() => { + expect(updateCredentials).toHaveBeenCalledWith(infaktConnection.id, { apiKey: 'sk_new_456' }); + }); + expect(await findToastTitle('Credentials rotated')).toBeInTheDocument(); + }); + + it('disables save while the field is empty', () => { + renderWithProviders(); + fireEvent.click(screen.getByText('Rotate API key')); + expect(screen.getByRole('button', { name: 'Save new API key' })).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText('New API key'), { + target: { value: 'sk_new_456' }, + }); + expect(screen.getByRole('button', { name: 'Save new API key' })).toBeEnabled(); + }); + + it('collapses the form after a successful save', async () => { + const updateCredentials = vi.fn().mockResolvedValue(undefined); + const apiClient = createMockApiClient({ connections: { updateCredentials } }); + renderWithProviders(, { apiClient }); + + fireEvent.click(screen.getByText('Rotate API key')); + fireEvent.change(screen.getByPlaceholderText('New API key'), { + target: { value: 'sk_new_456' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save new API key' })); + + await waitFor(() => { + expect(screen.getByText('Rotate API key')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('New API key')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx new file mode 100644 index 000000000..6d419e79f --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-credentials-panel.tsx @@ -0,0 +1,102 @@ +/** + * Infakt Credentials Panel + * + * Plugin-owned credentials affordance for inFakt connections — rotates the + * stored API key in place via PUT /credentials. Owns its own toggle state, + * mutation, and toast feedback; the parent `EditConnectionForm` renders this + * only when the plugin contributes it. When the credential is env-backed + * (`credentialsBacked === false`) the panel shows a read-only affordance + * instead. Mirrors `ErliCredentialsPanel` (single `apiKey`, no auth-type + * enum). + * + * @module plugins/infakt/components + */ +import { useState, type FormEvent, type ReactElement } from 'react'; +import type { Connection } from '../../../features/connections'; +import { useUpdateConnectionCredentialsMutation } from '../../../features/connections'; +import { Alert } from '../../../shared/ui/alert'; +import { Button } from '../../../shared/ui/button'; +import { FormField } from '../../../shared/ui/form-field'; +import { Input } from '../../../shared/ui/input'; +import { useToast } from '../../../shared/ui/toast-provider'; + +export function InfaktCredentialsPanel({ connection }: { connection: Connection }): ReactElement { + const [showRotate, setShowRotate] = useState(false); + const [apiKey, setApiKey] = useState(''); + const rotate = useUpdateConnectionCredentialsMutation(); + const { showToast } = useToast(); + + if (!connection.credentialsBacked) { + return ( + + + + ); + } + + const canSubmit = apiKey.trim().length > 0; + + const onRotate = async (event: FormEvent): Promise => { + event.preventDefault(); + if (!canSubmit) return; + try { + await rotate.mutateAsync({ + connectionId: connection.id, + credentials: { apiKey: apiKey.trim() }, + }); + showToast({ + tone: 'success', + title: 'Credentials rotated', + description: 'The new inFakt API key is now in use.', + }); + setApiKey(''); + setShowRotate(false); + } catch { + // surfaced via rotate.error + } + }; + + return ( + + {showRotate ? ( +
+ {rotate.error ? {rotate.error.message} : null} + setApiKey(event.target.value)} + /> +
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx new file mode 100644 index 000000000..fb7ab4ab4 --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.test.tsx @@ -0,0 +1,250 @@ +/** + * InfaktInvoiceCorrectionFlow tests (#1282) + * + * @module plugins/infakt/components + */ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createMockApiClient, + renderWithProviders, + sampleConnection, +} from '../../../test/test-utils'; +import type { InvoiceRecord } from '../../../features/invoicing/api/invoicing.types'; +import { InfaktInvoiceCorrectionFlow } from './infakt-invoice-correction-flow'; + +const infaktConnection = { ...sampleConnection, platformType: 'infakt' }; + +function makeInvoice(overrides: Partial = {}): InvoiceRecord { + return { + id: 'ol_invoice_test', + connectionId: infaktConnection.id, + orderId: 'ol_order_test', + providerType: 'infakt', + documentType: 'invoice', + status: 'issued', + providerInvoiceId: 'infakt-101', + providerInvoiceNumber: 'FV 101/inFakt/2026', + regulatoryStatus: 'accepted', + clearanceReference: '5260001246-20260625-A1B2-3D', + pdfUrl: null, + failureMode: null, + failureCode: null, + failureReason: null, + issuedAt: '2026-06-25T10:00:00.000Z', + createdAt: '2026-06-25T09:59:00.000Z', + updatedAt: '2026-06-25T10:00:00.000Z', + ...overrides, + }; +} + +afterEach(() => cleanup()); + +describe('InfaktInvoiceCorrectionFlow', () => { + it('renders the form header and invoice reference', async () => { + renderWithProviders( + , + ); + expect(await screen.findByRole('heading', { name: /Issue KOR correction/i })).toBeDefined(); + expect(await screen.findByText(/FV 101\/inFakt\/2026/i)).toBeDefined(); + }); + + it('renders the reason textarea', async () => { + renderWithProviders( + , + ); + expect(await screen.findByLabelText(/Reason for correction/i)).toBeDefined(); + }); + + it('starts with one empty line row', async () => { + renderWithProviders( + , + ); + const lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(1); + }); + + it('adds a line row on "+ Add line" click', async () => { + renderWithProviders( + , + ); + const addBtn = await screen.findByText(/Add line/i); + fireEvent.click(addBtn); + const lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(2); + }); + + it('removes a line row when Remove is clicked', async () => { + renderWithProviders( + , + ); + fireEvent.click(await screen.findByText(/Add line/i)); + let lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(2); + + const removeBtns = await screen.findAllByRole('button', { name: /Remove line/i }); + fireEvent.click(removeBtns[0]); + + lineInputs = await screen.findAllByLabelText(/Line number/i); + expect(lineInputs).toHaveLength(1); + }); + + it('calls onClose when Cancel is clicked', async () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + fireEvent.click(await screen.findByText(/Cancel/i)); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('blocks submit when no line has a line number and shows error', async () => { + const issueCorrection = vi.fn(); + renderWithProviders( + , + { apiClient: createMockApiClient({ invoicing: { issueCorrection } }) }, + ); + + fireEvent.click(await screen.findByRole('button', { name: /Issue KOR/i })); + + expect(await screen.findByRole('alert')).toBeDefined(); + expect(await screen.findByText(/at least one line/i)).toBeDefined(); + expect(issueCorrection).not.toHaveBeenCalled(); + }); + + it('submits issueCorrection with reason, line data and idempotencyKey on form submit', async () => { + const correctionInvoice = makeInvoice({ id: 'ol_invoice_correction' }); + const issueCorrection = vi.fn().mockResolvedValue(correctionInvoice); + const onCorrectionIssued = vi.fn(); + const onClose = vi.fn(); + + renderWithProviders( + , + { apiClient: createMockApiClient({ invoicing: { issueCorrection } }) }, + ); + + const reasonInput = await screen.findByLabelText(/Reason for correction/i); + fireEvent.change(reasonInput, { target: { value: 'Partial return' } }); + + const lineInput = await screen.findByLabelText(/Line number 1/i); + fireEvent.change(lineInput, { target: { value: '1' } }); + const qtyInput = await screen.findByLabelText(/New qty, line 1/i); + fireEvent.change(qtyInput, { target: { value: '0' } }); + const priceInput = await screen.findByLabelText(/New price, line 1/i); + fireEvent.change(priceInput, { target: { value: '34.84' } }); + + fireEvent.click(await screen.findByRole('button', { name: /Issue KOR/i })); + + await waitFor(() => { + expect(issueCorrection).toHaveBeenCalledWith( + 'ol_invoice_test', + expect.objectContaining({ + reason: 'Partial return', + lines: [{ originalLineNumber: 1, newQuantity: 0, newUnitPriceGross: 34.84 }], + idempotencyKey: expect.stringMatching(/^infakt-corr-/), + }), + ); + }); + + await waitFor(() => expect(onCorrectionIssued).toHaveBeenCalledWith('ol_invoice_correction')); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + }); + + it('does not include empty line rows in the submission', async () => { + const issueCorrection = vi.fn().mockResolvedValue(makeInvoice({ id: 'ol_invoice_cor2' })); + + renderWithProviders( + , + { apiClient: createMockApiClient({ invoicing: { issueCorrection } }) }, + ); + + // Add a second line but leave it empty + fireEvent.click(await screen.findByText(/Add line/i)); + + // Only fill line 1 — line number plus a delta, so it's a valid (non-no-op) row + const lineInputs = await screen.findAllByLabelText(/Line number/i); + fireEvent.change(lineInputs[0], { target: { value: '2' } }); + const qtyInputs = await screen.findAllByLabelText(/New qty, line/i); + fireEvent.change(qtyInputs[0], { target: { value: '3' } }); + + fireEvent.click(await screen.findByRole('button', { name: /Issue KOR/i })); + + await waitFor(() => { + const call = issueCorrection.mock.calls[0] as [ + string, + { lines: { originalLineNumber: number }[] }, + ]; + expect(call[1].lines).toHaveLength(1); + expect(call[1].lines[0].originalLineNumber).toBe(2); + }); + }); + + it('blocks submit when a filled line has neither new quantity nor new price', async () => { + const issueCorrection = vi.fn(); + + renderWithProviders( + , + { apiClient: createMockApiClient({ invoicing: { issueCorrection } }) }, + ); + + const lineInputs = await screen.findAllByLabelText(/Line number/i); + fireEvent.change(lineInputs[0], { target: { value: '1' } }); + + fireEvent.click(await screen.findByRole('button', { name: /Issue KOR/i })); + + expect( + await screen.findByText(/Each line must specify a new quantity and\/or a new price/i), + ).toBeInTheDocument(); + expect(issueCorrection).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx new file mode 100644 index 000000000..9be002dcc --- /dev/null +++ b/apps/web/src/plugins/infakt/components/infakt-invoice-correction-flow.tsx @@ -0,0 +1,308 @@ +/** + * InfaktInvoiceCorrectionFlow (#1282) + * + * Per-provider `invoiceCorrectionFlow` slot for inFakt. Issues a KOR (faktura + * korygująca) via `POST /invoices/:invoiceId/correct` — the same generic + * capability-gated endpoint KSeF uses (backed by `InfaktInvoicingAdapter` + * implementing `CorrectionIssuer`, PR #1292). The operator supplies: + * - an optional free-text reason; + * - per-line new quantity and/or new unit price gross. + * + * The form starts with ONE empty row and an "Add line" affordance for + * multi-line corrections. Near-1:1 port of `KsefInvoiceCorrectionFlow` — same + * line-row model, same generic `useIssueCorrectionMutation` hook, same + * `InvoiceCorrectionFlowProps` slot contract. The host dialog owns the outer + * chrome; this component is content-only — call `onClose` to close the + * dialog. + * + * @module plugins/infakt/components + */ +import { type ReactElement, useRef, useState } from 'react'; +import { useTranslation } from '../../../shared/i18n'; +import { Button } from '../../../shared/ui/button'; +import { useToast } from '../../../shared/ui/toast-provider'; +import type { InvoiceCorrectionFlowProps } from '../../../shared/plugins/plugin.types'; +import { + useIssueCorrectionMutation, + type CorrectionLineInput, +} from '../../../features/invoicing'; + +interface LineRow { + originalLineNumber: string; + newQuantity: string; + newUnitPriceGross: string; +} + +function emptyRow(): LineRow { + return { originalLineNumber: '', newQuantity: '', newUnitPriceGross: '' }; +} + +/** + * Returns `null` when a filled-in row changes neither quantity nor price — a + * no-op line the backend `CorrectionLineDto` rejects (at least one delta is + * required per line). + */ +function parseLineRows(rows: LineRow[]): CorrectionLineInput[] | null { + const filled = rows.filter((r) => r.originalLineNumber.trim() !== ''); + const parsed: CorrectionLineInput[] = []; + for (const r of filled) { + const lineNum = parseInt(r.originalLineNumber, 10); + const qty = r.newQuantity.trim() !== '' ? parseFloat(r.newQuantity) : undefined; + const price = r.newUnitPriceGross.trim() !== '' ? parseFloat(r.newUnitPriceGross) : undefined; + const hasQty = qty !== undefined && !Number.isNaN(qty); + const hasPrice = price !== undefined && !Number.isNaN(price); + if (!hasQty && !hasPrice) { + return null; + } + parsed.push({ + originalLineNumber: lineNum, + ...(hasQty ? { newQuantity: qty } : {}), + ...(hasPrice ? { newUnitPriceGross: price } : {}), + }); + } + return parsed; +} + +export function InfaktInvoiceCorrectionFlow({ + invoice, + onClose, + onCorrectionIssued, +}: InvoiceCorrectionFlowProps): ReactElement { + const { t } = useTranslation(); + const { showToast } = useToast(); + const mutation = useIssueCorrectionMutation(); + + // Per-mount stable idempotency key — prevents duplicate KOR issuance on timeout/retry. + const idempotencyKeyRef = useRef( + `infakt-corr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + ); + + const [reason, setReason] = useState(''); + const [lines, setLines] = useState([emptyRow()]); + const [linesError, setLinesError] = useState(null); + + function updateLine(index: number, field: keyof LineRow, value: string): void { + setLines((prev) => prev.map((r, i) => (i === index ? { ...r, [field]: value } : r))); + } + + function addLine(): void { + setLines((prev) => [...prev, emptyRow()]); + } + + function removeLine(index: number): void { + setLines((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)); + } + + function handleSubmit(): void { + const parsedLines = parseLineRows(lines); + if (parsedLines === null) { + setLinesError( + t( + 'infakt.correction.lineDeltaRequired', + 'Each line must specify a new quantity and/or a new price.', + ), + ); + return; + } + if (parsedLines.length === 0) { + setLinesError( + t( + 'infakt.correction.linesRequired', + 'At least one line with a line number is required.', + ), + ); + return; + } + setLinesError(null); + mutation.mutate( + { + invoiceId: invoice.id, + input: { + reason: reason.trim() !== '' ? reason.trim() : undefined, + lines: parsedLines, + idempotencyKey: idempotencyKeyRef.current, + }, + }, + { + onSuccess: (correctionInvoice) => { + showToast({ + tone: 'success', + title: t('infakt.correction.issued', 'KOR issued'), + description: t( + 'infakt.correction.issuedBody', + 'The correcting document was submitted to KSeF via inFakt.', + ), + }); + onCorrectionIssued(correctionInvoice.id); + onClose(); + }, + onError: (error) => { + showToast({ + tone: 'error', + title: t('infakt.correction.failed', 'KOR failed'), + description: error.message, + }); + }, + }, + ); + } + + const isSubmitting = mutation.isPending; + + return ( +
+ {/* Header */} +
+

{t('infakt.correction.title', 'Issue KOR correction')}

+ + {t('infakt.correction.providerTag', 'inFakt · slot')} + +
+ + {/* Original invoice reference */} +
+ + {t('infakt.correction.correcting', 'Correcting')}{' '} + {invoice.providerInvoiceNumber ?? invoice.id} + + {invoice.clearanceReference ? ( + + {t('infakt.correction.ksefRef', 'KSeF')}{' '} + + {invoice.clearanceReference.slice(0, 14)}… + + + ) : null} + {invoice.issuedAt ? ( + + {t('infakt.correction.issuedOn', 'Issued')}{' '} + + {invoice.issuedAt.slice(0, 10)} + + + ) : null} +
+ + {/* Reason */} +
+ +