package com.alexanderlogistics.einvoice; import java.io.ByteArrayInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; 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 javax.xml.xpath.XPath; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpression; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; 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.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.w3c.dom.Node; import org.w3c.dom.NodeList; 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 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
 *  
 * }
 * 
* *

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

* 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 { 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; @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; } /** * 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 { try { EInvoiceModel model = EInvoiceModelFactory.read(new ByteArrayInputStream(fileDataXMLTemplate.getContent())); model.setId(workitem.getItemValueString("invoice.number")); // Issue date model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date")); // Performance date - resync from Cargosoft XML if missing if (workitem.getItemValueDate("invoice.performancedate") == null) { syncPerformanceDate(workitem); } // CargoSoft VAT code per child item - resync from Cargosoft XML if any // child item is missing it (legacy data created before the import was // extended to capture cargosoft.vat.code) if (isAnyChildMissingVatCode(workitem)) { syncCargosoftVatCodes(workitem); } ((EInvoiceModelKSeF) model) .setPerformanceDateTime(workitem.getItemValueLocalDate("invoice.performancedate")); // Buyer address TradeParty billingAddress = buildAddress(workitem, "buyer", model); model.setTradeParty(billingAddress); // 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()); // 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"); // Correction data (DaneFaKorygowanej) - required for KOR invoices Element daneFaKorygowanej = model.findOrCreateChildNodeAfter( elementFa, EInvoiceNS.KSEF, "DaneFaKorygowanej", "RodzajFaktury"); Date correctionInvoiceDate = workitem.getItemValueDate("invoice.date"); 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 - 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 - 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 { ((EInvoiceModelKSeF) model).setRodzajFaktury("VAT"); logger.info("│ ├── invoice type=VAT"); } // Currency code model.updateElementValue(elementFa, EInvoiceNS.KSEF, "KodWaluty", workitem.getItemValueString("invoice.currency")); // ================================================================ // 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); // Due date - written at the end of the XML tree model.setDueDateTime(workitem.getItemValueLocalDate("invoice.duedate")); // Strip all XML comments from the document. The template comments // are useful for template maintainers but should not appear in // the final invoice that is submitted to KSeF or reviewed by // tax advisors. stripComments(model.getRoot().getOwnerDocument()); // Persist the modified template fileDataXMLTemplate.setContent(model.getContent()); } catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) { throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e); } } /** * 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 { if (!workitem.hasItem("invoice.performancedate")) { try { logger.info("----Resync cargosoft performancedate...."); DocumentBuilder documentBuilder; 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); CargosoftXMLInvoiceImportService.readXMLValue(doc, "/Invoices/Invoice/InvoiceHeader/PerformanceDate", workitem, "invoice.performancedate", Date.class); // Fallback to invoice date if no performance date was found 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()); } } } /** * Returns {@code true} if at least one entry of {@code _childitems} does * not yet have the item {@code cargosoft.vat.code} populated. This can * happen for legacy workitems that were imported before the Cargosoft * import was extended to capture the VAT code per row. */ private boolean isAnyChildMissingVatCode(ItemCollection workitem) { List childItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); if (childItems == null || childItems.isEmpty()) { return false; } for (ItemCollection child : childItems) { if (child.getItemValueString("cargosoft.vat.code").isBlank()) { return true; } } return false; } /** * Resync of the {@code cargosoft.vat.code} item on each child of * {@code _childitems} from the original Cargosoft import XML. *

* Strategy: parse all {@code } elements of the source XML in * document order, skipping rows whose {@code BillingCode} is * {@code "WÄHRUNG"} or {@code "KURS"} (special non-position rows). The * remaining rows are paired with the {@code _childitems} entries by their * one-based position number ({@code numpos}). Each child item without a * VAT code receives the value extracted from its matching XML row. *

* Rows where the VAT code cannot be extracted are silently skipped here; * the {@link com.alexanderlogistics.einvoice.KSeFInvoiceLineBuilder} will * later fail with a {@link PluginException} for that specific position, * which is the desired behaviour. */ private void syncCargosoftVatCodes(ItemCollection workitem) throws PluginException { try { logger.info("----Resync cargosoft.vat.code...."); FileData cargoXML = snapshotService.getWorkItemFile(workitem.getUniqueID(), workitem.getItemValueString("cargosoft.import.filename")); if (cargoXML == null) { logger.warning("│ ├── ⚠️ Cargosoft source XML not found - cannot resync vat codes"); return; } InputStream inputStream = new ByteArrayInputStream(cargoXML.getContent()); InputSource inputSource = new InputSource(inputStream); DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); Document doc = documentBuilder.parse(inputSource); // Build a map: numpos (1-based position number) -> vatCode // by iterating the InvoiceRow elements in document order, skipping // the special rows for currency / exchange-rate metadata. XPath xPath = XPathFactory.newInstance().newXPath(); XPathExpression billingCodeExpr = xPath.compile("BillingCode/Codes/Code"); XPathExpression vatCodeExpr = xPath.compile( "InvoiceAmount/VATInformation/VAT/Codes/Code[@Type='cs']/text()"); Map vatCodeByPos = new HashMap<>(); NodeList rowList = doc.getElementsByTagName("InvoiceRow"); int posCounter = 0; for (int i = 0; i < rowList.getLength(); i++) { Node rowNode = rowList.item(i); if (rowNode.getNodeType() != Node.ELEMENT_NODE) { continue; } String billingCode = (String) billingCodeExpr.evaluate(rowNode, XPathConstants.STRING); if ("WÄHRUNG".equals(billingCode) || "KURS".equals(billingCode)) { continue; } posCounter++; String vatCode = (String) vatCodeExpr.evaluate(rowNode, XPathConstants.STRING); if (vatCode != null && !vatCode.isBlank()) { vatCodeByPos.put(posCounter, vatCode); } } // Patch each child item that is still missing the vat code, // matching by numpos. Re-implode the list at the end. List childItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); boolean changed = false; for (ItemCollection child : childItems) { if (!child.getItemValueString("cargosoft.vat.code").isBlank()) { continue; } int numpos; try { numpos = Integer.parseInt(child.getItemValueString("numpos")); } catch (NumberFormatException nfe) { logger.warning("│ ├── ⚠️ child item without numpos - cannot resync vat code"); continue; } String vatCode = vatCodeByPos.get(numpos); if (vatCode != null) { child.setItemValue("cargosoft.vat.code", vatCode); changed = true; logger.info("│ ├── resynced numpos=" + numpos + " cargosoft.vat.code=" + vatCode); } else { logger.warning("│ ├── ⚠️ no matching InvoiceRow for numpos=" + numpos); } } if (changed) { InvoiceUtil.implodeChildList(workitem, childItems); } } catch (ParserConfigurationException | SAXException | IOException | XPathExpressionException e) { throw new PluginException(CargosoftXMLInvoiceImportService.class.getName(), "XML_ERROR", e.getMessage()); } } /** * Returns {@code true} if the workitem represents a correction invoice. */ private boolean isKorrekturRechnung(ItemCollection workitem) { return ("true".equals(workitem.getItemValueString("invoice.correction")) && !workitem.getItemValueString("invoice.CorrectionInvoiceNumber").isEmpty()); } /** * 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 { 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: resync partner.vat from legacy cargosoftkreditor object if missing 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 = 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 Registration ID: " + vatID); businessPartner.setItemValue("partner.vat", vatID); documentService.saveByNewTransaction(businessPartner); } } catch (Exception e) { logger.info("│ ├── ⚠️ Failed to sync BusinessPartner : " + e.getMessage()); } } } TradeParty tradeParty = new TradeParty(type); 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)) { String partnerVAT = businessPartner.getItemValueString("partner.vat"); if (partnerVAT.isBlank()) { logger.warning("│ ├── ⚠️ 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; } /** * Loads the e-invoice XML template referenced by the configuration. */ 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) { // ignore - debug remains false } String targetName = "ksef.xml"; 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!"); } FileData fileData = loadTextBlockFileData(textblock, template); 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; } /** * Returns a text-block FileData by name and filename. */ public FileData loadTextBlockFileData(String name, String fileName) { ItemCollection textBlockItemCollection = null; String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")"; Collection col; try { col = documentService.find(sQuery, 1, 0); if (col.size() > 0) { textBlockItemCollection = col.iterator().next(); 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; } /** * Removes all XML comment nodes from the given document. *

* The KSeF template carries comments to help template maintainers * (field explanations, fixed-value markers, business rules). These * comments must not appear in the final invoice that is submitted * to KSeF or reviewed by the tax advisor - they only add noise. */ private void stripComments(Document doc) { if (doc == null) { return; } removeCommentsRecursive(doc); } /** * Recursively walks a node and removes every direct child that is an * XML comment. Iterates from the last child backwards so that * removing a node does not affect the index of the still-to-visit * children. */ private void removeCommentsRecursive(Node node) { Node child = node.getLastChild(); while (child != null) { Node previous = child.getPreviousSibling(); if (child.getNodeType() == Node.COMMENT_NODE) { node.removeChild(child); } else if (child.hasChildNodes()) { removeCommentsRecursive(child); } child = previous; } } }