diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java index debdb73..57e7794 100644 --- a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapter.java @@ -4,8 +4,6 @@ import java.io.ByteArrayInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.Collection; @@ -25,7 +23,6 @@ import org.imixs.einvoice.EInvoiceModel; import org.imixs.einvoice.EInvoiceModelFactory; import org.imixs.einvoice.EInvoiceModelKSeF; import org.imixs.einvoice.EInvoiceNS; -import org.imixs.einvoice.TradeLineItem; import org.imixs.einvoice.TradeParty; import org.imixs.workflow.FileData; import org.imixs.workflow.ItemCollection; @@ -43,41 +40,41 @@ import org.xml.sax.SAXException; import com.alexanderlogistics.BusinessPartnerService; import com.alexanderlogistics.InvoiceService; -import com.alexanderlogistics.InvoiceUtil; import com.alexanderlogistics.xml.CargosoftXMLInvoiceImportService; import jakarta.inject.Inject; /** - * The KSeFAdapter converts a Cargsoft outbound invoice into an KSeF XML - * e-invoice and sends the xml file to the polish KSeF API - * - * The adapter can be configured by the model: + * The KSeFAdapter converts a Cargosoft outbound invoice into a KSeF FA(3) XML + * e-invoice and sends the xml file to the polish KSeF API. + *
+ * The adapter is configured via the BPMN model: * *
* {@code
-
- textblock-ref
- filename
- true
-
-
- }
+ *
+ * textblock-ref
+ * filename
+ * true
+ *
+ * }
*
*
*
- * NIP: The adapter needs the NIP (vat-id). If we do not find the partner.vat in
- * the business object, we do a lookup on the D-Cargosoft object and try the vat
- * id from there. This is because the partner.vat is a field which was not
- * defined before.
- *
+ * Architecture: The adapter handles invoice header data (parties,
+ * dates, invoice type, currency code, KOR/correction data). All logic
+ * concerning invoice positions ({@code
- * Wenn Debug = true gibt der Adapter alle einzelschritte auf der console aus
- * und erzeugt zusätzlich die factur-x xml und txt dateien.
- *
- *
- *
- * @version 1.0
+ * NIP: The adapter needs the NIP (vat-id). If we do not find the
+ * partner.vat in the business object, we do a lookup on the D-Cargosoft
+ * object and try the vat id from there.
+ *
+ * @version 2.0
* @author rsoika
*/
public class KSeFAdapter implements SignalAdapter {
@@ -109,33 +106,27 @@ public class KSeFAdapter implements SignalAdapter {
@Inject
InvoiceService invoiceService;
- /**
- * This method
- *
- * @throws PluginException
- */
@Override
public ItemCollection execute(ItemCollection workitem, ItemCollection event)
throws AdapterException, PluginException {
logger.info("├── 🔜 Convert Invoice to KSeF...");
- // read configuration....
- ItemCollection eInvoiceConfig = workflowService.evalWorkflowResult(event, "ksef",
- workitem,
- false);
+
+ // Read configuration
+ ItemCollection eInvoiceConfig = workflowService.evalWorkflowResult(event, "ksef", workitem, false);
if (eInvoiceConfig == null || !eInvoiceConfig.hasItem("CREATE")) {
throw new PluginException(EInvoiceAdapter.class.getSimpleName(), CONFIG_ERROR,
"missing e-invoice/ksef configuration in model event - please check model configuration");
}
ItemCollection ksefCreateDefinition = XMLParser.parseItemStructure(eInvoiceConfig.getItemValueString("CREATE"));
- try {
- // Load the e-invoice template....
+ try {
+ // Load the e-invoice template
FileData xmlFileData = loadXMLTemplate(workitem, ksefCreateDefinition);
updateEInvoice(xmlFileData, workitem);
- // append XML document
+ // Append XML document
logger.info("│ ├── attach KSeF e-invoice...");
workitem.addFileData(xmlFileData);
@@ -147,15 +138,15 @@ public class KSeFAdapter implements SignalAdapter {
}
/**
- * This method updates an e-invoice template with the data stored in the
- * workitem.
- *
- * First the method loads an EInvoiceModel based on the provided XML Template
- * and than updates the e-invoice data based on the items stored in the given
- * workitem.
- *
- * @param workitem
- * @throws PluginException
+ * Updates the e-invoice template with the data stored in the workitem.
+ *
+ * The method handles invoice header data (parties, invoice type, dates,
+ * currency code, KOR data) and then delegates the entire line-item and
+ * summary construction to {@link KSeFInvoiceLineBuilder}.
+ *
+ * @param fileDataXMLTemplate the XML template to be filled
+ * @param workitem the workitem holding the invoice data
+ * @throws PluginException if the model cannot be parsed or written
*/
public void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException {
@@ -164,38 +155,37 @@ public class KSeFAdapter implements SignalAdapter {
model.setId(workitem.getItemValueString("invoice.number"));
- // date
+ // Issue date
model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date"));
- // Set Performance Date
+ // Performance date - resync from Cargosoft XML if missing
if (workitem.getItemValueDate("invoice.performancedate") == null) {
- // hilfs code um das invoice.performancedate nachträglich zu parsen
syncPerformanceDate(workitem);
}
((EInvoiceModelKSeF) model)
.setPerformanceDateTime(workitem.getItemValueLocalDate("invoice.performancedate"));
- // Update Addresses
+ // Buyer address
TradeParty billingAddress = buildAddress(workitem, "buyer", model);
model.setTradeParty(billingAddress);
- // set Tax Type based on vatID - as a result taxType is 1=Poland 2=EU 3=Other
+ // Tax type derived from NIP - 1=Poland, 2=EU, 3=Other
+ // Still used by setTradeParty (NIP vs. NrID decision via prefix)
((EInvoiceModelKSeF) model).setTaxType(workitem.getItemValueString("partner.vat"));
workitem.setItemValue("invoice.tax.type", ((EInvoiceModelKSeF) model).getTaxType());
- // Update Invoice Type (RodzajFaktury) -> VAT | KOR
+ // Invoice type (RodzajFaktury) -> VAT or KOR
Element elementFa = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Fa");
if (isKorrekturRechnung(workitem)) {
((EInvoiceModelKSeF) model).setRodzajFaktury("KOR");
logger.info("│ ├── invoice type=KOR");
- // Set correction data (DaneFaKorygowanej) - required for KOR invoices
+ // Correction data (DaneFaKorygowanej) - required for KOR invoices
Element daneFaKorygowanej = model.findOrCreateChildNodeAfter(
elementFa, EInvoiceNS.KSEF, "DaneFaKorygowanej", "RodzajFaktury");
Date correctionInvoiceDate = workitem.getItemValueDate("invoice.date");
- // try to lookup original invoice by invoice.CorrectionInvoiceNumber
ItemCollection correctionInvoice = invoiceService
.findOutboundInvoiceByNumber(workitem.getItemValueString("invoice.CorrectionInvoiceNumber"));
if (correctionInvoice != null) {
@@ -204,7 +194,8 @@ public class KSeFAdapter implements SignalAdapter {
} else {
logger.info("│ ├── ⚠️ original invoice not found, using current date as fallback");
}
- // Original invoice date (DataWystFaKorygowanej) - required
+
+ // Original invoice date - required
if (correctionInvoiceDate != null) {
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
String sCorrectionDate = formatter.format(correctionInvoiceDate);
@@ -212,7 +203,7 @@ public class KSeFAdapter implements SignalAdapter {
"DataWystFaKorygowanej", sCorrectionDate);
}
- // Original invoice number (NrFaKorygowanej) - required
+ // Original invoice number - required
model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF,
"NrFaKorygowanej", workitem.getItemValueString("invoice.CorrectionInvoiceNumber"));
@@ -220,118 +211,35 @@ public class KSeFAdapter implements SignalAdapter {
model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, "NrKSeFN", "1");
} else {
- // normale rechnung
((EInvoiceModelKSeF) model).setRodzajFaktury("VAT");
logger.info("│ ├── invoice type=VAT");
}
- // set currency
+ // Currency code
model.updateElementValue(elementFa, EInvoiceNS.KSEF, "KodWaluty",
workitem.getItemValueString("invoice.currency"));
- // Update Invoice Items
- // Here we compute the pos number independent if the _childitems hold a posnum.
- // This is because a new imported cargosoft invoice does not provide numPos
- // items in the child list. So it is important to generate the pos numbers here
- // one by one
- List
+ * If the business partner is missing the {@code partner.vat} item, a
+ * one-time migration step tries to read the VAT ID from the legacy
+ * {@code cargosoftkreditor} object.
*/
public TradeParty buildAddress(ItemCollection workitem, String type, EInvoiceModel model) throws PluginException {
@@ -415,20 +314,16 @@ public class KSeFAdapter implements SignalAdapter {
if (!dNumber.isEmpty()) {
try {
String query = "(type:cargosoftkreditor) AND (name:" + dNumber + ")";
- List
+ * NIP: The adapter needs the NIP (vat-id). If we do not find the partner.vat in
+ * the business object, we do a lookup on the D-Cargosoft object and try the vat
+ * id from there. This is because the partner.vat is a field which was not
+ * defined before.
+ *
+ *
+ * Wenn Debug = true gibt der Adapter alle einzelschritte auf der console aus
+ * und erzeugt zusätzlich die factur-x xml und txt dateien.
+ *
+ *
+ *
+ * @version 1.0
+ * @author rsoika
+ */
+public class KSeFAdapterDeprecated implements SignalAdapter {
+
+ final String TYPE_TEXTBLOCK = "textblock";
+ public static final String DOCUMENT_ERROR = "DOCUMENT_ERROR";
+ public static final String CONFIG_ERROR = "CONFIG_ERROR";
+ public static final String API_ERROR = "API_ERROR";
+ public static final String BUSINESSPARTNER_ERROR = "BUSINESSPARTNER_ERROR";
+ public static final String LINE_ITEMS_PROPERTY = "invoice.items";
+ private static Logger logger = Logger.getLogger(EInvoiceAdapter.class.getName());
+
+ public static SimpleDateFormat dateFormatter = new SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN);
+ public static NumberFormat numberFormat = NumberFormat.getInstance(Locale.GERMANY);
+ boolean debug = false;
+
+ @Inject
+ WorkflowService workflowService;
+
+ @Inject
+ DocumentService documentService;
+
+ @Inject
+ SnapshotService snapshotService;
+
+ @Inject
+ BusinessPartnerService businessPartnerService;
+
+ @Inject
+ InvoiceService invoiceService;
+
+ /**
+ * This method
+ *
+ * @throws PluginException
+ */
+ @Override
+ public ItemCollection execute(ItemCollection workitem, ItemCollection event)
+ throws AdapterException, PluginException {
+
+ logger.info("├── 🔜 Convert Invoice to KSeF...");
+ // read configuration....
+ ItemCollection eInvoiceConfig = workflowService.evalWorkflowResult(event, "ksef",
+ workitem,
+ false);
+ if (eInvoiceConfig == null || !eInvoiceConfig.hasItem("CREATE")) {
+ throw new PluginException(EInvoiceAdapter.class.getSimpleName(), CONFIG_ERROR,
+ "missing e-invoice/ksef configuration in model event - please check model configuration");
+ }
+ ItemCollection ksefCreateDefinition = XMLParser.parseItemStructure(eInvoiceConfig.getItemValueString("CREATE"));
+ try {
+
+ // Load the e-invoice template....
+ FileData xmlFileData = loadXMLTemplate(workitem, ksefCreateDefinition);
+
+ updateEInvoice(xmlFileData, workitem);
+
+ // append XML document
+ logger.info("│ ├── attach KSeF e-invoice...");
+ workitem.addFileData(xmlFileData);
+
+ } catch (PluginException e) {
+ throw new AdapterException(e);
+ }
+
+ return workitem;
+ }
+
+ /**
+ * This method updates an e-invoice template with the data stored in the
+ * workitem.
+ *
+ * First the method loads an EInvoiceModel based on the provided XML Template
+ * and than updates the e-invoice data based on the items stored in the given
+ * workitem.
+ *
+ * @param workitem
+ * @throws PluginException
+ */
+ public void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException {
+
+ try {
+ EInvoiceModel model = EInvoiceModelFactory.read(new ByteArrayInputStream(fileDataXMLTemplate.getContent()));
+
+ model.setId(workitem.getItemValueString("invoice.number"));
+
+ // date
+ model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date"));
+
+ // Set Performance Date
+ if (workitem.getItemValueDate("invoice.performancedate") == null) {
+ // hilfs code um das invoice.performancedate nachträglich zu parsen
+ syncPerformanceDate(workitem);
+ }
+ ((EInvoiceModelKSeF) model)
+ .setPerformanceDateTime(workitem.getItemValueLocalDate("invoice.performancedate"));
+
+ // Update Addresses
+ TradeParty billingAddress = buildAddress(workitem, "buyer", model);
+ model.setTradeParty(billingAddress);
+
+ // set Tax Type based on vatID - as a result taxType is 1=Poland 2=EU 3=Other
+ ((EInvoiceModelKSeF) model).setTaxType(workitem.getItemValueString("partner.vat"));
+ workitem.setItemValue("invoice.tax.type", ((EInvoiceModelKSeF) model).getTaxType());
+
+ // Update Invoice Type (RodzajFaktury) -> VAT | KOR
+ Element elementFa = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Fa");
+
+ if (isKorrekturRechnung(workitem)) {
+ ((EInvoiceModelKSeF) model).setRodzajFaktury("KOR");
+ logger.info("│ ├── invoice type=KOR");
+
+ // Set correction data (DaneFaKorygowanej) - required for KOR invoices
+ Element daneFaKorygowanej = model.findOrCreateChildNodeAfter(
+ elementFa, EInvoiceNS.KSEF, "DaneFaKorygowanej", "RodzajFaktury");
+
+ Date correctionInvoiceDate = workitem.getItemValueDate("invoice.date");
+ // try to lookup original invoice by invoice.CorrectionInvoiceNumber
+ ItemCollection correctionInvoice = invoiceService
+ .findOutboundInvoiceByNumber(workitem.getItemValueString("invoice.CorrectionInvoiceNumber"));
+ if (correctionInvoice != null) {
+ correctionInvoiceDate = correctionInvoice.getItemValueDate("invoice.date");
+ logger.info("│ ├── found original invoice, date: " + correctionInvoiceDate);
+ } else {
+ logger.info("│ ├── ⚠️ original invoice not found, using current date as fallback");
+ }
+ // Original invoice date (DataWystFaKorygowanej) - required
+ if (correctionInvoiceDate != null) {
+ SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
+ String sCorrectionDate = formatter.format(correctionInvoiceDate);
+ model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF,
+ "DataWystFaKorygowanej", sCorrectionDate);
+ }
+
+ // Original invoice number (NrFaKorygowanej) - required
+ model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF,
+ "NrFaKorygowanej", workitem.getItemValueString("invoice.CorrectionInvoiceNumber"));
+
+ // NrKSeFN = 1 means the original invoice was issued outside KSeF
+ model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, "NrKSeFN", "1");
+
+ } else {
+ // normale rechnung
+ ((EInvoiceModelKSeF) model).setRodzajFaktury("VAT");
+ logger.info("│ ├── invoice type=VAT");
+ }
+
+ // set currency
+ model.updateElementValue(elementFa, EInvoiceNS.KSEF, "KodWaluty",
+ workitem.getItemValueString("invoice.currency"));
+
+ // Update Invoice Items
+ // Here we compute the pos number independent if the _childitems hold a posnum.
+ // This is because a new imported cargosoft invoice does not provide numPos
+ // items in the child list. So it is important to generate the pos numbers here
+ // one by one
+ List
+ * This builder is the single entry point for everything related to invoice
+ * positions and totals. It replaces the previous logic in {@code KSeFAdapter}
+ * which used {@code model.setNetTotalAmount(...)},
+ * {@code model.setTaxTotalAmount(...)} and a per-position
+ * {@code TradeLineItem} round-trip - all of which made the wrong implicit
+ * assumption that one invoice has exactly one tax rate.
+ *
+ * The builder runs in three sequential phases:
+ *
+ * Source of truth for amounts: All amounts are computed from the
+ * position data ({@code datev.umsatz}, {@code datev.vatrate}). Aggregated
+ * workitem fields like {@code invoice.total.net} or {@code invoice.total}
+ * are intentionally NOT consulted here, because rounding the difference of
+ * two aggregates can drift away from the sum of positions.
+ *
+ * @author imixs.com
+ */
+public class KSeFInvoiceLineBuilder {
+
+ private static final Logger logger = Logger.getLogger(KSeFInvoiceLineBuilder.class.getName());
+
+ /** Scale (decimal places) for all monetary amounts written into the XML. */
+ private static final int MONEY_SCALE = 2;
+
+ /** Standard rounding mode for monetary amounts. */
+ private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_UP;
+
+ /** Hundred, used for VAT percentage calculations. */
+ private static final BigDecimal HUNDRED = new BigDecimal("100");
+
+ /**
+ * Builds all line items and summary fields of the given KSeF model from
+ * the data of the given workitem.
+ *
+ * @param model the KSeF model whose {@code
+ * Each enum constant maps a CargoSoft VAT code (as it appears in the
+ * {@code cargosoft.vat.code} item of an invoice line) to:
+ *
+ * The mapping is derived from the field-mapping document provided by the
+ * Polish tax advisor of AGLP sp. z o.o. and reflects the requirements of the
+ * Polish VAT act (UStG-PL) and the SAF-T reporting (JPK_V7).
+ *
+ * Note on Java identifiers: The enum constants are prefixed with
+ * {@code CARGO_} because plain numeric names like {@code 8} or {@code 23} are
+ * not valid Java identifiers. The CargoSoft code itself is held as a String
+ * field and matches the original code unchanged ({@code "8"}, {@code "23"}).
+ *
+ * Mixed code "023": The CargoSoft code {@code 023} is intentionally
+ * NOT defined here. It is a CargoSoft-internal mixed code (either 0% or 23%)
+ * for which no deterministic mapping exists. Such codes will trigger a
+ * {@link PluginException} via {@link #fromCargosoftCode(String)}.
+ *
+ * @see FA(3) XSD
+ * schema
+ */
+public enum TaxCode {
+
+ /**
+ * Domestic 0% rate, services in Polish seaports (Art. 83 sec. 1 no. 9 UStG-PL).
+ */
+ CARGO_0("0", "0 KR", "P_13_6_1", null),
+
+ /**
+ * Domestic 0% rate, forwarding services for export of goods (Art. 83 sec. 1 no.
+ * 19 UStG-PL).
+ */
+ CARGO_0E("0%E", "0 KR", "P_13_6_1", null),
+
+ /**
+ * Domestic 0% rate, shipping agency services (Art. 83 sec. 1 no. 17 UStG-PL).
+ */
+ CARGO_01("01", "0 KR", "P_13_6_1", null),
+
+ /**
+ * Domestic 0% rate, other maritime transport services (Art. 83 sec. 1 no. 17 or
+ * 20 UStG-PL).
+ */
+ CARGO_0MR("0MR", "0 KR", "P_13_6_1", null),
+
+ /** Standard tax rate 23% (Art. 41 sec. 1 / Art. 146ef UStG-PL). */
+ CARGO_23("23", "23", "P_13_1", "P_14_1"),
+
+ /** First reduced tax rate 8% (Art. 41 sec. 2 UStG-PL). */
+ CARGO_8("8", "8", "P_13_2", "P_14_2"),
+
+ /**
+ * Not subject to Polish VAT, B2B service to an EU taxable person
+ * (Art. 28b + Art. 100 sec. 1 no. 4 UStG-PL).
+ * Reportable in the EU recapitulative statement (VAT-UE / ZM).
+ */
+ CARGO_NP("NP", "np II", "P_13_9", null),
+
+ /**
+ * Not subject to Polish VAT, customer outside the EU or transit
+ * (Art. 28b UStG-PL, excluding Art. 100 sec. 1 no. 4).
+ * NOT reportable in VAT-UE.
+ */
+ CARGO_NPT("NPT", "np I", "P_13_8", null);
+
+ private final String cargosoftCode;
+ private final String p12Value;
+ private final String netSummaryField;
+ private final String vatSummaryField;
+
+ TaxCode(String cargosoftCode, String p12Value, String netSummaryField, String vatSummaryField) {
+ this.cargosoftCode = cargosoftCode;
+ this.p12Value = p12Value;
+ this.netSummaryField = netSummaryField;
+ this.vatSummaryField = vatSummaryField;
+ }
+
+ /**
+ * @return the original CargoSoft VAT code (e.g. "23", "0%E", "NP")
+ */
+ public String getCargosoftCode() {
+ return cargosoftCode;
+ }
+
+ /**
+ * @return the value to be written into the {@code
+ * Throws a {@link PluginException} if the code is unknown, blank or
+ * {@code null}. This is by design: an unmapped code MUST stop processing,
+ * because writing the wrong summary field would silently produce an
+ * invalid JPK_V7 entry while still passing KSeF XSD validation.
+ *
+ * @param cargosoftCode the value of {@code cargosoft.vat.code} from a
+ * CargoSoft invoice line (e.g. "23", "NP")
+ * @return the matching {@link TaxCode} enum constant
+ * @throws PluginException if the code is unknown, blank or {@code null}
+ */
+ public static TaxCode fromCargosoftCode(String cargosoftCode) throws PluginException {
+ if (cargosoftCode == null || cargosoftCode.isBlank()) {
+ throw new PluginException(
+ TaxCode.class.getSimpleName(),
+ "TAX_CODE_ERROR",
+ "CargoSoft VAT code is null or blank - cannot map to KSeF tax field");
+ }
+ for (TaxCode tc : values()) {
+ if (tc.cargosoftCode.equals(cargosoftCode)) {
+ return tc;
+ }
+ }
+ throw new PluginException(
+ TaxCode.class.getSimpleName(),
+ "TAX_CODE_ERROR",
+ "Unknown CargoSoft VAT code: '" + cargosoftCode
+ + "' - no mapping to a KSeF FA(3) tax field defined");
+ }
+}
\ No newline at end of file
diff --git a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAdapterTest.java b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAdapterTest.java
index cdf9450..419b5e1 100644
--- a/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAdapterTest.java
+++ b/office-alexander-logistics-app/src/test/java/com/alexanderlogistics/ksef/api/KSeFAdapterTest.java
@@ -1,6 +1,7 @@
package com.alexanderlogistics.ksef.api;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.when;
@@ -19,6 +20,7 @@ import java.util.logging.Logger;
import org.imixs.workflow.FileData;
import org.imixs.workflow.ItemCollection;
import org.imixs.workflow.engine.DocumentService;
+import org.imixs.workflow.exceptions.PluginException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -39,10 +41,14 @@ import com.alexanderlogistics.einvoice.KSeFAdapter;
* Der Test simuliert eine Invoice und ruft dann den KSeFAdapter an um ein
* Output File zu erzeugen.
*
- * Zum validieren kann man dann den validate_xml.sh Script hernehmen
- *
+ * Zum validieren kann man dann den validate_xml.sh Script hernehmen.
+ *
+ *
+ * Each test produces an XML output file under
+ * {@code src/test/resources/ksef/output/} for manual inspection and external
+ * schema validation. JUnit assertions are intentionally limited to a sanity
+ * check that the file was produced.
*/
-
@ExtendWith(MockitoExtension.class)
public class KSeFAdapterTest {
@@ -81,14 +87,14 @@ public class KSeFAdapterTest {
}
/**
- * Erzeugt ein lokales XML File zum testen
+ * Simple PLN invoice with one tax rate (23%).
+ * Expected: P_13_1, P_14_1, P_15.
*/
@Test
- @DisplayName("Test Simple Invoice")
+ @DisplayName("Test Simple Invoice (PLN, 23%)")
public void testSimpleInvoice() throws Exception {
- logger.info("==> Test: Upload Invoice XML");
+ logger.info("==> Test: Simple Invoice");
- // Prepare a workitem with typical invoice data
ItemCollection workitem = new ItemCollection();
workitem.setItemValue("invoice.number", "FV/2025/001");
workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10));
@@ -105,52 +111,55 @@ public class KSeFAdapterTest {
// Prepare child items (invoice line items)
List
+ * This is the case from the tax advisor mapping document (invoice 7471)
+ * where the previous implementation incorrectly aggregated all amounts
+ * into a single field. Expected output: P_13_1+P_14_1 (for the 23%
+ * position) AND P_13_6_1 (for the 0% position) populated separately.
+ */
+ @Test
+ @DisplayName("Test Mixed Tax Rates (PLN, 0% KR + 23%)")
+ public void testMixedTaxRates() throws Exception {
+ logger.info("==> Test: Mixed Tax Rates");
+
+ ItemCollection workitem = new ItemCollection();
+ workitem.setItemValue("invoice.number", "FV/2025/7471");
+ workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10));
+ workitem.setItemValue("invoice.duedate", LocalDate.of(2025, 3, 10));
+ workitem.setItemValue("invoice.currency", "PLN");
+ workitem.setItemValue("invoice.correction", "false");
+ workitem.setItemValue("invoice.CorrectionInvoiceNumber", "");
+ workitem.setItemValue("partner.id", "BP-001");
+ workitem.setItemValue("partner.vat", "PL1234567890");
+ workitem.setItemValue("invoice.performancedate", new Date());
+
+ List
+ * This is the case from the tax advisor mapping document (invoice 7470)
+ * where code "NP" must map to {@code
- *
- */
- model.setNetTotalAmount(workitem.getItemValueDouble("invoice.total.net"));
-
- // ?? has no function
- model.setTaxRate(workitem.getItemValueDouble("invoice.total.tax"));
-
- // Tax
- /*
- * P_14_1
- *
- * This call also generate P_14_1W bei fremdwährung
- */
- model.setTaxTotalAmount(InvoiceUtil.round(workitem.getItemValueDouble("invoice.total")
- - workitem.getItemValueDouble("invoice.total.net")));
- /*
- * If we have Foreign Currency we need to set P_14_1W in case we are tax type 1
- */
- if (!"PLN".equals(workitem.getItemValueString("invoice.currency"))) {
- if ("1".equals(((EInvoiceModelKSeF) model).getTaxType())) {
- // compute rate
- double rate = workitem.getItemValueDouble("invoice.rate");
- logger.info("│ ├── rate=" + rate);
- double totalTax = InvoiceUtil.round(workitem.getItemValueDouble("invoice.total.tax"));
- logger.info("│ ├── total.tax=" + totalTax);
- BigDecimal value = BigDecimal.valueOf(totalTax)
- .divide(BigDecimal.valueOf(rate), 10, RoundingMode.HALF_UP);
- // P_14_1W must come directly after P_14_1
- logger.info("│ ├── P_14_1W=" + value);
- Element element = model.findOrCreateChildNodeAfter(elementFa, EInvoiceNS.KSEF, "P_14_1W", "P_14_1");
- element.setTextContent(value.setScale(2, RoundingMode.HALF_UP).toPlainString());
- }
-
- }
-
- // Brutto
- /*
- * P_15
- */
- model.setGrandTotalAmount(workitem.getItemValueDouble("invoice.total"));
-
- /*
- * KursWalutyZ
- *
- * Für Rechnungen in Fremdwährung obligatorisch. Anzuwenden ist der NBP-
- * Durchschnittskurs des Tages vor Entstehen der Steuerpflicht (Art. 31a
- * UStG-PL).
- * Dezimalformat mit Punkt.
- */
- String currency = workitem.getItemValueString("invoice.currency");
- if (!currency.isBlank() && !"PLN".equals(currency)) {
- Element p15 = model.findChildNode(elementFa, EInvoiceNS.KSEF, "P_15");
- if (p15 != null) {
- Double rate = workitem.getItemValueDouble("invoice.rate");
- logger.info("│ ├── set KursWalutyZ = " + rate);
- Element element = model.findOrCreateChildNodeAfter(elementFa, EInvoiceNS.KSEF, "KursWalutyZ",
- "P_15");
- element.setTextContent(rate.toString());
- }
- }
-
- // finally set the due date at the end of the XML tree
+ // Due date - written at the end of the XML tree
model.setDueDateTime(workitem.getItemValueLocalDate("invoice.duedate"));
- /*
- * finally update the template file
- */
+ // Persist the modified template
fileDataXMLTemplate.setContent(model.getContent());
} catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) {
throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e);
}
-
}
/**
- * Dies ist eine Hilfsmethode die nachträglich das invoice.performancedate aus
- * dem Cargosoft XML ausliest
- *
- * @param workitem
- * @throws PluginException
+ * Helper to resync the {@code invoice.performancedate} from the Cargosoft
+ * source XML if it is not yet set on the workitem.
*/
private void syncPerformanceDate(ItemCollection workitem) throws PluginException {
@@ -339,7 +247,6 @@ public class KSeFAdapter implements SignalAdapter {
try {
logger.info("----Resync cargosoft performancedate....");
DocumentBuilder documentBuilder;
- // hole die XML Datei
FileData cargoXML = snapshotService.getWorkItemFile(workitem.getUniqueID(),
workitem.getItemValueString("cargosoft.import.filename"));
if (cargoXML != null) {
@@ -349,13 +256,12 @@ public class KSeFAdapter implements SignalAdapter {
documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = documentBuilder.parse(inputSource);
- // Performance Date
CargosoftXMLInvoiceImportService.readXMLValue(doc,
"/Invoices/Invoice/InvoiceHeader/PerformanceDate",
workitem, "invoice.performancedate",
Date.class);
- // falls keines gefunden wurde nehmen wir das invoice date
+ // Fallback to invoice date if no performance date was found
if (workitem.getItemValueDate("invoice.performancedate") == null) {
workitem.setItemValue("invoice.performancedate", workitem.getItemValueDate("invoice.date"));
}
@@ -368,14 +274,7 @@ public class KSeFAdapter implements SignalAdapter {
}
/**
- * Gibt True zurück wenn es eine korrektur rechnung ist
- *
- * InvoiceHeader/Correction=true
- * InvoiceHeader/CorrectionInvoiceNumber !empty
- *
- *
- * @param workitem
- * @return
+ * Returns {@code true} if the workitem represents a correction invoice.
*/
private boolean isKorrekturRechnung(ItemCollection workitem) {
return ("true".equals(workitem.getItemValueString("invoice.correction"))
@@ -383,11 +282,11 @@ public class KSeFAdapter implements SignalAdapter {
}
/**
- * Erstellt ein TradeParty Objekt aus einer Liste von Adresszeilen.
- *
- * @param addressLines Liste der Adresszeilen
- * @param type Der Typ der TradeParty
- * @return TradeParty
+ * Builds a TradeParty (buyer/seller) from the business partner data.
+ *
+ * {@code
+
+ *
+ *
+ *
+ */
+ model.setNetTotalAmount(workitem.getItemValueDouble("invoice.total.net"));
+
+ // ?? has no function
+ model.setTaxRate(workitem.getItemValueDouble("invoice.total.tax"));
+
+ // Tax
+ /*
+ * P_14_1
+ *
+ * This call also generate P_14_1W bei fremdwährung
+ */
+ model.setTaxTotalAmount(InvoiceUtil.round(workitem.getItemValueDouble("invoice.total")
+ - workitem.getItemValueDouble("invoice.total.net")));
+ /*
+ * If we have Foreign Currency we need to set P_14_1W in case we are tax type 1
+ */
+ if (!"PLN".equals(workitem.getItemValueString("invoice.currency"))) {
+ if ("1".equals(((EInvoiceModelKSeF) model).getTaxType())) {
+ // compute rate
+ double rate = workitem.getItemValueDouble("invoice.rate");
+ logger.info("│ ├── rate=" + rate);
+ double totalTax = InvoiceUtil.round(workitem.getItemValueDouble("invoice.total.tax"));
+ logger.info("│ ├── total.tax=" + totalTax);
+ BigDecimal value = BigDecimal.valueOf(totalTax)
+ .divide(BigDecimal.valueOf(rate), 10, RoundingMode.HALF_UP);
+ // P_14_1W must come directly after P_14_1
+ logger.info("│ ├── P_14_1W=" + value);
+ Element element = model.findOrCreateChildNodeAfter(elementFa, EInvoiceNS.KSEF, "P_14_1W", "P_14_1");
+ element.setTextContent(value.setScale(2, RoundingMode.HALF_UP).toPlainString());
+ }
+
+ }
+
+ // Brutto
+ /*
+ * P_15
+ */
+ model.setGrandTotalAmount(workitem.getItemValueDouble("invoice.total"));
+
+ /*
+ * KursWalutyZ
+ *
+ * Für Rechnungen in Fremdwährung obligatorisch. Anzuwenden ist der NBP-
+ * Durchschnittskurs des Tages vor Entstehen der Steuerpflicht (Art. 31a
+ * UStG-PL).
+ * Dezimalformat mit Punkt.
+ */
+ String currency = workitem.getItemValueString("invoice.currency");
+ if (!currency.isBlank() && !"PLN".equals(currency)) {
+ Element p15 = model.findChildNode(elementFa, EInvoiceNS.KSEF, "P_15");
+ if (p15 != null) {
+ Double rate = workitem.getItemValueDouble("invoice.rate");
+ logger.info("│ ├── set KursWalutyZ = " + rate);
+ Element element = model.findOrCreateChildNodeAfter(elementFa, EInvoiceNS.KSEF, "KursWalutyZ",
+ "P_15");
+ element.setTextContent(rate.toString());
+ }
+ }
+
+ // finally set the due date at the end of the XML tree
+ model.setDueDateTime(workitem.getItemValueLocalDate("invoice.duedate"));
+
+ /*
+ * finally update the template file
+ */
+ fileDataXMLTemplate.setContent(model.getContent());
+
+ } catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) {
+ throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e);
+ }
+
+ }
+
+ /**
+ * Dies ist eine Hilfsmethode die nachträglich das invoice.performancedate aus
+ * dem Cargosoft XML ausliest
+ *
+ * @param workitem
+ * @throws PluginException
+ */
+ private void syncPerformanceDate(ItemCollection workitem) throws PluginException {
+
+ if (!workitem.hasItem("invoice.performancedate")) {
+ try {
+ logger.info("----Resync cargosoft performancedate....");
+ DocumentBuilder documentBuilder;
+ // hole die XML Datei
+ FileData cargoXML = snapshotService.getWorkItemFile(workitem.getUniqueID(),
+ workitem.getItemValueString("cargosoft.import.filename"));
+ if (cargoXML != null) {
+ InputStream inputStream = new ByteArrayInputStream(cargoXML.getContent());
+ InputSource inputSource = new InputSource(inputStream);
+
+ documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
+ Document doc = documentBuilder.parse(inputSource);
+
+ // Performance Date
+ CargosoftXMLInvoiceImportService.readXMLValue(doc,
+ "/Invoices/Invoice/InvoiceHeader/PerformanceDate",
+ workitem, "invoice.performancedate",
+ Date.class);
+
+ // falls keines gefunden wurde nehmen wir das invoice date
+ if (workitem.getItemValueDate("invoice.performancedate") == null) {
+ workitem.setItemValue("invoice.performancedate", workitem.getItemValueDate("invoice.date"));
+ }
+ }
+ } catch (ParserConfigurationException | SAXException | IOException e) {
+ throw new PluginException(CargosoftXMLInvoiceImportService.class.getName(), "XML_ERROR",
+ e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Gibt True zurück wenn es eine korrektur rechnung ist
+ *
+ * InvoiceHeader/Correction=true
+ * InvoiceHeader/CorrectionInvoiceNumber !empty
+ *
+ *
+ * @param workitem
+ * @return
+ */
+ private boolean isKorrekturRechnung(ItemCollection workitem) {
+ return ("true".equals(workitem.getItemValueString("invoice.correction"))
+ && !workitem.getItemValueString("invoice.CorrectionInvoiceNumber").isEmpty());
+ }
+
+ /**
+ * Erstellt ein TradeParty Objekt aus einer Liste von Adresszeilen.
+ *
+ * @param addressLines Liste der Adresszeilen
+ * @param type Der Typ der TradeParty
+ * @return TradeParty
+ */
+ public TradeParty buildAddress(ItemCollection workitem, String type, EInvoiceModel model) throws PluginException {
+
+ String partnerID = workitem.getItemValueString("partner.id");
+ if (partnerID == null || partnerID.isEmpty()) {
+ throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, "Missing partnerID");
+ }
+
+ ItemCollection businessPartner = businessPartnerService.getBusinessPartnerByID(partnerID);
+ if (businessPartner == null) {
+ throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR,
+ "Business Partner ID '" + partnerID + "' does not exist");
+ }
+
+ /**
+ * Migration:
+ * If the business partner does not yet have the item "partner.vat" we try here
+ * to resync this information from the cargosoftkreditor object. This is needed
+ * because the partner.vat was not initially defined by the synch processor and
+ * so many partner objects do not yet provide this information.
+ */
+ if (businessPartner.getItemValueString("partner.vat").isEmpty()) {
+ logger.info("│ ├── BusinessPartner " + partnerID + " does not provide vat-id - try to migrate....");
+ String dNumber = businessPartner.getItemValueString("dbtr.number");
+ if (!dNumber.isEmpty()) {
+ try {
+ String query = "(type:cargosoftkreditor) AND (name:" + dNumber + ")";
+ List
+ *
+ *
+ *
+ *