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..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 @@ -1,30 +1,69 @@ /** - * 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 */ 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'; -// 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; correctedKsefNumber?: 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 OL builder +// emits a default namespace; prefixed documents are covered by a dedicated +// test below since getElementsByTagName does NOT match prefixed tags). 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 +73,517 @@ 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} + ${ + 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' + } + ` + : ''; + + 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 + ? ` + + ${terminXml} + ${payment.formCode !== undefined ? `${payment.formCode}` : ''} + ${payment.bankAccount !== undefined ? `${payment.bankAccount}` : ''} + ${payment.skonto !== undefined ? `${payment.skonto.conditions}${payment.skonto.amount}` : ''} + ` + : ''; + 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( + , + ); + + // 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 seller and buyer names from Nazwa with NIP and address', () => { + 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(); + expect(screen.getByText('ul. Testowa 1')).toBeInTheDocument(); + expect(screen.getByText('00-001 Warszawa')).toBeInTheDocument(); + expect(screen.getByText('ul. Kupiecka 7')).toBeInTheDocument(); + }); + + it('should render sale date, currency, and the "Standard invoice" subtitle', () => { + renderWithProviders(); + + 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(); - 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(); + const total = document.querySelector('.ksef-fa3-view__total'); + expect(total?.textContent).toContain('Total due'); + expect(total?.textContent).toContain('1230.00 PLN'); + }); + + 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'); + }); }); - it('should render invoice number and issue date when valid XML is provided', () => { - renderWithProviders(); + 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/2024/001')).toBeInTheDocument(); - expect(screen.getByText('2024-01-15')).toBeInTheDocument(); + expect(screen.getByText('FV/PREFIXED/001')).toBeInTheDocument(); + expect(screen.getByText('Prefixed Seller')).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); + 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 only the "after" line in the main table for a KOR correction, with the "before" line collapsed separately (#1364 follow-up)', () => { - renderWithProviders( - { + 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 the KSeF number of the corrected invoice when the original was cleared through KSeF', () => { + 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'); - }); + })} + />, + ); - it('should show grand total when present in XML', () => { - 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'); + }); - expect(screen.getByText('9999.99')).toBeInTheDocument(); - }); + it('should render only the "after" line in the main table with the "before" line collapsed separately (#1364 follow-up)', () => { + renderWithProviders(); - 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); - }); + 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 render KSeF assigned number when present in XML', () => { - renderWithProviders(); + it('should compute the conditional columns per table when only the before rows carry unit and net unit price', () => { + renderWithProviders( + , + ); - expect(screen.getByText('KSEF-NUM-123')).toBeInTheDocument(); + // 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.'); + }); }); - 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 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'); + }); }); - 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(); + + 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(); - // Component returns null — the .ksef-fa3-view root element must not appear. - expect(container.querySelector('.ksef-fa3-view')).toBeNull(); + 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 f85dfe5a6..c0443c775 100644 --- a/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx +++ b/apps/web/src/plugins/ksef/components/ksef-fa3-view.tsx @@ -1,42 +1,184 @@ /** * 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 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 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 (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) * - * Returns `null` if the XML cannot be parsed or required fields are missing — - * the parent (`ksef-invoice-detail-section`) falls through to the placeholder. + * Skipped by design: Adnotacje (P_16-P_23), WZ, amount-in-words, footer. * - * FA(3) uses XML namespaces; element lookup uses `getElementsByTagName(tagName)` - * which matches regardless of namespace prefix (the local name is sufficient for FA(3)). + * 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 + * namespace used (see `collectByLocalName`). * * @module plugins/ksef/components */ +import { useEffect, useMemo } from 'react'; import type { ReactElement } from 'react'; import { useTranslation } from '../../../shared/i18n'; -import type { FaData, FaLine } from './ksef-fa3-view.types'; +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; + /** + * 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; +} + +/** + * 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' }, +]; + +/** + * 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. 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< + Record +> = { + '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' }, +}; + +function isKsefFormaPlatnosci(code: string): code is KsefFormaPlatnosci { + return (KSEF_FORMA_PLATNOSCI_VALUES as readonly string[]).includes(code); +} + +/** + * Collect descendant elements matching a local name, ignoring namespace + * prefixes. `getElementsByTagName('Faktura')` does NOT match `` + * (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); } -/** 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 - // for FA(3) XML which uses `tns:` or other namespace prefixes. - const el = root.getElementsByTagName(tagName).item(0); - return el?.textContent?.trim() ?? null; +/** 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 ? firstByLocalName(podmiot, 'DaneIdentyfikacyjne') : null; + const adresEl = podmiot ? firstByLocalName(podmiot, 'Adres') : 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 = firstByLocalName(fa, 'Platnosc'); + if (!platnosc) return null; + const terminEl = firstByLocalName(platnosc, 'TerminPlatnosci'); + const termDate = terminEl ? getText(terminEl, 'Termin') : null; + let termDescription: string | null = 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 = 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 = Object.values(payment).some((value) => value !== null); + return hasContent ? payment : null; } function parseFa3Xml(xmlText: string): FaData | null { @@ -45,7 +187,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 { @@ -53,30 +195,18 @@ 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 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 fa = firstByLocalName(faktura, 'Fa'); + if (!fa) return 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; - - 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 = collectByLocalName(fa, 'FaWiersz'); const lines: FaLine[] = lineEls.map((el) => ({ lineNo: getText(el, 'NrWierszaFa'), description: getText(el, 'P_7'), @@ -88,272 +218,413 @@ 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 = 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 { - 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(firstByLocalName(faktura, 'Podmiot1')), + buyer: parseParty(firstByLocalName(faktura, 'Podmiot2')), 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, + // 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), }; } -export function KsefFa3View({ xmlText }: KsefFa3ViewProps): ReactElement | null { +/** + * 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; 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[]; +} + +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 ( +
+ + + + + + {showNetUnitPrice ? ( + + ) : null} + + {showUnit ? : null} + + + + + + + {/* lineNo '2' and index 2 stringify identically - prefix the index + fallback so partially-numbered documents can't collide. */} + {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, idx) => ( + // Keyed by position, not content - AdresL1 may equal AdresL2. +
+ {line} +
+ ))} + {party.countryCode !== null ? ( +
{party.countryCode}
+ ) : null} +
+ ); +} + +export function KsefFa3View({ + xmlText, + ksefNumber, + onParseError, +}: 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]); + useEffect(() => { + if (!data) onParseError?.(); + }, [data, onParseError]); 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'); + + // 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 "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); + const paymentFormCode = data.payment?.formCode ?? null; + const paymentFormLabel = + paymentFormCode !== null && isKsefFormaPlatnosci(paymentFormCode) + ? PAYMENT_FORM_LABELS[paymentFormCode] + : null; + return (
- {/* Invoice header */} -
+ {/* Document header */} +
-
- {t('invoice.ksef.fa3InvoiceNumber', 'Invoice number')} -
+
{data.invoiceNumber}
+
{subtitle}
- {data.invoiceNumber} -
+ {displayKsefNumber !== null && displayKsefNumber !== undefined ? ( +
+
+ {t('invoice.ksef.fa3KsefNumber', 'KSeF number')} +
+
{displayKsefNumber}
+
+ ) : null} + - {data.issueDate !== null ? ( -
+ {/* Seller / Buyer */} +
+ + +
+ + {/* 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.correctedInvoiceKsefNumber !== null ? ( +
+ + {t('invoice.ksef.fa3CorrectedKsefNumber', 'KSeF number of corrected invoice')} + {' '} + {data.correctedInvoiceKsefNumber} +
+ ) : 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} -
- ) : null} - {data.vatNet8 !== null ? ( -
-
8% {t('invoice.ksef.fa3VatNet', 'Net')}
- {data.vatNet8} -
- ) : null} - {data.vatTax8 !== null ? ( -
-
8% {t('invoice.ksef.fa3VatTax', 'Tax')}
- {data.vatTax8} -
- ) : null} - {data.vatNet5 !== null ? ( -
-
5% {t('invoice.ksef.fa3VatNet', 'Net')}
- {data.vatNet5} + {paymentFormCode !== null ? ( +
+ + {t('invoice.ksef.fa3PaymentForm', 'Payment form')} + {' '} + {paymentFormLabel !== null + ? t(paymentFormLabel.key, paymentFormLabel.fallback) + : // Unmapped code (future TFormaPlatnosci extension): show the + // raw code rather than dropping the row. + paymentFormCode}
) : null} - {data.vatTax5 !== null ? ( -
-
5% {t('invoice.ksef.fa3VatTax', 'Tax')}
- {data.vatTax5} + {data.payment.termDate !== null || data.payment.termDescription !== null ? ( +
+ + {t('invoice.ksef.fa3PaymentTerm', 'Payment term')} + {' '} + + {data.payment.termDate ?? data.payment.termDescription} +
) : null} - {data.vatNet0 !== null ? ( -
-
0% {t('invoice.ksef.fa3VatNet', 'Net')}
- {data.vatNet0} + {data.payment.bankAccount !== null ? ( +
+ + {t('invoice.ksef.fa3PaymentAccount', 'Bank account')} + {' '} + {data.payment.bankAccount}
) : null} - {data.vatTax0 !== null ? ( -
-
0% {t('invoice.ksef.fa3VatTax', 'Tax')}
- {data.vatTax0} + {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} - - {/* 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..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 @@ -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,70 @@ 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; + /** `Platnosc/Skonto/WarunkiSkonta` - early-payment discount conditions. */ + skontoConditions: string | null; + /** `Platnosc/Skonto/WysokoscSkonta` - early-payment discount amount. */ + skontoAmount: 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; + /** + * 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; } 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..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 @@ -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() @@ -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 fa963abb3..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.")} )}