neue Imple KSEF Adapter

This commit is contained in:
Ralph Soika 2026-04-27 22:42:09 +02:00
parent 04acdb4ff3
commit edacbbdd6d
9 changed files with 1772 additions and 356 deletions

View file

@ -4,8 +4,6 @@ import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Collection; import java.util.Collection;
@ -25,7 +23,6 @@ import org.imixs.einvoice.EInvoiceModel;
import org.imixs.einvoice.EInvoiceModelFactory; import org.imixs.einvoice.EInvoiceModelFactory;
import org.imixs.einvoice.EInvoiceModelKSeF; import org.imixs.einvoice.EInvoiceModelKSeF;
import org.imixs.einvoice.EInvoiceNS; import org.imixs.einvoice.EInvoiceNS;
import org.imixs.einvoice.TradeLineItem;
import org.imixs.einvoice.TradeParty; import org.imixs.einvoice.TradeParty;
import org.imixs.workflow.FileData; import org.imixs.workflow.FileData;
import org.imixs.workflow.ItemCollection; import org.imixs.workflow.ItemCollection;
@ -43,41 +40,41 @@ import org.xml.sax.SAXException;
import com.alexanderlogistics.BusinessPartnerService; import com.alexanderlogistics.BusinessPartnerService;
import com.alexanderlogistics.InvoiceService; import com.alexanderlogistics.InvoiceService;
import com.alexanderlogistics.InvoiceUtil;
import com.alexanderlogistics.xml.CargosoftXMLInvoiceImportService; import com.alexanderlogistics.xml.CargosoftXMLInvoiceImportService;
import jakarta.inject.Inject; import jakarta.inject.Inject;
/** /**
* The KSeFAdapter converts a Cargsoft outbound invoice into an KSeF XML * 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 * e-invoice and sends the xml file to the polish KSeF API.
* * <p>
* The adapter can be configured by the model: * The adapter is configured via the BPMN model:
* *
* <pre> * <pre>
* {@code * {@code
<ksef name="create"> * <ksef name="create">
<textblock>textblock-ref</textblock> * <textblock>textblock-ref</textblock>
<template>filename</template> * <template>filename</template>
<debug>true</debug> * <debug>true</debug>
</ksef> * </ksef>
* }
}
* </pre> * </pre>
* *
* <p> * <p>
* NIP: The adapter needs the NIP (vat-id). If we do not find the partner.vat in * <b>Architecture:</b> The adapter handles invoice header data (parties,
* the business object, we do a lookup on the D-Cargosoft object and try the vat * dates, invoice type, currency code, KOR/correction data). All logic
* id from there. This is because the partner.vat is a field which was not * concerning invoice positions ({@code <FaWiersz>}) and summary fields
* defined before. * ({@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 <Fa>} block.
*
* <p> * <p>
* Wenn Debug = true gibt der Adapter alle einzelschritte auf der console aus * <b>NIP:</b> The adapter needs the NIP (vat-id). If we do not find the
* und erzeugt zusätzlich die factur-x xml und txt dateien. * 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
* @version 1.0
* @author rsoika * @author rsoika
*/ */
public class KSeFAdapter implements SignalAdapter { public class KSeFAdapter implements SignalAdapter {
@ -109,33 +106,27 @@ public class KSeFAdapter implements SignalAdapter {
@Inject @Inject
InvoiceService invoiceService; InvoiceService invoiceService;
/**
* This method
*
* @throws PluginException
*/
@Override @Override
public ItemCollection execute(ItemCollection workitem, ItemCollection event) public ItemCollection execute(ItemCollection workitem, ItemCollection event)
throws AdapterException, PluginException { throws AdapterException, PluginException {
logger.info("├── 🔜 Convert Invoice to KSeF..."); logger.info("├── 🔜 Convert Invoice to KSeF...");
// read configuration....
ItemCollection eInvoiceConfig = workflowService.evalWorkflowResult(event, "ksef", // Read configuration
workitem, ItemCollection eInvoiceConfig = workflowService.evalWorkflowResult(event, "ksef", workitem, false);
false);
if (eInvoiceConfig == null || !eInvoiceConfig.hasItem("CREATE")) { if (eInvoiceConfig == null || !eInvoiceConfig.hasItem("CREATE")) {
throw new PluginException(EInvoiceAdapter.class.getSimpleName(), CONFIG_ERROR, throw new PluginException(EInvoiceAdapter.class.getSimpleName(), CONFIG_ERROR,
"missing e-invoice/ksef configuration in model event - please check model configuration"); "missing e-invoice/ksef configuration in model event - please check model configuration");
} }
ItemCollection ksefCreateDefinition = XMLParser.parseItemStructure(eInvoiceConfig.getItemValueString("CREATE")); ItemCollection ksefCreateDefinition = XMLParser.parseItemStructure(eInvoiceConfig.getItemValueString("CREATE"));
try {
// Load the e-invoice template.... try {
// Load the e-invoice template
FileData xmlFileData = loadXMLTemplate(workitem, ksefCreateDefinition); FileData xmlFileData = loadXMLTemplate(workitem, ksefCreateDefinition);
updateEInvoice(xmlFileData, workitem); updateEInvoice(xmlFileData, workitem);
// append XML document // Append XML document
logger.info("│ ├── attach KSeF e-invoice..."); logger.info("│ ├── attach KSeF e-invoice...");
workitem.addFileData(xmlFileData); 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 * Updates the e-invoice template with the data stored in the workitem.
* workitem. * <p>
* * The method handles invoice header data (parties, invoice type, dates,
* First the method loads an EInvoiceModel based on the provided XML Template * currency code, KOR data) and then delegates the entire line-item and
* and than updates the e-invoice data based on the items stored in the given * summary construction to {@link KSeFInvoiceLineBuilder}.
* workitem. *
* * @param fileDataXMLTemplate the XML template to be filled
* @param workitem * @param workitem the workitem holding the invoice data
* @throws PluginException * @throws PluginException if the model cannot be parsed or written
*/ */
public void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException { public void updateEInvoice(FileData fileDataXMLTemplate, ItemCollection workitem) throws PluginException {
@ -164,38 +155,37 @@ public class KSeFAdapter implements SignalAdapter {
model.setId(workitem.getItemValueString("invoice.number")); model.setId(workitem.getItemValueString("invoice.number"));
// date // Issue date
model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date")); model.setIssueDateTime(workitem.getItemValueLocalDate("invoice.date"));
// Set Performance Date // Performance date - resync from Cargosoft XML if missing
if (workitem.getItemValueDate("invoice.performancedate") == null) { if (workitem.getItemValueDate("invoice.performancedate") == null) {
// hilfs code um das invoice.performancedate nachträglich zu parsen
syncPerformanceDate(workitem); syncPerformanceDate(workitem);
} }
((EInvoiceModelKSeF) model) ((EInvoiceModelKSeF) model)
.setPerformanceDateTime(workitem.getItemValueLocalDate("invoice.performancedate")); .setPerformanceDateTime(workitem.getItemValueLocalDate("invoice.performancedate"));
// Update Addresses // Buyer address
TradeParty billingAddress = buildAddress(workitem, "buyer", model); TradeParty billingAddress = buildAddress(workitem, "buyer", model);
model.setTradeParty(billingAddress); 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")); ((EInvoiceModelKSeF) model).setTaxType(workitem.getItemValueString("partner.vat"));
workitem.setItemValue("invoice.tax.type", ((EInvoiceModelKSeF) model).getTaxType()); 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"); Element elementFa = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Fa");
if (isKorrekturRechnung(workitem)) { if (isKorrekturRechnung(workitem)) {
((EInvoiceModelKSeF) model).setRodzajFaktury("KOR"); ((EInvoiceModelKSeF) model).setRodzajFaktury("KOR");
logger.info("│ ├── invoice type=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( Element daneFaKorygowanej = model.findOrCreateChildNodeAfter(
elementFa, EInvoiceNS.KSEF, "DaneFaKorygowanej", "RodzajFaktury"); elementFa, EInvoiceNS.KSEF, "DaneFaKorygowanej", "RodzajFaktury");
Date correctionInvoiceDate = workitem.getItemValueDate("invoice.date"); Date correctionInvoiceDate = workitem.getItemValueDate("invoice.date");
// try to lookup original invoice by invoice.CorrectionInvoiceNumber
ItemCollection correctionInvoice = invoiceService ItemCollection correctionInvoice = invoiceService
.findOutboundInvoiceByNumber(workitem.getItemValueString("invoice.CorrectionInvoiceNumber")); .findOutboundInvoiceByNumber(workitem.getItemValueString("invoice.CorrectionInvoiceNumber"));
if (correctionInvoice != null) { if (correctionInvoice != null) {
@ -204,7 +194,8 @@ public class KSeFAdapter implements SignalAdapter {
} else { } else {
logger.info("│ ├── ⚠️ original invoice not found, using current date as fallback"); logger.info("│ ├── ⚠️ original invoice not found, using current date as fallback");
} }
// Original invoice date (DataWystFaKorygowanej) - required
// Original invoice date - required
if (correctionInvoiceDate != null) { if (correctionInvoiceDate != null) {
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
String sCorrectionDate = formatter.format(correctionInvoiceDate); String sCorrectionDate = formatter.format(correctionInvoiceDate);
@ -212,7 +203,7 @@ public class KSeFAdapter implements SignalAdapter {
"DataWystFaKorygowanej", sCorrectionDate); "DataWystFaKorygowanej", sCorrectionDate);
} }
// Original invoice number (NrFaKorygowanej) - required // Original invoice number - required
model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF,
"NrFaKorygowanej", workitem.getItemValueString("invoice.CorrectionInvoiceNumber")); "NrFaKorygowanej", workitem.getItemValueString("invoice.CorrectionInvoiceNumber"));
@ -220,118 +211,35 @@ public class KSeFAdapter implements SignalAdapter {
model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, "NrKSeFN", "1"); model.updateElementValue(daneFaKorygowanej, EInvoiceNS.KSEF, "NrKSeFN", "1");
} else { } else {
// normale rechnung
((EInvoiceModelKSeF) model).setRodzajFaktury("VAT"); ((EInvoiceModelKSeF) model).setRodzajFaktury("VAT");
logger.info("│ ├── invoice type=VAT"); logger.info("│ ├── invoice type=VAT");
} }
// set currency // Currency code
model.updateElementValue(elementFa, EInvoiceNS.KSEF, "KodWaluty", model.updateElementValue(elementFa, EInvoiceNS.KSEF, "KodWaluty",
workitem.getItemValueString("invoice.currency")); workitem.getItemValueString("invoice.currency"));
// Update Invoice Items // ================================================================
// Here we compute the pos number independent if the _childitems hold a posnum. // Delegate: line items, summary fields, foreign currency fields.
// This is because a new imported cargosoft invoice does not provide numPos // The builder uses the CargoSoft VAT code per position to write
// items in the child list. So it is important to generate the pos numbers here // each amount into the correct P_13_x / P_14_x / P_14_xW field.
// one by one // ================================================================
List<ItemCollection> invoiceItems = InvoiceUtil.explodeChildList(workitem, "_childitems"); new KSeFInvoiceLineBuilder().build((EInvoiceModelKSeF) model, workitem);
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 // Due date - written at the end of the XML tree
/*
* Field mapping:
* <ul>
* <li>taxType "1" (Poland): P_13_1 (domestic VAT)</li>
* <li>taxType "2" (EU): P_13_6_2 (intra-community delivery, 0%)</li>
* <li>taxType "3" (Non-EU): P_13_6_3 (export, 0%)</li>
* </ul>
*/
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")); model.setDueDateTime(workitem.getItemValueLocalDate("invoice.duedate"));
/* // Persist the modified template
* finally update the template file
*/
fileDataXMLTemplate.setContent(model.getContent()); fileDataXMLTemplate.setContent(model.getContent());
} catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) { } catch (FileNotFoundException | EInvoiceFormatException | TransformerException e) {
throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e); throw new PluginException(this.getClass().getName(), DOCUMENT_ERROR, e.getMessage(), e);
} }
} }
/** /**
* Dies ist eine Hilfsmethode die nachträglich das invoice.performancedate aus * Helper to resync the {@code invoice.performancedate} from the Cargosoft
* dem Cargosoft XML ausliest * source XML if it is not yet set on the workitem.
*
* @param workitem
* @throws PluginException
*/ */
private void syncPerformanceDate(ItemCollection workitem) throws PluginException { private void syncPerformanceDate(ItemCollection workitem) throws PluginException {
@ -339,7 +247,6 @@ public class KSeFAdapter implements SignalAdapter {
try { try {
logger.info("----Resync cargosoft performancedate...."); logger.info("----Resync cargosoft performancedate....");
DocumentBuilder documentBuilder; DocumentBuilder documentBuilder;
// hole die XML Datei
FileData cargoXML = snapshotService.getWorkItemFile(workitem.getUniqueID(), FileData cargoXML = snapshotService.getWorkItemFile(workitem.getUniqueID(),
workitem.getItemValueString("cargosoft.import.filename")); workitem.getItemValueString("cargosoft.import.filename"));
if (cargoXML != null) { if (cargoXML != null) {
@ -349,13 +256,12 @@ public class KSeFAdapter implements SignalAdapter {
documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = documentBuilder.parse(inputSource); Document doc = documentBuilder.parse(inputSource);
// Performance Date
CargosoftXMLInvoiceImportService.readXMLValue(doc, CargosoftXMLInvoiceImportService.readXMLValue(doc,
"/Invoices/Invoice/InvoiceHeader/PerformanceDate", "/Invoices/Invoice/InvoiceHeader/PerformanceDate",
workitem, "invoice.performancedate", workitem, "invoice.performancedate",
Date.class); 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) { if (workitem.getItemValueDate("invoice.performancedate") == null) {
workitem.setItemValue("invoice.performancedate", workitem.getItemValueDate("invoice.date")); 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 * Returns {@code true} if the workitem represents a correction invoice.
*
* InvoiceHeader/Correction=true
* InvoiceHeader/CorrectionInvoiceNumber !empty
*
*
* @param workitem
* @return
*/ */
private boolean isKorrekturRechnung(ItemCollection workitem) { private boolean isKorrekturRechnung(ItemCollection workitem) {
return ("true".equals(workitem.getItemValueString("invoice.correction")) 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. * Builds a TradeParty (buyer/seller) from the business partner data.
* * <p>
* @param addressLines Liste der Adresszeilen * If the business partner is missing the {@code partner.vat} item, a
* @param type Der Typ der TradeParty * one-time migration step tries to read the VAT ID from the legacy
* @return TradeParty * {@code cargosoftkreditor} object.
*/ */
public TradeParty buildAddress(ItemCollection workitem, String type, EInvoiceModel model) throws PluginException { public TradeParty buildAddress(ItemCollection workitem, String type, EInvoiceModel model) throws PluginException {
@ -415,20 +314,16 @@ public class KSeFAdapter implements SignalAdapter {
if (!dNumber.isEmpty()) { if (!dNumber.isEmpty()) {
try { try {
String query = "(type:cargosoftkreditor) AND (name:" + dNumber + ")"; String query = "(type:cargosoftkreditor) AND (name:" + dNumber + ")";
List<ItemCollection> result; List<ItemCollection> result = documentService.find(query, 1, 0);
result = documentService.find(query, 1, 0);
if (result != null && result.size() > 0) { if (result != null && result.size() > 0) {
ItemCollection creditorData = result.get(0); ItemCollection creditorData = result.get(0);
String vatID = creditorData.getItemValueString("_vendor_vat_registration_id"); String vatID = creditorData.getItemValueString("_vendor_vat_registration_id");
logger.info("│ ├── synchronize VAT Registrytion ID: " + vatID); logger.info("│ ├── synchronize VAT Registration ID: " + vatID);
// update business partner
businessPartner.setItemValue("partner.vat", vatID); businessPartner.setItemValue("partner.vat", vatID);
documentService.saveByNewTransaction(businessPartner); documentService.saveByNewTransaction(businessPartner);
} }
} catch (Exception e) { } catch (Exception e) {
// should not happen!
logger.info("│ ├── ⚠️ Failed to sync BusinessPartner : " + e.getMessage()); logger.info("│ ├── ⚠️ Failed to sync BusinessPartner : " + e.getMessage());
} }
} }
} }
@ -448,87 +343,22 @@ public class KSeFAdapter implements SignalAdapter {
if (partnerVAT.isBlank()) { if (partnerVAT.isBlank()) {
// Just a warning // Just a warning
logger.warning("│ ├── ⚠️ Business Partner ID '" + partnerID + "' does not contain a VAT ID !"); 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); workitem.setItemValue("partner.vat", partnerVAT);
tradeParty.setVatNumber(partnerVAT); tradeParty.setVatNumber(partnerVAT);
// Update or create NrKlienta element directly under Podmiot2 // Update or create NrKlienta element directly under Podmiot2
Element podmiot2 = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Podmiot2"); Element podmiot2 = model.findOrCreateChildNode(model.getRoot(), EInvoiceNS.KSEF, "Podmiot2");
model.updateElementValue(podmiot2, EInvoiceNS.KSEF, "NrKlienta", model.updateElementValue(podmiot2, EInvoiceNS.KSEF, "NrKlienta",
businessPartner.getItemValueString("dbtr.number")); businessPartner.getItemValueString("dbtr.number"));
} }
return tradeParty; return tradeParty;
} }
/** /**
* Parses the data list of an invoice line and returns a TradeLineItem object. * Loads the e-invoice XML template referenced by the configuration.
* 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<String> 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) private FileData loadXMLTemplate(ItemCollection workitem, ItemCollection config)
throws PluginException { throws PluginException {
@ -539,10 +369,10 @@ public class KSeFAdapter implements SignalAdapter {
try { try {
debug = Boolean.parseBoolean(config.getItemValueString("debug")); debug = Boolean.parseBoolean(config.getItemValueString("debug"));
} catch (Exception e) { } catch (Exception e) {
// ignore - debug remains false
} }
String targetName = "ksef.xml"; String targetName = "ksef.xml";
// adapt text....
sourceName = workflowService.adaptText(sourceName, workitem); sourceName = workflowService.adaptText(sourceName, workitem);
if ((template == null || template.isEmpty()) || (textblock == null || textblock.isEmpty())) { 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!"); "invalid e-invoice configuration in model event - textblock/template reference not defined!");
} }
// load the text block
FileData fileData = loadTextBlockFileData(textblock, template); FileData fileData = loadTextBlockFileData(textblock, template);
// do we found the document?
if (fileData == null) { if (fileData == null) {
throw new PluginException(EInvoiceAdapter.class.getSimpleName(), throw new PluginException(EInvoiceAdapter.class.getSimpleName(),
CONFIG_ERROR, CONFIG_ERROR,
@ -567,26 +395,18 @@ public class KSeFAdapter implements SignalAdapter {
} }
/** /**
* This method returns a text-block ItemCollection for a specified name. * Returns a text-block FileData by name and filename.
*
* @param name in attribute txtname
*
*
*/ */
public FileData loadTextBlockFileData(String name, String fileName) { public FileData loadTextBlockFileData(String name, String fileName) {
ItemCollection textBlockItemCollection = null; ItemCollection textBlockItemCollection = null;
// load text-block by name....
String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")"; String sQuery = "(type:\"" + TYPE_TEXTBLOCK + "\" AND txtname:\"" + name + "\")";
Collection<ItemCollection> col; Collection<ItemCollection> col;
try { try {
// find the textblock...
col = documentService.find(sQuery, 1, 0); col = documentService.find(sQuery, 1, 0);
if (col.size() > 0) { if (col.size() > 0) {
textBlockItemCollection = col.iterator().next(); textBlockItemCollection = col.iterator().next();
// fetch the fileData...
return snapshotService.getWorkItemFile(textBlockItemCollection.getUniqueID(), fileName); return snapshotService.getWorkItemFile(textBlockItemCollection.getUniqueID(), fileName);
} else { } else {
logger.warning("Missing text-block : '" + name + "'"); logger.warning("Missing text-block : '" + name + "'");
} }
@ -596,5 +416,4 @@ public class KSeFAdapter implements SignalAdapter {
return null; return null;
} }
} }

View file

@ -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:
*
* <pre>
* {@code
<ksef name="create">
<textblock>textblock-ref</textblock>
<template>filename</template>
<debug>true</debug>
</ksef>
}
* </pre>
*
* <p>
* 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.
*
* <p>
* 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<ItemCollection> 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:
* <ul>
* <li>taxType "1" (Poland): P_13_1 (domestic VAT)</li>
* <li>taxType "2" (EU): P_13_6_2 (intra-community delivery, 0%)</li>
* <li>taxType "3" (Non-EU): P_13_6_3 (export, 0%)</li>
* </ul>
*/
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<ItemCollection> 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<String> 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<ItemCollection> 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;
}
}

View file

@ -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 <FaWiersz>} (line item) blocks and the
* {@code <Fa>}-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.
* <p>
* 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.
* <p>
* The builder runs in three sequential phases:
* <ol>
* <li><b>Line items:</b> for each position in {@code _childitems} a
* {@code <FaWiersz>} block is written. The CargoSoft VAT code drives the
* mapping into the correct {@code <P_12>} value and into the aggregation
* bucket.</li>
* <li><b>Summary fields:</b> aggregated net and VAT amounts per
* {@link TaxCode} are written into the corresponding
* {@code <P_13_x>}/{@code <P_14_x>} elements; {@code <P_15>} receives the
* gross total of all positions.</li>
* <li><b>Foreign currency:</b> if the invoice currency differs from PLN, a
* {@code <P_14_xW>} (VAT amount converted to PLN) is written for every
* populated VAT field, and {@code <KursWalutyZ>} is written after
* {@code <P_15>}.</li>
* </ol>
* <p>
* <b>Source of truth for amounts:</b> 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 <Fa>} 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<ItemCollection> 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<TaxCode, NetVatPair> 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 <FaWiersz>} block per invoice line and fills the
* aggregation buckets. Returns the gross total of all positions.
*/
private BigDecimal buildLineItems(
EInvoiceModelKSeF model,
Element elementFa,
List<ItemCollection> invoiceItems,
Map<TaxCode, NetVatPair> 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 <FaWiersz>} 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 <Fa>} element that must appear
* AFTER all {@code <FaWiersz>} blocks. {@code null} if none exists, in
* which case new {@code <FaWiersz>} 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 <P_7>}).
*/
@SuppressWarnings("unchecked")
private String buildDescription(ItemCollection invoiceItem) {
final int MAX_LENGTH = 512;
List<String> 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 <P_13_x>}/{@code <P_14_x>} elements of the {@code <Fa>} block,
* and writes {@code <P_15>} as the gross total.
*/
private void writeSummaryFields(
EInvoiceModelKSeF model,
Element elementFa,
Map<TaxCode, NetVatPair> aggregates,
BigDecimal grossTotal) {
for (Map.Entry<TaxCode, NetVatPair> 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 <P_14_xW>} fields (VAT amount converted to PLN) and
* the {@code <KursWalutyZ>} element after {@code <P_15>}.
*/
private void writeForeignCurrencyFields(
EInvoiceModelKSeF model,
Element elementFa,
Map<TaxCode, NetVatPair> aggregates,
BigDecimal exchangeRate) {
// For every aggregated VAT entry write the corresponding P_14_xW
for (Map.Entry<TaxCode, NetVatPair> 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));
}
}
}

View file

@ -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.
* <p>
* Each enum constant maps a CargoSoft VAT code (as it appears in the
* {@code cargosoft.vat.code} item of an invoice line) to:
* <ul>
* <li>the value to be written into the {@code <P_12>} element of a
* {@code <FaWiersz>} (line item) block,</li>
* <li>the target net summary field in the {@code <Fa>} block (one of
* {@code P_13_1}, {@code P_13_2}, {@code P_13_6_1}, {@code P_13_8},
* {@code P_13_9}),</li>
* <li>the optional target VAT summary field in the {@code <Fa>} block
* ({@code P_14_1}, {@code P_14_2}) - {@code null} for 0% / non-taxable
* codes.</li>
* </ul>
* <p>
* 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).
* <p>
* <b>Note on Java identifiers:</b> 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"}).
* <p>
* <b>Mixed code "023":</b> 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 <a href="http://crd.gov.pl/wzor/2025/06/25/13775/schemat.xsd">FA(3) XSD
* schema</a>
*/
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 <P_12>} element of a
* {@code <FaWiersz>} 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 <Fa>}
* 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 <Fa>}
* 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.
* <p>
* 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");
}
}

View file

@ -1,6 +1,7 @@
package com.alexanderlogistics.ksef.api; package com.alexanderlogistics.ksef.api;
import static org.junit.jupiter.api.Assertions.assertNotNull; 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.assertTrue;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -19,6 +20,7 @@ import java.util.logging.Logger;
import org.imixs.workflow.FileData; import org.imixs.workflow.FileData;
import org.imixs.workflow.ItemCollection; import org.imixs.workflow.ItemCollection;
import org.imixs.workflow.engine.DocumentService; import org.imixs.workflow.engine.DocumentService;
import org.imixs.workflow.exceptions.PluginException;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; 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 * Der Test simuliert eine Invoice und ruft dann den KSeFAdapter an um ein
* Output File zu erzeugen. * Output File zu erzeugen.
* <p> * <p>
* Zum validieren kann man dann den validate_xml.sh Script hernehmen * Zum validieren kann man dann den validate_xml.sh Script hernehmen.
* *
* <p>
* 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) @ExtendWith(MockitoExtension.class)
public class KSeFAdapterTest { 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 @Test
@DisplayName("Test Simple Invoice") @DisplayName("Test Simple Invoice (PLN, 23%)")
public void testSimpleInvoice() throws Exception { 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(); ItemCollection workitem = new ItemCollection();
workitem.setItemValue("invoice.number", "FV/2025/001"); workitem.setItemValue("invoice.number", "FV/2025/001");
workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10)); workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10));
@ -105,52 +111,55 @@ public class KSeFAdapterTest {
// Prepare child items (invoice line items) // Prepare child items (invoice line items)
List<Object> childItems = new ArrayList<>(); List<Object> childItems = new ArrayList<>();
ItemCollection lineItem1 = new ItemCollection(); ItemCollection lineItem1 = new ItemCollection();
lineItem1.setItemValue("numpos", "1"); lineItem1.setItemValue("numpos", "1");
lineItem1.setItemValue("datev.text", "Transport Berlin - Warsaw"); lineItem1.setItemValue("datev.text", "Transport Berlin - Warsaw");
lineItem1.setItemValue("billingtext", "Transport Berlin - Warsaw");
lineItem1.setItemValue("datev.umsatz", 6000.00); lineItem1.setItemValue("datev.umsatz", 6000.00);
lineItem1.setItemValue("datev.vatrate", 23.0);
lineItem1.setItemValue("cargosoft.vat.code", "23");
childItems.add(lineItem1.getAllItems()); childItems.add(lineItem1.getAllItems());
ItemCollection lineItem2 = new ItemCollection(); ItemCollection lineItem2 = new ItemCollection();
lineItem2.setItemValue("numpos", "2"); lineItem2.setItemValue("numpos", "2");
lineItem2.setItemValue("datev.text", "Customs handling"); lineItem2.setItemValue("datev.text", "Customs handling");
lineItem2.setItemValue("billingtext", "Customs handling");
lineItem2.setItemValue("datev.umsatz", 4000.00); lineItem2.setItemValue("datev.umsatz", 4000.00);
lineItem2.setItemValue("datev.vatrate", 23.0);
lineItem2.setItemValue("cargosoft.vat.code", "23");
childItems.add(lineItem2.getAllItems()); childItems.add(lineItem2.getAllItems());
workitem.setItemValue("_childitems", childItems); workitem.setItemValue("_childitems", childItems);
// Arrange
when(businessPartnerService.getBusinessPartnerByID("BP-001")) when(businessPartnerService.getBusinessPartnerByID("BP-001"))
.thenReturn(businessPartner); .thenReturn(businessPartner);
FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE);
// Act
adapter.updateEInvoice(xmlTemplate, workitem); adapter.updateEInvoice(xmlTemplate, workitem);
// Assert - basic checks
assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update"); assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update");
assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty"); assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty");
// Write output for manual inspection
writeOutputToResources(xmlTemplate, "invoice-simple.xml"); 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 @Test
@DisplayName("Test Invoice Korrektur") @DisplayName("Test Invoice Korrektur (EUR, 23%)")
public void testInvoiceKorektur() throws Exception { 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(); ItemCollection workitem = new ItemCollection();
workitem.setItemValue("invoice.number", "FV/2025/001"); workitem.setItemValue("invoice.number", "FV/2025/001");
workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10)); workitem.setItemValue("invoice.date", LocalDate.of(2025, 2, 10));
workitem.setItemValue("invoice.duedate", LocalDate.of(2025, 3, 10)); workitem.setItemValue("invoice.duedate", LocalDate.of(2025, 3, 10));
workitem.setItemValue("invoice.currency", "EUR"); workitem.setItemValue("invoice.currency", "EUR");
workitem.setItemValue("invoice.rate", 4.30);
workitem.setItemValue("invoice.total.net", 0.00); workitem.setItemValue("invoice.total.net", 0.00);
workitem.setItemValue("invoice.total.tax", 0.0); workitem.setItemValue("invoice.total.tax", 0.0);
workitem.setItemValue("invoice.total", 0.00); workitem.setItemValue("invoice.total", 0.00);
@ -162,7 +171,7 @@ public class KSeFAdapterTest {
workitem.setItemValue("invoice.correction", true); workitem.setItemValue("invoice.correction", true);
workitem.setItemValue("invoice.CorrectionInvoiceNumber", "6551"); workitem.setItemValue("invoice.CorrectionInvoiceNumber", "6551");
workitem.setItemValue("invoice.performancedate", new Date()); workitem.setItemValue("invoice.performancedate", new Date());
// Prepare child items (invoice line items)
List<Object> childItems = new ArrayList<>(); List<Object> childItems = new ArrayList<>();
ItemCollection lineItem1 = new ItemCollection() ItemCollection lineItem1 = new ItemCollection()
@ -173,93 +182,194 @@ public class KSeFAdapterTest {
.setItemValue("datev.text", "Transport Berlin - Warsaw") .setItemValue("datev.text", "Transport Berlin - Warsaw")
.setItemValue("billingtext", "usługa spedycyjna / transport w relacji, PL63 - CZ43") .setItemValue("billingtext", "usługa spedycyjna / transport w relacji, PL63 - CZ43")
.setItemValue("datev.umsatz", -900.00) .setItemValue("datev.umsatz", -900.00)
.setItemValue("datev.vatrate", 23.0)
.setItemValue("cargosoft.vat.code", "23")
.setItemValue("datev.wkz", "EUR") .setItemValue("datev.wkz", "EUR")
.setItemValue("billingcode", "TRANSLKW"); .setItemValue("billingcode", "TRANSLKW");
childItems.add(lineItem1.getAllItems()); childItems.add(lineItem1.getAllItems());
/**
* <NrWierszaFa>1</NrWierszaFa>
* <UU_ID>7f846436-7c29-48b3-8f5a-fed996ea30a3</UU_ID>
* <P_7>IM-POL-2512-008</P_7>
* <P_8A>szt.</P_8A>
* <P_8B>1</P_8B>
* <P_9A>0.00</P_9A>
* <P_11>0.00</P_11>
* </FaWiersz>
* <FaWiersz>
* <NrWierszaFa>2</NrWierszaFa>
* <UU_ID>f3077e37-9198-4431-a82b-68d9439b587e</UU_ID>
* <P_7>IM-POL-2512-008</P_7>
* <P_8A>szt.</P_8A>
* <P_8B>1</P_8B>
* <P_9A>-900.00</P_9A>
* <P_11>-900.00</P_11>
* </FaWiersz>
* <FaWiersz>
* <NrWierszaFa>3</NrWierszaFa>
* <UU_ID>31fdfe9e-671e-42f6-9f7e-ceb227a3a42c</UU_ID>
* <P_7>IM-POL-2512-008</P_7>
* <P_8A>szt.</P_8A>
* <P_8B>1</P_8B>
* <P_9A>0.00</P_9A>
* <P_11>0.00</P_11>
* </FaWiersz>
* <FaWiersz>
* <NrWierszaFa>4</NrWierszaFa>
* <UU_ID>79ffd1e8-be87-478b-97b5-d37aa302d090</UU_ID>
* <P_7>IM-POL-2512-008</P_7>
* <P_8A>szt.</P_8A>
* <P_8B>1</P_8B>
* <P_9A>0.00</P_9A>
* <P_11>0.00</P_11>
* </FaWiersz>
* <FaWiersz>
* <NrWierszaFa>5</NrWierszaFa>
* <UU_ID>38e8cbd3-e558-4b0e-875c-c4f2d96d1659</UU_ID>
* <P_7>IM-POL-2512-008</P_7>
* <P_8A>szt.</P_8A>
* <P_8B>1</P_8B>
* <P_9A>900.00</P_9A>
* <P_11>900.00</P_11>
* </FaWiersz>
* <FaWiersz>
* <NrWierszaFa>6</NrWierszaFa>
* <UU_ID>3774dab3-b6c4-433e-8298-60ecf6cbab74</UU_ID>
* <P_7>IM-POL-2512-008</P_7>
* <P_8A>szt.</P_8A>
* <P_8B>1</P_8B>
* <P_9A>0.00</P_9A>
* <P_11>0.00</P_11>
* </FaWiersz>
* <FaWiersz>
* <NrWierszaFa>7</NrWierszaFa>
* <UU_ID>f19846ab-3567-4e5d-823b-807d474af062</UU_ID>
* <P_7>IM-POL-2512-008</P_7>
* <P_8A>szt.</P_8A>
* <P_8B>1</P_8B>
* <P_9A>0.00</P_9A>
* <P_11>0.00</P_11>
*/
workitem.setItemValue("_childitems", childItems); workitem.setItemValue("_childitems", childItems);
// Arrange
when(businessPartnerService.getBusinessPartnerByID("BP-001")) when(businessPartnerService.getBusinessPartnerByID("BP-001"))
.thenReturn(businessPartner); .thenReturn(businessPartner);
FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE); FileData xmlTemplate = loadTemplateFromResources(TEMPLATE_FILE);
// Act
adapter.updateEInvoice(xmlTemplate, workitem); adapter.updateEInvoice(xmlTemplate, workitem);
// Assert - basic checks
assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update"); assertNotNull(xmlTemplate.getContent(), "XML content should not be null after update");
assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty"); assertTrue(xmlTemplate.getContent().length > 0, "XML content should not be empty");
// Write output for manual inspection
writeOutputToResources(xmlTemplate, "test-korrekturrechnung.xml"); writeOutputToResources(xmlTemplate, "test-korrekturrechnung.xml");
}
/**
* Mixed tax rates: 0% (Art. 83) + 23% on the same invoice in PLN.
* <p>
* 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<Object> 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.
* <p>
* This is the case from the tax advisor mapping document (invoice 7470)
* where code "NP" must map to {@code <P_12>np II</P_12>} and
* {@code <P_13_9>}, NOT to {@code <P_13_8>}. 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<Object> 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<Object> 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 // Helper methods
@ -293,4 +403,4 @@ public class KSeFAdapterTest {
System.out.println("Output written to: " + outputPath.toAbsolutePath()); System.out.println("Output written to: " + outputPath.toAbsolutePath());
System.out.println("──────────────────────────────────────────────"); System.out.println("──────────────────────────────────────────────");
} }
} }

View file

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8"?>
<Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/"
xmlns:etd="http://crd.gov.pl/xml/schematy/dziedzinowe/mf/2022/01/05/eD/DefinicjeTypy/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Naglowek>
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
<WariantFormularza>3</WariantFormularza>
<DataWytworzeniaFa>2026-04-27T22:36:23.680605Z</DataWytworzeniaFa>
<SystemInfo>Imixs eInvoice</SystemInfo>
</Naglowek>
<!-- Seller (Your Polish Company - Pre-filled) -->
<Podmiot1>
<PrefiksPodatnika>PL</PrefiksPodatnika>
<DaneIdentyfikacyjne>
<NIP>9552521552</NIP>
<Nazwa>ALEXANDER GLOBAL LOGISTICS POLAND sp. z o.o.</Nazwa>
</DaneIdentyfikacyjne>
<Adres>
<KodKraju>PL</KodKraju>
<AdresL1>ul. Gdańska 36, 70-660 Szczecin</AdresL1>
</Adres>
<DaneKontaktowe>
<Email>MBudas@alexander-logistics.com</Email>
</DaneKontaktowe>
</Podmiot1>
<!-- Buyer (Customer - Empty, to be filled) -->
<Podmiot2>
<DaneIdentyfikacyjne>
<!-- NIP falls eine PL Ust Vorliegt
ansonsten NrID - geht immer -->
<!-- <Nazwa>#Customer Name#</Nazwa> -->
<NrID>DE310968950</NrID>
<Nazwa>Muster GmbH</Nazwa>
</DaneIdentyfikacyjne>
<Adres>
<KodKraju>DE</KodKraju>
<AdresL1>Museumstr. 2-6</AdresL1>
<AdresL2>28195 Bremen</AdresL2>
</Adres>
<!---
Contact information from buyer can be left
<DaneKontaktowe>
<Email></Email>
<Telefon></Telefon>
</DaneKontaktowe>
-->
<NrKlienta>D-18804</NrKlienta>
<JST>2</JST>
<!-- fixed -->
<GV>2</GV>
<!-- fixed -->
</Podmiot2>
<!-- Invoice Data -->
<Fa>
<KodWaluty>EUR</KodWaluty>
<P_1>2025-02-10</P_1>
<!-- Invoice Date -->
<P_1M>Szczecin</P_1M>
<P_2>FV/2025/7470</P_2>
<!-- Invoice Number -->
<P_6>2026-04-27</P_6>
<!-- Due Date -->
<!-- Totals will only be computed by the EInvoiceModelKSeF Class-->
<!--
<P_13_1>0.00</P_13_1>
<P_14_1>0.00</P_14_1>
<P_15>0.00</P_15>
-->
<Adnotacje>
<!-- 1 yes - 2 no -->
<P_16>2</P_16>
<!-- Keine Selbstfakturierung -->
<P_17>2</P_17>
<!-- Kein Reverse Charge -->
<P_18>2</P_18>
<!-- Kein Split Payment (Rechnung unter 15.000 PLN/EUR) -->
<P_18A>2</P_18A>
<!-- Keine Steuerbefreiung -->
<Zwolnienie>
<P_19N>1</P_19N>
</Zwolnienie>
<!-- Keine neuen Verkehrsmittel -->
<NoweSrodkiTransportu>
<P_22N>1</P_22N>
</NoweSrodkiTransportu>
<!-- Kein vereinfachtes Verfahren nach Art. 135 -->
<P_23>2</P_23>
<!-- Keine Margenregelung -->
<PMarzy>
<P_PMarzyN>1</P_PMarzyN>
</PMarzy>
</Adnotacje>
<!-- Invoice Type (VAT) -->
<RodzajFaktury>VAT</RodzajFaktury>
<!-- Invoice Positions: FaWiersz -->
<FaWiersz>
<NrWierszaFa>1</NrWierszaFa>
<P_7>International transport service Germany - Poland</P_7>
<P_8A>szt.</P_8A>
<P_8B>1.00</P_8B>
<P_9A>4620.00</P_9A>
<P_11>4620.00</P_11>
<P_11Vat>0.00</P_11Vat>
<P_11A>4620.00</P_11A>
<P_12>np II</P_12>
</FaWiersz>
<Platnosc>
<!-- Setzen der Zahlungsart (immer 6)
* 1 Barzahlung
* 2 Karte
* 3 Gutschein
* 4 Scheck
* 5 Kredit
* 6 Überweisung
* 7 Mobilzahlung
-->
<TerminPlatnosci>
<Termin>2025-03-10</Termin>
</TerminPlatnosci>
<FormaPlatnosci>6</FormaPlatnosci>
<!-- BANKEN-->
<RachunekBankowy>
<NrRB>PL79116022020000000654306674</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto PLN</OpisRachunku>
</RachunekBankowy>
<RachunekBankowy>
<NrRB>PL16116022020000000654910469</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto EUR</OpisRachunku>
</RachunekBankowy>
<RachunekBankowy>
<NrRB>PL72116022020000000654911595</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto USD</OpisRachunku>
</RachunekBankowy>
</Platnosc>
<P_13_9>4620.00</P_13_9>
<P_15>4620.00</P_15>
<KursWalutyZ>4.3</KursWalutyZ>
</Fa>
</Faktura>

View file

@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/"
xmlns:etd="http://crd.gov.pl/xml/schematy/dziedzinowe/mf/2022/01/05/eD/DefinicjeTypy/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Naglowek>
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
<WariantFormularza>3</WariantFormularza>
<DataWytworzeniaFa>2026-04-27T22:36:23.965831Z</DataWytworzeniaFa>
<SystemInfo>Imixs eInvoice</SystemInfo>
</Naglowek>
<!-- Seller (Your Polish Company - Pre-filled) -->
<Podmiot1>
<PrefiksPodatnika>PL</PrefiksPodatnika>
<DaneIdentyfikacyjne>
<NIP>9552521552</NIP>
<Nazwa>ALEXANDER GLOBAL LOGISTICS POLAND sp. z o.o.</Nazwa>
</DaneIdentyfikacyjne>
<Adres>
<KodKraju>PL</KodKraju>
<AdresL1>ul. Gdańska 36, 70-660 Szczecin</AdresL1>
</Adres>
<DaneKontaktowe>
<Email>MBudas@alexander-logistics.com</Email>
</DaneKontaktowe>
</Podmiot1>
<!-- Buyer (Customer - Empty, to be filled) -->
<Podmiot2>
<DaneIdentyfikacyjne>
<!-- NIP falls eine PL Ust Vorliegt
ansonsten NrID - geht immer -->
<!-- <Nazwa>#Customer Name#</Nazwa> -->
<NIP>1234567890</NIP>
<Nazwa>Test Sp. z o.o.</Nazwa>
</DaneIdentyfikacyjne>
<Adres>
<KodKraju>PL</KodKraju>
<AdresL1>ul. Testowa 1</AdresL1>
<AdresL2>00-001 Warszawa</AdresL2>
</Adres>
<!---
Contact information from buyer can be left
<DaneKontaktowe>
<Email></Email>
<Telefon></Telefon>
</DaneKontaktowe>
-->
<NrKlienta>D-12345</NrKlienta>
<JST>2</JST>
<!-- fixed -->
<GV>2</GV>
<!-- fixed -->
</Podmiot2>
<!-- Invoice Data -->
<Fa>
<KodWaluty>PLN</KodWaluty>
<P_1>2025-02-10</P_1>
<!-- Invoice Date -->
<P_1M>Szczecin</P_1M>
<P_2>FV/2025/7471</P_2>
<!-- Invoice Number -->
<P_6>2026-04-27</P_6>
<!-- Due Date -->
<!-- Totals will only be computed by the EInvoiceModelKSeF Class-->
<!--
<P_13_1>0.00</P_13_1>
<P_14_1>0.00</P_14_1>
<P_15>0.00</P_15>
-->
<Adnotacje>
<!-- 1 yes - 2 no -->
<P_16>2</P_16>
<!-- Keine Selbstfakturierung -->
<P_17>2</P_17>
<!-- Kein Reverse Charge -->
<P_18>2</P_18>
<!-- Kein Split Payment (Rechnung unter 15.000 PLN/EUR) -->
<P_18A>2</P_18A>
<!-- Keine Steuerbefreiung -->
<Zwolnienie>
<P_19N>1</P_19N>
</Zwolnienie>
<!-- Keine neuen Verkehrsmittel -->
<NoweSrodkiTransportu>
<P_22N>1</P_22N>
</NoweSrodkiTransportu>
<!-- Kein vereinfachtes Verfahren nach Art. 135 -->
<P_23>2</P_23>
<!-- Keine Margenregelung -->
<PMarzy>
<P_PMarzyN>1</P_PMarzyN>
</PMarzy>
</Adnotacje>
<!-- Invoice Type (VAT) -->
<RodzajFaktury>VAT</RodzajFaktury>
<!-- Invoice Positions: FaWiersz -->
<FaWiersz>
<NrWierszaFa>1</NrWierszaFa>
<P_7>Forwarding service for export shipment</P_7>
<P_8A>szt.</P_8A>
<P_8B>1.00</P_8B>
<P_9A>14040.00</P_9A>
<P_11>14040.00</P_11>
<P_11Vat>0.00</P_11Vat>
<P_11A>14040.00</P_11A>
<P_12>0 KR</P_12>
</FaWiersz>
<FaWiersz>
<NrWierszaFa>2</NrWierszaFa>
<P_7>Krajowa obsługa transportu</P_7>
<P_8A>szt.</P_8A>
<P_8B>1.00</P_8B>
<P_9A>3960.00</P_9A>
<P_11>3960.00</P_11>
<P_11Vat>910.80</P_11Vat>
<P_11A>4870.80</P_11A>
<P_12>23</P_12>
</FaWiersz>
<Platnosc>
<!-- Setzen der Zahlungsart (immer 6)
* 1 Barzahlung
* 2 Karte
* 3 Gutschein
* 4 Scheck
* 5 Kredit
* 6 Überweisung
* 7 Mobilzahlung
-->
<TerminPlatnosci>
<Termin>2025-03-10</Termin>
</TerminPlatnosci>
<FormaPlatnosci>6</FormaPlatnosci>
<!-- BANKEN-->
<RachunekBankowy>
<NrRB>PL79116022020000000654306674</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto PLN</OpisRachunku>
</RachunekBankowy>
<RachunekBankowy>
<NrRB>PL16116022020000000654910469</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto EUR</OpisRachunku>
</RachunekBankowy>
<RachunekBankowy>
<NrRB>PL72116022020000000654911595</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto USD</OpisRachunku>
</RachunekBankowy>
</Platnosc>
<P_13_6_1>14040.00</P_13_6_1>
<P_13_1>3960.00</P_13_1>
<P_14_1>910.80</P_14_1>
<P_15>18910.80</P_15>
</Fa>
</Faktura>

View file

@ -6,7 +6,7 @@
<Naglowek> <Naglowek>
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza> <KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
<WariantFormularza>3</WariantFormularza> <WariantFormularza>3</WariantFormularza>
<DataWytworzeniaFa>2026-04-27T16:20:53.265515Z</DataWytworzeniaFa> <DataWytworzeniaFa>2026-04-27T22:36:24.034379Z</DataWytworzeniaFa>
<SystemInfo>Imixs eInvoice</SystemInfo> <SystemInfo>Imixs eInvoice</SystemInfo>
</Naglowek> </Naglowek>
<!-- Seller (Your Polish Company - Pre-filled) --> <!-- Seller (Your Polish Company - Pre-filled) -->
@ -67,9 +67,6 @@
<P_14_1>0.00</P_14_1> <P_14_1>0.00</P_14_1>
<P_15>0.00</P_15> <P_15>0.00</P_15>
--> -->
<P_13_1>10000.00</P_13_1>
<P_14_1>2300.00</P_14_1>
<P_15>12300.00</P_15>
<Adnotacje> <Adnotacje>
<!-- 1 yes - 2 no --> <!-- 1 yes - 2 no -->
<P_16>2</P_16> <P_16>2</P_16>
@ -99,17 +96,25 @@
<!-- Invoice Positions: FaWiersz --> <!-- Invoice Positions: FaWiersz -->
<FaWiersz> <FaWiersz>
<NrWierszaFa>1</NrWierszaFa> <NrWierszaFa>1</NrWierszaFa>
<P_7>Transport Berlin - Warsaw</P_7>
<P_8A>szt.</P_8A> <P_8A>szt.</P_8A>
<P_8B>1</P_8B> <P_8B>1.00</P_8B>
<P_9A>6000.00</P_9A> <P_9A>6000.00</P_9A>
<P_11>6000.00</P_11> <P_11>6000.00</P_11>
<P_11Vat>1380.00</P_11Vat>
<P_11A>7380.00</P_11A>
<P_12>23</P_12>
</FaWiersz> </FaWiersz>
<FaWiersz> <FaWiersz>
<NrWierszaFa>2</NrWierszaFa> <NrWierszaFa>2</NrWierszaFa>
<P_7>Customs handling</P_7>
<P_8A>szt.</P_8A> <P_8A>szt.</P_8A>
<P_8B>1</P_8B> <P_8B>1.00</P_8B>
<P_9A>4000.00</P_9A> <P_9A>4000.00</P_9A>
<P_11>4000.00</P_11> <P_11>4000.00</P_11>
<P_11Vat>920.00</P_11Vat>
<P_11A>4920.00</P_11A>
<P_12>23</P_12>
</FaWiersz> </FaWiersz>
<Platnosc> <Platnosc>
@ -139,6 +144,15 @@
<NazwaBanku>Bank Millennium S.A.</NazwaBanku> <NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto EUR</OpisRachunku> <OpisRachunku>Konto EUR</OpisRachunku>
</RachunekBankowy> </RachunekBankowy>
<RachunekBankowy>
<NrRB>PL72116022020000000654911595</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto USD</OpisRachunku>
</RachunekBankowy>
</Platnosc> </Platnosc>
<P_13_1>10000.00</P_13_1>
<P_14_1>2300.00</P_14_1>
<P_15>12300.00</P_15>
</Fa> </Fa>
</Faktura> </Faktura>

View file

@ -6,19 +6,19 @@
<Naglowek> <Naglowek>
<KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza> <KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
<WariantFormularza>3</WariantFormularza> <WariantFormularza>3</WariantFormularza>
<DataWytworzeniaFa>2026-04-16T06:22:32.198172335Z</DataWytworzeniaFa> <DataWytworzeniaFa>2026-04-27T22:36:23.999774Z</DataWytworzeniaFa>
<SystemInfo>Imixs eInvoice</SystemInfo> <SystemInfo>Imixs eInvoice</SystemInfo>
</Naglowek> </Naglowek>
<!-- Seller (Your Polish Company - Pre-filled) --> <!-- Seller (Your Polish Company - Pre-filled) -->
<Podmiot1> <Podmiot1>
<PrefiksPodatnika>PL</PrefiksPodatnika>
<DaneIdentyfikacyjne> <DaneIdentyfikacyjne>
<NIP>9552521552</NIP> <NIP>9552521552</NIP>
<Nazwa>Alexander Global Logistics</Nazwa> <Nazwa>ALEXANDER GLOBAL LOGISTICS POLAND sp. z o.o.</Nazwa>
</DaneIdentyfikacyjne> </DaneIdentyfikacyjne>
<Adres> <Adres>
<KodKraju>PL</KodKraju> <KodKraju>PL</KodKraju>
<AdresL1>Gdanska 36</AdresL1> <AdresL1>ul. Gdańska 36, 70-660 Szczecin</AdresL1>
<AdresL2>70-660 Szczecin</AdresL2>
</Adres> </Adres>
<DaneKontaktowe> <DaneKontaktowe>
<Email>MBudas@alexander-logistics.com</Email> <Email>MBudas@alexander-logistics.com</Email>
@ -56,9 +56,10 @@
<KodWaluty>EUR</KodWaluty> <KodWaluty>EUR</KodWaluty>
<P_1>2025-02-10</P_1> <P_1>2025-02-10</P_1>
<!-- Invoice Date --> <!-- Invoice Date -->
<P_1M>Szczecin</P_1M>
<P_2>FV/2025/001</P_2> <P_2>FV/2025/001</P_2>
<!-- Invoice Number --> <!-- Invoice Number -->
<P_6>2025-03-10</P_6> <P_6>2026-04-27</P_6>
<!-- Due Date --> <!-- Due Date -->
<!-- Totals will only be computed by the EInvoiceModelKSeF Class--> <!-- Totals will only be computed by the EInvoiceModelKSeF Class-->
<!-- <!--
@ -66,9 +67,6 @@
<P_14_1>0.00</P_14_1> <P_14_1>0.00</P_14_1>
<P_15>0.00</P_15> <P_15>0.00</P_15>
--> -->
<P_13_1>0.00</P_13_1>
<P_14_1>0.00</P_14_1>
<P_15>0.00</P_15>
<Adnotacje> <Adnotacje>
<!-- 1 yes - 2 no --> <!-- 1 yes - 2 no -->
<P_16>2</P_16> <P_16>2</P_16>
@ -103,13 +101,54 @@
</DaneFaKorygowanej> </DaneFaKorygowanej>
<FaWiersz> <FaWiersz>
<NrWierszaFa>1</NrWierszaFa> <NrWierszaFa>1</NrWierszaFa>
<UU_ID>d437ac36-9439-4bd6-96fb-0fe0b91fd32d</UU_ID>
<P_7>usługa spedycyjna / transport w relacji, PL63 - CZ43</P_7> <P_7>usługa spedycyjna / transport w relacji, PL63 - CZ43</P_7>
<P_8A>szt.</P_8A> <P_8A>szt.</P_8A>
<P_8B>1</P_8B> <P_8B>1.00</P_8B>
<P_9A>-900.00</P_9A> <P_9A>-900.00</P_9A>
<P_11>-900.00</P_11> <P_11>-900.00</P_11>
<P_12>0</P_12> <P_11Vat>-207.00</P_11Vat>
<P_11A>-1107.00</P_11A>
<P_12>23</P_12>
</FaWiersz> </FaWiersz>
<Platnosc>
<!-- Setzen der Zahlungsart (immer 6)
* 1 Barzahlung
* 2 Karte
* 3 Gutschein
* 4 Scheck
* 5 Kredit
* 6 Überweisung
* 7 Mobilzahlung
-->
<TerminPlatnosci>
<Termin>2025-03-10</Termin>
</TerminPlatnosci>
<FormaPlatnosci>6</FormaPlatnosci>
<!-- BANKEN-->
<RachunekBankowy>
<NrRB>PL79116022020000000654306674</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto PLN</OpisRachunku>
</RachunekBankowy>
<RachunekBankowy>
<NrRB>PL16116022020000000654910469</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto EUR</OpisRachunku>
</RachunekBankowy>
<RachunekBankowy>
<NrRB>PL72116022020000000654911595</NrRB>
<SWIFT>BIGBPLPWXXX</SWIFT>
<NazwaBanku>Bank Millennium S.A.</NazwaBanku>
<OpisRachunku>Konto USD</OpisRachunku>
</RachunekBankowy>
</Platnosc>
<P_13_1>-900.00</P_13_1>
<P_14_1>-207.00</P_14_1>
<P_14_1W>-890.10</P_14_1W>
<P_15>-1107.00</P_15>
<KursWalutyZ>4.3</KursWalutyZ>
</Fa> </Fa>
</Faktura> </Faktura>