libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 17bfb9cb1fe43bc4e8f939306e059cba3827f714
parent a35695866144b15d7ecd02af201371cf0c073721
Author: Antoine A <>
Date:   Sun, 15 Feb 2026 22:54:25 +0100

nexus: expect debtor name to consider a transaction complete as exchange
require it

Diffstat:
Mlibeufin-common/src/main/kotlin/TalerCommon.kt | 140++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 4++--
Mlibeufin-nexus/src/test/kotlin/helpers.kt | 4++--
Mtestbench/src/test/kotlin/IntegrationTest.kt | 12++++++------
4 files changed, 85 insertions(+), 75 deletions(-)

diff --git a/libeufin-common/src/main/kotlin/TalerCommon.kt b/libeufin-common/src/main/kotlin/TalerCommon.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -33,10 +33,10 @@ import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit import org.bouncycastle.math.ec.rfc8032.Ed25519 -sealed class CommonError(msg: String): Exception(msg) { - class AmountFormat(msg: String): CommonError(msg) - class AmountNumberTooBig(msg: String): CommonError(msg) - class Payto(msg: String): CommonError(msg) +sealed class CommonError(msg: String) : Exception(msg) { + class AmountFormat(msg: String) : CommonError(msg) + class AmountNumberTooBig(msg: String) : CommonError(msg) + class Payto(msg: String) : CommonError(msg) } /** @@ -48,9 +48,9 @@ sealed class CommonError(msg: String): Exception(msg) { value class RelativeTime(val duration: Duration) { private object Serializer : KSerializer<RelativeTime> { override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("RelativeTime") { - element<JsonElement>("d_us") - } + buildClassSerialDescriptor("RelativeTime") { + element<JsonElement>("d_us") + } override fun serialize(encoder: Encoder, value: RelativeTime) { val composite = encoder.beginStructure(descriptor) @@ -99,9 +99,9 @@ value class RelativeTime(val duration: Duration) { value class TalerTimestamp constructor(val instant: Instant) { private object Serializer : KSerializer<TalerTimestamp> { override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("Timestamp") { - element<JsonElement>("t_s") - } + buildClassSerialDescriptor("Timestamp") { + element<JsonElement>("t_s") + } override fun serialize(encoder: Encoder, value: TalerTimestamp) { val composite = encoder.beginStructure(descriptor) @@ -112,7 +112,7 @@ value class TalerTimestamp constructor(val instant: Instant) { } composite.endStructure(descriptor) } - + override fun deserialize(decoder: Decoder): TalerTimestamp { val dec = decoder.beginStructure(descriptor) val jsonInput = dec as? JsonDecoder ?: error("Can be deserialized only by JSON") @@ -165,7 +165,7 @@ value class BaseURL private constructor(val url: URL) { private object Serializer : KSerializer<BaseURL> { override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("BaseURL", PrimitiveKind.STRING) + PrimitiveSerialDescriptor("BaseURL", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: BaseURL) { encoder.encodeString(value.url.toString()) @@ -186,19 +186,18 @@ class DecimalNumber { this.value = value this.frac = frac } + constructor(encoded: String) { val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format") val (value, frac) = match.destructured - this.value = value.toLongOrNull() ?: - throw badRequest("Invalid value") - if (this.value > TalerAmount.MAX_VALUE) + this.value = value.toLongOrNull() ?: throw badRequest("Invalid value") + if (this.value > TalerAmount.MAX_VALUE) throw badRequest("Value specified in decimal number is too large") this.frac = if (frac.isEmpty()) { 0 } else { - var tmp = frac.toIntOrNull() ?: - throw badRequest("Invalid fractional value") - if (tmp > TalerAmount.FRACTION_BASE) + var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value") + if (tmp > TalerAmount.FRACTION_BASE) throw badRequest("Fractional value specified in decimal number is too large") repeat(8 - frac.length) { tmp *= 10 @@ -227,11 +226,11 @@ class DecimalNumber { private object Serializer : KSerializer<DecimalNumber> { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING) - + override fun serialize(encoder: Encoder, value: DecimalNumber) { encoder.encodeString(value.toString()) } - + override fun deserialize(decoder: Decoder): DecimalNumber { return DecimalNumber(decoder.decodeString()) } @@ -244,7 +243,7 @@ class DecimalNumber { } @Serializable(with = TalerAmount.Serializer::class) -class TalerAmount: Comparable<TalerAmount> { +class TalerAmount : Comparable<TalerAmount> { val value: Long val frac: Int val currency: String @@ -254,26 +253,24 @@ class TalerAmount: Comparable<TalerAmount> { this.frac = frac this.currency = currency } + constructor(encoded: String) { - val match = PATTERN.matchEntire(encoded) ?: - throw CommonError.AmountFormat("Invalid amount format") + val match = PATTERN.matchEntire(encoded) ?: throw CommonError.AmountFormat("Invalid amount format") val (currency, value, frac) = match.destructured this.currency = currency - this.value = value.toLongOrNull() ?: - throw CommonError.AmountFormat("Invalid value") - if (this.value > MAX_VALUE) + this.value = value.toLongOrNull() ?: throw CommonError.AmountFormat("Invalid value") + if (this.value > MAX_VALUE) throw CommonError.AmountNumberTooBig("Value specified in amount is too large") this.frac = if (frac.isEmpty()) { 0 } else { - var tmp = frac.toIntOrNull() ?: - throw CommonError.AmountFormat("Invalid fractional value") - if (tmp > FRACTION_BASE) + var tmp = frac.toIntOrNull() ?: throw CommonError.AmountFormat("Invalid fractional value") + if (tmp > FRACTION_BASE) throw CommonError.AmountFormat("Fractional value specified in amount is too large") repeat(8 - frac.length) { tmp *= 10 } - + tmp } } @@ -316,7 +313,7 @@ class TalerAmount: Comparable<TalerAmount> { operator fun plus(increment: TalerAmount): TalerAmount { require(this.currency == increment.currency) { "currency mismatch ${this.currency} != ${increment.currency}" } val value = Math.addExact(this.value, increment.value) - val frac = Math.addExact(this.frac, increment.frac) + val frac = Math.addExact(this.frac, increment.frac) return TalerAmount(value, frac, currency).normalize() } @@ -339,15 +336,15 @@ class TalerAmount: Comparable<TalerAmount> { private object Serializer : KSerializer<TalerAmount> { override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) - + PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: TalerAmount) { encoder.encodeString(value.toString()) } - - override fun deserialize(decoder: Decoder): TalerAmount { - return TalerAmount(decoder.decodeString()) - } + + override fun deserialize(decoder: Decoder): TalerAmount = + TalerAmount(decoder.decodeString()) + } companion object { @@ -357,7 +354,7 @@ class TalerAmount: Comparable<TalerAmount> { private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?") fun zero(currency: String) = TalerAmount(0, 0, currency) - fun max(currency: String) = TalerAmount(MAX_VALUE, FRACTION_BASE-1, currency) + fun max(currency: String) = TalerAmount(MAX_VALUE, FRACTION_BASE - 1, currency) } } @@ -435,7 +432,7 @@ sealed class Payto { return when (parsed.host) { "iban" -> { - val splitPath = parsed.path.split("/", limit=3).filter { it.isNotEmpty() } + val splitPath = parsed.path.split("/", limit = 3).filter { it.isNotEmpty() } val (bic, rawIban) = when (splitPath.size) { 1 -> Pair(null, splitPath[0]) 2 -> Pair(splitPath[0], splitPath[1]) @@ -443,29 +440,31 @@ sealed class Payto { } val iban = IBAN.parse(rawIban) IbanPayto( - parsed, + parsed, "payto://iban/$iban", - amount, + amount, message, receiverName, bic, iban ) } + "x-taler-bank" -> { - val splitPath = parsed.path.split("/", limit=3).filter { it.isNotEmpty() } + val splitPath = parsed.path.split("/", limit = 3).filter { it.isNotEmpty() } if (splitPath.size != 2) throw CommonError.Payto("bad number of path segments for a x-taler-bank payto URI") val username = splitPath[1] XTalerBankPayto( - parsed, + parsed, "payto://x-taler-bank/localhost/$username", - amount, + amount, message, receiverName, username ) } + else -> throw CommonError.Payto("unsupported payto URI kind '${parsed.host}'") } } @@ -481,7 +480,7 @@ class IbanPayto internal constructor( override val receiverName: String?, val bic: String?, val iban: IBAN -): Payto() { +) : Payto() { override fun toString(): String = parsed.toString() /** Format an IbanPayto in a more human readable way */ @@ -525,9 +524,15 @@ class IbanPayto internal constructor( return "payto://iban/$bic$iban$name" } - fun rand(): IbanPayto { - return parse("payto://iban/SANDBOXX/${IBAN.rand(Country.DE)}").expectIban() - } + fun rand(name: String? = null): IbanPayto = parse( + "payto://iban/SANDBOXX/${IBAN.rand(Country.DE)}${ + if (name != null) { + "?receiver-name=${name.encodeURLParameter()}" + } else { + "" + } + }" + ).expectIban() } } @@ -538,7 +543,7 @@ class XTalerBankPayto internal constructor( override val message: String?, override val receiverName: String?, val username: String -): Payto() { +) : Payto() { override fun toString(): String = parsed.toString() companion object { @@ -563,7 +568,7 @@ class Base32Crockford16B { constructor(encoded: String) { val decoded = try { - Base32Crockford.decode(encoded) + Base32Crockford.decode(encoded) } catch (e: IllegalArgumentException) { null } @@ -573,6 +578,7 @@ class Base32Crockford16B { this.raw = decoded this.encoded = encoded } + constructor(raw: ByteArray) { require(raw.size == 16) { "encoded data should be 16 bytes long" @@ -593,12 +599,13 @@ class Base32Crockford16B { override fun equals(other: Any?) = (other is Base32Crockford16B) && raw.contentEquals(other.raw) internal object Serializer : KSerializer<Base32Crockford16B> { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford16B", PrimitiveKind.STRING) - + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Base32Crockford16B", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Base32Crockford16B) { encoder.encodeString(value.encoded()) } - + override fun deserialize(decoder: Decoder): Base32Crockford16B { return Base32Crockford16B(decoder.decodeString()) } @@ -618,7 +625,7 @@ class Base32Crockford32B { constructor(encoded: String) { val decoded = try { - Base32Crockford.decode(encoded) + Base32Crockford.decode(encoded) } catch (e: IllegalArgumentException) { null } @@ -628,6 +635,7 @@ class Base32Crockford32B { this.raw = decoded this.encoded = encoded } + constructor(raw: ByteArray) { require(raw.size == 32) { "encoded data should be 32 bytes long" @@ -648,12 +656,13 @@ class Base32Crockford32B { override fun equals(other: Any?) = (other is Base32Crockford32B) && raw.contentEquals(other.raw) internal object Serializer : KSerializer<Base32Crockford32B> { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford32B", PrimitiveKind.STRING) - + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Base32Crockford32B", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Base32Crockford32B) { encoder.encodeString(value.encoded()) } - + override fun deserialize(decoder: Decoder): Base32Crockford32B { return Base32Crockford32B(decoder.decodeString()) } @@ -680,17 +689,18 @@ class Base32Crockford64B { constructor(encoded: String) { val decoded = try { - Base32Crockford.decode(encoded) + Base32Crockford.decode(encoded) } catch (e: IllegalArgumentException) { null } - + require(decoded != null && decoded.size == 64) { "expected 64 bytes encoded in Crockford's base32" } this.raw = decoded this.encoded = encoded } + constructor(raw: ByteArray) { require(raw.size == 64) { "encoded data should be 64 bytes long" @@ -711,12 +721,13 @@ class Base32Crockford64B { override fun equals(other: Any?) = (other is Base32Crockford64B) && raw.contentEquals(other.raw) private object Serializer : KSerializer<Base32Crockford64B> { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford64B", PrimitiveKind.STRING) - + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Base32Crockford64B", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Base32Crockford64B) { encoder.encodeString(value.encoded()) } - + override fun deserialize(decoder: Decoder): Base32Crockford64B { return Base32Crockford64B(decoder.decodeString()) } @@ -736,4 +747,4 @@ typealias HashCode = Base32Crockford64B * and represented using the standard 256 bits Ed25519 compact format, * converted to Crockford Base32. */ -typealias EddsaPublicKey = Base32Crockford32B -\ No newline at end of file +typealias EddsaPublicKey = Base32Crockford32B diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -165,7 +165,7 @@ suspend fun registerIncomingPayment( } } // Check we have enough info to handle this transaction - if (payment.debtor == null) { + if (payment.debtor == null || payment.debtor.receiverName == null) { val res = db.payment.registerIncoming(payment) logRes(res, kind = "incomplete") return diff --git a/libeufin-nexus/src/test/kotlin/helpers.kt b/libeufin-nexus/src/test/kotlin/helpers.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -90,7 +90,7 @@ fun genInPay( executionTime: Instant = Instant.now() ) = IncomingPayment( amount = TalerAmount(amount), - debtor = ibanPayto("DE84500105177118117964"), + debtor = ibanPayto("DE84500105177118117964", "John Smith"), subject = subject, executionTime = executionTime, id = IncomingId(null, randEbicsId(), null) diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -166,7 +166,7 @@ class IntegrationTest { setup("conf/integration.conf") { db -> val cfg = NexusIngestConfig.default(AccountType.exchange) - val userPayTo = IbanPayto.rand() + val userPayTo = IbanPayto.rand("Sir Florian") // Load conversion setup manually as the server would refuse to start without an exchange account val sqlProcedures = Path("../database-versioning/libeufin-conversion-setup.sql") @@ -300,7 +300,7 @@ class IntegrationTest { } setup("conf/integration.conf") { db -> - val userPayTo = IbanPayto.rand() + val userPayTo = IbanPayto.rand("Sir Christian") val fiatPayTo = IbanPayto.rand() // Create user @@ -404,7 +404,7 @@ class IntegrationTest { } }.assertOkJson<TransferResponse>() - db.checkInitiated(amount, null) + db.checkInitiated(amount, "Sir Christian") } // Exchange bounce with name @@ -414,7 +414,7 @@ class IntegrationTest { val subject = "exchange bounce test $i: $reservePub" // Cashin - nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo?receiver-name=John%20d%27Smith") + nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo") val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${40 + i}") .assertOkJson<ConversionResponse>().amount_credit client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> { @@ -440,7 +440,7 @@ class IntegrationTest { } }.assertOkJson<TransferResponse>() - db.checkInitiated(amount, "John d'Smith") + db.checkInitiated(amount, "Sir Christian") } } }