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
* filename
* true
*
* }
*
*
*
* Architecture: The adapter handles invoice header data (parties,
* dates, invoice type, currency code, KOR/correction data). All logic
* concerning invoice positions ({@code
* 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"));
// 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
* Strategy: parse all {@code
* 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
* 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