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:
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")
}
}
}