Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/resources/i18n/labels.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<entry key="row.uniqueRowId">Unikalny numer wiersza</entry>
<entry key="row.netValueShort">Wartość netto</entry>
<entry key="row.stateBefore">Stan przed</entry>
<entry key="row.exchangeRate">Kurs waluty</entry>
<entry key="common.yes">Tak</entry>
<entry key="duplicate.fromDate">Duplikat z dnia</entry>
<entry key="invoice.advance">Faktura zaliczkowa</entry>
Expand Down Expand Up @@ -67,6 +68,7 @@
<entry key="correctionType.other">Korekta skutkująca w dacie innej, w tym gdy dla różnych pozycji faktury korygującej daty te są różne</entry>
<entry key="issuePlace">Miejsce wystawienia</entry>
<entry key="exchangeRate">Kurs waluty</entry>
<entry key="exchangeRateCommonNote">Kurs waluty wspólny dla wszystkich wierszy faktury</entry>
<entry key="discountPeriod">Okres, którego dotyczy rabat</entry>
<entry key="exchangeRateDate">Data kursu waluty</entry>
<entry key="previousAdvanceInvoices">Numery wcześniejszych faktur zaliczkowych</entry>
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/i18n/labels_en.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<entry key="row.uniqueRowId">Unique row ID</entry>
<entry key="row.netValueShort">Net value</entry>
<entry key="row.stateBefore">State before</entry>
<entry key="row.exchangeRate">Exchange rate</entry>
<entry key="common.yes">Yes</entry>
<entry key="duplicate.fromDate">Duplicate from</entry>
<entry key="invoice.advance">Advance Invoice</entry>
Expand Down Expand Up @@ -67,6 +68,7 @@
<entry key="correctionType.other">Correction effective on another date, including when dates differ for various items</entry>
<entry key="issuePlace">Place of issue</entry>
<entry key="exchangeRate">Exchange rate</entry>
<entry key="exchangeRateCommonNote">Common exchange rate for all invoice rows</entry>
<entry key="discountPeriod">Discount period</entry>
<entry key="exchangeRateDate">Exchange rate date</entry>
<entry key="previousAdvanceInvoices">Previous advance invoice numbers</entry>
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/templates/fa3/common-functions.xsl
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,10 @@
</xsl:otherwise>
</xsl:choose>
</xsl:function>

<!-- Counts distinct values, comparing them as decimals to preserve numeric equality regardless of formatting. -->
<xsl:function name="local:distinctDecimalCount" as="xsd:integer">
<xsl:param name="values"/>
<xsl:sequence select="count(distinct-values(for $v in $values return xsd:decimal($v)))"/>
</xsl:function>
</xsl:stylesheet>
39 changes: 37 additions & 2 deletions src/main/resources/templates/fa3/invoice-rows.xsl
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,30 @@
<xsl:attribute name="padding-bottom">4pt</xsl:attribute>
</xsl:attribute-set>

<!-- Checks whether all rows share one exchange rate. Returns true when every row has a rate and all are numerically equal. -->
<xsl:function name="local:hasCommonExchangeRate" as="xs:boolean">
<xsl:param name="rows"/>
<xsl:sequence select="count($rows) = count($rows/crd:KursWaluty) and local:distinctDecimalCount($rows/crd:KursWaluty) = 1"/>
</xsl:function>

<!-- Checks whether the shared common-rate header may be shown.
Returns true when exchange rate is common between lines and the invoice has no before/after correction data. -->
<xsl:function name="local:showCommonExchangeRateHeader" as="xs:boolean">
<xsl:param name="rows"/>
<xsl:sequence select="local:hasCommonExchangeRate($rows) and not($rows[crd:StanPrzed])"/>
</xsl:function>

<!-- Template for rendering the positions table -->
<xsl:template name="positionsTable">
<xsl:param name="faWiersz"/>

<!-- Show the per-line exchange rate column when a rate is present and the invoice rates are not common across all rows.
Checked invoice-wide so before/after correction rows are taken into account. -->
<xsl:variable name="showExchangeRateColumn" select="boolean($faWiersz/crd:KursWaluty) and not(local:hasCommonExchangeRate(//crd:FaWiersz))"/>

<!-- Calculate column width for name based on presence of other columns -->
<!-- Fixed columns: Lp (4%), Quantity (8%), Unit (5%) = 17% -->
<!-- Optional columns: Indeks (8%), GTIN/PKWiU (5%), PKOB (6%), CN (6%), KwotaAkcyzy (8%), P_6A (9%), P_9A (10%), P_9B (10%), P_10 (7%), P_12 (8%), P_11 (10%), P_11Vat (7%), P_11A (10%) -->
<!-- Optional columns: Indeks (8%), GTIN/PKWiU (5%), PKOB (6%), CN (6%), KwotaAkcyzy (8%), P_6A (9%), P_9A (10%), P_9B (10%), P_10 (7%), P_12 (8%), P_11 (10%), P_11Vat (7%), P_11A (10%), KursWaluty (8%) -->
<xsl:variable name="nameColumnWidth">
<xsl:variable name="fixedWidth" select="17"/> <!-- Lp + Quantity + Unit -->
<xsl:variable name="indeksWidth" select="if ($faWiersz/crd:Indeks) then 8 else 0"/>
Expand All @@ -52,7 +69,8 @@
<xsl:variable name="p11Width" select="if ($faWiersz/crd:P_11) then 10 else 0"/>
<xsl:variable name="p11vatWidth" select="if ($faWiersz/crd:P_11Vat) then 7 else 0"/>
<xsl:variable name="p11aWidth" select="if ($faWiersz/crd:P_11A) then 10 else 0"/>
<xsl:variable name="calculatedWidth" select="100 - $fixedWidth - $indeksWidth - $gtinWidth - $pkwiuWidth - $cnWidth - $pkobWidth - $kwotaAkcyzyWidth - $p6aWidth - $p9aWidth - $p9bWidth - $p10Width - $p12Width - $p11Width - $p11vatWidth - $p11aWidth"/>
<xsl:variable name="exchangeRateWidth" select="if ($showExchangeRateColumn) then 8 else 0"/>
<xsl:variable name="calculatedWidth" select="100 - $fixedWidth - $indeksWidth - $gtinWidth - $pkwiuWidth - $cnWidth - $pkobWidth - $kwotaAkcyzyWidth - $p6aWidth - $p9aWidth - $p9bWidth - $p10Width - $p12Width - $p11Width - $p11vatWidth - $p11aWidth - $exchangeRateWidth"/>
<xsl:value-of select="concat($calculatedWidth, '%')"/>
</xsl:variable>

Expand Down Expand Up @@ -105,6 +123,9 @@
<xsl:if test="$faWiersz/crd:P_6A">
<fo:table-column column-width="9%"/> <!-- Data dostawy (P_6A) -->
</xsl:if>
<xsl:if test="$showExchangeRateColumn">
<fo:table-column column-width="8%"/> <!-- Kurs waluty -->
</xsl:if>

<!-- Table header -->
<fo:table-header>
Expand Down Expand Up @@ -191,6 +212,11 @@
<fo:block><xsl:value-of select="key('kLabels', 'row.deliveryDate', $labels)"/></fo:block>
</fo:table-cell>
</xsl:if>
<xsl:if test="$showExchangeRateColumn">
<fo:table-cell xsl:use-attribute-sets="tableHeaderFont tableBorder table.cell.padding">
<fo:block><xsl:value-of select="key('kLabels', 'row.exchangeRate', $labels)"/></fo:block>
</fo:table-cell>
</xsl:if>
</fo:table-row>
</fo:table-header>

Expand All @@ -212,6 +238,7 @@
<xsl:with-param name="showP11" select="boolean($faWiersz/crd:P_11)" tunnel="yes"/>
<xsl:with-param name="showP11Vat" select="boolean($faWiersz/crd:P_11Vat)" tunnel="yes"/>
<xsl:with-param name="showP11A" select="boolean($faWiersz/crd:P_11A)" tunnel="yes"/>
<xsl:with-param name="showExchangeRate" select="$showExchangeRateColumn" tunnel="yes"/>
</xsl:apply-templates>
</fo:table-body>
</fo:table>
Expand All @@ -233,6 +260,7 @@
<xsl:param name="showP11" select="boolean(//crd:FaWiersz/crd:P_11)" tunnel="yes"/>
<xsl:param name="showP11Vat" select="boolean(//crd:FaWiersz/crd:P_11Vat)" tunnel="yes"/>
<xsl:param name="showP11A" select="boolean(//crd:FaWiersz/crd:P_11A)" tunnel="yes"/>
<xsl:param name="showExchangeRate" select="boolean(//crd:FaWiersz/crd:KursWaluty) and not(local:hasCommonExchangeRate(//crd:FaWiersz))" tunnel="yes"/>
<fo:table-row>
<fo:table-cell xsl:use-attribute-sets="tableFont tableBorder table.cell.padding" text-align="left">
<fo:block>
Expand Down Expand Up @@ -499,6 +527,13 @@
</fo:block>
</fo:table-cell>
</xsl:if>
<xsl:if test="$showExchangeRate">
<fo:table-cell id="lineExchangeRate-{generate-id()}" xsl:use-attribute-sets="tableFont tableBorder table.cell.padding" text-align="right">
<fo:block>
<xsl:value-of select="crd:KursWaluty"/> <!-- Kurs waluty -->
</fo:block>
</fo:table-cell>
</xsl:if>
</fo:table-row>
</xsl:template>

Expand Down
7 changes: 5 additions & 2 deletions src/main/resources/templates/fa3/ksef_invoice.xsl
Original file line number Diff line number Diff line change
Expand Up @@ -865,11 +865,14 @@
<xsl:value-of select="crd:Fa/crd:OkresFaKorygowanej"/>
</fo:block>
</xsl:if>
<xsl:if test="crd:Fa/crd:FaWiersz[1]/crd:KursWaluty">
<fo:block text-align="left" font-size="8pt">
<xsl:if test="local:showCommonExchangeRateHeader(crd:Fa/crd:FaWiersz)">
<fo:block id="exchangeRate" text-align="left" font-size="8pt">
<fo:inline font-weight="bold"><xsl:value-of select="key('kLabels', 'exchangeRate', $labels)"/>: </fo:inline>
<xsl:value-of select="crd:Fa/crd:FaWiersz[1]/crd:KursWaluty"/>
</fo:block>
<fo:block id="exchangeRateCommonNote" text-align="left" font-size="8pt">
<xsl:value-of select="key('kLabels', 'exchangeRateCommonNote', $labels)"/>
</fo:block>
</xsl:if>
<xsl:choose>
<xsl:when test="$currencyDate">
Expand Down
123 changes: 123 additions & 0 deletions src/test/java/io/alapierre/ksef/fop/ExchangeRateTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
87 changes: 87 additions & 0 deletions src/test/resources/ExchangeRateTest/correction_unchanged_rate.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<Faktura
xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/">
<Naglowek>
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
<WariantFormularza>3</WariantFormularza>
<DataWytworzeniaFa>2026-02-25T10:33:34.1462546Z</DataWytworzeniaFa>
<SystemInfo>Aplikacja Podatnika KSeF</SystemInfo>
</Naglowek>
<Podmiot1>
<DaneIdentyfikacyjne>
<NIP>1234567890</NIP>
<Nazwa>TEST SP z.o.o.</Nazwa>
</DaneIdentyfikacyjne>
<Adres>
<KodKraju>PL</KodKraju>
<AdresL1>ul. Test-Wilcza 1 Nowy Sącz 33-300 Nowy Sącz</AdresL1>
</Adres>
</Podmiot1>
<Podmiot2>
<DaneIdentyfikacyjne>
<NIP>1234567890</NIP>
<Nazwa>TEST 2 SP z.o.o.</Nazwa>
</DaneIdentyfikacyjne>
<Adres>
<KodKraju>PL</KodKraju>
<AdresL1>ul. Test-Wilcza 1 Nowy Sącz 33-300 Nowy Sącz</AdresL1>
</Adres>
<JST>2</JST>
<GV>2</GV>
</Podmiot2>
<Fa>
<KodWaluty>EUR</KodWaluty>
<P_1>2026-02-25</P_1>
<P_2>001/01/26/K</P_2>
<P_6>2026-01-01</P_6>
<P_13_1>0</P_13_1>
<P_14_1>0</P_14_1>
<P_14_1W>0.66</P_14_1W>
<P_15>0</P_15>
<Adnotacje>
<P_16>2</P_16>
<P_17>2</P_17>
<P_18>2</P_18>
<P_18A>2</P_18A>
<Zwolnienie>
<P_19N>1</P_19N>
</Zwolnienie>
<NoweSrodkiTransportu>
<P_22N>1</P_22N>
</NoweSrodkiTransportu>
<P_23>2</P_23>
<PMarzy>
<P_PMarzyN>1</P_PMarzyN>
</PMarzy>
</Adnotacje>
<RodzajFaktury>KOR</RodzajFaktury>
<PrzyczynaKorekty>Błędna ilość.</PrzyczynaKorekty>
<DaneFaKorygowanej>
<DataWystFaKorygowanej>2026-02-13</DataWystFaKorygowanej>
<NrFaKorygowanej>001/01/26</NrFaKorygowanej>
<NrKSeF>1</NrKSeF>
<NrKSeFFaKorygowanej>210415750-20250310-FF4861C9CC3F-BA</NrKSeFFaKorygowanej>
</DaneFaKorygowanej>
<FaWiersz>
<NrWierszaFa>1</NrWierszaFa>
<P_7>Usługa transportowa</P_7>
<P_8A>szt.</P_8A>
<P_8B>1</P_8B>
<P_9A>800</P_9A>
<P_11>800</P_11>
<P_12>23</P_12>
<KursWaluty>4.2106</KursWaluty>
<StanPrzed>1</StanPrzed>
</FaWiersz>
<FaWiersz>
<NrWierszaFa>1</NrWierszaFa>
<P_7>Usługa transportowa</P_7>
<P_8A>szt.</P_8A>
<P_8B>2</P_8B>
<P_9A>800</P_9A>
<P_11>1600</P_11>
<P_12>23</P_12>
<KursWaluty>4.2106</KursWaluty>
</FaWiersz>
</Fa>
</Faktura>
Loading