Compare commits

...

10 Commits

20 changed files with 918 additions and 65 deletions

View File

@ -10,21 +10,28 @@ kotlin {
val mustangVersion: String by project
val angusMailVersion: String by project
val klfVersion: String by project
val assertKVersion: String by project
val xunitVersion: String by project
val logbackVersion: String by project
dependencies {
implementation("org.mustangproject:library:$mustangVersion")
implementation("org.eclipse.angus:angus-mail:$angusMailVersion")
implementation("net.codinux.log:klf:$klfVersion")
testImplementation(kotlin("test"))
implementation("com.willowtreeapps.assertk:assertk:$assertKVersion")
testImplementation("com.willowtreeapps.assertk:assertk:$assertKVersion")
testImplementation("org.xmlunit:xmlunit-core:$xunitVersion")
testImplementation("ch.qos.logback:logback-classic:$logbackVersion")
}

View File

@ -0,0 +1,73 @@
package net.codinux.invoicing.converter
import net.codinux.invoicing.creation.EInvoiceCreator
import net.codinux.invoicing.model.Invoice
import org.mustangproject.CII.CIIToUBL
import org.mustangproject.ZUGFeRD.ZUGFeRDVisualizer
import java.io.File
class EInvoiceConverter {
fun convertInvoiceToHtml(invoice: Invoice, outputFile: File, language: ZUGFeRDVisualizer.Language = ZUGFeRDVisualizer.Language.DE) =
convertInvoiceToHtml(createXRechnungXml(invoice), outputFile, language)
fun convertInvoiceToHtml(invoiceXml: String, outputFile: File, language: ZUGFeRDVisualizer.Language = ZUGFeRDVisualizer.Language.DE): String {
val xmlFile = File.createTempFile("Zugferd", ".xml")
.also { it.writeText(invoiceXml) }
val visualizer = ZUGFeRDVisualizer()
val html = visualizer.visualize(xmlFile.absolutePath, language)
outputFile.writeText(html)
copyResource("xrechnung-viewer.css", outputFile, ".css")
copyResource("xrechnung-viewer.js", outputFile, ".js")
xmlFile.delete()
return html
}
/**
* Converts a CII (Cross Industry Invoice) invoice, e.g. a Zugferd or Factur-X invoice, to UBL (Universal Business Language).
*/
fun convertCiiToUbl(invoice: Invoice) = convertCiiToUbl(createXRechnungXml(invoice))
/**
* Converts a CII (Cross Industry Invoice) invoice, e.g. a Zugferd or Factur-X invoice, to UBL (Universal Business Language).
*/
fun convertCiiToUbl(invoiceXml: String): String {
// TODO: extract a common method for this
val xmlFile = File.createTempFile("Zugferd", ".xml")
.also { it.writeText(invoiceXml) }
val ublFile = File(xmlFile.parentFile, xmlFile.nameWithoutExtension + "-ubl.xml")
convertCiiToUbl(xmlFile, ublFile)
val ubl = ublFile.readText()
xmlFile.delete()
ublFile.delete()
return ubl
}
/**
* Converts a CII (Cross Industry Invoice) invoice, e.g. a Zugferd or Factur-X invoice, to UBL (Universal Business Language).
*/
fun convertCiiToUbl(xmlFile: File, outputFile: File) {
val cii2Ubl = CIIToUBL()
cii2Ubl.convert(xmlFile, outputFile)
}
private fun createXRechnungXml(invoice: Invoice): String = EInvoiceCreator().createXRechnungXml(invoice)
private fun copyResource(resourceName: String, outputFile: File, outputFileExtension: String) {
javaClass.classLoader.getResourceAsStream(resourceName).use {
it?.copyTo(File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream())
}
}
}

View File

@ -1,10 +1,9 @@
package net.codinux.invoicing.creation
import net.codinux.invoicing.mapper.MustangMapper
import net.codinux.invoicing.model.Invoice
import org.mustangproject.ZUGFeRD.IXMLProvider
import org.mustangproject.ZUGFeRD.Profiles
import org.mustangproject.ZUGFeRD.ZUGFeRD2PullProvider
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA3
import org.mustangproject.ZUGFeRD.*
import java.io.File
class EInvoiceCreator(
private val mapper: MustangMapper = MustangMapper()
@ -17,14 +16,40 @@ class EInvoiceCreator(
return createXml(provider, invoice)
}
fun createZugferdXml(invoice: Invoice, zugferdVersion: Int = 2): String {
fun createZugferdXml(invoice: Invoice): String {
val exporter = ZUGFeRDExporterFromA3()
.setZUGFeRDVersion(zugferdVersion)
.setProfile("EN16931")
.setProfile("EN16931") // required for XML?
return createXml(exporter.provider, invoice)
}
fun createZugferdPdf(invoice: Invoice, outputFile: File) {
val xml = createZugferdXml(invoice)
val xmlFile = File.createTempFile(outputFile.nameWithoutExtension, ".xml")
.also { it.writeText(xml) }
val pdfFile = File(xmlFile.parentFile, xmlFile.nameWithoutExtension + ".pdf")
val visualizer = ZUGFeRDVisualizer()
visualizer.toPDF(xmlFile.absolutePath, pdfFile.absolutePath)
combinePdfAndXml(pdfFile, xml, outputFile)
xmlFile.delete()
pdfFile.delete()
}
fun combinePdfAndXml(pdfFile: File, xml: String, outputFile: File) {
val exporter = ZUGFeRDExporterFromA3()
.setZUGFeRDVersion(2)
.setProfile("EN16931") // available values: MINIMUM, BASICWL, BASIC, CIUS, EN16931, EXTENDED, XRECHNUNG
// .disableFacturX()
.setProducer("danki die geile Sau")
.setCreator(System.getProperty("user.name"))
return createXml(exporter.provider, invoice)
exporter.load(pdfFile.inputStream())
exporter.setXML(xml.toByteArray())
exporter.export(outputFile.outputStream())
}

View File

@ -0,0 +1,16 @@
package net.codinux.invoicing.mail
class MailAccount(
val username: String,
val password: String,
/**
* For reading mails the IMAP server address, for sending mails the SMTP server address.
*/
val serverAddress: String,
/**
* Even though not mandatory it's better to specify the port, otherwise default port is tried.
*/
val port: Int? = null
) {
override fun toString() = "$username $serverAddress${port?.let { ":$it" } ?: ""}"
}

View File

@ -0,0 +1,13 @@
package net.codinux.invoicing.mail
import net.codinux.invoicing.model.Invoice
import java.io.File
class MailAttachmentWithEInvoice(
val filename: String,
val contentType: String,
val invoice: Invoice,
val file: File
) {
override fun toString() = "$filename: $invoice"
}

View File

@ -0,0 +1,131 @@
package net.codinux.invoicing.mail
import jakarta.mail.BodyPart
import jakarta.mail.Folder
import jakarta.mail.Part
import jakarta.mail.Session
import jakarta.mail.internet.MimeMultipart
import net.codinux.invoicing.model.Invoice
import net.codinux.invoicing.reader.EInvoiceReader
import net.codinux.log.logger
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
import java.util.*
class MailReader(
private val eInvoiceReader: EInvoiceReader = EInvoiceReader()
) {
private val extractionErrorMessages = mutableSetOf<String?>()
private val extractionErrors = mutableSetOf<Throwable>()
private val log by logger()
fun listAllMessagesWithEInvoice(account: MailAccount): List<MailWithInvoice> {
val properties = mapAccountToJavaMailProperties(account)
try {
val session = Session.getInstance(properties)
session.getStore("imap").use { store ->
store.connect(account.serverAddress, account.username, account.password)
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_ONLY)
return listAllMessagesWithEInvoiceInFolder(inbox).also {
inbox.close(false)
}
}
} catch (e: Throwable) {
log.error(e) { "Could not read mails of account $account" }
}
return emptyList()
}
private fun mapAccountToJavaMailProperties(account: MailAccount) = Properties().apply {
put("mail.store.protocol", "imap")
put("mail.imap.host", account.serverAddress)
put("mail.imap.port", account.port?.toString() ?: "993") // Default IMAP over SSL
put("mail.imap.ssl.enable", "true")
put("mail.imap.connectiontimeout", "5000")
put("mail.imap.timeout", "5000")
}
private fun listAllMessagesWithEInvoiceInFolder(folder: Folder): List<MailWithInvoice> = folder.messages.mapNotNull { message ->
try {
if (message.isMimeType("multipart/*")) {
val multipart = message.content as MimeMultipart
val parts = IntRange(0, multipart.count - 1).map { multipart.getBodyPart(it) }
val attachmentsWithEInvoice = parts.mapNotNull { part ->
findEInvoice(part)
}
if (attachmentsWithEInvoice.isNotEmpty()) {
return@mapNotNull MailWithInvoice(message.from.joinToString(), message.subject, map(message.sentDate), attachmentsWithEInvoice)
}
}
} catch (e: Throwable) {
log.error(e) { "Could not read mail $message" }
}
null
}
private fun findEInvoice(part: BodyPart): MailAttachmentWithEInvoice? {
try {
if (part.disposition == Part.ATTACHMENT) {
val invoice = tryToReadEInvoice(part)
if (invoice != null) {
var contentType = part.contentType
val indexOfSeparator = contentType.indexOf(';')
if (indexOfSeparator > -1) {
contentType = contentType.substring(0, indexOfSeparator)
}
val filename = File(part.fileName)
val file = File.createTempFile(filename.nameWithoutExtension, filename.extension).also { file ->
part.inputStream.use { it.copyTo(file.outputStream()) }
}
return MailAttachmentWithEInvoice(part.fileName, contentType, invoice, file)
}
}
} catch (e: Throwable) {
log.error(e) { "Could not check attachment '${part.fileName}' for eInvoice" }
}
return null
}
private fun tryToReadEInvoice(part: BodyPart): Invoice? {
val filename = part.fileName.lowercase()
val contentType = part.contentType.lowercase()
return if (filename.endsWith(".pdf") || contentType.startsWith("application/pdf") || contentType.startsWith("application/octet-stream")) {
try {
eInvoiceReader.extractFromPdf(part.inputStream)
} catch (e: Throwable) {
extractionErrorMessages.add(e.message)
extractionErrors.add(e)
null
}
} else if (filename.endsWith(".xml") || contentType.startsWith("application/xml") || contentType.startsWith("text/xml")) {
eInvoiceReader.readFromXml(part.inputStream)
} else {
null
}
}
// TODO: same code as in MustangMapper
private fun map(date: Date): LocalDate =
date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
}

View File

@ -0,0 +1,12 @@
package net.codinux.invoicing.mail
import java.time.LocalDate
class MailWithInvoice(
val sender: String,
val subject: String,
val date: LocalDate,
val attachmentsWithEInvoice: List<MailAttachmentWithEInvoice>
) {
override fun toString() = "$date $sender: $subject, ${attachmentsWithEInvoice.size} invoices"
}

View File

@ -29,7 +29,7 @@ class MustangMapper {
fun mapParty(party: Party): TradeParty = TradeParty(
party.name, party.street, party.postalCode, party.city, party.countryIsoCode
).apply {
this.taxID = party.vatId
this.setVATID(party.vatId)
// TODO: description?
this.email = party.email
@ -53,6 +53,30 @@ class MustangMapper {
}
fun mapToInvoice(invoice: Invoice) = net.codinux.invoicing.model.Invoice(
invoiceNumber = invoice.number,
invoicingDate = map(invoice.issueDate),
sender = mapParty(invoice.sender),
recipient = mapParty(invoice.recipient),
items = invoice.zfItems.map { mapLineItem(it) },
dueDate = map(invoice.dueDate ?: invoice.paymentTerms?.dueDate),
paymentDescription = invoice.paymentTermDescription ?: invoice.paymentTerms?.description,
buyerReference = invoice.referenceNumber
)
fun mapParty(party: TradeParty) = Party(
party.name, party.street, party.zip, party.location, party.country, party.vatID,
party.email ?: party.contact?.eMail, party.contact?.phone, party.contact?.fax, party.contact?.name,
party.bankDetails?.firstOrNull()?.let { net.codinux.invoicing.model.BankDetails(it.iban, it.bic, it.accountName) }
)
fun mapLineItem(item: IZUGFeRDExportableItem) = LineItem(
item.product.name, item.product.unit, item.quantity, item.price, item.product.vatPercent, item.product.description.takeUnless { it.isBlank() }
)
@JvmName("mapNullable")
private fun map(date: LocalDate?) =
date?.let { map(it) }
@ -63,4 +87,11 @@ class MustangMapper {
private fun mapToInstant(date: LocalDate): Instant =
date.atStartOfDay(ZoneId.systemDefault()).toInstant()
@JvmName("mapNullable")
private fun map(date: Date?) =
date?.let { map(it) }
private fun map(date: Date): LocalDate =
date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
}

View File

@ -0,0 +1,54 @@
package net.codinux.invoicing.reader
import net.codinux.invoicing.mapper.MustangMapper
import net.codinux.invoicing.model.Invoice
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
import java.io.File
import java.io.InputStream
class EInvoiceReader(
private val mapper: MustangMapper = MustangMapper()
) {
fun readFromXml(xmlFile: File) = readFromXml(xmlFile.inputStream())
fun readFromXml(stream: InputStream) = readFromXml(stream.reader().readText())
fun readFromXml(xml: String): Invoice {
val importer = ZUGFeRDInvoiceImporter() // XRechnungImporter only reads properties but not to a Invoice object
importer.fromXML(xml)
return extractInvoice(importer)
}
fun extractFromPdf(pdfFile: File) = extractFromPdf(pdfFile.inputStream())
fun extractFromPdf(stream: InputStream): Invoice {
val importer = ZUGFeRDInvoiceImporter(stream)
return extractInvoice(importer)
}
fun extractXmlFromPdf(pdfFile: File) = extractXmlFromPdf(pdfFile.inputStream())
fun extractXmlFromPdf(stream: InputStream): String {
val importer = ZUGFeRDInvoiceImporter(stream)
return String(importer.rawXML, Charsets.UTF_8)
}
private fun extractInvoice(importer: ZUGFeRDInvoiceImporter): Invoice {
val invoice = importer.extractInvoice()
// TODO: the values LineTotalAmount, ChargeTotalAmount, AllowanceTotalAmount, TaxBasisTotalAmount, TaxTotalAmount,
// GrandTotalAmount, TotalPrepaidAmount adn DuePayableAmount are not extracted from XML document
// we could use TransactionCalculator to manually calculate these values - Importer also does this and asserts
// that its calculated value matches XML doc's GrandTotalAmount value. But then we would have to make some
// methods of TransactionCalculator public
// Another option would be to manually extract these values from XML document.
return mapper.mapToInvoice(invoice)
}
}

View File

@ -0,0 +1,36 @@
package net.codinux.invoicing.converter
import assertk.assertThat
import assertk.assertions.isNotEmpty
import net.codinux.invoicing.test.DataGenerator
import java.io.File
import kotlin.test.Test
class EInvoiceConverterTest {
private val underTest = EInvoiceConverter()
@Test
fun convertInvoiceToHtml() {
val invoice = createInvoice()
val testFile = File.createTempFile("Zugferd", ".html")
val result = underTest.convertInvoiceToHtml(invoice, testFile)
assertThat(result).isNotEmpty()
}
@Test
fun convertCiiToUbl() {
val invoice = createInvoice()
val result = underTest.convertCiiToUbl(invoice)
assertThat(result).isNotEmpty()
}
private fun createInvoice() = DataGenerator.createInvoice()
}

View File

@ -1,10 +1,9 @@
package net.codinux.invoicing.creation
import assertk.assertThat
import assertk.assertions.isNotEmpty
import net.codinux.invoicing.test.DataGenerator
import net.codinux.invoicing.test.XPathAsserter
import java.math.BigDecimal
import net.codinux.invoicing.test.InvoiceAsserter
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
import java.io.File
import kotlin.test.Test
class EInvoiceCreatorTest {
@ -30,51 +29,24 @@ class EInvoiceCreatorTest {
assertInvoiceXml(result)
}
@Test
fun createZugferdPdf() {
val invoice = createInvoice()
val testFile = File.createTempFile("Zugferd", ".pdf")
underTest.createZugferdPdf(invoice, testFile)
val importer = ZUGFeRDInvoiceImporter(testFile.inputStream())
val xml = String(importer.rawXML, Charsets.UTF_8)
assertInvoiceXml(xml)
}
private fun createInvoice() = DataGenerator.createInvoice()
private fun assertInvoiceXml(xml: String) {
assertThat(xml).isNotEmpty()
val asserter = XPathAsserter(xml)
asserter.xpathHasValue("//rsm:ExchangedDocument/ram:ID", DataGenerator.InvoiceNumber)
asserter.xpathHasValue("//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString", DataGenerator.InvoicingDate.toString().replace("-", ""))
val senderXPath = "//rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty"
assertParty(asserter, senderXPath, DataGenerator.SenderName, DataGenerator.SenderStreet, DataGenerator.SenderPostalCode, DataGenerator.SenderCity, DataGenerator.SenderVatId, DataGenerator.SenderEmail, DataGenerator.SenderPhone)
val receiverXPath = "//rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty"
assertParty(asserter, receiverXPath, DataGenerator.RecipientName, DataGenerator.RecipientStreet, DataGenerator.RecipientPostalCode, DataGenerator.RecipientCity, DataGenerator.RecipientVatId, DataGenerator.RecipientEmail, DataGenerator.RecipientPhone)
val lineItemXPath = "//rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem"
assertLineItem(asserter, lineItemXPath, DataGenerator.ItemName, DataGenerator.ItemUnit, DataGenerator.ItemQuantity, DataGenerator.ItemPrice, DataGenerator.ItemVat, DataGenerator.ItemDescription)
}
private fun assertParty(asserter: XPathAsserter, partyXPath: String, name: String, street: String, postalCode: String, city: String, vatId: String, email: String, phone: String) {
asserter.xpathHasValue("$partyXPath/ram:Name", name)
asserter.xpathHasValue("$partyXPath/ram:PostalTradeAddress/ram:LineOne", street)
asserter.xpathHasValue("$partyXPath/ram:PostalTradeAddress/ram:PostcodeCode", postalCode)
asserter.xpathHasValue("$partyXPath/ram:PostalTradeAddress/ram:CityName", city)
asserter.xpathHasValue("$partyXPath/ram:SpecifiedTaxRegistration/ram:ID", vatId)
asserter.xpathHasValue("$partyXPath/ram:URIUniversalCommunication/ram:URIID", email)
asserter.xpathHasValue("$partyXPath/ram:DefinedTradeContact/ram:EmailURIUniversalCommunication/ram:URIID", email)
asserter.xpathHasValue("$partyXPath/ram:DefinedTradeContact/ram:TelephoneUniversalCommunication/ram:CompleteNumber", phone)
}
private fun assertLineItem(asserter: XPathAsserter, partyXPath: String, name: String, unit: String, quantity: BigDecimal, price: BigDecimal, vatPercentage: BigDecimal, description: String?) {
asserter.xpathHasValue("$partyXPath/ram:SpecifiedTradeProduct/ram:Name", name)
asserter.xpathHasValue("$partyXPath/ram:SpecifiedLineTradeDelivery/ram:BilledQuantity/@unitCode", unit)
asserter.xpathHasValue("$partyXPath/ram:SpecifiedLineTradeDelivery/ram:BilledQuantity", quantity, 4)
asserter.xpathHasValue("$partyXPath/ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount", price, 2)
asserter.xpathHasValue("$partyXPath/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent", vatPercentage, 2)
// asserter.xpathHasValue("$partyXPath/ram:URIUniversalCommunication/ram:URIID", description)
InvoiceAsserter.assertInvoiceXml(xml)
}
}

View File

@ -0,0 +1,32 @@
package net.codinux.invoicing.mail
import assertk.assertThat
import assertk.assertions.isNotEmpty
import org.junit.jupiter.api.Test
import kotlin.test.Ignore
@Ignore // not an automatic test, set your mail account settings below
class MailReaderTest {
companion object {
// specify your mail account here
private val mailAccount = MailAccount(
username = "",
password = "",
serverAddress = "",
port = null // can be left as null if default port 993 is used
)
}
private val underTest = MailReader()
@Test
fun listAllMessagesWithEInvoice() {
val result = underTest.listAllMessagesWithEInvoice(mailAccount)
assertThat(result).isNotEmpty()
}
}

View File

@ -0,0 +1,42 @@
package net.codinux.invoicing.reader
import net.codinux.invoicing.model.Invoice
import net.codinux.invoicing.test.InvoiceAsserter
import java.io.InputStream
import kotlin.test.Test
class EInvoiceReaderTest {
private val underTest = EInvoiceReader()
@Test
fun readFromXml() {
val result = underTest.readFromXml(getTestFile("XRechnung.xml"))
assertInvoice(result)
}
@Test
fun extractFromPdf() {
val result = underTest.extractFromPdf(getTestFile("ZUGFeRD.pdf"))
assertInvoice(result)
}
@Test
fun extractXmlFromPdf() {
val result = underTest.extractXmlFromPdf(getTestFile("ZUGFeRD.pdf"))
InvoiceAsserter.assertInvoiceXml(result)
}
private fun getTestFile(filename: String): InputStream =
this.javaClass.classLoader.getResourceAsStream("files/$filename")!!
private fun assertInvoice(invoice: Invoice?) {
InvoiceAsserter.assertInvoice(invoice)
}
}

View File

@ -19,27 +19,26 @@ object DataGenerator {
const val SenderPostalCode = "12345"
const val SenderCity = "Glückstadt"
const val SenderCountry = "DE"
const val SenderVatId = "DE12345678"
const val SenderVatId = "DE123456789"
const val SenderEmail = "working-class-hero@rock.me"
const val SenderPhone = "+4917012345678"
const val SenderAccountId = "DE00123456780987654321"
const val SenderBankCode = "12345678"
const val SenderAccountHolderName = "Manuela Musterfrau"
val SenderBankDetails = BankDetails("DE00123456780987654321", "ABZODEFFXXX", "Manuela Musterfrau")
const val RecipientName = "Untertänigster Leistungsempfänger"
const val RecipientStreet = "Party Street 1"
const val RecipientPostalCode = SenderPostalCode
const val RecipientCity = SenderCity
const val RecipientCountry = "DE"
const val RecipientVatId = "DE87654321"
const val RecipientVatId = "DE987654321"
const val RecipientEmail = "exploiter@your.boss"
const val RecipientPhone = "+4912345678"
const val RecipientPhone = "+491234567890"
val RecipientBankDetails: BankDetails? = null
const val ItemName = "Erbrachte Dienstleistungen"
const val ItemUnit = "HUR" // EN code for 'hour'
val ItemQuantity = BigDecimal(1)
val ItemPrice = BigDecimal(99)
val ItemVat = BigDecimal(0.19)
val ItemVatPercentage = BigDecimal(19)
val ItemDescription: String? = null
@ -47,8 +46,9 @@ object DataGenerator {
invoiceNumber: String = InvoiceNumber,
invoicingDate: LocalDate = InvoicingDate,
sender: Party = createParty(SenderName, SenderStreet, SenderPostalCode, SenderCity, SenderCountry, SenderVatId, SenderEmail, SenderPhone,
bankDetails = BankDetails(SenderAccountId, SenderBankCode, SenderAccountHolderName)),
recipient: Party = createParty(RecipientName, RecipientStreet, RecipientPostalCode, RecipientCity, RecipientCountry, RecipientVatId, RecipientEmail, RecipientPhone),
bankDetails = SenderBankDetails),
recipient: Party = createParty(RecipientName, RecipientStreet, RecipientPostalCode, RecipientCity, RecipientCountry, RecipientVatId, RecipientEmail, RecipientPhone,
bankDetails = RecipientBankDetails),
items: List<LineItem> = listOf(createItem()),
dueDate: LocalDate? = DueDate,
paymentDescription: String? = dueDate?.let { "Zahlbar ohne Abzug bis ${DateTimeFormatter.ofPattern("dd.MM.yyyy").format(dueDate)}" },
@ -74,7 +74,7 @@ object DataGenerator {
unit: String = ItemUnit,
quantity: BigDecimal = ItemQuantity,
price: BigDecimal = ItemPrice,
vatPercentage: BigDecimal = ItemVat,
vatPercentage: BigDecimal = ItemVatPercentage,
description: String? = ItemDescription,
) = LineItem(name, unit, quantity, price, vatPercentage, description)

View File

@ -0,0 +1,107 @@
package net.codinux.invoicing.test
import assertk.assertThat
import assertk.assertions.*
import net.codinux.invoicing.model.BankDetails
import net.codinux.invoicing.model.Invoice
import net.codinux.invoicing.model.LineItem
import net.codinux.invoicing.model.Party
import java.math.BigDecimal
object InvoiceAsserter {
fun assertInvoiceXml(xml: String) {
assertThat(xml).isNotEmpty()
val asserter = XPathAsserter(xml)
asserter.xpathHasValue("//rsm:ExchangedDocument/ram:ID", DataGenerator.InvoiceNumber)
asserter.xpathHasValue("//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString", DataGenerator.InvoicingDate.toString().replace("-", ""))
val senderXPath = "//rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty"
assertParty(asserter, senderXPath, DataGenerator.SenderName, DataGenerator.SenderStreet, DataGenerator.SenderPostalCode, DataGenerator.SenderCity, DataGenerator.SenderVatId, DataGenerator.SenderEmail, DataGenerator.SenderPhone)
val receiverXPath = "//rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty"
assertParty(asserter, receiverXPath, DataGenerator.RecipientName, DataGenerator.RecipientStreet, DataGenerator.RecipientPostalCode, DataGenerator.RecipientCity, DataGenerator.RecipientVatId, DataGenerator.RecipientEmail, DataGenerator.RecipientPhone)
val lineItemXPath = "//rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem"
assertLineItem(asserter, lineItemXPath, DataGenerator.ItemName, DataGenerator.ItemUnit, DataGenerator.ItemQuantity, DataGenerator.ItemPrice, DataGenerator.ItemVatPercentage, DataGenerator.ItemDescription)
}
private fun assertParty(asserter: XPathAsserter, partyXPath: String, name: String, street: String, postalCode: String, city: String, vatId: String, email: String, phone: String) {
asserter.xpathHasValue("$partyXPath/ram:Name", name)
asserter.xpathHasValue("$partyXPath/ram:PostalTradeAddress/ram:LineOne", street)
asserter.xpathHasValue("$partyXPath/ram:PostalTradeAddress/ram:PostcodeCode", postalCode)
asserter.xpathHasValue("$partyXPath/ram:PostalTradeAddress/ram:CityName", city)
asserter.xpathHasValue("$partyXPath/ram:SpecifiedTaxRegistration/ram:ID", vatId)
asserter.xpathHasValue("$partyXPath/ram:URIUniversalCommunication/ram:URIID", email)
asserter.xpathHasValue("$partyXPath/ram:DefinedTradeContact/ram:EmailURIUniversalCommunication/ram:URIID", email)
asserter.xpathHasValue("$partyXPath/ram:DefinedTradeContact/ram:TelephoneUniversalCommunication/ram:CompleteNumber", phone)
}
private fun assertLineItem(asserter: XPathAsserter, itemXPath: String, name: String, unit: String, quantity: BigDecimal, price: BigDecimal, vatPercentage: BigDecimal, description: String?) {
asserter.xpathHasValue("$itemXPath/ram:SpecifiedTradeProduct/ram:Name", name)
asserter.xpathHasValue("$itemXPath/ram:SpecifiedLineTradeDelivery/ram:BilledQuantity/@unitCode", unit)
asserter.xpathHasValue("$itemXPath/ram:SpecifiedLineTradeDelivery/ram:BilledQuantity", quantity, 4)
asserter.xpathHasValue("$itemXPath/ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount", price, 2)
asserter.xpathHasValue("$itemXPath/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent", vatPercentage, 2)
// asserter.xpathHasValue("$partyXPath/ram:URIUniversalCommunication/ram:URIID", description)
}
fun assertInvoice(invoice: Invoice?) {
assertThat(invoice).isNotNull()
assertThat(invoice!!.invoiceNumber).isEqualTo(DataGenerator.InvoiceNumber)
assertThat(invoice.invoicingDate).isEqualTo(DataGenerator.InvoicingDate)
assertParty(invoice.sender, DataGenerator.SenderName, DataGenerator.SenderStreet, DataGenerator.SenderPostalCode, DataGenerator.SenderCity, DataGenerator.SenderCountry, DataGenerator.SenderVatId, DataGenerator.SenderEmail, DataGenerator.SenderPhone, DataGenerator.SenderBankDetails)
assertParty(invoice.recipient, DataGenerator.RecipientName, DataGenerator.RecipientStreet, DataGenerator.RecipientPostalCode, DataGenerator.RecipientCity, DataGenerator.RecipientCountry, DataGenerator.RecipientVatId, DataGenerator.RecipientEmail, DataGenerator.RecipientPhone, DataGenerator.RecipientBankDetails)
assertThat(invoice.items).hasSize(1)
assertLineItem(invoice.items.first(), DataGenerator.ItemName, DataGenerator.ItemUnit, DataGenerator.ItemQuantity, DataGenerator.ItemPrice, DataGenerator.ItemVatPercentage, DataGenerator.ItemDescription)
}
private fun assertParty(party: Party, name: String, street: String, postalCode: String, city: String, country: String?, vatId: String, email: String, phone: String, bankDetails: BankDetails?) {
assertThat(party.name).isEqualTo(name)
assertThat(party.street).isEqualTo(street)
assertThat(party.postalCode).isEqualTo(postalCode)
assertThat(party.city).isEqualTo(city)
assertThat(party.countryIsoCode).isEqualTo(country)
assertThat(party.vatId).isEqualTo(vatId)
assertThat(party.email).isEqualTo(email)
assertThat(party.phone).isEqualTo(phone)
if (bankDetails == null) {
assertThat(party.bankDetails).isNull()
} else {
assertThat(party.bankDetails!!.accountNumber).isEqualTo(bankDetails.accountNumber)
assertThat(party.bankDetails!!.bankCode).isEqualTo(bankDetails.bankCode)
// due to a bug in Mustang accountName doesn't get extracted from XML, see https://github.com/ZUGFeRD/mustangproject/issues/558
// assertThat(party.bankDetails!!.accountHolderName).isEqualTo(bankDetails.accountHolderName)
}
}
private fun assertLineItem(item: LineItem, name: String, unit: String, quantity: BigDecimal, price: BigDecimal, vatPercentage: BigDecimal, description: String?) {
assertThat(item.name).isEqualTo(name)
assertThat(item.unit).isEqualTo(unit)
assertThat(item.quantity).isEqualTo(quantity.setScale(4))
assertThat(item.price).isEqualTo(price.setScale(4))
assertThat(item.vatPercentage).isEqualTo(vatPercentage.setScale(2))
// assertThat(item.description).isEqualTo(description)
}
}

View File

@ -0,0 +1,140 @@
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100">
<!-- generated by: mustangproject.org vnull-->
<rsm:ExchangedDocumentContext>
<ram:BusinessProcessSpecifiedDocumentContextParameter>
<ram:ID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</ram:ID>
</ram:BusinessProcessSpecifiedDocumentContextParameter>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>12345</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20151021</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>1</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>Erbrachte Dienstleistungen</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>99.0000</ram:ChargeAmount>
<ram:BasisQuantity unitCode="HUR">1.0000</ram:BasisQuantity>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="HUR">1.0000</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>99.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>Hochwürdiger Leistungserbringer</ram:Name>
<ram:DefinedTradeContact>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>+4917012345678</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID>working-class-hero@rock.me</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>12345</ram:PostcodeCode>
<ram:LineOne>Fun Street 1</ram:LineOne>
<ram:CityName>Glückstadt</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="EM">working-class-hero@rock.me</ram:URIID>
</ram:URIUniversalCommunication>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE123456789</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>Untertänigster Leistungsempfänger</ram:Name>
<ram:DefinedTradeContact>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>+491234567890</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID>exploiter@your.boss</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>12345</ram:PostcodeCode>
<ram:LineOne>Party Street 1</ram:LineOne>
<ram:CityName>Glückstadt</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="EM">exploiter@your.boss</ram:URIID>
</ram:URIUniversalCommunication>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE987654321</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery/>
<ram:ApplicableHeaderTradeSettlement>
<ram:PaymentReference>12345</ram:PaymentReference>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>58</ram:TypeCode>
<ram:Information>SEPA credit transfer</ram:Information>
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID>DE00123456780987654321</ram:IBANID>
<ram:AccountName>Manuela Musterfrau</ram:AccountName>
</ram:PayeePartyCreditorFinancialAccount>
<ram:PayeeSpecifiedCreditorFinancialInstitution>
<ram:BICID>ABZODEFFXXX</ram:BICID>
</ram:PayeeSpecifiedCreditorFinancialInstitution>
</ram:SpecifiedTradeSettlementPaymentMeans>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>18.81</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:BasisAmount>99.00</ram:BasisAmount>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradePaymentTerms>
<ram:Description>Zahlbar ohne Abzug bis 15.06.2016</ram:Description>
<ram:DueDateDateTime>
<udt:DateTimeString format="102">20160615</udt:DateTimeString>
</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>99.00</ram:LineTotalAmount>
<ram:ChargeTotalAmount>0.00</ram:ChargeTotalAmount>
<ram:AllowanceTotalAmount>0.00</ram:AllowanceTotalAmount>
<ram:TaxBasisTotalAmount>99.00</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="EUR">18.81</ram:TaxTotalAmount>
<ram:GrandTotalAmount>117.81</ram:GrandTotalAmount>
<ram:TotalPrepaidAmount>0.00</ram:TotalPrepaidAmount>
<ram:DuePayableAmount>117.81</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>

View File

@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100">
<!-- generated by: mustangproject.org vnull-->
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>12345</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20151021</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>1</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>Erbrachte Dienstleistungen</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>99.0000</ram:ChargeAmount>
<ram:BasisQuantity unitCode="HUR">1.0000</ram:BasisQuantity>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="HUR">1.0000</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>99.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>Hochwürdiger Leistungserbringer</ram:Name>
<ram:DefinedTradeContact>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>+4917012345678</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID>working-class-hero@rock.me</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>12345</ram:PostcodeCode>
<ram:LineOne>Fun Street 1</ram:LineOne>
<ram:CityName>Glückstadt</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="EM">working-class-hero@rock.me</ram:URIID>
</ram:URIUniversalCommunication>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE123456789</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>Untertänigster Leistungsempfänger</ram:Name>
<ram:DefinedTradeContact>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>+491234567890</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID>exploiter@your.boss</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>12345</ram:PostcodeCode>
<ram:LineOne>Party Street 1</ram:LineOne>
<ram:CityName>Glückstadt</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="EM">exploiter@your.boss</ram:URIID>
</ram:URIUniversalCommunication>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE987654321</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery/>
<ram:ApplicableHeaderTradeSettlement>
<ram:PaymentReference>12345</ram:PaymentReference>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>58</ram:TypeCode>
<ram:Information>SEPA credit transfer</ram:Information>
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID>DE00123456780987654321</ram:IBANID>
<ram:AccountName>Manuela Musterfrau</ram:AccountName>
</ram:PayeePartyCreditorFinancialAccount>
<ram:PayeeSpecifiedCreditorFinancialInstitution>
<ram:BICID>ABZODEFFXXX</ram:BICID>
</ram:PayeeSpecifiedCreditorFinancialInstitution>
</ram:SpecifiedTradeSettlementPaymentMeans>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>18.81</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:BasisAmount>99.00</ram:BasisAmount>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradePaymentTerms>
<ram:Description>Zahlbar ohne Abzug bis 15.06.2016</ram:Description>
<ram:DueDateDateTime>
<udt:DateTimeString format="102">20160615</udt:DateTimeString>
</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>99.00</ram:LineTotalAmount>
<ram:ChargeTotalAmount>0.00</ram:ChargeTotalAmount>
<ram:AllowanceTotalAmount>0.00</ram:AllowanceTotalAmount>
<ram:TaxBasisTotalAmount>99.00</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="EUR">18.81</ram:TaxTotalAmount>
<ram:GrandTotalAmount>117.81</ram:GrandTotalAmount>
<ram:TotalPrepaidAmount>0.00</ram:TotalPrepaidAmount>
<ram:DuePayableAmount>117.81</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>

View File

@ -0,0 +1,24 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>DEBUG</level>
</filter>
</appender>
<!-- Insert the current time formatted as "yyyyMMdd'T'HHmmss" under
the key "bySecond" into the logger context. This value will be
available to all subsequent configuration elements. -->
<timestamp key="bySecond" datePattern="yyyyMMdd'T'HHmmss"/>
<root level="ALL">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@ -7,7 +7,11 @@ kotlinVersion=1.9.25
mustangVersion=2.14.2
angusMailVersion=2.0.3
klfVersion=1.6.2
# only used for tests
logbackVersion=1.5.12
assertKVersion=0.28.1
xunitVersion=2.10.0