From edacbbdd6d6c768c6005270f8d91042be15a7e35 Mon Sep 17 00:00:00 2001 From: Ralph Soika Date: Mon, 27 Apr 2026 22:42:09 +0200 Subject: [PATCH] neue Imple KSEF Adapter --- .../einvoice/KSeFAdapter.java | 325 +++------- .../einvoice/KSeFAdapterDeprecated.java | 600 ++++++++++++++++++ .../einvoice/KSeFInvoiceLineBuilder.java | 362 +++++++++++ .../alexanderlogistics/einvoice/TaxCode.java | 166 +++++ .../ksef/api/KSeFAdapterTest.java | 282 +++++--- .../ksef/output/invoice-eu-service-np.xml | 147 +++++ .../ksef/output/invoice-mixed-rates.xml | 159 +++++ .../resources/ksef/output/invoice-simple.xml | 26 +- .../ksef/output/test-korrekturrechnung.xml | 61 +- 9 files changed, 1772 insertions(+), 356 deletions(-) create mode 100644 office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapterDeprecated.java create mode 100644 office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFInvoiceLineBuilder.java create mode 100644 office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/TaxCode.java create mode 100644 office-alexander-logistics-app/src/test/resources/ksef/output/invoice-eu-service-np.xml create mode 100644 office-alexander-logistics-app/src/test/resources/ksef/output/invoice-mixed-rates.xml 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
-            
-            true
-         
-
-   }
+ *  
+ *      textblock-ref
+ *      
+ *      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 }) and summary fields + * ({@code P_13_x}, {@code P_14_x}, {@code P_14_xW}, {@code P_15}, + * {@code KursWalutyZ}) is delegated to {@link KSeFInvoiceLineBuilder}, which + * uses the CargoSoft VAT code per position to determine the correct target + * field in the {@code } block. + * *

- * 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 invoiceItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); - double lineTotalAmount = 0.00; - int pos = 1; - for (ItemCollection invoiceItem : invoiceItems) { - TradeLineItem tradeLineItem = buildTradeLineItem(invoiceItem, pos); - model.setTradeLineItem(tradeLineItem); - lineTotalAmount = lineTotalAmount + tradeLineItem.getTotal(); - pos++; - } + // ================================================================ + // Delegate: line items, summary fields, foreign currency fields. + // The builder uses the CargoSoft VAT code per position to write + // each amount into the correct P_13_x / P_14_x / P_14_xW field. + // ================================================================ + new KSeFInvoiceLineBuilder().build((EInvoiceModelKSeF) model, workitem); - // Summenbildung - /* - * Field mapping: - *

- */ - 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. + *

+ * 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 result; - result = documentService.find(query, 1, 0); + List result = documentService.find(query, 1, 0); if (result != null && result.size() > 0) { ItemCollection creditorData = result.get(0); String vatID = creditorData.getItemValueString("_vendor_vat_registration_id"); - logger.info("│ ├── synchronize VAT Registrytion ID: " + vatID); - // update business partner + logger.info("│ ├── synchronize VAT Registration ID: " + vatID); businessPartner.setItemValue("partner.vat", vatID); documentService.saveByNewTransaction(businessPartner); } } catch (Exception e) { - // should not happen! logger.info("│ ├── ⚠️ Failed to sync BusinessPartner : " + e.getMessage()); - } } } @@ -448,87 +343,22 @@ public class KSeFAdapter implements SignalAdapter { if (partnerVAT.isBlank()) { // Just a warning logger.warning("│ ├── ⚠️ Business Partner ID '" + partnerID + "' does not contain a VAT ID !"); - // throw new PluginException(this.getClass().getName(), BUSINESSPARTNER_ERROR, - // "Business Partner ID '" + partnerID + "' does not contain a VAT ID !"); } workitem.setItemValue("partner.vat", partnerVAT); tradeParty.setVatNumber(partnerVAT); + // Update or create NrKlienta element directly under Podmiot2 Element podmiot2 = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Podmiot2"); model.updateElementValue(podmiot2, EInvoiceNS.KSEF, "NrKlienta", businessPartner.getItemValueString("dbtr.number")); - } return tradeParty; } /** - * Parses the data list of an invoice line and returns a TradeLineItem object. - * The order of the list items must be exactly! - * - * # 16.04.2026 - * Laut Herrn Grzegorz Grzelczyk dürfen in den Rechnungspositionen nur netto - * werte eingetragen sein. Siehe buildTradeLineItem() - * - * Des weiteren soll anstatt datev.text (pos nummer) der original billing text - * ausgewiesen werden + * Loads the e-invoice XML template referenced by the configuration. */ - private TradeLineItem buildTradeLineItem(ItemCollection orderItem, int pos) { - if (orderItem == null) { - return null; - } - - // TradeLineItem tradeLineItem = new - // TradeLineItem(orderItem.getItemValueString("numpos")); - // We do not trust the item 'numPos' here because it can be empty for new - // invoices! - TradeLineItem tradeLineItem = new TradeLineItem(pos + ""); - - // Herrn Grzegorz Grzelczyk möchte hier BillingText - // tradeLineItem.setName(orderItem.getItemValueString("datev.text")); - // tradeLineItem.setName(orderItem.getItemValueString("billingtext")); - // Herr Grzelczyk will das alle billing texte aus der liste, verkettet - // ausgegeben werden. - List billingTexts = orderItem.getItemValue("billingtext"); - String billingTextName = billingTexts == null - ? "" - : String.join(", ", billingTexts); - final int MAX_LENGTH = 512; - if (billingTextName.length() > MAX_LENGTH) { - billingTextName = billingTextName.substring(0, MAX_LENGTH); - } - tradeLineItem.setName(billingTextName); - - tradeLineItem.setQuantity(1); - - double vat = orderItem.getItemValueDouble("datev.vatrate"); // 23.00 - double netto = orderItem.getItemValueDouble("datev.umsatz"); // 970.00 - - // # 16.04.2026 - // Der total bezieht sich laut Herrn Grzegorz Grzelczyk ausschließlich auf den - // Nettowert x Menge. - // Der Brutto Wert ist hier falsch! Da die Menge immer 1 ist ist das beide male - // der selbe wert - // WRONG: tradeLineItem.setTotal(brutto); - - // double brutto = netto * (1 + (vat / 100)); - // double steuer = brutto - netto; - tradeLineItem.setTaxRate(vat); - tradeLineItem.setNetPrice(netto); - tradeLineItem.setTotal(netto); // menge x netto - - return tradeLineItem; - } - - /** - * This method loads a text-block for a specified ref and appends the named - * fileData object of this document. - * - * @param document - * @throws PluginException - */ - private FileData loadXMLTemplate(ItemCollection workitem, ItemCollection config) throws PluginException { @@ -539,10 +369,10 @@ public class KSeFAdapter implements SignalAdapter { try { debug = Boolean.parseBoolean(config.getItemValueString("debug")); } catch (Exception e) { + // ignore - debug remains false } String targetName = "ksef.xml"; - // adapt text.... sourceName = workflowService.adaptText(sourceName, workitem); if ((template == null || template.isEmpty()) || (textblock == null || textblock.isEmpty())) { @@ -551,10 +381,8 @@ public class KSeFAdapter implements SignalAdapter { "invalid e-invoice configuration in model event - textblock/template reference not defined!"); } - // load the text block FileData fileData = loadTextBlockFileData(textblock, template); - // do we found the document? if (fileData == null) { throw new PluginException(EInvoiceAdapter.class.getSimpleName(), CONFIG_ERROR, @@ -567,26 +395,18 @@ public class KSeFAdapter implements SignalAdapter { } /** - * This method returns a text-block ItemCollection for a specified name. - * - * @param name in attribute txtname - * - * + * Returns a text-block FileData by name and filename. */ public FileData loadTextBlockFileData(String name, String fileName) { ItemCollection textBlockItemCollection = null; - // load text-block by name.... String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")"; Collection col; try { - // find the textblock... col = documentService.find(sQuery, 1, 0); if (col.size() > 0) { textBlockItemCollection = col.iterator().next(); - // fetch the fileData... return snapshotService.getWorkItemFile(textBlockItemCollection.getUniqueID(), fileName); - } else { logger.warning("Missing text-block : '" + name + "'"); } @@ -596,5 +416,4 @@ public class KSeFAdapter implements SignalAdapter { return null; } - } \ No newline at end of file diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapterDeprecated.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapterDeprecated.java new file mode 100644 index 0000000..68cdcd7 --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFAdapterDeprecated.java @@ -0,0 +1,600 @@ +package com.alexanderlogistics.einvoice; + +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; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.logging.Logger; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerException; + +import org.imixs.archive.core.SnapshotService; +import org.imixs.einvoice.EInvoiceFormatException; +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; +import org.imixs.workflow.SignalAdapter; +import org.imixs.workflow.engine.DocumentService; +import org.imixs.workflow.engine.WorkflowService; +import org.imixs.workflow.exceptions.AdapterException; +import org.imixs.workflow.exceptions.PluginException; +import org.imixs.workflow.exceptions.QueryException; +import org.imixs.workflow.util.XMLParser; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; +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: + * + *

+ * {@code
+        
+            textblock-ref
+            
+            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. + * + *

+ * 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 invoiceItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); + double lineTotalAmount = 0.00; + int pos = 1; + for (ItemCollection invoiceItem : invoiceItems) { + TradeLineItem tradeLineItem = buildTradeLineItem(invoiceItem, pos); + model.setTradeLineItem(tradeLineItem); + lineTotalAmount = lineTotalAmount + tradeLineItem.getTotal(); + pos++; + } + + // Summenbildung + /* + * Field mapping: + *

+ */ + 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 result; + result = documentService.find(query, 1, 0); + if (result != null && result.size() > 0) { + ItemCollection creditorData = result.get(0); + String vatID = creditorData.getItemValueString("_vendor_vat_registration_id"); + logger.info("│ ├── synchronize VAT Registrytion ID: " + vatID); + // update business partner + businessPartner.setItemValue("partner.vat", vatID); + documentService.saveByNewTransaction(businessPartner); + } + } catch (Exception e) { + // should not happen! + logger.info("│ ├── ⚠️ Failed to sync BusinessPartner : " + e.getMessage()); + + } + } + } + + TradeParty tradeParty = new TradeParty(type); + + // Name ist immer die erste Zeile + tradeParty.setName(businessPartner.getItemValueString("partner.name")); + tradeParty.setCountryId(businessPartner.getItemValueString("partner.country")); + tradeParty.setCityName(businessPartner.getItemValueString("partner.city")); + tradeParty.setPostcodeCode(businessPartner.getItemValueString("partner.zip")); + tradeParty.setStreetAddress(businessPartner.getItemValueString("partner.address")); + + if ("buyer".equals(type)) { + // if we do NOT have a partner.vat we can not upload the invoice! + String partnerVAT = businessPartner.getItemValueString("partner.vat"); + if (partnerVAT.isBlank()) { + // Just a warning + logger.warning("│ ├── ⚠️ Business Partner ID '" + partnerID + "' does not contain a VAT ID !"); + // throw new PluginException(this.getClass().getName(), BUSINESSPARTNER_ERROR, + // "Business Partner ID '" + partnerID + "' does not contain a VAT ID !"); + } + workitem.setItemValue("partner.vat", partnerVAT); + tradeParty.setVatNumber(partnerVAT); + // Update or create NrKlienta element directly under Podmiot2 + Element podmiot2 = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Podmiot2"); + model.updateElementValue(podmiot2, EInvoiceNS.KSEF, "NrKlienta", + businessPartner.getItemValueString("dbtr.number")); + + } + + return tradeParty; + } + + /** + * Parses the data list of an invoice line and returns a TradeLineItem object. + * The order of the list items must be exactly! + * + * # 16.04.2026 + * Laut Herrn Grzegorz Grzelczyk dürfen in den Rechnungspositionen nur netto + * werte eingetragen sein. Siehe buildTradeLineItem() + * + * Des weiteren soll anstatt datev.text (pos nummer) der original billing text + * ausgewiesen werden + */ + private TradeLineItem buildTradeLineItem(ItemCollection orderItem, int pos) { + if (orderItem == null) { + return null; + } + + // TradeLineItem tradeLineItem = new + // TradeLineItem(orderItem.getItemValueString("numpos")); + // We do not trust the item 'numPos' here because it can be empty for new + // invoices! + TradeLineItem tradeLineItem = new TradeLineItem(pos + ""); + + // Herrn Grzegorz Grzelczyk möchte hier BillingText + // tradeLineItem.setName(orderItem.getItemValueString("datev.text")); + // tradeLineItem.setName(orderItem.getItemValueString("billingtext")); + // Herr Grzelczyk will das alle billing texte aus der liste, verkettet + // ausgegeben werden. + List billingTexts = orderItem.getItemValue("billingtext"); + String billingTextName = billingTexts == null + ? "" + : String.join(", ", billingTexts); + final int MAX_LENGTH = 512; + if (billingTextName.length() > MAX_LENGTH) { + billingTextName = billingTextName.substring(0, MAX_LENGTH); + } + tradeLineItem.setName(billingTextName); + + tradeLineItem.setQuantity(1); + + double vat = orderItem.getItemValueDouble("datev.vatrate"); // 23.00 + double netto = orderItem.getItemValueDouble("datev.umsatz"); // 970.00 + + // # 16.04.2026 + // Der total bezieht sich laut Herrn Grzegorz Grzelczyk ausschließlich auf den + // Nettowert x Menge. + // Der Brutto Wert ist hier falsch! Da die Menge immer 1 ist ist das beide male + // der selbe wert + // WRONG: tradeLineItem.setTotal(brutto); + + // double brutto = netto * (1 + (vat / 100)); + // double steuer = brutto - netto; + tradeLineItem.setTaxRate(vat); + tradeLineItem.setNetPrice(netto); + tradeLineItem.setTotal(netto); // menge x netto + + return tradeLineItem; + } + + /** + * This method loads a text-block for a specified ref and appends the named + * fileData object of this document. + * + * @param document + * @throws PluginException + */ + + private FileData loadXMLTemplate(ItemCollection workitem, ItemCollection config) + throws PluginException { + + String textblock = config.getItemValueString("textblock"); + String template = config.getItemValueString("template"); + String sourceName = config.getItemValueString("source"); + + try { + debug = Boolean.parseBoolean(config.getItemValueString("debug")); + } catch (Exception e) { + } + String targetName = "ksef.xml"; + + // adapt text.... + sourceName = workflowService.adaptText(sourceName, workitem); + + if ((template == null || template.isEmpty()) || (textblock == null || textblock.isEmpty())) { + throw new PluginException(EInvoiceAdapter.class.getSimpleName(), + CONFIG_ERROR, + "invalid e-invoice configuration in model event - textblock/template reference not defined!"); + } + + // load the text block + FileData fileData = loadTextBlockFileData(textblock, template); + + // do we found the document? + if (fileData == null) { + throw new PluginException(EInvoiceAdapter.class.getSimpleName(), + CONFIG_ERROR, + "invalid e-invoice configuration in model event - textblock/template: " + textblock + "/" + template + + " not found!"); + } + fileData.setName(targetName); + + return fileData; + } + + /** + * This method returns a text-block ItemCollection for a specified name. + * + * @param name in attribute txtname + * + * + */ + public FileData loadTextBlockFileData(String name, String fileName) { + ItemCollection textBlockItemCollection = null; + + // load text-block by name.... + String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")"; + Collection col; + try { + // find the textblock... + col = documentService.find(sQuery, 1, 0); + if (col.size() > 0) { + textBlockItemCollection = col.iterator().next(); + // fetch the fileData... + return snapshotService.getWorkItemFile(textBlockItemCollection.getUniqueID(), fileName); + + } else { + logger.warning("Missing text-block : '" + name + "'"); + } + } catch (QueryException e) { + logger.warning("getTextBlock - invalid query: " + e.getMessage()); + } + + return null; + } + +} \ No newline at end of file diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFInvoiceLineBuilder.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFInvoiceLineBuilder.java new file mode 100644 index 0000000..265f96e --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/KSeFInvoiceLineBuilder.java @@ -0,0 +1,362 @@ +package com.alexanderlogistics.einvoice; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import org.imixs.einvoice.EInvoiceModelKSeF; +import org.imixs.einvoice.EInvoiceNS; +import org.imixs.workflow.ItemCollection; +import org.imixs.workflow.exceptions.PluginException; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import com.alexanderlogistics.InvoiceUtil; + +/** + * Builds the {@code } (line item) blocks and the + * {@code }-level summary fields ({@code P_13_x}, {@code P_14_x}, + * {@code P_14_xW}, {@code P_15}, {@code KursWalutyZ}) of a Polish KSeF FA(3) + * e-invoice. + *

+ * 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: + *

    + *
  1. Line items: for each position in {@code _childitems} a + * {@code } block is written. The CargoSoft VAT code drives the + * mapping into the correct {@code } value and into the aggregation + * bucket.
  2. + *
  3. Summary fields: aggregated net and VAT amounts per + * {@link TaxCode} are written into the corresponding + * {@code }/{@code } elements; {@code } receives the + * gross total of all positions.
  4. + *
  5. Foreign currency: if the invoice currency differs from PLN, a + * {@code } (VAT amount converted to PLN) is written for every + * populated VAT field, and {@code } is written after + * {@code }.
  6. + *
+ *

+ * 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 } block will be + * populated + * @param workitem the workitem holding the invoice header data + * ({@code invoice.currency}, {@code invoice.rate}) and + * the line items ({@code _childitems}) + * @throws PluginException if a CargoSoft VAT code cannot be mapped or any + * required field is missing + */ + public void build(EInvoiceModelKSeF model, ItemCollection workitem) throws PluginException { + + Element elementFa = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Fa"); + + List invoiceItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); + if (invoiceItems == null || invoiceItems.isEmpty()) { + logger.warning("│ ├── ⚠️ no invoice items found - nothing to build"); + return; + } + + // Aggregation buckets: net and VAT amount per TaxCode + Map aggregates = new EnumMap<>(TaxCode.class); + + // Phase 1: write each FaWiersz, fill aggregation buckets + BigDecimal grossTotal = buildLineItems(model, elementFa, invoiceItems, aggregates); + + // Phase 2: write summary fields P_13_x, P_14_x and P_15 + writeSummaryFields(model, elementFa, aggregates, grossTotal); + + // Phase 3: write foreign currency fields (P_14_xW, KursWalutyZ) + String currency = workitem.getItemValueString("invoice.currency"); + if (!"PLN".equalsIgnoreCase(currency) && !currency.isBlank()) { + BigDecimal exchangeRate = BigDecimal.valueOf(workitem.getItemValueDouble("invoice.rate")); + writeForeignCurrencyFields(model, elementFa, aggregates, exchangeRate); + } + } + + // ------------------------------------------------------------------ + // Phase 1 - line items + // ------------------------------------------------------------------ + + /** + * Writes one {@code } block per invoice line and fills the + * aggregation buckets. Returns the gross total of all positions. + */ + private BigDecimal buildLineItems( + EInvoiceModelKSeF model, + Element elementFa, + List invoiceItems, + Map aggregates) throws PluginException { + + BigDecimal grossTotal = BigDecimal.ZERO; + int pos = 1; + + for (ItemCollection invoiceItem : invoiceItems) { + + // Resolve the CargoSoft VAT code into a TaxCode + String cargoCode = invoiceItem.getItemValueString("cargosoft.vat.code"); + TaxCode taxCode = TaxCode.fromCargosoftCode(cargoCode); + + // Read raw position data + BigDecimal netAmount = BigDecimal.valueOf(invoiceItem.getItemValueDouble("datev.umsatz")); + BigDecimal vatRate = BigDecimal.valueOf(invoiceItem.getItemValueDouble("datev.vatrate")); + + // Compute VAT and gross amount of this position + BigDecimal vatAmount; + if (taxCode.hasVat()) { + vatAmount = netAmount.multiply(vatRate).divide(HUNDRED, MONEY_SCALE, MONEY_ROUNDING); + } else { + // 0% / np I / np II - no VAT amount, even if vatRate would be > 0 + vatAmount = BigDecimal.ZERO; + } + BigDecimal grossAmount = netAmount.add(vatAmount); + + // Write the FaWiersz block + writeFaWiersz(model, elementFa, invoiceItem, pos, taxCode, netAmount, vatAmount, grossAmount); + + // Aggregate per TaxCode + aggregates.merge(taxCode, + new NetVatPair(netAmount, vatAmount), + NetVatPair::plus); + + grossTotal = grossTotal.add(grossAmount); + pos++; + } + + return grossTotal; + } + + /** + * Writes a single {@code } block at the schema-correct position + * (before the first of {@code Rozliczenie}, {@code Platnosc}, + * {@code WarunkiTransakcji}, {@code Zamowienie}). + */ + private void writeFaWiersz( + EInvoiceModelKSeF model, + Element elementFa, + ItemCollection invoiceItem, + int pos, + TaxCode taxCode, + BigDecimal netAmount, + BigDecimal vatAmount, + BigDecimal grossAmount) { + + // Determine schema-correct insert position + Element insertBefore = findFirstSuccessorOfFaWiersz(elementFa, model); + Element faWiersz = model.createChildNode(elementFa, EInvoiceNS.KSEF, "FaWiersz", insertBefore); + + // NrWierszaFa - line number + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "NrWierszaFa", String.valueOf(pos)); + + // P_7 - description (concatenated billingtext, max 512 chars) + String description = buildDescription(invoiceItem); + if (!description.isEmpty()) { + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_7", description); + } + + // P_8A - unit of measure (default "szt." = pieces) + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_8A", "szt."); + + // P_8B - quantity (always 1.00 in this context, see business rule + // of 16.04.2026: total = net x quantity, quantity is always 1) + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_8B", "1.00"); + + // P_9A - net unit price (= net amount, because quantity is 1) + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_9A", format(netAmount)); + + // P_11 - net amount of the position + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_11", format(netAmount)); + + // P_11Vat - VAT amount of the position (0 for 0% / np codes) + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_11Vat", format(vatAmount)); + + // P_11A - gross amount of the position + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_11A", format(grossAmount)); + + // P_12 - tax category value driven by the CargoSoft code + model.updateElementValue(faWiersz, EInvoiceNS.KSEF, "P_12", taxCode.getP12Value()); + } + + /** + * Returns the first child of the {@code } element that must appear + * AFTER all {@code } blocks. {@code null} if none exists, in + * which case new {@code } blocks are simply appended. + */ + private Element findFirstSuccessorOfFaWiersz(Element elementFa, EInvoiceModelKSeF model) { + String prefix = model.getPrefix(EInvoiceNS.KSEF); + String[] successors = { "Rozliczenie", "Platnosc", "WarunkiTransakcji", "Zamowienie" }; + Node child = elementFa.getFirstChild(); + while (child != null) { + if (child.getNodeType() == Node.ELEMENT_NODE) { + String childName = child.getNodeName(); + for (String name : successors) { + if ((prefix + name).equals(childName)) { + return (Element) child; + } + } + } + child = child.getNextSibling(); + } + return null; + } + + /** + * Concatenates the {@code billingtext} multi-value into one string, + * truncated to 512 characters (XSD limit for {@code }). + */ + @SuppressWarnings("unchecked") + private String buildDescription(ItemCollection invoiceItem) { + final int MAX_LENGTH = 512; + List billingTexts = invoiceItem.getItemValue("billingtext"); + if (billingTexts == null || billingTexts.isEmpty()) { + return ""; + } + String joined = String.join(", ", billingTexts); + if (joined.length() > MAX_LENGTH) { + joined = joined.substring(0, MAX_LENGTH); + } + return joined; + } + + // ------------------------------------------------------------------ + // Phase 2 - summary fields + // ------------------------------------------------------------------ + + /** + * Writes the aggregated net and VAT amounts into the corresponding + * {@code }/{@code } elements of the {@code } block, + * and writes {@code } as the gross total. + */ + private void writeSummaryFields( + EInvoiceModelKSeF model, + Element elementFa, + Map aggregates, + BigDecimal grossTotal) { + + for (Map.Entry entry : aggregates.entrySet()) { + TaxCode taxCode = entry.getKey(); + NetVatPair pair = entry.getValue(); + + // Net summary field (always present) + String netField = taxCode.getNetSummaryField(); + Element netElement = model.findOrCreateChildNode(elementFa, EInvoiceNS.KSEF, netField); + netElement.setTextContent(format(pair.net)); + logger.info("│ ├── set " + netField + " = " + format(pair.net)); + + // VAT summary field (only for taxable codes) + if (taxCode.hasVat()) { + String vatField = taxCode.getVatSummaryField(); + Element vatElement = model.findOrCreateChildNode(elementFa, EInvoiceNS.KSEF, vatField); + vatElement.setTextContent(format(pair.vat)); + logger.info("│ ├── set " + vatField + " = " + format(pair.vat)); + } + } + + // P_15 - gross total of the entire invoice + Element p15 = model.findOrCreateChildNode(elementFa, EInvoiceNS.KSEF, "P_15"); + p15.setTextContent(format(grossTotal)); + logger.info("│ ├── set P_15 = " + format(grossTotal)); + } + + // ------------------------------------------------------------------ + // Phase 3 - foreign currency + // ------------------------------------------------------------------ + + /** + * Writes the {@code } fields (VAT amount converted to PLN) and + * the {@code } element after {@code }. + */ + private void writeForeignCurrencyFields( + EInvoiceModelKSeF model, + Element elementFa, + Map aggregates, + BigDecimal exchangeRate) { + + // For every aggregated VAT entry write the corresponding P_14_xW + for (Map.Entry entry : aggregates.entrySet()) { + TaxCode taxCode = entry.getKey(); + if (!taxCode.hasVat()) { + continue; + } + String vatField = taxCode.getVatSummaryField(); + String vatFieldW = vatField + "W"; + + // P_14_xW = P_14_x * exchangeRate, rounded to 2 decimals + BigDecimal vatInPln = entry.getValue().vat + .multiply(exchangeRate) + .setScale(MONEY_SCALE, MONEY_ROUNDING); + + // Insert P_14_xW directly after its sibling P_14_x + Element vatWElement = model.findOrCreateChildNodeAfter( + elementFa, EInvoiceNS.KSEF, vatFieldW, vatField); + vatWElement.setTextContent(format(vatInPln)); + logger.info("│ ├── set " + vatFieldW + " = " + format(vatInPln)); + } + + // KursWalutyZ - exchange rate, written directly after P_15 + Element kursElement = model.findOrCreateChildNodeAfter( + elementFa, EInvoiceNS.KSEF, "KursWalutyZ", "P_15"); + kursElement.setTextContent(exchangeRate.toPlainString()); + logger.info("│ ├── set KursWalutyZ = " + exchangeRate.toPlainString()); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * Formats a monetary amount as a plain string with exactly 2 decimal + * places and HALF_UP rounding, suitable for KSeF FA(3) XML. + */ + private String format(BigDecimal value) { + return value.setScale(MONEY_SCALE, MONEY_ROUNDING).toPlainString(); + } + + /** + * Aggregation bucket: net and VAT amount summed per {@link TaxCode}. + */ + private static final class NetVatPair { + final BigDecimal net; + final BigDecimal vat; + + NetVatPair(BigDecimal net, BigDecimal vat) { + this.net = net; + this.vat = vat; + } + + NetVatPair plus(NetVatPair other) { + return new NetVatPair(this.net.add(other.net), this.vat.add(other.vat)); + } + } +} \ No newline at end of file diff --git a/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/TaxCode.java b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/TaxCode.java new file mode 100644 index 0000000..e6eb42f --- /dev/null +++ b/office-alexander-logistics-app/src/main/java/com/alexanderlogistics/einvoice/TaxCode.java @@ -0,0 +1,166 @@ +package com.alexanderlogistics.einvoice; + +import org.imixs.workflow.exceptions.PluginException; + +/** + * Single source of truth for the mapping between CargoSoft VAT codes and the + * Polish KSeF FA(3) XML schema fields. + *

+ * Each enum constant maps a CargoSoft VAT code (as it appears in the + * {@code cargosoft.vat.code} item of an invoice line) to: + *

    + *
  • the value to be written into the {@code } element of a + * {@code } (line item) block,
  • + *
  • the target net summary field in the {@code } block (one of + * {@code P_13_1}, {@code P_13_2}, {@code P_13_6_1}, {@code P_13_8}, + * {@code P_13_9}),
  • + *
  • the optional target VAT summary field in the {@code } block + * ({@code P_14_1}, {@code P_14_2}) - {@code null} for 0% / non-taxable + * codes.
  • + *
+ *

+ * 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 } element of a + * {@code } block (e.g. "23", "0 KR", "np II") + */ + public String getP12Value() { + return p12Value; + } + + /** + * @return the name of the target net summary tag in the {@code } + * block (e.g. "P_13_1", "P_13_6_1", "P_13_9") + */ + public String getNetSummaryField() { + return netSummaryField; + } + + /** + * @return the name of the target VAT summary tag in the {@code } + * block (e.g. "P_14_1") or {@code null} for 0% / non-taxable codes + */ + public String getVatSummaryField() { + return vatSummaryField; + } + + /** + * @return {@code true} if this code carries a non-zero VAT amount + * (i.e. a VAT summary field exists); {@code false} otherwise + */ + public boolean hasVat() { + return vatSummaryField != null; + } + + /** + * Resolves a CargoSoft VAT code string to the corresponding {@link TaxCode} + * enum constant. + *

+ * 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 childItems = new ArrayList<>(); + ItemCollection lineItem1 = new ItemCollection(); lineItem1.setItemValue("numpos", "1"); lineItem1.setItemValue("datev.text", "Transport Berlin - Warsaw"); + lineItem1.setItemValue("billingtext", "Transport Berlin - Warsaw"); lineItem1.setItemValue("datev.umsatz", 6000.00); + lineItem1.setItemValue("datev.vatrate", 23.0); + lineItem1.setItemValue("cargosoft.vat.code", "23"); childItems.add(lineItem1.getAllItems()); ItemCollection lineItem2 = new ItemCollection(); lineItem2.setItemValue("numpos", "2"); lineItem2.setItemValue("datev.text", "Customs handling"); + lineItem2.setItemValue("billingtext", "Customs handling"); lineItem2.setItemValue("datev.umsatz", 4000.00); + lineItem2.setItemValue("datev.vatrate", 23.0); + lineItem2.setItemValue("cargosoft.vat.code", "23"); childItems.add(lineItem2.getAllItems()); workitem.setItemValue("_childitems", childItems); - // Arrange when(businessPartnerService.getBusinessPartnerByID("BP-001")) .thenReturn(businessPartner); FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); - // Act adapter.updateEInvoice(xmlTemplate, workitem); - // Assert - basic checks assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update"); assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty"); - // Write output for manual inspection writeOutputToResources(xmlTemplate, "invoice-simple.xml"); - } /** - * Erzeugt ein lokales XML File zum testen + * Correction invoice (KOR) in EUR with 23% tax rate. + * Expected: KOR header data, P_13_1, P_14_1, P_14_1W, KursWalutyZ. */ @Test - @DisplayName("Test Invoice Korrektur") + @DisplayName("Test Invoice Korrektur (EUR, 23%)") public void testInvoiceKorektur() throws Exception { - logger.info("==> Test: Upload Invoice XML"); + logger.info("==> Test: Invoice Korrektur"); - // 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)); workitem.setItemValue("invoice.duedate", LocalDate.of(2025, 3, 10)); workitem.setItemValue("invoice.currency", "EUR"); + workitem.setItemValue("invoice.rate", 4.30); workitem.setItemValue("invoice.total.net", 0.00); workitem.setItemValue("invoice.total.tax", 0.0); workitem.setItemValue("invoice.total", 0.00); @@ -162,7 +171,7 @@ public class KSeFAdapterTest { workitem.setItemValue("invoice.correction", true); workitem.setItemValue("invoice.CorrectionInvoiceNumber", "6551"); workitem.setItemValue("invoice.performancedate", new Date()); - // Prepare child items (invoice line items) + List childItems = new ArrayList<>(); ItemCollection lineItem1 = new ItemCollection() @@ -173,93 +182,194 @@ public class KSeFAdapterTest { .setItemValue("datev.text", "Transport Berlin - Warsaw") .setItemValue("billingtext", "usługa spedycyjna / transport w relacji, PL63 - CZ43") .setItemValue("datev.umsatz", -900.00) + .setItemValue("datev.vatrate", 23.0) + .setItemValue("cargosoft.vat.code", "23") .setItemValue("datev.wkz", "EUR") .setItemValue("billingcode", "TRANSLKW"); childItems.add(lineItem1.getAllItems()); - /** - * 1 - * 7f846436-7c29-48b3-8f5a-fed996ea30a3 - * IM-POL-2512-008 - * szt. - * 1 - * 0.00 - * 0.00 - * - * - * 2 - * f3077e37-9198-4431-a82b-68d9439b587e - * IM-POL-2512-008 - * szt. - * 1 - * -900.00 - * -900.00 - * - * - * 3 - * 31fdfe9e-671e-42f6-9f7e-ceb227a3a42c - * IM-POL-2512-008 - * szt. - * 1 - * 0.00 - * 0.00 - * - * - * 4 - * 79ffd1e8-be87-478b-97b5-d37aa302d090 - * IM-POL-2512-008 - * szt. - * 1 - * 0.00 - * 0.00 - * - * - * 5 - * 38e8cbd3-e558-4b0e-875c-c4f2d96d1659 - * IM-POL-2512-008 - * szt. - * 1 - * 900.00 - * 900.00 - * - * - * 6 - * 3774dab3-b6c4-433e-8298-60ecf6cbab74 - * IM-POL-2512-008 - * szt. - * 1 - * 0.00 - * 0.00 - * - * - * 7 - * f19846ab-3567-4e5d-823b-807d474af062 - * IM-POL-2512-008 - * szt. - * 1 - * 0.00 - * 0.00 - */ - workitem.setItemValue("_childitems", childItems); - // Arrange when(businessPartnerService.getBusinessPartnerByID("BP-001")) .thenReturn(businessPartner); FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); - // Act adapter.updateEInvoice(xmlTemplate, workitem); - // Assert - basic checks assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update"); assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty"); - // Write output for manual inspection writeOutputToResources(xmlTemplate, "test-korrekturrechnung.xml"); + } + /** + * Mixed tax rates: 0% (Art. 83) + 23% on the same invoice in PLN. + *

+ * 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 childItems = new ArrayList<>(); + + // Position 1: 0% domestic (Art. 83 sec. 1 no. 19 - forwarding for export) + ItemCollection lineItem1 = new ItemCollection() + .setItemValue("numpos", "1") + .setItemValue("datev.text", "EX-GDY-2602-001") + .setItemValue("billingtext", "Forwarding service for export shipment") + .setItemValue("datev.umsatz", 14040.00) + .setItemValue("datev.vatrate", 0.0) + .setItemValue("cargosoft.vat.code", "0%E"); + childItems.add(lineItem1.getAllItems()); + + // Position 2: 23% domestic taxable + ItemCollection lineItem2 = new ItemCollection() + .setItemValue("numpos", "2") + .setItemValue("datev.text", "Domestic transport handling") + .setItemValue("billingtext", "Krajowa obsługa transportu") + .setItemValue("datev.umsatz", 3960.00) + .setItemValue("datev.vatrate", 23.0) + .setItemValue("cargosoft.vat.code", "23"); + childItems.add(lineItem2.getAllItems()); + + workitem.setItemValue("_childitems", childItems); + + when(businessPartnerService.getBusinessPartnerByID("BP-001")) + .thenReturn(businessPartner); + + FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); + + adapter.updateEInvoice(xmlTemplate, workitem); + + assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update"); + assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty"); + + writeOutputToResources(xmlTemplate, "invoice-mixed-rates.xml"); + } + + /** + * EU B2B service (Art. 28b UStG-PL) in EUR. + *

+ * This is the case from the tax advisor mapping document (invoice 7470) + * where code "NP" must map to {@code np II} and + * {@code }, NOT to {@code }. The destination field drives + * correct VAT-UE / ZM reporting in JPK_V7. + */ + @Test + @DisplayName("Test EU B2B Service (EUR, NP / np II)") + public void testEUService_NP() throws Exception { + logger.info("==> Test: EU B2B Service"); + + // German EU customer + ItemCollection germanPartner = new ItemCollection(); + germanPartner.setItemValue("partner.name", "Muster GmbH"); + germanPartner.setItemValue("partner.country", "DE"); + germanPartner.setItemValue("partner.city", "Bremen"); + germanPartner.setItemValue("partner.zip", "28195"); + germanPartner.setItemValue("partner.address", "Museumstr. 2-6"); + germanPartner.setItemValue("partner.vat", "DE310968950"); + germanPartner.setItemValue("dbtr.number", "D-18804"); + + ItemCollection workitem = new ItemCollection(); + workitem.setItemValue("invoice.number", "FV/2025/7470"); + workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10)); + workitem.setItemValue("invoice.duedate", LocalDate.of(2025, 3, 10)); + workitem.setItemValue("invoice.currency", "EUR"); + workitem.setItemValue("invoice.rate", 4.30); + workitem.setItemValue("invoice.correction", "false"); + workitem.setItemValue("invoice.CorrectionInvoiceNumber", ""); + workitem.setItemValue("partner.id", "BP-DE-001"); + workitem.setItemValue("partner.vat", "DE310968950"); + workitem.setItemValue("invoice.performancedate", new Date()); + + List childItems = new ArrayList<>(); + + ItemCollection lineItem1 = new ItemCollection() + .setItemValue("numpos", "1") + .setItemValue("datev.text", "Transport DE-PL") + .setItemValue("billingtext", "International transport service Germany - Poland") + .setItemValue("datev.umsatz", 4620.00) + .setItemValue("datev.vatrate", 0.0) + .setItemValue("cargosoft.vat.code", "NP"); + childItems.add(lineItem1.getAllItems()); + + workitem.setItemValue("_childitems", childItems); + + when(businessPartnerService.getBusinessPartnerByID("BP-DE-001")) + .thenReturn(germanPartner); + + FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); + + adapter.updateEInvoice(xmlTemplate, workitem); + + assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update"); + assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty"); + + writeOutputToResources(xmlTemplate, "invoice-eu-service-np.xml"); + } + + /** + * Negative test: an unknown CargoSoft VAT code (here the mixed code "023" + * which is intentionally not mapped) must abort processing with a + * PluginException. This guards against silently producing JPK_V7-invalid + * invoices when CargoSoft is extended with a new code. + */ + @Test + @DisplayName("Test Unknown CargoSoft Code raises PluginException") + public void testUnknownTaxCode() throws Exception { + logger.info("==> Test: Unknown CargoSoft Code"); + + ItemCollection workitem = new ItemCollection(); + workitem.setItemValue("invoice.number", "FV/2025/999"); + 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 childItems = new ArrayList<>(); + ItemCollection lineItem = new ItemCollection() + .setItemValue("numpos", "1") + .setItemValue("datev.text", "Mixed code position") + .setItemValue("billingtext", "Mixed code position") + .setItemValue("datev.umsatz", 1000.00) + .setItemValue("datev.vatrate", 23.0) + // 023 is the CargoSoft mixed code - NOT mapped, expected to fail + .setItemValue("cargosoft.vat.code", "023"); + childItems.add(lineItem.getAllItems()); + + workitem.setItemValue("_childitems", childItems); + + when(businessPartnerService.getBusinessPartnerByID("BP-001")) + .thenReturn(businessPartner); + + FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); + + // Expect a PluginException with TAX_CODE_ERROR error code + assertThrows(PluginException.class, + () -> adapter.updateEInvoice(xmlTemplate, workitem), + "Unknown CargoSoft VAT code must raise a PluginException"); } // ── Helper methods ────────────────────────────────────────────── @@ -293,4 +403,4 @@ public class KSeFAdapterTest { System.out.println("Output written to: " + outputPath.toAbsolutePath()); System.out.println("──────────────────────────────────────────────"); } -} +} \ No newline at end of file diff --git a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-eu-service-np.xml b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-eu-service-np.xml new file mode 100644 index 0000000..6b2af94 --- /dev/null +++ b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-eu-service-np.xml @@ -0,0 +1,147 @@ + + + + FA + 3 + 2026-04-27T22:36:23.680605Z + Imixs eInvoice + + + + PL + + 9552521552 + ALEXANDER GLOBAL LOGISTICS POLAND sp. z o.o. + + + PL + ul. Gdańska 36, 70-660 Szczecin + + + MBudas@alexander-logistics.com + + + + + + + + DE310968950 + Muster GmbH + + + DE + Museumstr. 2-6 + 28195 Bremen + + + D-18804 + 2 + + 2 + + + + + EUR + 2025-02-10 + + Szczecin + FV/2025/7470 + + 2026-04-27 + + + + + + 2 + + 2 + + 2 + + 2 + + + 1 + + + + 1 + + + 2 + + + 1 + + + + VAT + + + 1 + International transport service Germany - Poland + szt. + 1.00 + 4620.00 + 4620.00 + 0.00 + 4620.00 + np II + + + + + + 2025-03-10 + + 6 + + + PL79116022020000000654306674 + BIGBPLPWXXX + Bank Millennium S.A. + Konto PLN + + + PL16116022020000000654910469 + BIGBPLPWXXX + Bank Millennium S.A. + Konto EUR + + + PL72116022020000000654911595 + BIGBPLPWXXX + Bank Millennium S.A. + Konto USD + + + 4620.00 + 4620.00 + 4.3 + + diff --git a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-mixed-rates.xml b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-mixed-rates.xml new file mode 100644 index 0000000..9834ca4 --- /dev/null +++ b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-mixed-rates.xml @@ -0,0 +1,159 @@ + + + + FA + 3 + 2026-04-27T22:36:23.965831Z + Imixs eInvoice + + + + PL + + 9552521552 + ALEXANDER GLOBAL LOGISTICS POLAND sp. z o.o. + + + PL + ul. Gdańska 36, 70-660 Szczecin + + + MBudas@alexander-logistics.com + + + + + + + + 1234567890 + Test Sp. z o.o. + + + PL + ul. Testowa 1 + 00-001 Warszawa + + + D-12345 + 2 + + 2 + + + + + PLN + 2025-02-10 + + Szczecin + FV/2025/7471 + + 2026-04-27 + + + + + + 2 + + 2 + + 2 + + 2 + + + 1 + + + + 1 + + + 2 + + + 1 + + + + VAT + + + 1 + Forwarding service for export shipment + szt. + 1.00 + 14040.00 + 14040.00 + 0.00 + 14040.00 + 0 KR + + + 2 + Krajowa obsługa transportu + szt. + 1.00 + 3960.00 + 3960.00 + 910.80 + 4870.80 + 23 + + + + + + 2025-03-10 + + 6 + + + PL79116022020000000654306674 + BIGBPLPWXXX + Bank Millennium S.A. + Konto PLN + + + PL16116022020000000654910469 + BIGBPLPWXXX + Bank Millennium S.A. + Konto EUR + + + PL72116022020000000654911595 + BIGBPLPWXXX + Bank Millennium S.A. + Konto USD + + + 14040.00 + 3960.00 + 910.80 + 18910.80 + + diff --git a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml index 5625343..ccc22b2 100644 --- a/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml +++ b/office-alexander-logistics-app/src/test/resources/ksef/output/invoice-simple.xml @@ -6,7 +6,7 @@ FA 3 - 2026-04-27T16:20:53.265515Z + 2026-04-27T22:36:24.034379Z Imixs eInvoice @@ -67,9 +67,6 @@ 0.00 0.00 --> - 10000.00 - 2300.00 - 12300.00 2 @@ -99,17 +96,25 @@ 1 + Transport Berlin - Warsaw szt. - 1 + 1.00 6000.00 6000.00 + 1380.00 + 7380.00 + 23 2 + Customs handling szt. - 1 + 1.00 4000.00 4000.00 + 920.00 + 4920.00 + 23 @@ -139,6 +144,15 @@ Bank Millennium S.A. Konto EUR + + PL72116022020000000654911595 + BIGBPLPWXXX + Bank Millennium S.A. + Konto USD + + 10000.00 + 2300.00 + 12300.00 diff --git a/office-alexander-logistics-app/src/test/resources/ksef/output/test-korrekturrechnung.xml b/office-alexander-logistics-app/src/test/resources/ksef/output/test-korrekturrechnung.xml index 883b811..f710395 100644 --- a/office-alexander-logistics-app/src/test/resources/ksef/output/test-korrekturrechnung.xml +++ b/office-alexander-logistics-app/src/test/resources/ksef/output/test-korrekturrechnung.xml @@ -6,19 +6,19 @@ FA 3 - 2026-04-16T06:22:32.198172335Z + 2026-04-27T22:36:23.999774Z Imixs eInvoice + PL 9552521552 - Alexander Global Logistics + ALEXANDER GLOBAL LOGISTICS POLAND sp. z o.o. PL - Gdanska 36 - 70-660 Szczecin + ul. Gdańska 36, 70-660 Szczecin MBudas@alexander-logistics.com @@ -56,9 +56,10 @@ EUR 2025-02-10 + Szczecin FV/2025/001 - 2025-03-10 + 2026-04-27 - 0.00 - 0.00 - 0.00 2 @@ -103,13 +101,54 @@ 1 - d437ac36-9439-4bd6-96fb-0fe0b91fd32d usługa spedycyjna / transport w relacji, PL63 - CZ43 szt. - 1 + 1.00 -900.00 -900.00 - 0 + -207.00 + -1107.00 + 23 + + + + + 2025-03-10 + + 6 + + + PL79116022020000000654306674 + BIGBPLPWXXX + Bank Millennium S.A. + Konto PLN + + + PL16116022020000000654910469 + BIGBPLPWXXX + Bank Millennium S.A. + Konto EUR + + + PL72116022020000000654911595 + BIGBPLPWXXX + Bank Millennium S.A. + Konto USD + + + -900.00 + -207.00 + -890.10 + -1107.00 + 4.3