diff --git a/src/main/resources/i18n/labels.xml b/src/main/resources/i18n/labels.xml
index d9cee77..ee4c081 100644
--- a/src/main/resources/i18n/labels.xml
+++ b/src/main/resources/i18n/labels.xml
@@ -28,6 +28,7 @@
Unikalny numer wiersza
Wartość netto
Stan przed
+ Kurs waluty
Tak
Duplikat z dnia
Faktura zaliczkowa
@@ -67,6 +68,7 @@
Korekta skutkująca w dacie innej, w tym gdy dla różnych pozycji faktury korygującej daty te są różne
Miejsce wystawienia
Kurs waluty
+ Kurs waluty wspólny dla wszystkich wierszy faktury
Okres, którego dotyczy rabat
Data kursu waluty
Numery wcześniejszych faktur zaliczkowych
diff --git a/src/main/resources/i18n/labels_en.xml b/src/main/resources/i18n/labels_en.xml
index 2222175..7c11d32 100644
--- a/src/main/resources/i18n/labels_en.xml
+++ b/src/main/resources/i18n/labels_en.xml
@@ -28,6 +28,7 @@
Unique row ID
Net value
State before
+ Exchange rate
Yes
Duplicate from
Advance Invoice
@@ -67,6 +68,7 @@
Correction effective on another date, including when dates differ for various items
Place of issue
Exchange rate
+ Common exchange rate for all invoice rows
Discount period
Exchange rate date
Previous advance invoice numbers
diff --git a/src/main/resources/templates/fa3/common-functions.xsl b/src/main/resources/templates/fa3/common-functions.xsl
index 003dfce..1bd8aa1 100644
--- a/src/main/resources/templates/fa3/common-functions.xsl
+++ b/src/main/resources/templates/fa3/common-functions.xsl
@@ -61,4 +61,10 @@
+
+
+
+
+
+
diff --git a/src/main/resources/templates/fa3/invoice-rows.xsl b/src/main/resources/templates/fa3/invoice-rows.xsl
index b9bd202..e5fd5d3 100644
--- a/src/main/resources/templates/fa3/invoice-rows.xsl
+++ b/src/main/resources/templates/fa3/invoice-rows.xsl
@@ -29,13 +29,30 @@
4pt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -52,7 +69,8 @@
-
+
+
@@ -105,6 +123,9 @@
+
+
+
@@ -191,6 +212,11 @@
+
+
+
+
+
@@ -212,6 +238,7 @@
+
@@ -233,6 +260,7 @@
+
@@ -499,6 +527,13 @@
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/fa3/ksef_invoice.xsl b/src/main/resources/templates/fa3/ksef_invoice.xsl
index b6591ea..d819c27 100644
--- a/src/main/resources/templates/fa3/ksef_invoice.xsl
+++ b/src/main/resources/templates/fa3/ksef_invoice.xsl
@@ -865,11 +865,14 @@
-
-
+
+
:
+
+
+
diff --git a/src/test/java/io/alapierre/ksef/fop/ExchangeRateTest.java b/src/test/java/io/alapierre/ksef/fop/ExchangeRateTest.java
new file mode 100644
index 0000000..59cee8b
--- /dev/null
+++ b/src/test/java/io/alapierre/ksef/fop/ExchangeRateTest.java
@@ -0,0 +1,123 @@
+package io.alapierre.ksef.fop;
+
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Node;
+import java.net.URL;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Tests how the currency exchange rate ({@code crd:KursWaluty}) is visualized on FA(3) invoices.
+ */
+class ExchangeRateTest extends AbstractStyleSheetTest {
+
+ private static final String INVOICE_XPATH = "/fa3:Faktura";
+ private static final String EXCHANGE_RATE_HEADER_XPATH = "//fo:block[@id='exchangeRate']";
+ private static final String EXCHANGE_RATE_NOTE_HEADER_XPATH = "//fo:block[@id='exchangeRateCommonNote']";
+ private static final String FIRST_ROW_XPATH = "(//fo:table-cell[starts-with(@id,'lineExchangeRate')])[1]";
+ private static final String SECOND_ROW_XPATH = "(//fo:table-cell[starts-with(@id,'lineExchangeRate')])[2]";
+
+ @Test
+ void shouldCorrectlyDisplayDifferentLineRatesForVatInvoice() throws Exception {
+ URL input = resource("ExchangeRateTest/vat_different_rates_in_rows.xml");
+
+ String firstRowExpectedRate = "4.3200";
+ String secondRowExpectedRate = "4.500";
+
+ Node exchangeRateHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_HEADER_XPATH);
+ Node exchangeRateNoteHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_NOTE_HEADER_XPATH);
+ Node firstRow = transformFa3Invoice(input, INVOICE_XPATH, FIRST_ROW_XPATH);
+ Node secondRow = transformFa3Invoice(input, INVOICE_XPATH, SECOND_ROW_XPATH);
+
+ assertNull(exchangeRateNoteHeader, "Exchange rate header note should not be visible when rows have different rates");
+ assertNull(exchangeRateHeader, "Exchange rate header should not be visible when rows have different rates");
+ assertTrue(firstRow.getTextContent().contains(firstRowExpectedRate),
+ () -> "Expected line 1 exchange rate " + firstRowExpectedRate + " but got: " + firstRow.getTextContent());
+ assertTrue(secondRow.getTextContent().contains(secondRowExpectedRate),
+ () -> "Expected line 2 exchange rate " + secondRowExpectedRate + " but got: " + secondRow.getTextContent());
+ }
+
+ @Test
+ void shouldCorrectlyDisplaySameLineRatesForVatInvoice() throws Exception {
+ URL input = resource("ExchangeRateTest/vat_same_rates_in_rows.xml");
+
+ String firstRowExpectedRate = "4.3200";
+ String commonRateExpectedNote = "Kurs waluty wspólny dla wszystkich wierszy faktury";
+
+ Node exchangeRateHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_HEADER_XPATH);
+ Node exchangeRateNoteHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_NOTE_HEADER_XPATH);
+ Node firstRow = transformFa3Invoice(input, INVOICE_XPATH, FIRST_ROW_XPATH);
+
+ assertNotNull(exchangeRateNoteHeader, "Exchange rate header should be visible when rows have equal rates");
+ assertTrue(exchangeRateHeader.getTextContent().contains(firstRowExpectedRate),
+ () -> "Expected common exchange rate " + firstRowExpectedRate
+ + " but got: " + exchangeRateHeader.getTextContent());
+
+ assertTrue(exchangeRateNoteHeader.getTextContent().contains(commonRateExpectedNote),
+ () -> "Expected common exchange rate note but got: " + exchangeRateNoteHeader.getTextContent());
+ assertNull(firstRow, "Per-line exchange rate column must not be shown when all line rates are equal");
+ }
+
+ @Test
+ void shouldCorrectlyDisplayIncompleteLineRatesForVatInvoice() throws Exception {
+ URL input = resource("ExchangeRateTest/vat_incomplete_rates_in_rows.xml");
+
+ String firstRowExpectedRate = "4.3200";
+
+ Node exchangeRateHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_HEADER_XPATH);
+ Node exchangeRateNoteHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_NOTE_HEADER_XPATH);
+ Node firstRow = transformFa3Invoice(input, INVOICE_XPATH, FIRST_ROW_XPATH);
+
+ assertNull(exchangeRateHeader,
+ "Exchange rate header should not be visible when rows have incomplete rates");
+ assertNull(exchangeRateNoteHeader,
+ "Exchange rate header note should not be visible when rows have incomplete rates");
+
+ assertNotNull(firstRow,
+ "Exchange rate should be shown per line when rows have incomplete rates");
+ assertTrue(firstRow.getTextContent().contains(firstRowExpectedRate),
+ () -> "Expected line 1 exchange rate " + firstRowExpectedRate + " but got: " + firstRow.getTextContent());
+ }
+
+ @Test
+ void shouldCorrectlyDisplayCorrectedExchangeRates() throws Exception {
+ URL input = resource("faktury/fa3/korygujaca/FA_3_Przyklad_3.xml");
+
+ String beforeCorrectionRate = "4.2106";
+ String afterCorrectionRate = "4.2142";
+
+ Node exchangeRateHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_HEADER_XPATH);
+ Node exchangeRateNoteHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_NOTE_HEADER_XPATH);
+ Node firstRow = transformFa3Invoice(input, INVOICE_XPATH, FIRST_ROW_XPATH);
+ Node secondRow = transformFa3Invoice(input, INVOICE_XPATH, SECOND_ROW_XPATH);
+
+ assertNull(exchangeRateHeader,
+ "Common exchange rate should not be shown when the rate is corrected");
+ assertNull(exchangeRateNoteHeader,
+ "Common exchange rate note should not be shown when the rate is corrected");
+ assertNotNull(firstRow,
+ "Exchange rate should be shown per line for a rate correction");
+ assertTrue(firstRow.getTextContent().contains(beforeCorrectionRate),
+ () -> "Expected before-correction rate " + beforeCorrectionRate + " but got: " + firstRow.getTextContent());
+ assertTrue(secondRow.getTextContent().contains(afterCorrectionRate),
+ () -> "Expected after-correction rate " + afterCorrectionRate + " but got: " + secondRow.getTextContent());
+ }
+
+ @Test
+ void shouldHideExchangeRateForCorrectionWithoutRateChange() throws Exception {
+ URL input = resource("ExchangeRateTest/correction_unchanged_rate.xml");
+
+ Node exchangeRateHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_HEADER_XPATH);
+ Node exchangeRateNoteHeader = transformFa3Invoice(input, INVOICE_XPATH, EXCHANGE_RATE_NOTE_HEADER_XPATH);
+ Node firstRow = transformFa3Invoice(input, INVOICE_XPATH, FIRST_ROW_XPATH);
+
+ assertNull(exchangeRateHeader,
+ "Common exchange rate header should not be shown on a correction that does not change the rate");
+ assertNull(exchangeRateNoteHeader,
+ "Common exchange rate note should not be shown on a correction that does not change the rate");
+ assertNull(firstRow,
+ "Per-line exchange rate column should not be shown on a correction that does not change the rate");
+ }
+}
diff --git a/src/test/resources/ExchangeRateTest/correction_unchanged_rate.xml b/src/test/resources/ExchangeRateTest/correction_unchanged_rate.xml
new file mode 100644
index 0000000..3ad2fa1
--- /dev/null
+++ b/src/test/resources/ExchangeRateTest/correction_unchanged_rate.xml
@@ -0,0 +1,87 @@
+
+
+
+ FA
+ 3
+ 2026-02-25T10:33:34.1462546Z
+ Aplikacja Podatnika KSeF
+
+
+
+ 1234567890
+ TEST SP z.o.o.
+
+
+ PL
+ ul. Test-Wilcza 1 Nowy Sącz 33-300 Nowy Sącz
+
+
+
+
+ 1234567890
+ TEST 2 SP z.o.o.
+
+
+ PL
+ ul. Test-Wilcza 1 Nowy Sącz 33-300 Nowy Sącz
+
+ 2
+ 2
+
+
+ EUR
+ 2026-02-25
+ 001/01/26/K
+ 2026-01-01
+ 0
+ 0
+ 0.66
+ 0
+
+ 2
+ 2
+ 2
+ 2
+
+ 1
+
+
+ 1
+
+ 2
+
+ 1
+
+
+ KOR
+ Błędna ilość.
+
+ 2026-02-13
+ 001/01/26
+ 1
+ 210415750-20250310-FF4861C9CC3F-BA
+
+
+ 1
+ Usługa transportowa
+ szt.
+ 1
+ 800
+ 800
+ 23
+ 4.2106
+ 1
+
+
+ 1
+ Usługa transportowa
+ szt.
+ 2
+ 800
+ 1600
+ 23
+ 4.2106
+
+
+
diff --git a/src/test/resources/ExchangeRateTest/vat_different_rates_in_rows.xml b/src/test/resources/ExchangeRateTest/vat_different_rates_in_rows.xml
new file mode 100644
index 0000000..54a19b7
--- /dev/null
+++ b/src/test/resources/ExchangeRateTest/vat_different_rates_in_rows.xml
@@ -0,0 +1,99 @@
+
+
+
+ FA
+ 3
+ 2025-12-02T15:48:23.5668913Z
+ Aplikacja Podatnika KSeF
+
+
+ PL
+
+ 5451824119
+ test
+
+
+ PL
+ test
+
+
+
+
+ 1111111111
+ eestest
+
+
+ PL
+ test
+ test
+
+ 2
+ 2
+
+
+ EUR
+ 2025-12-04
+ Warszawa
+ 15/2024
+ 2024-10-21
+ 100
+ 23
+ 99.36
+ 123
+
+ 2
+ 2
+ 2
+ 2
+
+ 1
+
+
+ 1
+
+ 2
+
+ 1
+
+
+ VAT
+
+ 1
+ Pierwsza pozycja
+ szt
+ 1
+ 123
+ 123
+ 23
+ 4.3200
+
+
+ 2
+ Druga pozycja
+ szt
+ 1
+ 123
+ 123
+ 23
+ 4.500
+
+
+ 1
+
+ 100
+ 2025-01-01
+
+
+ 200
+ 2025-02-01
+
+
+ 300
+ 2025-03-01
+
+
+
+
\ No newline at end of file
diff --git a/src/test/resources/ExchangeRateTest/vat_incomplete_rates_in_rows.xml b/src/test/resources/ExchangeRateTest/vat_incomplete_rates_in_rows.xml
new file mode 100644
index 0000000..04e24e5
--- /dev/null
+++ b/src/test/resources/ExchangeRateTest/vat_incomplete_rates_in_rows.xml
@@ -0,0 +1,98 @@
+
+
+
+ FA
+ 3
+ 2025-12-02T15:48:23.5668913Z
+ Aplikacja Podatnika KSeF
+
+
+ PL
+
+ 5451824119
+ test
+
+
+ PL
+ test
+
+
+
+
+ 1111111111
+ eestest
+
+
+ PL
+ test
+ test
+
+ 2
+ 2
+
+
+ EUR
+ 2025-12-04
+ Warszawa
+ 15/2024
+ 2024-10-21
+ 100
+ 23
+ 99.36
+ 123
+
+ 2
+ 2
+ 2
+ 2
+
+ 1
+
+
+ 1
+
+ 2
+
+ 1
+
+
+ VAT
+
+ 1
+ Pierwsza pozycja
+ szt
+ 1
+ 123
+ 123
+ 23
+ 4.3200
+
+
+ 2
+ Druga pozycja
+ szt
+ 1
+ 123
+ 123
+ 23
+
+
+ 1
+
+ 100
+ 2025-01-01
+
+
+ 200
+ 2025-02-01
+
+
+ 300
+ 2025-03-01
+
+
+
+
diff --git a/src/test/resources/ExchangeRateTest/vat_same_rates_in_rows.xml b/src/test/resources/ExchangeRateTest/vat_same_rates_in_rows.xml
new file mode 100644
index 0000000..d619494
--- /dev/null
+++ b/src/test/resources/ExchangeRateTest/vat_same_rates_in_rows.xml
@@ -0,0 +1,99 @@
+
+
+
+ FA
+ 3
+ 2025-12-02T15:48:23.5668913Z
+ Aplikacja Podatnika KSeF
+
+
+ PL
+
+ 5451824119
+ test
+
+
+ PL
+ test
+
+
+
+
+ 1111111111
+ eestest
+
+
+ PL
+ test
+ test
+
+ 2
+ 2
+
+
+ EUR
+ 2025-12-04
+ Warszawa
+ 15/2024
+ 2024-10-21
+ 100
+ 23
+ 99.36
+ 123
+
+ 2
+ 2
+ 2
+ 2
+
+ 1
+
+
+ 1
+
+ 2
+
+ 1
+
+
+ VAT
+
+ 1
+ Pierwsza pozycja
+ szt
+ 1
+ 123
+ 123
+ 23
+ 4.3200
+
+
+ 2
+ Druga pozycja
+ szt
+ 1
+ 123
+ 123
+ 23
+ 4.32
+
+
+ 1
+
+ 100
+ 2025-01-01
+
+
+ 200
+ 2025-02-01
+
+
+ 300
+ 2025-03-01
+
+
+
+