From 21e9f9fbc1500d0afbf1daf5beb1a09ddd5dc03b Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Mon, 13 Jul 2026 23:00:13 +0200 Subject: [PATCH 1/4] feat(web/ksef): rebuild FA(3) preview to mirror the official KSeF visualization Fixes two parser bugs: P_1/P_2 were read swapped (FA(3) defines P_1 = issue date, P_2 = invoice number) and party names were read from NazwaSkrocona while the builder emits Nazwa (NazwaSkrocona kept as a fallback for foreign documents). Extends the parser with party addresses (AdresL1/AdresL2/KodKraju), KodWaluty, P_6, P_8A, P_9A, RodzajFaktury, Platnosc (TerminPlatnosci, FormaPlatnosci, RachunekBankowy/NrRB), PrzyczynaKorekty, and the corrected-invoice number from DaneFaKorygowanej. Rebuilds the layout as a theme-aware document: header with invoice number + type subtitle + KSeF number, two-column Seller/Buyer blocks, details row, line-items table with render-derived gross and conditional Unit / Net-unit-price columns (hidden on pre-backend-change documents), per-band VAT summary with gross, total due with currency, payment section reusing the 1-7 FormaPlatnosci vocabulary (#1311), and the KOR before-lines collapsed section kept from #1364. Closes #1526 Co-Authored-By: Claude Fable 5 Signed-off-by: norbert-kulus-blockydevs --- apps/web/src/index.css | 138 +++- .../ksef/components/ksef-fa3-view.test.tsx | 468 ++++++++----- .../plugins/ksef/components/ksef-fa3-view.tsx | 627 +++++++++++------- .../ksef/components/ksef-fa3-view.types.ts | 67 +- .../ksef-invoice-detail-section.test.tsx | 6 +- 5 files changed, 861 insertions(+), 445 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0e76e585d..d5ea00b34 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -10737,64 +10737,116 @@ html[data-density='compact'] .status-badge { border: none; } -/* ── KsefFa3View parsed invoice display (#1228) ───────────────────── - * Structured human-readable visualization of an FA(3) XML document. - * Fills the `.doc-preview` container that replaces the former iframe. +/* -- KSeF FA(3) document preview (#1526) ------------------------------- + * Document-styled, theme-aware visualization of an FA(3) XML invoice + * mirroring the official KSeF/MF layout. Fills the `.doc-preview` + * container; tokens only, so it follows light/dark themes. */ .ksef-fa3-view { width: 100%; - padding: var(--space-4, 1rem); + max-width: 800px; + margin: 0 auto; + padding: var(--space-5); text-align: left; - color: var(--text-default); + color: var(--text-primary); display: flex; flex-direction: column; - gap: 0; + gap: var(--space-4); } -.ksef-fa3-view__lines { - margin-top: var(--space-3, 0.75rem); +.ksef-fa3-view__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); + flex-wrap: wrap; + border-bottom: 1px solid var(--border-default); + padding-bottom: var(--space-3); +} +.ksef-fa3-view__doc-number { + font-size: 1.125rem; + font-weight: 600; +} +.ksef-fa3-view__doc-type { + color: var(--text-muted); + font-size: 0.8125rem; + margin-top: var(--space-1); } -.ksef-fa3-view__lines-title, -.ksef-fa3-view__vat-title { +.ksef-fa3-view__ksef-number { text-align: right; } +.ksef-fa3-view__field-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: var(--tracking-caps); + color: var(--text-muted); +} +.ksef-fa3-view__section-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; - letter-spacing: var(--tracking-caps, 0.05em); + letter-spacing: var(--tracking-caps); color: var(--text-muted); - margin-bottom: var(--space-2, 0.5rem); - margin-top: var(--space-3, 0.75rem); + margin-bottom: var(--space-2); } -.ksef-fa3-view__vat { margin-top: var(--space-2, 0.5rem); } -.ksef-fa3-view__total { border-top: 2px solid var(--border-default); margin-top: var(--space-2, 0.5rem); } -.ksef-fa3-view__total-value { font-weight: 600; font-size: 1rem; } - -/* ── Invoice line-items table (#1228) ──────────────────────────────── - * Compact scrollable table used inside `.ksef-fa3-view__lines`. - */ -.ksef-fa3-view__lines-table-wrap { overflow-x: auto; } -.ksef-fa3-view__lineitems { +.ksef-fa3-view__parties { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4); +} +.ksef-fa3-view__party { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + font-size: 0.8125rem; +} +.ksef-fa3-view__party-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: var(--tracking-caps); + color: var(--text-muted); + margin-bottom: var(--space-1); +} +.ksef-fa3-view__party-name { + font-weight: 600; + margin-bottom: var(--space-1); +} +.ksef-fa3-view__party-line { color: var(--text-secondary); } +.ksef-fa3-view__details { + display: flex; + gap: var(--space-5); + flex-wrap: wrap; + font-size: 0.8125rem; +} +.ksef-fa3-view__correction { + border: 1px solid var(--border-subtle); + border-left: 3px solid var(--status-info); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + font-size: 0.8125rem; + display: flex; + flex-direction: column; + gap: var(--space-1); +} +.ksef-fa3-view__table-wrap { overflow-x: auto; } +.ksef-fa3-view__table { width: 100%; border-collapse: collapse; font-size: 0.75rem; } -.ksef-fa3-view__lineitems th, -.ksef-fa3-view__lineitems td { - padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem); +.ksef-fa3-view__table th, +.ksef-fa3-view__table td { + padding: var(--space-1) var(--space-2); text-align: left; - border-bottom: 1px solid var(--border-subtle, var(--border-default)); + border-bottom: 1px solid var(--border-subtle); white-space: nowrap; } -.ksef-fa3-view__lineitems th { +.ksef-fa3-view__table th { font-weight: 600; color: var(--text-muted); background: var(--bg-surface-muted); } -.ksef-fa3-view__lineitems tbody tr:last-child td { border-bottom: none; } - -/* ── KOR "before correction" lines (#1364 follow-up) ────────────────── - * A correction document's pre-correction rows are collapsed by default — - * kept out of the main lines/totals table, but reachable for audit. - */ -.ksef-fa3-view__before-correction { margin-top: var(--space-3, 0.75rem); } +.ksef-fa3-view__table .ksef-fa3-view__col-num { text-align: right; } +.ksef-fa3-view__table .ksef-fa3-view__col-desc { white-space: normal; } .ksef-fa3-view__before-correction summary { cursor: pointer; list-style: none; @@ -10805,6 +10857,24 @@ html[data-density='compact'] .status-badge { box-shadow: var(--shadow-focus); border-radius: var(--radius-sm); } +.ksef-fa3-view__total { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: var(--space-3); + border-top: 2px solid var(--border-strong); + padding-top: var(--space-2); +} +.ksef-fa3-view__total-value { + font-weight: 600; + font-size: 1rem; +} +.ksef-fa3-view__payment { + font-size: 0.8125rem; + display: flex; + flex-direction: column; + gap: var(--space-1); +} /* ── Inline Disclosure (#1303) ─────────────────────────────────────── * Native
/ "collapsed value + Change affordance" diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx index 14b12d043..47e242d90 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx @@ -1,8 +1,15 @@ /** - * KsefFa3View tests (#1228) + * KsefFa3View tests (#1228, rebuilt #1526) * - * Covers: seller/buyer rendering, invoice lines table, grand total, - * VAT band summary, KSeF number, and graceful null on unparseable input. + * Fixtures mirror the XML shape emitted by the backend FA(3) builder + * (`fa3-xml.builder.ts`): P_1 = issue date, P_2 = invoice number, party + * names in `Nazwa`, addresses in `Adres`, optional `P_6`/`P_8A`/`P_9A`, + * `Platnosc`, and KOR correction metadata. + * + * Covers: P_1/P_2 mapping regression, party name + address rendering, + * conditional Unit / Net-unit-price columns, per-line gross derivation, + * VAT band summary with gross, total due with currency, payment section, + * KOR reason + before-lines separation, and graceful null on bad input. * * @module plugins/ksef/components */ @@ -11,20 +18,45 @@ import { describe, it, expect } from 'vitest'; import { renderWithProviders } from '../../../test/test-utils'; import { KsefFa3View } from './ksef-fa3-view'; -// Minimal FA(3) namespace prefix — real documents use `tns:`, but the component -// uses getElementsByTagName(tagName) which matches local names regardless of prefix. +interface FixtureLine { + lineNo: string; + description: string; + unit?: string; + quantity: string; + netUnitPrice?: string; + netTotal: string; + vatRate: string; + isBeforeCorrection?: boolean; +} + +interface FixtureOptions { + invoiceNumber?: string; + issueDate?: string; + saleDate?: string | null; + currency?: string; + invoiceType?: string; + lines?: FixtureLine[]; + vatNet23?: string | null; + vatTax23?: string | null; + grandTotal?: string | null; + ksefNumber?: string | null; + correction?: { reason: string; correctedNumber: string } | null; + payment?: { formCode?: string; termDate?: string; bankAccount?: string } | null; +} + +// Builder-produced-like FA(3) XML (default xmlns, no prefix - the component's +// getElementsByTagName lookup is namespace/prefix agnostic anyway). function buildXml({ - sellerName = 'Acme Sp. z o.o.', - sellerNip = '1234567890', - buyerName = 'Buyer Corp.', - buyerNip = '0987654321', - invoiceNumber = 'FV/2024/001', - issueDate = '2024-01-15', + invoiceNumber = 'FV/2026/07/001', + issueDate = '2026-07-01', + saleDate = '2026-06-28', + currency = 'PLN', + invoiceType = 'VAT', lines = [ { lineNo: '1', description: 'Widget A', - unit: 'pcs', + unit: 'szt.', quantity: '10', netUnitPrice: '100.00', netTotal: '1000.00', @@ -34,209 +66,321 @@ function buildXml({ vatNet23 = '1000.00', vatTax23 = '230.00', grandTotal = '1230.00', - ksefNumber = '20240115-SE-ABCDEF1234', -}: { - sellerName?: string; - sellerNip?: string; - buyerName?: string; - buyerNip?: string; - invoiceNumber?: string; - issueDate?: string; - lines?: Array<{ - lineNo: string; - description: string; - unit: string; - quantity: string; - netUnitPrice: string; - netTotal: string; - vatRate: string; - isBeforeCorrection?: boolean; - }>; - vatNet23?: string | null; - vatTax23?: string | null; - grandTotal?: string | null; - ksefNumber?: string | null; -} = {}): string { + ksefNumber = null, + correction = null, + payment = null, +}: FixtureOptions = {}): string { const linesXml = lines .map( (l) => ` ${l.lineNo} + ${l.isBeforeCorrection ? '1' : ''} ${l.description} - ${l.unit} + ${l.unit !== undefined ? `${l.unit}` : ''} ${l.quantity} - ${l.netUnitPrice} + ${l.netUnitPrice !== undefined ? `${l.netUnitPrice}` : ''} ${l.netTotal} ${l.vatRate} - ${l.isBeforeCorrection ? '1' : ''} `, ) .join(''); + const correctionXml = correction + ? ` + ${correction.reason} + 2 + + 2026-06-01 + ${correction.correctedNumber} + 1 + ` + : ''; + + const paymentXml = payment + ? ` + + ${payment.termDate !== undefined ? `${payment.termDate}` : ''} + ${payment.formCode !== undefined ? `${payment.formCode}` : ''} + ${payment.bankAccount !== undefined ? `${payment.bankAccount}` : ''} + ` + : ''; + return ` - + - ${sellerNip} - ${sellerName} + 1234567890 + Acme Sp. z o.o. + + PL + ul. Testowa 1 + 00-001 Warszawa + - ${buyerNip} - ${buyerName} + 0987654321 + Buyer Corp. + + PL + ul. Kupiecka 7 + - ${invoiceNumber} - ${issueDate} - ${linesXml} + ${currency} + ${issueDate} + ${invoiceNumber} + ${saleDate !== null ? `${saleDate}` : ''} ${vatNet23 !== null ? `${vatNet23}` : ''} ${vatTax23 !== null ? `${vatTax23}` : ''} ${grandTotal !== null ? `${grandTotal}` : ''} + ${invoiceType} + ${correctionXml} + ${linesXml} + ${paymentXml} ${ksefNumber !== null ? `${ksefNumber}` : ''} `; } describe('KsefFa3View', () => { - it('should render seller and buyer names when valid XML is provided', () => { - renderWithProviders(); + describe('full plain invoice', () => { + it('should render the invoice number from P_2 and the issue date from P_1 (swap regression)', () => { + renderWithProviders( + , + ); - expect(screen.getByText('Acme Sp. z o.o.')).toBeInTheDocument(); - expect(screen.getByText(/1234567890/)).toBeInTheDocument(); - expect(screen.getByText('Buyer Corp.')).toBeInTheDocument(); - expect(screen.getByText(/0987654321/)).toBeInTheDocument(); - }); + // The invoice number is P_2 - the header must show it, not the date. + expect(screen.getByText('FV/2026/07/001')).toBeInTheDocument(); + expect(screen.getByText('2026-07-01')).toBeInTheDocument(); + const header = document.querySelector('.ksef-fa3-view__doc-number'); + expect(header?.textContent).toBe('FV/2026/07/001'); + }); - it('should render invoice number and issue date when valid XML is provided', () => { - renderWithProviders(); + it('should render seller and buyer names from Nazwa with NIP and address', () => { + renderWithProviders(); - expect(screen.getByText('FV/2024/001')).toBeInTheDocument(); - expect(screen.getByText('2024-01-15')).toBeInTheDocument(); - }); + expect(screen.getByText('Acme Sp. z o.o.')).toBeInTheDocument(); + expect(screen.getByText('1234567890')).toBeInTheDocument(); + expect(screen.getByText('Buyer Corp.')).toBeInTheDocument(); + expect(screen.getByText('0987654321')).toBeInTheDocument(); + expect(screen.getByText('ul. Testowa 1')).toBeInTheDocument(); + expect(screen.getByText('00-001 Warszawa')).toBeInTheDocument(); + expect(screen.getByText('ul. Kupiecka 7')).toBeInTheDocument(); + }); - it('should render all invoice lines when valid XML with multiple lines is provided', () => { - renderWithProviders( - , - ); - - expect(screen.getByText('Widget A')).toBeInTheDocument(); - expect(screen.getByText('Service B')).toBeInTheDocument(); - // 1000.00 appears in both the lines table (line 1 netTotal) and the VAT band row - expect(screen.getAllByText('1000.00').length).toBeGreaterThanOrEqual(1); - // 100.00 appears as netTotal for line 2 and netUnitPrice for line 1 - expect(screen.getAllByText('100.00').length).toBeGreaterThanOrEqual(1); - }); + it('should render sale date, currency, and the "Standard invoice" subtitle', () => { + renderWithProviders(); - it('should render only the "after" line in the main table for a KOR correction, with the "before" line collapsed separately (#1364 follow-up)', () => { - renderWithProviders( - , - ); - - // Main table renders the "after" line only. - const mainTable = document.querySelector('.ksef-fa3-view__lines .ksef-fa3-view__lineitems'); - expect(mainTable?.textContent).toContain('500.00'); - expect(mainTable?.textContent).not.toContain('1000.00'); - - // The "before" line is present, but only inside the collapsed disclosure. - expect(screen.getByText('Lines before correction')).toBeInTheDocument(); - const beforeTable = document.querySelector( - '.ksef-fa3-view__before-correction .ksef-fa3-view__lineitems', - ); - expect(beforeTable?.textContent).toContain('1000.00'); - }); + expect(screen.getByText('Standard invoice')).toBeInTheDocument(); + expect(screen.getByText('2026-06-28')).toBeInTheDocument(); + expect(screen.getByText('Currency')).toBeInTheDocument(); + expect(screen.getByText('PLN')).toBeInTheDocument(); + }); + + it('should render Unit and Net unit price columns when lines carry them', () => { + renderWithProviders(); + + expect(screen.getByText('Unit')).toBeInTheDocument(); + expect(screen.getByText('Net unit price')).toBeInTheDocument(); + expect(screen.getByText('szt.')).toBeInTheDocument(); + }); + + it('should derive line gross from net and the numeric VAT band and display the rate with %', () => { + renderWithProviders(); + + const mainTable = document.querySelector('.ksef-fa3-view__lines .ksef-fa3-view__table'); + // 1000.00 net * 1.23 = 1230.00 gross + expect(mainTable?.textContent).toContain('1230.00'); + expect(mainTable?.textContent).toContain('23%'); + }); + + it('should render gross = net for a non-numeric VAT band (zw)', () => { + renderWithProviders( + , + ); + + const mainTable = document.querySelector('.ksef-fa3-view__lines .ksef-fa3-view__table'); + const grossCells = mainTable?.textContent?.match(/200\.00/g) ?? []; + // Net total and gross total both render 200.00. + expect(grossCells.length).toBeGreaterThanOrEqual(2); + expect(mainTable?.textContent).toContain('zw'); + }); + + it('should render the VAT summary with net, tax, and derived gross per band', () => { + renderWithProviders( + , + ); + + const vat = document.querySelector('.ksef-fa3-view__vat'); + expect(vat?.textContent).toContain('1000.00'); + expect(vat?.textContent).toContain('230.00'); + expect(vat?.textContent).toContain('1230.00'); + }); + + it('should render the total due with the currency code', () => { + renderWithProviders(); + + const total = document.querySelector('.ksef-fa3-view__total'); + expect(total?.textContent).toContain('Total due'); + expect(total?.textContent).toContain('1230.00 PLN'); + }); - it('should show grand total when present in XML', () => { - renderWithProviders(); + it('should render the KSeF assigned number when present', () => { + renderWithProviders( + , + ); - expect(screen.getByText('9999.99')).toBeInTheDocument(); + expect(screen.getByText('1234567890-20260701-ABCDEF123456-AB')).toBeInTheDocument(); + }); }); - it('should show VAT band net and tax values when present in XML', () => { - renderWithProviders( - // Use grand total that won't collide with the tax value in a regex match. - , - ); - - // VAT net and tax appear in the summary section span text. - expect(screen.getAllByText(/1000\.00/).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/230\.00/).length).toBeGreaterThanOrEqual(1); + describe('minimal legacy document (pre-P_6/P_8A/P_9A backend)', () => { + const legacyXml = buildXml({ + saleDate: null, + payment: null, + lines: [ + { + lineNo: '1', + description: 'Legacy item', + quantity: '2', + netTotal: '100.00', + vatRate: '23', + }, + ], + }); + + it('should hide the Unit and Net unit price columns when no line carries them', () => { + renderWithProviders(); + + expect(screen.queryByText('Unit')).not.toBeInTheDocument(); + expect(screen.queryByText('Net unit price')).not.toBeInTheDocument(); + expect(screen.getByText('Legacy item')).toBeInTheDocument(); + }); + + it('should not render the sale date or payment section when absent', () => { + renderWithProviders(); + + expect(screen.queryByText('Sale date')).not.toBeInTheDocument(); + expect(document.querySelector('.ksef-fa3-view__payment')).toBeNull(); + }); }); - it('should render KSeF assigned number when present in XML', () => { - renderWithProviders(); + describe('KOR correction', () => { + const korXml = buildXml({ + invoiceType: 'KOR', + invoiceNumber: 'FV/2026/07/001/KOR', + correction: { reason: 'Quantity reduced on line 1', correctedNumber: 'FV/2026/07/001' }, + lines: [ + { + lineNo: '1', + description: 'Widget A', + unit: 'szt.', + quantity: '10', + netUnitPrice: '100.00', + netTotal: '1000.00', + vatRate: '23', + isBeforeCorrection: true, + }, + { + lineNo: '2', + description: 'Widget A', + unit: 'szt.', + quantity: '5', + netUnitPrice: '100.00', + netTotal: '500.00', + vatRate: '23', + }, + ], + }); + + it('should show the correction subtitle, reason, and corrected-invoice number', () => { + renderWithProviders(); + + expect(screen.getByText('Correction invoice')).toBeInTheDocument(); + expect(screen.getByText('Quantity reduced on line 1')).toBeInTheDocument(); + const correction = document.querySelector('.ksef-fa3-view__correction'); + expect(correction?.textContent).toContain('FV/2026/07/001'); + }); + + it('should render only the "after" line in the main table with the "before" line collapsed separately (#1364 follow-up)', () => { + renderWithProviders(); - expect(screen.getByText('KSEF-NUM-123')).toBeInTheDocument(); + const mainTable = document.querySelector('.ksef-fa3-view__lines .ksef-fa3-view__table'); + expect(mainTable?.textContent).toContain('500.00'); + expect(mainTable?.textContent).not.toContain('1000.00'); + + expect(screen.getByText('Lines before correction')).toBeInTheDocument(); + const beforeTable = document.querySelector( + '.ksef-fa3-view__before-correction .ksef-fa3-view__table', + ); + expect(beforeTable?.textContent).toContain('1000.00'); + }); }); - it('should return null when XML cannot be parsed', () => { - const { container } = renderWithProviders(); + describe('payment section', () => { + it('should map the FormaPlatnosci code to its label and show term date and bank account', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Payment')).toBeInTheDocument(); + expect(screen.getByText('Transfer')).toBeInTheDocument(); + expect(screen.getByText('2026-07-15')).toBeInTheDocument(); + expect(screen.getByText('61109010140000071219812874')).toBeInTheDocument(); + }); - // Component returns null — the .ksef-fa3-view root element must not appear. - expect(container.querySelector('.ksef-fa3-view')).toBeNull(); + it('should map cash code 1 to the Cash label', () => { + renderWithProviders(); + + expect(screen.getByText('Cash')).toBeInTheDocument(); + }); }); - it('should return null when invoice number element is missing', () => { - // Build XML without the P_1 element by using a parseable doc with no invoice number. - const xml = ` + describe('parse-failure contract', () => { + it('should return null when XML cannot be parsed', () => { + const { container } = renderWithProviders(); + + expect(container.querySelector('.ksef-fa3-view')).toBeNull(); + }); + + it('should return null when the invoice number element (P_2) is missing', () => { + const xml = ` - 2024-01-15 + 2026-07-01 `; - const { container } = renderWithProviders(); + const { container } = renderWithProviders(); - // Component returns null — the .ksef-fa3-view root element must not appear. - expect(container.querySelector('.ksef-fa3-view')).toBeNull(); + expect(container.querySelector('.ksef-fa3-view')).toBeNull(); + }); }); }); diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx index f85dfe5a6..79e8adb27 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx @@ -1,36 +1,76 @@ /** * KsefFa3View * - * Renders a structured human-readable view of an FA(3) XML document (#1228). - * Parses the XML client-side using `DOMParser` — no server round-trip required. + * Document-styled, theme-aware preview of an FA(3) XML invoice mirroring the + * official KSeF/MF visualization (#1526, originally #1228). Parses the XML + * client-side using `DOMParser` - no server round-trip required. * * Displays: - * - Seller identity (name + NIP) - * - Buyer identity (name + NIP) - * - Invoice number and issue date - * - Invoice lines table (line#, description, unit, qty, net price, net total, VAT rate) — - * current ("after") lines only; a KOR correction's `StanPrzed=1` "before" rows render in - * a separate collapsed section so they never mix into the main table/totals (#1364 follow-up) - * - VAT band summary (23%, 8%, 5%, 0%) - * - Grand total - * - KSeF assigned number (if present) + * - Header: invoice number (P_2), invoice type subtitle (RodzajFaktury), + * KSeF assigned number when present + * - Two-column Seller/Buyer blocks with NIP and address + * - Details row: issue date (P_1), sale date (P_6), currency (KodWaluty) + * - Line-items table with render-derived gross per line; the Unit and + * Net unit price columns render only when at least one current line + * carries the value (documents issued before the backend emitted + * P_8A/P_9A get no dash-only columns) + * - VAT summary per band (net / tax / gross) + total due with currency + * - Payment section (Platnosc): form label from the 1-7 code map (#1311), + * payment term, bank account + * - KOR: correction reason + corrected-invoice number; `StanPrzed=1` + * "before" rows stay in a separate collapsed section (#1364 follow-up) * - * Returns `null` if the XML cannot be parsed or required fields are missing — + * Skipped by design: Adnotacje (P_16-P_23), WZ, amount-in-words, footer. + * + * Returns `null` if the XML cannot be parsed or required fields are missing - * the parent (`ksef-invoice-detail-section`) falls through to the placeholder. * * FA(3) uses XML namespaces; element lookup uses `getElementsByTagName(tagName)` - * which matches regardless of namespace prefix (the local name is sufficient for FA(3)). + * which matches regardless of namespace prefix (the local name is sufficient). * * @module plugins/ksef/components */ import type { ReactElement } from 'react'; import { useTranslation } from '../../../shared/i18n'; -import type { FaData, FaLine } from './ksef-fa3-view.types'; +import type { FaData, FaLine, FaParty, FaPayment, FaVatBand } from './ksef-fa3-view.types'; interface KsefFa3ViewProps { xmlText: string; } +/** + * FA(3) VAT-band net/tax element pairs in XSD emit order, mirroring the + * `VAT_BANDS` map in the backend builder (`fa3-xml.builder.ts`). Net-only + * bands carry no `tax` element. + */ +const VAT_BAND_ELEMENTS: ReadonlyArray<{ net: string; tax: string | null; label: string }> = [ + { net: 'P_13_1', tax: 'P_14_1', label: '23%' }, + { net: 'P_13_2', tax: 'P_14_2', label: '8%' }, + { net: 'P_13_3', tax: 'P_14_3', label: '5%' }, + { net: 'P_13_6_1', tax: null, label: '0%' }, + { net: 'P_13_6_2', tax: null, label: '0% WDT' }, + { net: 'P_13_6_3', tax: null, label: '0% EX' }, + { net: 'P_13_7', tax: null, label: 'zw' }, + { net: 'P_13_8', tax: null, label: 'np I' }, + { net: 'P_13_9', tax: null, label: 'np II' }, + { net: 'P_13_10', tax: null, label: 'oo' }, +]; + +/** + * `FormaPlatnosci` code (`TFormaPlatnosci`, 1-7) to i18n key + English + * fallback. Mirrors `KSEF_FORMA_PLATNOSCI_VALUES` / `FORMA_PLATNOSCI_LABELS` + * from the connection setup form (#1311). + */ +const PAYMENT_FORM_LABELS: Readonly> = { + '1': { key: 'invoice.ksef.fa3PaymentFormCash', fallback: 'Cash' }, + '2': { key: 'invoice.ksef.fa3PaymentFormCard', fallback: 'Card' }, + '3': { key: 'invoice.ksef.fa3PaymentFormVoucher', fallback: 'Voucher' }, + '4': { key: 'invoice.ksef.fa3PaymentFormCheque', fallback: 'Cheque' }, + '5': { key: 'invoice.ksef.fa3PaymentFormCredit', fallback: 'Credit' }, + '6': { key: 'invoice.ksef.fa3PaymentFormTransfer', fallback: 'Transfer' }, + '7': { key: 'invoice.ksef.fa3PaymentFormMobile', fallback: 'Mobile' }, +}; + /** Extract text content from the first matching element (namespace-agnostic). */ function getText(root: Element | Document, tagName: string): string | null { // getElementsByTagName matches regardless of namespace prefix, making it safe @@ -39,6 +79,55 @@ function getText(root: Element | Document, tagName: string): string | null { return el?.textContent?.trim() ?? null; } +/** Parse a `Podmiot1`/`Podmiot2` element into name + NIP + address. */ +function parseParty(podmiot: Element | null): FaParty { + const idEl = podmiot?.getElementsByTagName('DaneIdentyfikacyjne').item(0) ?? null; + const adresEl = podmiot?.getElementsByTagName('Adres').item(0) ?? null; + const addressLines: string[] = []; + if (adresEl) { + const l1 = getText(adresEl, 'AdresL1'); + const l2 = getText(adresEl, 'AdresL2'); + if (l1 !== null) addressLines.push(l1); + if (l2 !== null) addressLines.push(l2); + } + return { + // The builder emits `Nazwa` (FA(3) `DaneIdentyfikacyjne`); `NazwaSkrocona` + // is the fallback for foreign-issued documents. + name: idEl ? (getText(idEl, 'Nazwa') ?? getText(idEl, 'NazwaSkrocona')) : null, + nip: idEl ? getText(idEl, 'NIP') : null, + addressLines, + countryCode: adresEl ? getText(adresEl, 'KodKraju') : null, + }; +} + +/** Parse the optional `Platnosc` block. Returns null when absent/empty. */ +function parsePayment(fa: Element): FaPayment | null { + const platnosc = fa.getElementsByTagName('Platnosc').item(0); + if (!platnosc) return null; + const terminEl = platnosc.getElementsByTagName('TerminPlatnosci').item(0); + const termDate = terminEl ? getText(terminEl, 'Termin') : null; + let termDescription: string | null = null; + const opisEl = terminEl?.getElementsByTagName('TerminOpis').item(0) ?? null; + if (opisEl) { + const count = getText(opisEl, 'Ilosc'); + const unit = getText(opisEl, 'Jednostka'); + termDescription = [count, unit].filter((part) => part !== null).join(' ') || null; + } + const bankEl = platnosc.getElementsByTagName('RachunekBankowy').item(0); + const payment: FaPayment = { + termDate, + termDescription, + formCode: getText(platnosc, 'FormaPlatnosci'), + bankAccount: bankEl ? getText(bankEl, 'NrRB') : null, + }; + const hasContent = + payment.termDate !== null || + payment.termDescription !== null || + payment.formCode !== null || + payment.bankAccount !== null; + return hasContent ? payment : null; +} + function parseFa3Xml(xmlText: string): FaData | null { let doc: Document; try { @@ -56,27 +145,15 @@ function parseFa3Xml(xmlText: string): FaData | null { const faktura = doc.getElementsByTagName('Faktura').item(0); if (!faktura) return null; - const podmiot1 = faktura.getElementsByTagName('Podmiot1').item(0); - const podmiot2 = faktura.getElementsByTagName('Podmiot2').item(0); const fa = faktura.getElementsByTagName('Fa').item(0); - const ksef = faktura.getElementsByTagName('KSeF').item(0); - - const sellerIdEl = podmiot1?.getElementsByTagName('DaneIdentyfikacyjne').item(0) ?? null; - const buyerIdEl = podmiot2?.getElementsByTagName('DaneIdentyfikacyjne').item(0) ?? null; - - const sellerName = sellerIdEl - ? getText(sellerIdEl, 'NazwaSkrocona') - : null; - const sellerNip = sellerIdEl ? getText(sellerIdEl, 'NIP') : null; - const buyerName = buyerIdEl - ? getText(buyerIdEl, 'NazwaSkrocona') - : null; - const buyerNip = buyerIdEl ? getText(buyerIdEl, 'NIP') : null; + if (!fa) return null; - const invoiceNumber = fa ? getText(fa, 'P_1') : null; - const issueDate = fa ? getText(fa, 'P_2') : null; + // FA(3): P_1 = issue date, P_2 = invoice number (previously swapped, #1526). + const invoiceNumber = getText(fa, 'P_2'); + // Require at minimum the invoice number to consider the parse successful. + if (invoiceNumber === null) return null; - const lineEls = fa ? Array.from(fa.getElementsByTagName('FaWiersz')) : []; + const lineEls = Array.from(fa.getElementsByTagName('FaWiersz')); const lines: FaLine[] = lineEls.map((el) => ({ lineNo: getText(el, 'NrWierszaFa'), description: getText(el, 'P_7'), @@ -88,272 +165,360 @@ function parseFa3Xml(xmlText: string): FaData | null { isBeforeCorrection: getText(el, 'StanPrzed') === '1', })); - const vatNet23 = fa ? getText(fa, 'P_13_1') : null; - const vatTax23 = fa ? getText(fa, 'P_14_1') : null; - const vatNet8 = fa ? getText(fa, 'P_13_2') : null; - const vatTax8 = fa ? getText(fa, 'P_14_2') : null; - const vatNet5 = fa ? getText(fa, 'P_13_3') : null; - const vatTax5 = fa ? getText(fa, 'P_14_3') : null; - const vatNet0 = fa ? getText(fa, 'P_13_5') : null; - const vatTax0 = fa ? getText(fa, 'P_14_5') : null; - const grandTotal = fa ? getText(fa, 'P_15') : null; - const ksefNumber = ksef ? getText(ksef, 'NrKSeF') : null; + const vatBands: FaVatBand[] = []; + for (const band of VAT_BAND_ELEMENTS) { + const net = getText(fa, band.net); + if (net === null) continue; + vatBands.push({ + label: band.label, + net, + tax: band.tax !== null ? getText(fa, band.tax) : null, + }); + } - // Require at minimum the invoice number to consider the parse successful. - if (!invoiceNumber) return null; + const korygowanaEl = fa.getElementsByTagName('DaneFaKorygowanej').item(0); + const ksef = faktura.getElementsByTagName('KSeF').item(0); return { - sellerName, - sellerNip, - buyerName, - buyerNip, invoiceNumber, - issueDate, + issueDate: getText(fa, 'P_1'), + saleDate: getText(fa, 'P_6'), + currency: getText(fa, 'KodWaluty'), + invoiceType: getText(fa, 'RodzajFaktury'), + seller: parseParty(faktura.getElementsByTagName('Podmiot1').item(0)), + buyer: parseParty(faktura.getElementsByTagName('Podmiot2').item(0)), lines, - vatNet23, - vatTax23, - vatNet8, - vatTax8, - vatNet5, - vatTax5, - vatNet0, - vatTax0, - grandTotal, - ksefNumber, + vatBands, + grandTotal: getText(fa, 'P_15'), + ksefNumber: ksef ? getText(ksef, 'NrKSeF') : null, + correctionReason: getText(fa, 'PrzyczynaKorekty'), + correctedInvoiceNumber: korygowanaEl ? getText(korygowanaEl, 'NrFaKorygowanej') : null, + payment: parsePayment(fa), }; } +/** + * Derive a gross amount from a decimal string and a `P_12` VAT band. Numeric + * bands (23/8/5/0) apply `net * (1 + rate)`; non-numeric bands (zw, np, oo) + * carry no VAT, so gross = net. Returns null when the net is not numeric. + */ +function deriveGross(net: string | null, vatRate: string | null): string | null { + if (net === null) return null; + const netValue = Number(net); + if (!Number.isFinite(netValue)) return null; + const rate = vatRate !== null && /^\d+([.,]\d+)?$/.test(vatRate) ? Number(vatRate.replace(',', '.')) : 0; + return (netValue * (1 + rate / 100)).toFixed(2); +} + +/** Gross for a VAT summary band: net + tax (net-only bands carry no tax). */ +function bandGross(band: FaVatBand): string | null { + const netValue = Number(band.net); + if (!Number.isFinite(netValue)) return null; + const taxValue = band.tax !== null ? Number(band.tax) : 0; + if (!Number.isFinite(taxValue)) return null; + return (netValue + taxValue).toFixed(2); +} + +/** Format a `P_12` band for display: numeric bands get a `%` suffix. */ +function formatVatRate(vatRate: string | null): string { + if (vatRate === null) return '-'; + return /^\d+([.,]\d+)?$/.test(vatRate) ? `${vatRate}%` : vatRate; +} + +interface LinesTableProps { + lines: FaLine[]; + showUnit: boolean; + showNetUnitPrice: boolean; +} + +function LinesTable({ lines, showUnit, showNetUnitPrice }: LinesTableProps): ReactElement { + const { t } = useTranslation(); + return ( +
+ + + + + + {showNetUnitPrice ? ( + + ) : null} + + {showUnit ? : null} + + + + + + + {lines.map((line, idx) => ( + + + + {showNetUnitPrice ? ( + + ) : null} + + {showUnit ? : null} + + + + + ))} + +
{t('invoice.ksef.fa3LineNo', '#')} + {t('invoice.ksef.fa3LineDesc', 'Description')} + + {t('invoice.ksef.fa3LineNetPrice', 'Net unit price')} + {t('invoice.ksef.fa3LineQty', 'Qty')}{t('invoice.ksef.fa3LineUnit', 'Unit')}{t('invoice.ksef.fa3LineVat', 'VAT rate')} + {t('invoice.ksef.fa3LineNetTotal', 'Net total')} + + {t('invoice.ksef.fa3LineGrossTotal', 'Gross total')} +
{line.lineNo ?? String(idx + 1)}{line.description ?? '-'}{line.netUnitPrice ?? '-'}{line.quantity ?? '-'}{line.unit ?? '-'}{formatVatRate(line.vatRate)}{line.netTotal ?? '-'} + {deriveGross(line.netTotal, line.vatRate) ?? '-'} +
+
+ ); +} + +interface PartyBlockProps { + label: string; + party: FaParty; +} + +function PartyBlock({ label, party }: PartyBlockProps): ReactElement { + const { t } = useTranslation(); + return ( +
+
{label}
+
{party.name ?? '-'}
+ {party.nip !== null ? ( +
+ {t('invoice.ksef.fa3Nip', 'NIP')}: {party.nip} +
+ ) : null} + {party.addressLines.map((line) => ( +
+ {line} +
+ ))} + {party.countryCode !== null ? ( +
{party.countryCode}
+ ) : null} +
+ ); +} + export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null { const { t } = useTranslation(); const data = parseFa3Xml(xmlText); if (!data) return null; - const hasVatBands = - data.vatNet23 !== null || - data.vatNet8 !== null || - data.vatNet5 !== null || - data.vatNet0 !== null; + const isCorrection = data.invoiceType === 'KOR'; + const subtitle = isCorrection + ? t('invoice.ksef.fa3TypeCorrection', 'Correction invoice') + : t('invoice.ksef.fa3TypeStandard', 'Standard invoice'); // A KOR correction emits one "before" row (StanPrzed=1) per changed line // plus every current "after" line. The VAT summary / grand total below - // already reflect only the "after" state, so the main table must too — - // otherwise line counts and totals double up (#1364 follow-up). The + // already reflect only the corrected delta/state, so the main table must + // too - otherwise line counts and totals double up (#1364 follow-up). The // "before" rows are still shown, but in a separate, clearly labeled set. const currentLines = data.lines.filter((line) => !line.isBeforeCorrection); const beforeCorrectionLines = data.lines.filter((line) => line.isBeforeCorrection); + // Documents issued before the backend emitted P_8A/P_9A must not render + // dash-only columns - show them only when a current line carries the value. + const showUnit = currentLines.some((line) => line.unit !== null); + const showNetUnitPrice = currentLines.some((line) => line.netUnitPrice !== null); + + const paymentFormLabel = + data.payment?.formCode !== null && data.payment?.formCode !== undefined + ? PAYMENT_FORM_LABELS[data.payment.formCode] + : undefined; + return (
- {/* Invoice header */} -
+ {/* Document header */} +
-
- {t('invoice.ksef.fa3InvoiceNumber', 'Invoice number')} -
+
{data.invoiceNumber}
+
{subtitle}
- {data.invoiceNumber} -
+ {data.ksefNumber !== null ? ( +
+
+ {t('invoice.ksef.fa3KsefNumber', 'KSeF number')} +
+
{data.ksefNumber}
+
+ ) : null} + + + {/* Seller / Buyer */} +
+ + +
- {data.issueDate !== null ? ( -
+ {/* Details row */} +
+ {data.issueDate !== null ? (
-
+
{t('invoice.ksef.fa3IssueDate', 'Issue date')}
+
{data.issueDate}
- {data.issueDate} -
- ) : null} - - {/* Seller */} - {data.sellerName !== null || data.sellerNip !== null ? ( -
+ ) : null} + {data.saleDate !== null ? (
-
- {t('invoice.ksef.fa3Seller', 'Seller')} +
+ {t('invoice.ksef.fa3SaleDate', 'Sale date')}
- {data.sellerNip !== null ? ( -
- {t('invoice.ksef.fa3Nip', 'NIP')}: {data.sellerNip} -
- ) : null} +
{data.saleDate}
- {data.sellerName ?? '—'} -
- ) : null} - - {/* Buyer */} - {data.buyerName !== null || data.buyerNip !== null ? ( -
+ ) : null} + {data.currency !== null ? (
-
- {t('invoice.ksef.fa3Buyer', 'Buyer')} +
+ {t('invoice.ksef.fa3Currency', 'Currency')}
- {data.buyerNip !== null ? ( -
- {t('invoice.ksef.fa3Nip', 'NIP')}: {data.buyerNip} -
- ) : null} +
{data.currency}
- {data.buyerName ?? '—'} -
+ ) : null} +
+ + {/* KOR correction metadata */} + {data.correctionReason !== null || data.correctedInvoiceNumber !== null ? ( +
+ {data.correctedInvoiceNumber !== null ? ( +
+ + {t('invoice.ksef.fa3CorrectedInvoice', 'Corrects invoice')} + {' '} + {data.correctedInvoiceNumber} +
+ ) : null} + {data.correctionReason !== null ? ( +
+ + {t('invoice.ksef.fa3CorrectionReason', 'Correction reason')} + {' '} + {data.correctionReason} +
+ ) : null} +
) : null} {/* Line items (current / "after" state) */} {currentLines.length > 0 ? ( -
-
+
+
{t('invoice.ksef.fa3Lines', 'Invoice lines')}
-
- - - - - - - - - - - - - - {currentLines.map((line, idx) => ( - - - - - - - - - - ))} - -
{t('invoice.ksef.fa3LineNo', '#')}{t('invoice.ksef.fa3LineDesc', 'Description')}{t('invoice.ksef.fa3LineUnit', 'Unit')}{t('invoice.ksef.fa3LineQty', 'Qty')}{t('invoice.ksef.fa3LineNetPrice', 'Net price')}{t('invoice.ksef.fa3LineNetTotal', 'Net total')}{t('invoice.ksef.fa3LineVat', 'VAT')}
{line.lineNo ?? String(idx + 1)}{line.description ?? '—'}{line.unit ?? '—'}{line.quantity ?? '—'}{line.netUnitPrice ?? '—'}{line.netTotal ?? '—'}{line.vatRate ?? '—'}
-
-
+ + ) : null} - {/* KOR "before correction" lines — shown separately so they never mix + {/* KOR "before correction" lines - shown separately so they never mix into the current-state table/totals above. */} {beforeCorrectionLines.length > 0 ? (
- + {t('invoice.ksef.fa3LinesBefore', 'Lines before correction')} -
- + + + ) : null} + + {/* VAT summary */} + {data.vatBands.length > 0 ? ( +
+
+ {t('invoice.ksef.fa3VatSummary', 'VAT summary')} +
+
+
- - - - - - - + + + + - {beforeCorrectionLines.map((line, idx) => ( - - - - - - - - + {data.vatBands.map((band) => ( + + + + + ))}
{t('invoice.ksef.fa3LineNo', '#')}{t('invoice.ksef.fa3LineDesc', 'Description')}{t('invoice.ksef.fa3LineUnit', 'Unit')}{t('invoice.ksef.fa3LineQty', 'Qty')}{t('invoice.ksef.fa3LineNetPrice', 'Net price')}{t('invoice.ksef.fa3LineNetTotal', 'Net total')}{t('invoice.ksef.fa3LineVat', 'VAT')}{t('invoice.ksef.fa3VatRate', 'Rate')}{t('invoice.ksef.fa3VatNet', 'Net')}{t('invoice.ksef.fa3VatTax', 'Tax')} + {t('invoice.ksef.fa3VatGross', 'Gross')} +
{line.lineNo ?? String(idx + 1)}{line.description ?? '—'}{line.unit ?? '—'}{line.quantity ?? '—'}{line.netUnitPrice ?? '—'}{line.netTotal ?? '—'}{line.vatRate ?? '—'}
{band.label}{band.net}{band.tax ?? '-'}{bandGross(band) ?? '-'}
-
+ ) : null} - {/* VAT bands */} - {hasVatBands ? ( -
-
- {t('invoice.ksef.fa3VatSummary', 'VAT summary')} + {/* Total due */} + {data.grandTotal !== null ? ( +
+ + {t('invoice.ksef.fa3GrandTotal', 'Total due')} + + + {data.grandTotal} + {data.currency !== null ? ` ${data.currency}` : ''} + +
+ ) : null} + + {/* Payment */} + {data.payment !== null ? ( +
+
+ {t('invoice.ksef.fa3Payment', 'Payment')}
- {data.vatNet23 !== null ? ( -
-
23% {t('invoice.ksef.fa3VatNet', 'Net')}
- {data.vatNet23} -
- ) : null} - {data.vatTax23 !== null ? ( -
-
23% {t('invoice.ksef.fa3VatTax', 'Tax')}
- {data.vatTax23} + {paymentFormLabel !== undefined ? ( +
+ + {t('invoice.ksef.fa3PaymentForm', 'Payment form')} + {' '} + {t(paymentFormLabel.key, paymentFormLabel.fallback)}
) : null} - {data.vatNet8 !== null ? ( -
-
8% {t('invoice.ksef.fa3VatNet', 'Net')}
- {data.vatNet8} + {data.payment.termDate !== null || data.payment.termDescription !== null ? ( +
+ + {t('invoice.ksef.fa3PaymentTerm', 'Payment term')} + {' '} + + {data.payment.termDate ?? data.payment.termDescription} +
) : null} - {data.vatTax8 !== null ? ( -
-
8% {t('invoice.ksef.fa3VatTax', 'Tax')}
- {data.vatTax8} + {data.payment.bankAccount !== null ? ( +
+ + {t('invoice.ksef.fa3PaymentAccount', 'Bank account')} + {' '} + {data.payment.bankAccount}
) : null} - {data.vatNet5 !== null ? ( -
-
5% {t('invoice.ksef.fa3VatNet', 'Net')}
- {data.vatNet5} -
- ) : null} - {data.vatTax5 !== null ? ( -
-
5% {t('invoice.ksef.fa3VatTax', 'Tax')}
- {data.vatTax5} -
- ) : null} - {data.vatNet0 !== null ? ( -
-
0% {t('invoice.ksef.fa3VatNet', 'Net')}
- {data.vatNet0} -
- ) : null} - {data.vatTax0 !== null ? ( -
-
0% {t('invoice.ksef.fa3VatTax', 'Tax')}
- {data.vatTax0} -
- ) : null} -
- ) : null} - - {/* Grand total */} - {data.grandTotal !== null ? ( -
-
- {t('invoice.ksef.fa3GrandTotal', 'Grand total')} -
- {data.grandTotal} -
- ) : null} - - {/* KSeF number */} - {data.ksefNumber !== null ? ( -
-
-
- {t('invoice.ksef.fa3KsefNumber', 'KSeF number (FA(3))')} -
-
- {t('invoice.ksef.fa3KsefNumberHint', 'Embedded in the issued document')} -
-
- {data.ksefNumber} -
+
) : null}
); diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts b/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts index f524f92ff..7304686dd 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts @@ -1,5 +1,9 @@ /** - * Types for KSeF FA(3) XML view (#1228). + * Types for KSeF FA(3) XML view (#1228, rebuilt #1526). + * + * Parsed projection of an FA(3) document as emitted by the backend builder + * (`fa3-xml.builder.ts`): party identity + address, invoice dates, currency, + * lines, per-band VAT aggregates, payment block, and KOR correction metadata. * * @module plugins/ksef/components */ @@ -16,28 +20,61 @@ export interface FaLine { * FA(3) KOR before/after correction model: `true` when this row carries * `1` (the pre-correction "before" state). A * correction document emits one before row per changed line plus every - * current ("after") line — they must be rendered as two distinct sets, not + * current ("after") line - they must be rendered as two distinct sets, not * flattened into one table, or line counts/totals double up (#1364 follow-up). */ isBeforeCorrection: boolean; } +export interface FaParty { + /** `DaneIdentyfikacyjne/Nazwa` with `NazwaSkrocona` fallback (foreign docs). */ + name: string | null; + nip: string | null; + /** `Adres/AdresL1` + optional `AdresL2`, in display order. */ + addressLines: string[]; + /** `Adres/KodKraju` (ISO 3166-1 alpha-2). */ + countryCode: string | null; +} + +export interface FaVatBand { + /** Display label for the band, e.g. `23%`, `zw`, `np I`. */ + label: string; + net: string; + /** `null` for net-only bands (0%, zw, np, oo). */ + tax: string | null; +} + +export interface FaPayment { + /** `Platnosc/TerminPlatnosci/Termin` (a date) when present. */ + termDate: string | null; + /** Composed `TerminOpis` text (`{Ilosc} {Jednostka}`) when the descriptive branch is used. */ + termDescription: string | null; + /** `Platnosc/FormaPlatnosci` numeric code `1`-`7`. */ + formCode: string | null; + /** `Platnosc/RachunekBankowy/NrRB`. */ + bankAccount: string | null; +} + export interface FaData { - sellerName: string | null; - sellerNip: string | null; - buyerName: string | null; - buyerNip: string | null; - invoiceNumber: string | null; + /** `Fa/P_2` - the invoice number (NOT `P_1`, which is the issue date). */ + invoiceNumber: string; + /** `Fa/P_1` - the issue date. */ issueDate: string | null; + /** `Fa/P_6` - sale/delivery date, optional. */ + saleDate: string | null; + /** `Fa/KodWaluty`. */ + currency: string | null; + /** `Fa/RodzajFaktury` - `VAT` for a plain sale, `KOR` for a correction. */ + invoiceType: string | null; + seller: FaParty; + buyer: FaParty; lines: FaLine[]; - vatNet23: string | null; - vatTax23: string | null; - vatNet8: string | null; - vatTax8: string | null; - vatNet5: string | null; - vatTax5: string | null; - vatNet0: string | null; - vatTax0: string | null; + vatBands: FaVatBand[]; grandTotal: string | null; ksefNumber: string | null; + /** KOR only: `Fa/PrzyczynaKorekty`. */ + correctionReason: string | null; + /** KOR only: `Fa/DaneFaKorygowanej/NrFaKorygowanej`. */ + correctedInvoiceNumber: string | null; + payment: FaPayment | null; } diff --git a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx index 4d318ef24..79fc5351a 100644 --- a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx @@ -196,9 +196,9 @@ describe('KsefInvoiceDetailSection', () => { // Return a minimal valid FA(3) XML so KsefFa3View renders (not null). const fa3Xml = ` - 1111111111Seller Ltd - 2222222222Buyer Ltd - FV/2026/0012026-01-01123.00 + 1111111111Seller Ltd + 2222222222Buyer Ltd + 2026-01-01FV/2026/001123.00 `; const downloadDocument = vi .fn() From 616239e2b341f33f6835108e50d26c372efe03ed Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Tue, 14 Jul 2026 00:16:43 +0200 Subject: [PATCH 2/4] fix(web/ksef): address round-1 review findings on the FA(3) preview - Match FA(3) elements by localName (wildcard scan) instead of qualified tag names - prefixed documents (tns:) parsed to null before; happy-dom also mis-implements getElementsByTagNameNS so the NS API was not usable - Key PAYMENT_FORM_LABELS by KsefFormaPlatnosci from ksef-setup.schema (compile-time exhaustiveness with the #1311 vocabulary, guarded by the forma-platnosci drift script); unmapped codes render the raw code instead of dropping the Payment-form row - Accept the KSeF number as a prop from the invoice record (the builder never embeds it in the document); keep the XML parse as a fallback for foreign documents only - Normalize 0 KR / 0 WDT / 0 EX line-table rates to the VAT-summary band labels - Parse and render Platnosc/Skonto (early-payment discount) - Compute the conditional Unit / Net-unit-price columns per table so a KOR's before rows keep their values independently of the after rows - Tests: prefixed-document fixture, TerminOpis descriptive payment term (the only branch the OL builder emits), skonto row, unmapped payment code, per-table column visibility Co-Authored-By: Claude Fable 5 Signed-off-by: norbert-kulus-blockydevs --- .../ksef/components/ksef-fa3-view.test.tsx | 174 +++++++++++++++- .../plugins/ksef/components/ksef-fa3-view.tsx | 189 ++++++++++++------ .../ksef/components/ksef-fa3-view.types.ts | 4 + .../ksef-invoice-detail-section.tsx | 2 +- 4 files changed, 299 insertions(+), 70 deletions(-) diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx index 47e242d90..6b18149f7 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx @@ -41,11 +41,18 @@ interface FixtureOptions { grandTotal?: string | null; ksefNumber?: string | null; correction?: { reason: string; correctedNumber: string } | null; - payment?: { formCode?: string; termDate?: string; bankAccount?: string } | null; + payment?: { + formCode?: string; + termDate?: string; + termOpis?: { count: string; unit: string }; + bankAccount?: string; + skonto?: { conditions: string; amount: string }; + } | null; } -// Builder-produced-like FA(3) XML (default xmlns, no prefix - the component's -// getElementsByTagName lookup is namespace/prefix agnostic anyway). +// Builder-produced-like FA(3) XML (default xmlns, no prefix - the OL builder +// emits a default namespace; prefixed documents are covered by a dedicated +// test below since getElementsByTagName does NOT match prefixed tags). function buildXml({ invoiceNumber = 'FV/2026/07/001', issueDate = '2026-07-01', @@ -97,12 +104,22 @@ function buildXml({ ` : ''; + const terminXml = + payment?.termDate !== undefined + ? `${payment.termDate}` + : payment?.termOpis !== undefined + ? // The OL builder emits the descriptive TerminOpis branch, never a bare + // Termin date (fa3-xml.builder.ts platnoscNode). + `${payment.termOpis.count}${payment.termOpis.unit}data wystawienia faktury` + : ''; + const paymentXml = payment ? ` - ${payment.termDate !== undefined ? `${payment.termDate}` : ''} + ${terminXml} ${payment.formCode !== undefined ? `${payment.formCode}` : ''} ${payment.bankAccount !== undefined ? `${payment.bankAccount}` : ''} + ${payment.skonto !== undefined ? `${payment.skonto.conditions}${payment.skonto.amount}` : ''} ` : ''; @@ -245,13 +262,78 @@ describe('KsefFa3View', () => { expect(total?.textContent).toContain('1230.00 PLN'); }); - it('should render the KSeF assigned number when present', () => { + it('should render the KSeF number passed as a prop from the invoice record', () => { renderWithProviders( - , + , ); expect(screen.getByText('1234567890-20260701-ABCDEF123456-AB')).toBeInTheDocument(); }); + + it('should fall back to a document-embedded KSeF number when no prop is passed (foreign documents)', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('KSEF-FROM-XML-123')).toBeInTheDocument(); + }); + + it('should not render the KSeF number row when neither prop nor document carries one', () => { + renderWithProviders(); + + expect(document.querySelector('.ksef-fa3-view__ksef-number')).toBeNull(); + }); + + it('should normalize a zero-rate band code (0 EX) in the line table to the summary-style label', () => { + renderWithProviders( + , + ); + + const mainTable = document.querySelector('.ksef-fa3-view__lines .ksef-fa3-view__table'); + expect(mainTable?.textContent).toContain('0% EX'); + expect(mainTable?.textContent).not.toContain('0 EX'); + }); + }); + + describe('namespace handling', () => { + it('should parse a namespace-prefixed FA(3) document (tns:)', () => { + // Real FA(3) documents may prefix every element; qualified-name lookup + // (getElementsByTagName) would return zero matches for these. + const prefixedXml = ` + + + + 1234567890 + Prefixed Seller + + + + PLN + 2026-07-01 + FV/PREFIXED/001 + 100.00 + VAT + +`; + renderWithProviders(); + + expect(screen.getByText('FV/PREFIXED/001')).toBeInTheDocument(); + expect(screen.getByText('Prefixed Seller')).toBeInTheDocument(); + }); }); describe('minimal legacy document (pre-P_6/P_8A/P_9A backend)', () => { @@ -335,6 +417,48 @@ describe('KsefFa3View', () => { ); expect(beforeTable?.textContent).toContain('1000.00'); }); + + it('should compute the conditional columns per table when only the before rows carry unit and net unit price', () => { + renderWithProviders( + , + ); + + // The main ("after") table has no unit / net-unit-price values, so its + // columns are hidden; the before table carries them, so it shows both. + const mainTable = document.querySelector('.ksef-fa3-view__lines .ksef-fa3-view__table'); + expect(mainTable?.textContent).not.toContain('Unit'); + expect(mainTable?.textContent).not.toContain('Net unit price'); + const beforeTable = document.querySelector( + '.ksef-fa3-view__before-correction .ksef-fa3-view__table', + ); + expect(beforeTable?.textContent).toContain('Unit'); + expect(beforeTable?.textContent).toContain('Net unit price'); + expect(beforeTable?.textContent).toContain('szt.'); + }); }); describe('payment section', () => { @@ -362,6 +486,44 @@ describe('KsefFa3View', () => { expect(screen.getByText('Cash')).toBeInTheDocument(); }); + + it('should render the descriptive TerminOpis payment term the OL builder emits', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Payment term')).toBeInTheDocument(); + expect(screen.getByText('14 dni')).toBeInTheDocument(); + }); + + it('should render the skonto row when the document carries Platnosc/Skonto', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Early-payment discount (skonto)')).toBeInTheDocument(); + const payment = document.querySelector('.ksef-fa3-view__payment'); + expect(payment?.textContent).toContain('2% - Payment within 7 days'); + }); + + it('should render the raw code for an unmapped FormaPlatnosci value instead of dropping the row', () => { + renderWithProviders(); + + const payment = document.querySelector('.ksef-fa3-view__payment'); + expect(payment?.textContent).toContain('Payment form'); + expect(payment?.textContent).toContain('9'); + }); }); describe('parse-failure contract', () => { diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx index 79e8adb27..5a29c2c65 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx @@ -7,16 +7,17 @@ * * Displays: * - Header: invoice number (P_2), invoice type subtitle (RodzajFaktury), - * KSeF assigned number when present + * KSeF assigned number when known (passed by the parent from the + * InvoiceRecord; the builder does not embed it in the document) * - Two-column Seller/Buyer blocks with NIP and address * - Details row: issue date (P_1), sale date (P_6), currency (KodWaluty) * - Line-items table with render-derived gross per line; the Unit and - * Net unit price columns render only when at least one current line - * carries the value (documents issued before the backend emitted - * P_8A/P_9A get no dash-only columns) + * Net unit price columns render only when at least one line in the + * given table carries the value (documents issued before the backend + * emitted P_8A/P_9A get no dash-only columns) * - VAT summary per band (net / tax / gross) + total due with currency * - Payment section (Platnosc): form label from the 1-7 code map (#1311), - * payment term, bank account + * payment term (date or descriptive TerminOpis), bank account, skonto * - KOR: correction reason + corrected-invoice number; `StanPrzed=1` * "before" rows stay in a separate collapsed section (#1364 follow-up) * @@ -25,17 +26,26 @@ * Returns `null` if the XML cannot be parsed or required fields are missing - * the parent (`ksef-invoice-detail-section`) falls through to the placeholder. * - * FA(3) uses XML namespaces; element lookup uses `getElementsByTagName(tagName)` - * which matches regardless of namespace prefix (the local name is sufficient). + * FA(3) uses XML namespaces and real documents may prefix elements (`tns:`); + * element lookup matches by `localName` regardless of the prefix or + * namespace used (see `collectByLocalName`). * * @module plugins/ksef/components */ import type { ReactElement } from 'react'; import { useTranslation } from '../../../shared/i18n'; +import { KSEF_FORMA_PLATNOSCI_VALUES } from './ksef-setup.schema'; +import type { KsefFormaPlatnosci } from './ksef-setup.schema'; import type { FaData, FaLine, FaParty, FaPayment, FaVatBand } from './ksef-fa3-view.types'; interface KsefFa3ViewProps { xmlText: string; + /** + * KSeF-assigned number from the InvoiceRecord (clearance reference). The + * FA(3) document itself does not carry it - the number is assigned by the + * authority AFTER the document is built - so the parent passes it in. + */ + ksefNumber?: string | null; } /** @@ -56,12 +66,25 @@ const VAT_BAND_ELEMENTS: ReadonlyArray<{ net: string; tax: string | null; label: { net: 'P_13_10', tax: null, label: 'oo' }, ]; +/** + * Line-table display labels for the `P_12` zero-rate band codes, kept + * consistent with the VAT-summary band labels above. + */ +const ZERO_RATE_LINE_LABELS: Readonly> = { + '0 KR': '0%', + '0 WDT': '0% WDT', + '0 EX': '0% EX', +}; + /** * `FormaPlatnosci` code (`TFormaPlatnosci`, 1-7) to i18n key + English - * fallback. Mirrors `KSEF_FORMA_PLATNOSCI_VALUES` / `FORMA_PLATNOSCI_LABELS` - * from the connection setup form (#1311). + * fallback. Keyed by `KsefFormaPlatnosci` from the setup schema (#1311) so + * the compiler enforces exhaustiveness against the guarded vocabulary - + * adding an 8th code to `KSEF_FORMA_PLATNOSCI_VALUES` fails compilation here. */ -const PAYMENT_FORM_LABELS: Readonly> = { +const PAYMENT_FORM_LABELS: Readonly< + Record +> = { '1': { key: 'invoice.ksef.fa3PaymentFormCash', fallback: 'Cash' }, '2': { key: 'invoice.ksef.fa3PaymentFormCard', fallback: 'Card' }, '3': { key: 'invoice.ksef.fa3PaymentFormVoucher', fallback: 'Voucher' }, @@ -71,18 +94,37 @@ const PAYMENT_FORM_LABELS: Readonly` + * (the qualified-name comparison includes the prefix), and happy-dom's + * `getElementsByTagNameNS('*', name)` returns nothing even for + * default-namespace documents, so we filter the `'*'` wildcard - which every + * DOM implements - by `localName`. FA(3) documents are small, so the linear + * scan is negligible. + */ +function collectByLocalName(root: Element | Document, localName: string): Element[] { + return Array.from(root.getElementsByTagName('*')).filter((el) => el.localName === localName); +} + +/** Find the first element matching a local name, ignoring namespace prefixes. */ +function firstByLocalName(root: Element | Document, localName: string): Element | null { + return collectByLocalName(root, localName)[0] ?? null; +} + +/** Extract text content from the first matching element (prefix-agnostic). */ +function getText(root: Element | Document, localName: string): string | null { + return firstByLocalName(root, localName)?.textContent?.trim() ?? null; } /** Parse a `Podmiot1`/`Podmiot2` element into name + NIP + address. */ function parseParty(podmiot: Element | null): FaParty { - const idEl = podmiot?.getElementsByTagName('DaneIdentyfikacyjne').item(0) ?? null; - const adresEl = podmiot?.getElementsByTagName('Adres').item(0) ?? null; + const idEl = podmiot ? firstByLocalName(podmiot, 'DaneIdentyfikacyjne') : null; + const adresEl = podmiot ? firstByLocalName(podmiot, 'Adres') : null; const addressLines: string[] = []; if (adresEl) { const l1 = getText(adresEl, 'AdresL1'); @@ -102,29 +144,30 @@ function parseParty(podmiot: Element | null): FaParty { /** Parse the optional `Platnosc` block. Returns null when absent/empty. */ function parsePayment(fa: Element): FaPayment | null { - const platnosc = fa.getElementsByTagName('Platnosc').item(0); + const platnosc = firstByLocalName(fa, 'Platnosc'); if (!platnosc) return null; - const terminEl = platnosc.getElementsByTagName('TerminPlatnosci').item(0); + const terminEl = firstByLocalName(platnosc, 'TerminPlatnosci'); const termDate = terminEl ? getText(terminEl, 'Termin') : null; let termDescription: string | null = null; - const opisEl = terminEl?.getElementsByTagName('TerminOpis').item(0) ?? null; + const opisEl = terminEl ? firstByLocalName(terminEl, 'TerminOpis') : null; if (opisEl) { + // The OL builder emits the descriptive branch: `{Ilosc} {Jednostka}` + // counted from `ZdarzeniePoczatkowe` (e.g. "14 dni"). const count = getText(opisEl, 'Ilosc'); const unit = getText(opisEl, 'Jednostka'); termDescription = [count, unit].filter((part) => part !== null).join(' ') || null; } - const bankEl = platnosc.getElementsByTagName('RachunekBankowy').item(0); + const bankEl = firstByLocalName(platnosc, 'RachunekBankowy'); + const skontoEl = firstByLocalName(platnosc, 'Skonto'); const payment: FaPayment = { termDate, termDescription, formCode: getText(platnosc, 'FormaPlatnosci'), bankAccount: bankEl ? getText(bankEl, 'NrRB') : null, + skontoConditions: skontoEl ? getText(skontoEl, 'WarunkiSkonta') : null, + skontoAmount: skontoEl ? getText(skontoEl, 'WysokoscSkonta') : null, }; - const hasContent = - payment.termDate !== null || - payment.termDescription !== null || - payment.formCode !== null || - payment.bankAccount !== null; + const hasContent = Object.values(payment).some((value) => value !== null); return hasContent ? payment : null; } @@ -134,7 +177,7 @@ function parseFa3Xml(xmlText: string): FaData | null { const parser = new DOMParser(); doc = parser.parseFromString(xmlText, 'application/xml'); // DOMParser signals parse errors via a element rather than throwing. - if (doc.getElementsByTagName('parsererror').length > 0) { + if (collectByLocalName(doc, 'parsererror').length > 0) { return null; } } catch { @@ -142,10 +185,10 @@ function parseFa3Xml(xmlText: string): FaData | null { } // Locate the root element (tag may be namespace-prefixed in real docs). - const faktura = doc.getElementsByTagName('Faktura').item(0); + const faktura = firstByLocalName(doc, 'Faktura'); if (!faktura) return null; - const fa = faktura.getElementsByTagName('Fa').item(0); + const fa = firstByLocalName(faktura, 'Fa'); if (!fa) return null; // FA(3): P_1 = issue date, P_2 = invoice number (previously swapped, #1526). @@ -153,7 +196,7 @@ function parseFa3Xml(xmlText: string): FaData | null { // Require at minimum the invoice number to consider the parse successful. if (invoiceNumber === null) return null; - const lineEls = Array.from(fa.getElementsByTagName('FaWiersz')); + const lineEls = collectByLocalName(fa, 'FaWiersz'); const lines: FaLine[] = lineEls.map((el) => ({ lineNo: getText(el, 'NrWierszaFa'), description: getText(el, 'P_7'), @@ -176,8 +219,11 @@ function parseFa3Xml(xmlText: string): FaData | null { }); } - const korygowanaEl = fa.getElementsByTagName('DaneFaKorygowanej').item(0); - const ksef = faktura.getElementsByTagName('KSeF').item(0); + const korygowanaEl = firstByLocalName(fa, 'DaneFaKorygowanej'); + // The OL builder never embeds the KSeF number (it is assigned post-issue + // and passed in as a prop) - this parse path only fires for foreign + // documents that carry a non-schema extension. + const ksef = firstByLocalName(faktura, 'KSeF'); return { invoiceNumber, @@ -185,8 +231,8 @@ function parseFa3Xml(xmlText: string): FaData | null { saleDate: getText(fa, 'P_6'), currency: getText(fa, 'KodWaluty'), invoiceType: getText(fa, 'RodzajFaktury'), - seller: parseParty(faktura.getElementsByTagName('Podmiot1').item(0)), - buyer: parseParty(faktura.getElementsByTagName('Podmiot2').item(0)), + seller: parseParty(firstByLocalName(faktura, 'Podmiot1')), + buyer: parseParty(firstByLocalName(faktura, 'Podmiot2')), lines, vatBands, grandTotal: getText(fa, 'P_15'), @@ -206,7 +252,8 @@ function deriveGross(net: string | null, vatRate: string | null): string | null if (net === null) return null; const netValue = Number(net); if (!Number.isFinite(netValue)) return null; - const rate = vatRate !== null && /^\d+([.,]\d+)?$/.test(vatRate) ? Number(vatRate.replace(',', '.')) : 0; + const rate = + vatRate !== null && /^\d+([.,]\d+)?$/.test(vatRate) ? Number(vatRate.replace(',', '.')) : 0; return (netValue * (1 + rate / 100)).toFixed(2); } @@ -219,20 +266,30 @@ function bandGross(band: FaVatBand): string | null { return (netValue + taxValue).toFixed(2); } -/** Format a `P_12` band for display: numeric bands get a `%` suffix. */ +/** + * Format a `P_12` band for display: numeric bands get a `%` suffix; the + * `0 KR`/`0 WDT`/`0 EX` codes normalize to the VAT-summary band labels; + * other non-numeric bands (zw, np, oo) render verbatim. + */ function formatVatRate(vatRate: string | null): string { if (vatRate === null) return '-'; + const zeroLabel = ZERO_RATE_LINE_LABELS[vatRate]; + if (zeroLabel !== undefined) return zeroLabel; return /^\d+([.,]\d+)?$/.test(vatRate) ? `${vatRate}%` : vatRate; } interface LinesTableProps { lines: FaLine[]; - showUnit: boolean; - showNetUnitPrice: boolean; } -function LinesTable({ lines, showUnit, showNetUnitPrice }: LinesTableProps): ReactElement { +function LinesTable({ lines }: LinesTableProps): ReactElement { const { t } = useTranslation(); + // Documents issued before the backend emitted P_8A/P_9A must not render + // dash-only columns - show each column only when a line in THIS table + // carries the value (a KOR's before rows may carry them while the after + // rows do not, and vice versa). + const showUnit = lines.some((line) => line.unit !== null); + const showNetUnitPrice = lines.some((line) => line.netUnitPrice !== null); return (
@@ -309,7 +366,7 @@ function PartyBlock({ label, party }: PartyBlockProps): ReactElement { ); } -export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null { +export function KsefFa3View({ xmlText, ksefNumber }: KsefFa3ViewProps): ReactElement | null { const { t } = useTranslation(); const data = parseFa3Xml(xmlText); if (!data) return null; @@ -319,6 +376,10 @@ export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null ? t('invoice.ksef.fa3TypeCorrection', 'Correction invoice') : t('invoice.ksef.fa3TypeStandard', 'Standard invoice'); + // Prefer the record-sourced KSeF number (prop); the XML-parsed value only + // exists for foreign documents carrying a non-schema extension element. + const displayKsefNumber = ksefNumber ?? data.ksefNumber; + // A KOR correction emits one "before" row (StanPrzed=1) per changed line // plus every current "after" line. The VAT summary / grand total below // already reflect only the corrected delta/state, so the main table must @@ -327,15 +388,11 @@ export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null const currentLines = data.lines.filter((line) => !line.isBeforeCorrection); const beforeCorrectionLines = data.lines.filter((line) => line.isBeforeCorrection); - // Documents issued before the backend emitted P_8A/P_9A must not render - // dash-only columns - show them only when a current line carries the value. - const showUnit = currentLines.some((line) => line.unit !== null); - const showNetUnitPrice = currentLines.some((line) => line.netUnitPrice !== null); - + const paymentFormCode = data.payment?.formCode ?? null; const paymentFormLabel = - data.payment?.formCode !== null && data.payment?.formCode !== undefined - ? PAYMENT_FORM_LABELS[data.payment.formCode] - : undefined; + paymentFormCode !== null && isKsefFormaPlatnosci(paymentFormCode) + ? PAYMENT_FORM_LABELS[paymentFormCode] + : null; return (
@@ -345,12 +402,12 @@ export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null
{data.invoiceNumber}
{subtitle}
- {data.ksefNumber !== null ? ( + {displayKsefNumber !== null && displayKsefNumber !== undefined ? (
{t('invoice.ksef.fa3KsefNumber', 'KSeF number')}
-
{data.ksefNumber}
+
{displayKsefNumber}
) : null} @@ -417,11 +474,7 @@ export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null
{t('invoice.ksef.fa3Lines', 'Invoice lines')}
- + ) : null} @@ -432,11 +485,7 @@ export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null {t('invoice.ksef.fa3LinesBefore', 'Lines before correction')} - + ) : null} @@ -492,12 +541,16 @@ export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null
{t('invoice.ksef.fa3Payment', 'Payment')}
- {paymentFormLabel !== undefined ? ( + {paymentFormCode !== null ? (
{t('invoice.ksef.fa3PaymentForm', 'Payment form')} {' '} - {t(paymentFormLabel.key, paymentFormLabel.fallback)} + {paymentFormLabel !== null + ? t(paymentFormLabel.key, paymentFormLabel.fallback) + : // Unmapped code (future TFormaPlatnosci extension): show the + // raw code rather than dropping the row. + paymentFormCode}
) : null} {data.payment.termDate !== null || data.payment.termDescription !== null ? ( @@ -518,6 +571,16 @@ export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null {data.payment.bankAccount} ) : null} + {data.payment.skontoConditions !== null || data.payment.skontoAmount !== null ? ( +
+ + {t('invoice.ksef.fa3PaymentSkonto', 'Early-payment discount (skonto)')} + {' '} + {[data.payment.skontoAmount, data.payment.skontoConditions] + .filter((part) => part !== null) + .join(' - ')} +
+ ) : null} ) : null} diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts b/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts index 7304686dd..5b0fd4fcb 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts @@ -53,6 +53,10 @@ export interface FaPayment { formCode: string | null; /** `Platnosc/RachunekBankowy/NrRB`. */ bankAccount: string | null; + /** `Platnosc/Skonto/WarunkiSkonta` - early-payment discount conditions. */ + skontoConditions: string | null; + /** `Platnosc/Skonto/WysokoscSkonta` - early-payment discount amount. */ + skontoAmount: string | null; } export interface FaData { diff --git a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx index fa963abb3..9bafeac23 100644 --- a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx @@ -245,7 +245,7 @@ export function KsefInvoiceDetailSection({ {fa3.viewError ? ( {t('invoice.ksef.fa3ViewError', "Preview failed. Click 'View' to retry.")} ) : fa3.viewText !== null ? ( - + ) : ( {t('invoice.ksef.fa3PreviewPlaceholder', "Click 'View' to load the invoice.")} )} From ebc5cb809df29c9192feb3dd48088784c7b86d37 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Tue, 14 Jul 2026 00:28:15 +0200 Subject: [PATCH 3/4] refactor(web/ksef): round-2 polish on the FA(3) preview - Memoize parseFa3Xml on xmlText - the parse is a full DOMParser pass plus ~35 subtree scans and the parent re-renders on unrelated state - Fix React key collisions: prefix the index fallback for line rows (lineNo '2' vs index 2 stringify identically) and key address lines by position (AdresL1 may equal AdresL2) - Parse DaneFaKorygowanej/NrKSeFFaKorygowanej and render the corrected original's KSeF number in the correction block (the builder emits it whenever the corrected original was cleared - the normal OL case) Co-Authored-By: Claude Fable 5 Signed-off-by: norbert-kulus-blockydevs --- .../ksef/components/ksef-fa3-view.test.tsx | 29 +++++++++++++++++-- .../plugins/ksef/components/ksef-fa3-view.tsx | 27 ++++++++++++++--- .../ksef/components/ksef-fa3-view.types.ts | 5 ++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx index 6b18149f7..130fdb2de 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx @@ -40,7 +40,7 @@ interface FixtureOptions { vatTax23?: string | null; grandTotal?: string | null; ksefNumber?: string | null; - correction?: { reason: string; correctedNumber: string } | null; + correction?: { reason: string; correctedNumber: string; correctedKsefNumber?: string } | null; payment?: { formCode?: string; termDate?: string; @@ -100,7 +100,13 @@ function buildXml({ 2026-06-01 ${correction.correctedNumber} - 1 + ${ + correction.correctedKsefNumber !== undefined + ? // The builder's KSeF branch: NrKSeF is the choice FLAG ('1'); + // NrKSeFFaKorygowanej carries the actual 35-char number. + `1${correction.correctedKsefNumber}` + : '1' + } ` : ''; @@ -404,6 +410,25 @@ describe('KsefFa3View', () => { expect(correction?.textContent).toContain('FV/2026/07/001'); }); + it('should render the KSeF number of the corrected invoice when the original was cleared through KSeF', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('KSeF number of corrected invoice')).toBeInTheDocument(); + const correction = document.querySelector('.ksef-fa3-view__correction'); + expect(correction?.textContent).toContain('1234567890-20260601-ABCDEF123456-7F'); + }); + it('should render only the "after" line in the main table with the "before" line collapsed separately (#1364 follow-up)', () => { renderWithProviders(); diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx index 5a29c2c65..01dee1af5 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx @@ -32,6 +32,7 @@ * * @module plugins/ksef/components */ +import { useMemo } from 'react'; import type { ReactElement } from 'react'; import { useTranslation } from '../../../shared/i18n'; import { KSEF_FORMA_PLATNOSCI_VALUES } from './ksef-setup.schema'; @@ -239,6 +240,11 @@ function parseFa3Xml(xmlText: string): FaData | null { ksefNumber: ksef ? getText(ksef, 'NrKSeF') : null, correctionReason: getText(fa, 'PrzyczynaKorekty'), correctedInvoiceNumber: korygowanaEl ? getText(korygowanaEl, 'NrFaKorygowanej') : null, + // Emitted whenever the corrected original was cleared through KSeF (the + // normal OL case); the sibling NrKSeF element is only the choice FLAG. + correctedInvoiceKsefNumber: korygowanaEl + ? getText(korygowanaEl, 'NrKSeFFaKorygowanej') + : null, payment: parsePayment(fa), }; } @@ -316,8 +322,10 @@ function LinesTable({ lines }: LinesTableProps): ReactElement {
+ {/* lineNo '2' and index 2 stringify identically - prefix the index + fallback so partially-numbered documents can't collide. */} {lines.map((line, idx) => ( - + {showNetUnitPrice ? ( @@ -354,8 +362,9 @@ function PartyBlock({ label, party }: PartyBlockProps): ReactElement { {t('invoice.ksef.fa3Nip', 'NIP')}: {party.nip} ) : null} - {party.addressLines.map((line) => ( -
+ {party.addressLines.map((line, idx) => ( + // Keyed by position, not content - AdresL1 may equal AdresL2. +
{line}
))} @@ -368,7 +377,9 @@ function PartyBlock({ label, party }: PartyBlockProps): ReactElement { export function KsefFa3View({ xmlText, ksefNumber }: KsefFa3ViewProps): ReactElement | null { const { t } = useTranslation(); - const data = parseFa3Xml(xmlText); + // The parse is a full DOMParser pass + ~35 subtree scans; the parent + // re-renders on unrelated state (toasts, query refetches), so memoize. + const data = useMemo(() => parseFa3Xml(xmlText), [xmlText]); if (!data) return null; const isCorrection = data.invoiceType === 'KOR'; @@ -457,6 +468,14 @@ export function KsefFa3View({ xmlText, ksefNumber }: KsefFa3ViewProps): ReactEle {data.correctedInvoiceNumber}
) : null} + {data.correctedInvoiceKsefNumber !== null ? ( +
+ + {t('invoice.ksef.fa3CorrectedKsefNumber', 'KSeF number of corrected invoice')} + {' '} + {data.correctedInvoiceKsefNumber} +
+ ) : null} {data.correctionReason !== null ? (
diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts b/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts index 5b0fd4fcb..252257ef8 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.types.ts @@ -80,5 +80,10 @@ export interface FaData { correctionReason: string | null; /** KOR only: `Fa/DaneFaKorygowanej/NrFaKorygowanej`. */ correctedInvoiceNumber: string | null; + /** + * KOR only: `Fa/DaneFaKorygowanej/NrKSeFFaKorygowanej` - the KSeF number of + * the corrected original, present whenever it was cleared through KSeF. + */ + correctedInvoiceKsefNumber: string | null; payment: FaPayment | null; } From 4c04413093b38f6d9851084077158e5df44087e2 Mon Sep 17 00:00:00 2001 From: norbert-kulus-blockydevs Date: Tue, 14 Jul 2026 11:19:47 +0200 Subject: [PATCH 4/4] fix(web/ksef): address FA(3) preview a11y + parse-failure fallback review notes Addresses piotrswierzy's two non-blocking review notes on #1528: - Add scope="col" to the line-items and VAT-summary table headers for proper screen-reader header association. - KsefFa3View now calls an optional onParseError callback when the XML can't be parsed, and KsefInvoiceDetailSection uses it to fall through to the "Preview failed" copy instead of rendering an empty preview area for a fetched-but-unparseable document. Signed-off-by: norbert-kulus-blockydevs --- .../ksef/components/ksef-fa3-view.test.tsx | 18 +++++- .../plugins/ksef/components/ksef-fa3-view.tsx | 56 +++++++++++++------ .../ksef-invoice-detail-section.test.tsx | 23 ++++++++ .../ksef-invoice-detail-section.tsx | 15 ++++- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx index 130fdb2de..43f273122 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.test.tsx @@ -14,7 +14,7 @@ * @module plugins/ksef/components */ import { screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderWithProviders } from '../../../test/test-utils'; import { KsefFa3View } from './ksef-fa3-view'; @@ -569,5 +569,21 @@ describe('KsefFa3View', () => { expect(container.querySelector('.ksef-fa3-view')).toBeNull(); }); + + it('should call onParseError when the XML cannot be parsed', () => { + const onParseError = vi.fn(); + renderWithProviders( + , + ); + + expect(onParseError).toHaveBeenCalledTimes(1); + }); + + it('should not call onParseError when the XML parses successfully', () => { + const onParseError = vi.fn(); + renderWithProviders(); + + expect(onParseError).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx index 01dee1af5..c0443c775 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx @@ -23,8 +23,9 @@ * * Skipped by design: Adnotacje (P_16-P_23), WZ, amount-in-words, footer. * - * Returns `null` if the XML cannot be parsed or required fields are missing - - * the parent (`ksef-invoice-detail-section`) falls through to the placeholder. + * Returns `null` if the XML cannot be parsed or required fields are missing, + * and calls `onParseError` (if given) so the parent (`ksef-invoice-detail-section`) + * can fall through to its placeholder/error copy instead of an empty preview area. * * FA(3) uses XML namespaces and real documents may prefix elements (`tns:`); * element lookup matches by `localName` regardless of the prefix or @@ -32,7 +33,7 @@ * * @module plugins/ksef/components */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import type { ReactElement } from 'react'; import { useTranslation } from '../../../shared/i18n'; import { KSEF_FORMA_PLATNOSCI_VALUES } from './ksef-setup.schema'; @@ -47,6 +48,14 @@ interface KsefFa3ViewProps { * authority AFTER the document is built - so the parent passes it in. */ ksefNumber?: string | null; + /** + * Notifies the parent when `xmlText` cannot be parsed (or is missing + * required fields), so it can fall through to its placeholder/error copy + * instead of leaving an empty preview area. Fired from an effect, not + * during render, since the parent can't synchronously know the parse + * outcome without duplicating the parsing logic here. + */ + onParseError?: () => void; } /** @@ -301,22 +310,26 @@ function LinesTable({ lines }: LinesTableProps): ReactElement {
{line.lineNo ?? String(idx + 1)} {line.description ?? '-'}
- - + {showNetUnitPrice ? ( - ) : null} - - {showUnit ? : null} - - + {showUnit ? : null} + + - @@ -375,11 +388,18 @@ function PartyBlock({ label, party }: PartyBlockProps): ReactElement { ); } -export function KsefFa3View({ xmlText, ksefNumber }: KsefFa3ViewProps): ReactElement | null { +export function KsefFa3View({ + xmlText, + ksefNumber, + onParseError, +}: KsefFa3ViewProps): ReactElement | null { const { t } = useTranslation(); // The parse is a full DOMParser pass + ~35 subtree scans; the parent // re-renders on unrelated state (toasts, query refetches), so memoize. const data = useMemo(() => parseFa3Xml(xmlText), [xmlText]); + useEffect(() => { + if (!data) onParseError?.(); + }, [data, onParseError]); if (!data) return null; const isCorrection = data.invoiceType === 'KOR'; @@ -518,10 +538,14 @@ export function KsefFa3View({ xmlText, ksefNumber }: KsefFa3ViewProps): ReactEle
{t('invoice.ksef.fa3LineNo', '#')} + {t('invoice.ksef.fa3LineNo', '#')} {t('invoice.ksef.fa3LineDesc', 'Description')} + {t('invoice.ksef.fa3LineNetPrice', 'Net unit price')} {t('invoice.ksef.fa3LineQty', 'Qty')}{t('invoice.ksef.fa3LineUnit', 'Unit')}{t('invoice.ksef.fa3LineVat', 'VAT rate')} + + {t('invoice.ksef.fa3LineQty', 'Qty')} + {t('invoice.ksef.fa3LineUnit', 'Unit')} + {t('invoice.ksef.fa3LineVat', 'VAT rate')} + {t('invoice.ksef.fa3LineNetTotal', 'Net total')} + {t('invoice.ksef.fa3LineGrossTotal', 'Gross total')}
- - - - + + + diff --git a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx index 79fc5351a..05fffde3b 100644 --- a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.test.tsx @@ -223,6 +223,29 @@ describe('KsefInvoiceDetailSection', () => { expect(screen.queryByTitle('FA(3) document preview')).not.toBeInTheDocument(); }); + it('falls through to error copy when the fetched XML cannot be parsed', async () => { + URL.createObjectURL = vi.fn(() => 'blob:fa3-source'); + URL.revokeObjectURL = vi.fn(); + const downloadDocument = vi + .fn() + .mockResolvedValue(new Blob(['not xml at all ><>'], { type: 'application/xml' })); + const apiClient = createMockApiClient({ invoicing: { downloadDocument } }); + + renderWithProviders( + , + { apiClient }, + ); + + fireEvent.click(screen.getByRole('button', { name: 'View' })); + await waitFor(() => + expect(screen.getByText("Preview failed. Click 'View' to retry.")).toBeInTheDocument(), + ); + expect(screen.queryByText("Click 'View' to load the invoice.")).not.toBeInTheDocument(); + }); + it('calls downloadDocument with kind=source when Download XML is clicked', async () => { URL.createObjectURL = vi.fn(() => 'blob:xml'); URL.revokeObjectURL = vi.fn(); diff --git a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx index 9bafeac23..a6a4bb6a2 100644 --- a/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-invoice-detail-section.tsx @@ -17,6 +17,7 @@ * * @module plugins/ksef/components */ +import { useCallback, useState } from 'react'; import type { ReactElement } from 'react'; import type { InvoiceDetailSectionProps } from '../../../shared/plugins'; import { @@ -59,6 +60,11 @@ export function KsefInvoiceDetailSection({ const upoPreview = useKsefUpoPreview(); const fa3 = useKsefFa3(); const { showToast } = useToast(); + // Tracks a fetched-but-unparseable FA(3) document so the preview area falls + // through to placeholder/error copy instead of rendering nothing (KsefFa3View + // returns null on parse failure) - reset on every fresh load attempt. + const [viewParseFailed, setViewParseFailed] = useState(false); + const handleFa3ParseError = useCallback(() => setViewParseFailed(true), []); const ksefNumber = resolveKsefNumber(invoice.clearanceReference, invoice.providerInvoiceNumber); const hasRegulatoryData = invoice.regulatoryStatus !== 'not-applicable'; @@ -98,6 +104,7 @@ export function KsefInvoiceDetailSection({ } async function handleViewFa3(): Promise { + setViewParseFailed(false); // Use the returned error rather than reading fa3.viewError from the closure — // that would be stale (React state hasn't re-rendered yet after the await). const err = await fa3.loadView(invoice.id); @@ -242,10 +249,14 @@ export function KsefInvoiceDetailSection({ {t('invoice.ksef.fa3Preview', 'FA(3) preview')} - {fa3.viewError ? ( + {fa3.viewError || viewParseFailed ? ( {t('invoice.ksef.fa3ViewError', "Preview failed. Click 'View' to retry.")} ) : fa3.viewText !== null ? ( - + ) : ( {t('invoice.ksef.fa3PreviewPlaceholder', "Click 'View' to load the invoice.")} )}
{t('invoice.ksef.fa3VatRate', 'Rate')}{t('invoice.ksef.fa3VatNet', 'Net')}{t('invoice.ksef.fa3VatTax', 'Tax')} + {t('invoice.ksef.fa3VatRate', 'Rate')} + {t('invoice.ksef.fa3VatNet', 'Net')} + + {t('invoice.ksef.fa3VatTax', 'Tax')} + {t('invoice.ksef.fa3VatGross', 'Gross')}