libeufin-1.6.8/ 0000775 0001750 0001750 00000000000 15236145704 013552 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/ 0000775 0001750 0001750 00000000000 15236145704 016507 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/ 0000775 0001750 0001750 00000000000 15236145704 017276 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/ 0000775 0001750 0001750 00000000000 15236145704 020222 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/ 0000775 0001750 0001750 00000000000 15236145704 021522 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/ 0000775 0001750 0001750 00000000000 15236145704 022445 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/ 0000775 0001750 0001750 00000000000 15236145704 024242 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ 0000775 0001750 0001750 00000000000 15236145704 025404 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ 0000775 0001750 0001750 00000000000 15236145704 026155 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/PreparedTransferApi.kt 0000664 0001750 0001750 00000012701 15221677432 032421 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.api
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.github.smiley4.ktoropenapi.*
import io.ktor.util.pipeline.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.nexus.NexusConfig
import tech.libeufin.nexus.db.Database
import tech.libeufin.nexus.db.TransferDAO.RegistrationResult
import java.time.Instant
import java.time.Duration
fun Routing.preparedTransferAPI(db: Database, cfg: NexusConfig) = conditional(cfg.wireGatewayApiCfg) {
get("/taler-prepared-transfer/config", {
operationId = "getPreparedTransferConfig"
description = "Get the configuration of the prepared transfer API"
tags = listOf("Prepared Transfer")
response {
code(HttpStatusCode.OK) { description = "Configuration of the prepared transfer API"; body() }
}
}) {
call.respond(
PreparedTransferConfig(
currency = cfg.currency,
supported_formats = listOf(SubjectFormat.SIMPLE, SubjectFormat.CH_QR_BILL)
)
)
}
post("/taler-prepared-transfer/registration", {
operationId = "registerTransfer"
description = "Register a prepared transfer"
tags = listOf("Prepared Transfer")
request {
body()
}
response {
code(HttpStatusCode.OK) { description = "Registration successful"; body() }
code(HttpStatusCode.Conflict) { description = "Reserve pub or subject derivation already used" }
}
}) {
val req = call.receive();
if (!req.verify())
throw forbidden(
"invalid signature",
TalerErrorCode.BANK_BAD_SIGNATURE
)
val iban = req.credit_account.expectIban().iban
val reference = if (iban == cfg.ebics.account.iban) {
null
} else if (iban == cfg.ebics.qrIban) {
subjectFmtQrBill(req.authorization_pub)
} else {
throw conflict(
"Creditor account '${req.credit_account}' is not supported",
TalerErrorCode.BANK_UNKNOWN_CREDITOR
)
}
when (val result = db.transfer.register(
req.type,
req.account_pub,
req.authorization_pub,
req.authorization_sig,
req.recurrent,
reference,
Instant.now()
)) {
RegistrationResult.ReservePubReuse -> throw conflict(
"reserve_pub used already",
TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
)
RegistrationResult.SubjectReuse -> throw conflict(
"subject derivation used already",
TalerErrorCode.BANK_DERIVATION_REUSE
)
RegistrationResult.Success -> {
call.respond(
SubjectResult(
if (reference != null) {
listOf(TransferSubject.QrBill(reference, req.credit_amount))
} else {
listOf(TransferSubject.Simple(fmtIncomingSubject(IncomingType.map, req.authorization_pub), req.credit_amount))
},
TalerTimestamp.never()
)
)
}
}
}
post("/taler-prepared-transfer/unregistration", {
operationId = "unregisterTransfer"
description = "Unregister a prepared subject"
tags = listOf("Prepared Transfer")
response {
code(HttpStatusCode.NoContent) { description = "Successfully unregistered" }
code(HttpStatusCode.NotFound) { description = "Prepared transfer not found" }
code(HttpStatusCode.Conflict) { description = "Invalid signature or timestamp too old" }
}
}) {
val req = call.receive();
if (req.timestamp.instant.isBefore(Instant.now().minus(Duration.ofMinutes(15))))
throw conflict(
"timestamp too old",
TalerErrorCode.BANK_OLD_TIMESTAMP
)
if (!req.verify())
throw forbidden(
"invalid signature",
TalerErrorCode.BANK_BAD_SIGNATURE
)
if (db.transfer.unregister(req.authorization_pub, Instant.now())) {
call.respond(HttpStatusCode.NoContent)
} else {
throw notFound(
"Prepared transfer '${req.authorization_pub}' not found",
TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
)
}
}
}
libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt 0000664 0001750 0001750 00000033376 15204341712 031403 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.api
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.github.smiley4.ktoropenapi.*
import io.ktor.util.pipeline.*
import tech.libeufin.common.*
import tech.libeufin.nexus.NexusConfig
import tech.libeufin.nexus.checkCurrency
import tech.libeufin.nexus.db.Database
import tech.libeufin.nexus.db.ExchangeDAO
import tech.libeufin.nexus.db.ExchangeDAO.TransferResult
import tech.libeufin.nexus.db.PaymentDAO.IncomingRegistrationResult
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.ebics.randEbicsId
import java.time.Instant
fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wireGatewayApiCfg) {
get("/taler-wire-gateway/config", {
operationId = "getWireGatewayConfig"
description = "Get the configuration of the wire gateway"
tags = listOf("Wire Gateway")
response {
code(HttpStatusCode.OK) { description = "Configuration of the wire gateway"; body() }
}
}) {
call.respond(
WireGatewayConfig(
currency = cfg.currency,
support_account_check = true
)
)
}
auth(cfg.wireGatewayApiCfg) {
post("/taler-wire-gateway/transfer", {
operationId = "createTransfer"
description = "Initiate a wire transfer"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
body()
}
response {
code(HttpStatusCode.OK) { description = "Transfer initiated successfully"; body() }
code(HttpStatusCode.Conflict) { description = "Request UID or WTID already used" }
}
}) {
val req = call.receive()
cfg.checkCurrency(req.amount)
req.credit_account.expectIbanFull()
val endToEndId = randEbicsId()
val res = db.exchange.transfer(
req,
endToEndId,
Instant.now()
)
when (res) {
TransferResult.RequestUidReuse -> throw conflict(
"request_uid used already",
TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
)
TransferResult.WtidReuse -> throw conflict(
"wtid used already",
TalerErrorCode.BANK_TRANSFER_WTID_REUSED
)
is TransferResult.Success -> call.respond(
TransferResponse(
timestamp = res.timestamp,
row_id = res.id
)
)
}
}
get("/taler-wire-gateway/transfers", {
operationId = "listTransfers"
description = "List wire transfers"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
queryParameter("start") { description = "Row ID to start from (legacy alias for offset)"; required = false }
queryParameter("offset") { description = "Row ID to start from"; required = false }
queryParameter("delta") { description = "Number of results to return (legacy alias for limit)"; required = false }
queryParameter("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false }
queryParameter("status") { description = "Filter by transfer status"; required = false }
}
response {
code(HttpStatusCode.OK) { description = "List of transfers"; body() }
code(HttpStatusCode.NoContent) { description = "No transfers found" }
}
}) {
val params = TransferParams.extract(call.request.queryParameters)
val items = db.exchange.pageTransfer(params)
if (items.isEmpty()) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(TransferList(items, cfg.ebics.payto))
}
}
get("/taler-wire-gateway/transfers/{ROW_ID}", {
operationId = "getTransfer"
description = "Get details of a specific wire transfer"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
pathParameter("ROW_ID") { description = "Row ID of the transfer" }
}
response {
code(HttpStatusCode.OK) { description = "Transfer details"; body() }
code(HttpStatusCode.NotFound) { description = "Transfer not found" }
}
}) {
val id = call.longPath("ROW_ID")
val transfer = db.exchange.getTransfer(id) ?: throw notFound(
"Transfer '$id' not found",
TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
)
call.respond(transfer)
}
suspend fun ApplicationCall.historyEndpoint(
reduce: (List, String) -> Any,
dbLambda: suspend ExchangeDAO.(HistoryParams) -> List
) {
val params = HistoryParams.extract(this.request.queryParameters)
val items = db.exchange.dbLambda(params)
if (items.isEmpty()) {
this.respond(HttpStatusCode.NoContent)
} else {
this.respond(reduce(items, cfg.ebics.payto))
}
}
get("/taler-wire-gateway/history/incoming", {
operationId = "getIncomingHistory"
description = "Get incoming transaction history"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
queryParameter("start") { description = "Row ID to start from (legacy alias for offset)"; required = false }
queryParameter("offset") { description = "Row ID to start from"; required = false }
queryParameter("delta") { description = "Number of results to return (legacy alias for limit)"; required = false }
queryParameter("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false }
queryParameter("long_poll_ms") { description = "Long-polling timeout in milliseconds (legacy alias for timeout_ms)"; required = false }
queryParameter("timeout_ms") { description = "Long-polling timeout in milliseconds. Max 3600000 (1 hour)"; required = false }
}
response {
code(HttpStatusCode.OK) { description = "Incoming transaction history"; body() }
code(HttpStatusCode.NoContent) { description = "No incoming transactions found" }
}
}) {
call.historyEndpoint(::IncomingHistory, ExchangeDAO::incomingHistory)
}
get("/taler-wire-gateway/history/outgoing", {
operationId = "getOutgoingHistory"
description = "Get outgoing transaction history"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
queryParameter("start") { description = "Row ID to start from (legacy alias for offset)"; required = false }
queryParameter("offset") { description = "Row ID to start from"; required = false }
queryParameter("delta") { description = "Number of results to return (legacy alias for limit)"; required = false }
queryParameter("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false }
queryParameter("long_poll_ms") { description = "Long-polling timeout in milliseconds (legacy alias for timeout_ms)"; required = false }
queryParameter("timeout_ms") { description = "Long-polling timeout in milliseconds. Max 3600000 (1 hour)"; required = false }
}
response {
code(HttpStatusCode.OK) { description = "Outgoing transaction history"; body() }
code(HttpStatusCode.NoContent) { description = "No outgoing transactions found" }
}
}) {
call.historyEndpoint(::OutgoingHistory, ExchangeDAO::outgoingHistory)
}
get("/taler-wire-gateway/account/check", {
operationId = "checkAccount"
description = "Check account status"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
response {
code(HttpStatusCode.NotImplemented) { description = "Not implemented" }
}
}) {
throw notImplemented()
}
suspend fun ApplicationCall.addIncoming(
amount: TalerAmount,
debitAccount: Payto,
subject: String,
metadata: IncomingSubject
) {
cfg.checkCurrency(amount)
val debitAccount = debitAccount.expectIban()
val timestamp = Instant.now()
val res = db.payment.registerTalerableIncoming(
IncomingPayment(
amount = amount,
debtor = debitAccount,
subject = subject,
executionTime = timestamp,
id = IncomingId(null, randEbicsId(), null)
), metadata
)
when (res) {
IncomingRegistrationResult.ReservePubReuse -> throw conflict(
"reserve_pub used already",
TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
)
IncomingRegistrationResult.MappingReuse -> throw conflict(
"authorization_pub used already",
TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED
)
IncomingRegistrationResult.UnknownMapping -> throw conflict(
"authorization_pub unknown",
TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN
)
is IncomingRegistrationResult.Success -> respond(
AddIncomingResponse(
timestamp = TalerTimestamp(timestamp),
row_id = res.id
)
)
}
}
post("/taler-wire-gateway/admin/add-incoming", {
operationId = "addIncoming"
description = "Manually add an incoming transaction"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
body()
}
response {
code(HttpStatusCode.OK) { description = "Incoming transaction added"; body() }
code(HttpStatusCode.Conflict) { description = "Reserve pub already used" }
}
}) {
val req = call.receive()
call.addIncoming(
amount = req.amount,
debitAccount = req.debit_account,
subject = "Manual incoming ${req.reserve_pub}",
metadata = IncomingSubject.Reserve(req.reserve_pub)
)
}
post("/taler-wire-gateway/admin/add-kycauth", {
operationId = "addKycauth"
description = "Manually add a KYC auth incoming transaction"
tags = listOf("Wire Gateway")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
body()
}
response {
code(HttpStatusCode.OK) { description = "KYC auth incoming transaction added"; body() }
code(HttpStatusCode.Conflict) { description = "Reserve pub already used" }
}
}) {
val req = call.receive()
call.addIncoming(
amount = req.amount,
debitAccount = req.debit_account,
subject = "Manual incoming KYC:${req.account_pub}",
metadata = IncomingSubject.Kyc(req.account_pub)
)
}
post("/taler-wire-gateway/admin/add-mapped") {
val req = call.receive()
call.addIncoming(
amount = req.amount,
debitAccount = req.debit_account,
subject = "Manual incoming MAP:${req.authorization_pub}",
metadata = IncomingSubject.Map(req.authorization_pub)
)
}
}
}
libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt 0000664 0001750 0001750 00000020033 15204341712 031753 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.api
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.github.smiley4.ktoropenapi.*
import io.ktor.util.pipeline.*
import io.prometheus.metrics.core.metrics.*
import io.prometheus.metrics.model.registry.PrometheusRegistry
import io.prometheus.metrics.model.snapshots.Unit
import io.prometheus.metrics.instrumentation.jvm.JvmMetrics;
import io.prometheus.metrics.expositionformats.ExpositionFormats
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.db.KvDAO.*
import tech.libeufin.nexus.db.ExchangeDAO.TransferResult
import tech.libeufin.nexus.db.PaymentDAO.IncomingRegistrationResult
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.ebics.randEbicsId
import java.time.Instant
import java.io.ByteArrayOutputStream
object Metrics {
@Volatile
private var incomingTxTotal: Long = 0
@Volatile
private var outgoingTxTotal: Long = 0
@Volatile
private var incomingTalerTxTotal: Long = 0
@Volatile
private var outgoingTalerTxTotal: Long = 0
@Volatile
private var bouncedTotal: Long = 0
@Volatile
private var initiatedStatus: Map = emptyMap()
@Volatile
private var submitStatus: TaskStatus = TaskStatus()
@Volatile
private var fetchStatus: TaskStatus = TaskStatus()
init {
// Register JVM metrics
JvmMetrics.builder().register()
// Register custom metrics
CounterWithCallback.builder()
.name("libeufin_nexus_tx_incoming_total")
.help("Number of registered incoming transactions")
.callback { it.call(incomingTxTotal.toDouble()) }
.register()
CounterWithCallback.builder()
.name("libeufin_nexus_tx_outgoing_total")
.help("Number of initiated outgoing transactions")
.callback { it.call(outgoingTxTotal.toDouble()) }
.register()
CounterWithCallback.builder()
.name("libeufin_nexus_tx_incoming_talerable_total")
.help("Number of registered incoming talerable transactions")
.callback { it.call(incomingTalerTxTotal.toDouble()) }
.register()
CounterWithCallback.builder()
.name("libeufin_nexus_tx_outgoing_talerable_total")
.help("Number of initiated outgoing talerable transactions")
.callback { it.call(outgoingTalerTxTotal.toDouble()) }
.register()
CounterWithCallback.builder()
.name("libeufin_nexus_tx_bounced_total")
.help("Number of bounced transactions")
.callback { it.call(bouncedTotal.toDouble()) }
.register()
GaugeWithCallback.builder()
.name("libeufin_nexus_tx_initiated")
.help("Status of initiated transaction")
.labelNames("status")
.callback {
for ((label, count) in initiatedStatus) {
it.call(count.toDouble(), label)
}
}
.register()
GaugeWithCallback.builder()
.name("libeufin_nexus_task_execution_timestamp_seconds")
.help("Status of initiated transaction")
.unit(Unit.SECONDS)
.labelNames("name")
.callback {
submitStatus.last_trial?.let { timestamp ->
it.call(timestamp.getEpochSecond().toDouble(), "submit")
}
fetchStatus.last_trial?.let { timestamp ->
it.call(timestamp.getEpochSecond().toDouble(), "fetch")
}
}
.register()
GaugeWithCallback.builder()
.name("libeufin_nexus_task_success_timestamp_seconds")
.help("Status of initiated transaction")
.unit(Unit.SECONDS)
.labelNames("name")
.callback {
submitStatus.last_successfull?.let { timestamp ->
it.call(timestamp.getEpochSecond().toDouble(), "submit")
}
fetchStatus.last_successfull?.let { timestamp ->
it.call(timestamp.getEpochSecond().toDouble(), "fetch")
}
}
.register()
}
// Load metrics from the database
suspend fun sync(db: Database) {
db.serializable(
"""
SELECT
(SELECT count(*) FROM incoming_transactions) AS incoming_tx_count,
(SELECT count(*) FROM outgoing_transactions) AS outgoing_tx_count,
(SELECT count(*) FROM talerable_incoming_transactions) AS incoming_talerable_tx_count,
(SELECT count(*) FROM talerable_outgoing_transactions) AS outgoing_talerable_tx_count,
(SELECT count(*) FROM bounced_transactions) AS bounced_tx_count,
(SELECT value FROM kv WHERE key='$SUBMIT_TASK_KEY') AS submit_status,
(SELECT value FROM kv WHERE key='$FETCH_TASK_KEY') AS fetch_status
"""
) {
one {
incomingTxTotal = it.getLong("incoming_tx_count")
outgoingTxTotal = it.getLong("outgoing_tx_count")
incomingTalerTxTotal = it.getLong("incoming_talerable_tx_count")
outgoingTalerTxTotal = it.getLong("outgoing_talerable_tx_count")
bouncedTotal = it.getLong("bounced_tx_count")
submitStatus = it.getJson("submit_status") ?: TaskStatus()
fetchStatus = it.getJson("fetch_status") ?: TaskStatus()
Unit
}
}
db.serializable(
"""
SELECT count(*) as count, status FROM initiated_outgoing_transactions GROUP BY status
"""
) {
initiatedStatus = all {
it.getString("status") to it.getLong("count")
}.toMap()
}
}
}
fun Routing.observabilityApi(db: Database, cfg: NexusConfig) = conditional(cfg.observabilityApiCfg) {
get("/taler-observability/config", {
operationId = "getObservabilityConfig"
description = "Get the configuration of the observability API"
tags = listOf("Observability")
response {
code(HttpStatusCode.OK) { description = "Configuration of the observability API"; body() }
}
}) {
call.respond(TalerObservabilityConfig())
}
auth(cfg.observabilityApiCfg) {
get("/taler-observability/metrics", {
operationId = "getMetrics"
description = "Get Prometheus metrics"
tags = listOf("Observability")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
response {
code(HttpStatusCode.OK) { description = "Prometheus text format metrics" }
}
}) {
Metrics.sync(db)
val snapshot = PrometheusRegistry.defaultRegistry.scrape()
val outputStream = ByteArrayOutputStream()
ExpositionFormats.init().getPrometheusTextFormatWriter().write(outputStream, snapshot)
call.respondText(outputStream.toString(Charsets.UTF_8), ContentType.parse("text/plain; version=0.0.4; charset=utf-8"))
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt 0000664 0001750 0001750 00000006557 15204341712 030565 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.api
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.github.smiley4.ktoropenapi.*
import tech.libeufin.common.HistoryParams
import tech.libeufin.common.RevenueConfig
import tech.libeufin.common.RevenueIncomingHistory
import tech.libeufin.nexus.NexusConfig
import tech.libeufin.nexus.db.Database
fun Routing.revenueApi(db: Database, cfg: NexusConfig) = conditional(cfg.revenueApiCfg) {
get("/taler-revenue/config", {
operationId = "getRevenueConfig"
description = "Get the configuration of the revenue API"
tags = listOf("Revenue")
response {
code(HttpStatusCode.OK) { description = "Configuration of the revenue API"; body() }
}
}) {
call.respond(RevenueConfig(
currency = cfg.currency
))
}
auth(cfg.revenueApiCfg) {
get("/taler-revenue/history", {
operationId = "getRevenueHistory"
description = "Get incoming revenue history"
tags = listOf("Revenue")
protected = true
securitySchemeNames("bearerAuth", "basicAuth")
request {
queryParameter("start") { description = "Row ID to start from (legacy alias for offset)"; required = false }
queryParameter("offset") { description = "Row ID to start from"; required = false }
queryParameter("delta") { description = "Number of results to return (legacy alias for limit)"; required = false }
queryParameter("limit") { description = "Number of results to return. Negative for descending order. Max 1024"; required = false }
queryParameter("long_poll_ms") { description = "Long-polling timeout in milliseconds (legacy alias for timeout_ms)"; required = false }
queryParameter("timeout_ms") { description = "Long-polling timeout in milliseconds. Max 3600000 (1 hour)"; required = false }
}
response {
code(HttpStatusCode.OK) { description = "Incoming revenue history"; body() }
code(HttpStatusCode.NoContent) { description = "No revenue transactions found" }
}
}) {
val params = HistoryParams.extract(call.request.queryParameters)
val items = db.payment.revenueHistory(params)
if (items.isEmpty()) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(RevenueIncomingHistory(items, cfg.ebics.payto))
}
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt 0000664 0001750 0001750 00000003001 15122266731 030147 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.api
import io.ktor.http.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import tech.libeufin.common.*
import tech.libeufin.common.api.intercept
import tech.libeufin.common.api.apiAuth
import tech.libeufin.nexus.ApiConfig
/** Apply authentication api configuration for a route */
fun Route.auth(cfg: ApiConfig?, callback: Route.() -> Unit): Route {
val method = cfg?.authMethod
if (method != null) {
return apiAuth(method, callback)
} else {
return this
}
}
/** Apply conditional api configuration for a route */
fun Route.conditional(cfg: ApiConfig?, callback: Route.() -> Unit): Route =
intercept("Conditional", callback) {
if (cfg == null) {
throw notImplemented()
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ 0000775 0001750 0001750 00000000000 15236145704 025771 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt 0000664 0001750 0001750 00000023712 15156463305 030300 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.db
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.nexus.iso20022.IncomingPayment
import tech.libeufin.nexus.iso20022.OutgoingPayment
import java.sql.Types
import java.time.Instant
/** Data access logic for incoming & outgoing payments */
class PaymentDAO(private val db: Database) {
/** Outgoing payments registration result */
data class OutgoingRegistrationResult(
val id: Long,
val initiated: Boolean,
val new: Boolean
)
/** Register an outgoing payment reconciling it with its initiated payment counterpart if present */
suspend fun registerOutgoing(
payment: OutgoingPayment,
wtid: ShortHashCode?,
baseUrl: BaseURL?,
metadata: String?
): OutgoingRegistrationResult = db.serializable(
"""
SELECT out_tx_id, out_initiated, out_found
FROM register_outgoing((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?,?,?)
"""
) {
val executionTime = payment.executionTime.micros()
bind(payment.amount)
bind(payment.debitFee ?: TalerAmount.zero(db.currency))
bind(payment.subject)
bind(executionTime)
bind(payment.creditor?.toString())
bind(payment.id.endToEndId)
bind(payment.id.msgId)
bind(payment.id.acctSvcrRef)
bind(wtid)
bind(baseUrl?.url?.toString())
bind(metadata)
one {
OutgoingRegistrationResult(
it.getLong("out_tx_id"),
it.getBoolean("out_initiated"),
!it.getBoolean("out_found")
)
}
}
interface InResult {
val new: Boolean
val completed: Boolean
val bounceId: String?
}
/** Incoming payments bounce registration result */
sealed interface IncomingBounceRegistrationResult {
data class Success(val id: Long, override val bounceId: String, override val new: Boolean, override val completed: Boolean): IncomingBounceRegistrationResult, InResult
data object Talerable: IncomingBounceRegistrationResult
}
/** Register an incoming payment and bounce it */
suspend fun registerMalformedIncoming(
payment: IncomingPayment,
bounceAmount: TalerAmount,
bounceEndToEndId: String,
timestamp: Instant,
cause: String
): IncomingBounceRegistrationResult = db.serializable(
"""
SELECT out_found, out_tx_id, out_completed, out_bounce_id, out_talerable
FROM register_and_bounce_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,(?,?)::taler_amount,?,?, ?)
"""
) {
bind(payment.amount)
bind(payment.creditFee ?: TalerAmount.zero(db.currency))
bind(payment.subject)
bind(payment.executionTime)
bind(payment.debtor?.toString())
bind(payment.id.uetr)
bind(payment.id.txId)
bind(payment.id.acctSvcrRef)
bind(bounceAmount)
bind(timestamp)
bind(bounceEndToEndId)
bind(cause)
one {
if (it.getBoolean("out_talerable")) {
IncomingBounceRegistrationResult.Talerable
} else {
IncomingBounceRegistrationResult.Success(
it.getLong("out_tx_id"),
it.getString("out_bounce_id"),
!it.getBoolean("out_found"),
it.getBoolean("out_completed")
)
}
}
}
/** Incoming payments registration result */
sealed interface IncomingRegistrationResult {
data class Success(
val id: Long,
override val new: Boolean,
override val completed: Boolean,
override val bounceId: String?,
val pending: Boolean
): IncomingRegistrationResult, InResult
data object ReservePubReuse: IncomingRegistrationResult
data object MappingReuse: IncomingRegistrationResult
data object UnknownMapping: IncomingRegistrationResult
}
/** Register an talerable incoming payment */
suspend fun registerTalerableIncoming(
payment: IncomingPayment,
metadata: IncomingSubject
): IncomingRegistrationResult = db.serializable(
"""
SELECT
out_reserve_pub_reuse,
out_mapping_reuse,
out_unknown_mapping,
out_found,
out_completed,
out_pending,
out_tx_id,
out_bounce_id
FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,?::taler_incoming_type,?,NULL)
"""
) {
bind(payment.amount)
bind(payment.creditFee ?: TalerAmount.zero(db.currency))
bind(payment.subject)
bind(payment.executionTime)
bind(payment.debtor?.toString())
bind(payment.id.uetr)
bind(payment.id.txId)
bind(payment.id.acctSvcrRef)
bind(metadata.type)
bind(metadata.key)
one {
when {
it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse
it.getBoolean("out_mapping_reuse") -> IncomingRegistrationResult.MappingReuse
it.getBoolean("out_unknown_mapping") -> IncomingRegistrationResult.UnknownMapping
else -> IncomingRegistrationResult.Success(
it.getLong("out_tx_id"),
!it.getBoolean("out_found"),
it.getBoolean("out_completed"),
it.getString("out_bounce_id"),
it.getBoolean("out_pending")
)
}
}
}
/** Register an QR-Bill incoming payment */
suspend fun registerQrBillIncoming(
payment: IncomingPayment,
reference: String
): IncomingRegistrationResult = db.serializable(
"""
SELECT
out_reserve_pub_reuse,
out_mapping_reuse,
out_unknown_mapping,
out_found,
out_completed,
out_pending,
out_tx_id,
out_bounce_id
FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL,?)
"""
) {
bind(payment.amount)
bind(payment.creditFee ?: TalerAmount.zero(db.currency))
bind(payment.subject)
bind(payment.executionTime)
bind(payment.debtor?.toString())
bind(payment.id.uetr)
bind(payment.id.txId)
bind(payment.id.acctSvcrRef)
bind(reference)
one {
when {
it.getBoolean("out_reserve_pub_reuse") -> IncomingRegistrationResult.ReservePubReuse
it.getBoolean("out_mapping_reuse") -> IncomingRegistrationResult.MappingReuse
it.getBoolean("out_unknown_mapping") -> IncomingRegistrationResult.UnknownMapping
else -> IncomingRegistrationResult.Success(
it.getLong("out_tx_id"),
!it.getBoolean("out_found"),
it.getBoolean("out_completed"),
it.getString("out_bounce_id"),
it.getBoolean("out_pending")
)
}
}
}
/** Register an incoming payment */
suspend fun registerIncoming(
payment: IncomingPayment
): IncomingRegistrationResult.Success = db.serializable(
"""
SELECT out_found, out_completed, out_tx_id, out_bounce_id
FROM register_incoming((?,?)::taler_amount,(?,?)::taler_amount,?,?,?,?,?,?,NULL,NULL,NULL)
"""
) {
bind(payment.amount)
bind(payment.creditFee ?: TalerAmount.zero(db.currency))
bind(payment.subject)
bind(payment.executionTime)
bind(payment.debtor?.toString())
bind(payment.id.uetr)
bind(payment.id.txId)
bind(payment.id.acctSvcrRef)
one {
IncomingRegistrationResult.Success(
it.getLong("out_tx_id"),
!it.getBoolean("out_found"),
it.getBoolean("out_completed"),
it.getString("out_bounce_id"),
false
)
}
}
/** Query history of incoming transactions */
suspend fun revenueHistory(
params: HistoryParams
): List
= db.poolHistoryGlobal(params, db::listenRevenue, """
SELECT
incoming_transaction_id
,execution_time
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,(credit_fee).val AS credit_fee_val
,(credit_fee).frac AS credit_fee_frac
,debit_payto
,subject
FROM incoming_transactions
WHERE debit_payto IS NOT NULL AND subject IS NOT NULL AND
""", "incoming_transaction_id") {
RevenueIncomingBankTransaction(
row_id = it.getLong("incoming_transaction_id"),
date = it.getTalerTimestamp("execution_time"),
amount = it.getAmount("amount", db.currency),
credit_fee = it.getAmount("credit_fee", db.currency).notZeroOrNull(),
debit_account = it.getString("debit_payto"),
subject = it.getString("subject")
)
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/KvDAO.kt 0000664 0001750 0001750 00000006200 15122266731 027231 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.db
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.nexus.iso20022.IncomingPayment
import tech.libeufin.nexus.iso20022.OutgoingPayment
import java.sql.*
import java.time.Instant
import kotlinx.serialization.Contextual
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encodeToString
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
object InstantSerialize : KSerializer {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeLong(value.micros())
override fun deserialize(decoder: Decoder): Instant =
decoder.decodeLong().asInstant()
}
val JSON = Json {
this.serializersModule = SerializersModule {
contextual(Instant::class) { InstantSerialize }
}
}
inline fun ResultSet.getJson(name: String): T? {
val value = this.getString(name)
if (value == null) {
return value
}
return JSON.decodeFromString(value)
}
/** Data access logic for key value */
class KvDAO( val db: Database) {
/** Get current value for [key] */
suspend inline fun get(key: String): T? = db.serializable(
"SELECT value FROM kv WHERE key=?"
) {
bind(key)
oneOrNull {
it.getJson("value")
}
}
/** Update a TaskStatus timestamp */
suspend fun updateTaskStatus(key: String, timestamp: Instant, success: Boolean) = db.serializable(
if (success) {
"INSERT INTO kv (key, value) VALUES (?, jsonb_build_object('last_successfull', ?, 'last_trial', ?)) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value"
} else {
"INSERT INTO kv (key, value) VALUES (?, jsonb_build_object('last_trial', ?)) ON CONFLICT (key) DO UPDATE SET value=jsonb_set(EXCLUDED.value, '{last_trial}'::text[], to_jsonb(?))"
}
) {
bind(key)
bind(timestamp)
bind(timestamp)
executeUpdate()
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt 0000664 0001750 0001750 00000011021 15156463305 030031 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024, 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.db
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.slf4j.LoggerFactory
import tech.libeufin.common.TalerAmount
import tech.libeufin.common.TransferStatusState
import tech.libeufin.common.IbanPayto
import tech.libeufin.common.db.DatabaseConfig
import tech.libeufin.common.db.DbPool
import tech.libeufin.common.db.watchNotifications
import tech.libeufin.ebics.EbicsDAO
import java.time.Instant
/** Batch of initiated outgoing payment to sent together */
data class PaymentBatch(
val id: Long,
val messageId: String,
val creationDate: Instant,
val sum: TalerAmount,
val payments: List,
)
/** Initiated outgoing transaction */
data class InitiatedPayment(
val id: Long,
val amount: TalerAmount,
val subject: String,
val creditor: IbanPayto,
val initiationTime: Instant,
val endToEndId: String
)
enum class StatusUpdate {
pending,
transient_failure,
permanent_failure,
success
}
/** Outgoing transactions and batches submission status */
enum class SubmissionState {
// Initiated but not yet submitted
unsubmitted,
// Submission failed, retry possible
transient_failure,
// Submission succeed, pending settltment
pending,
// Definitive failure, will never succeed
permanent_failure,
// Definitive success, booked and settled
success,
// Late failure after a success, happens when a payment is returned
late_failure;
companion object {
val SETTLED = listOf(SubmissionState.success, SubmissionState.permanent_failure, SubmissionState.late_failure)
val PENDING = listOf(SubmissionState.unsubmitted, SubmissionState.pending)
}
fun toTransferStatus(): TransferStatusState {
return when (this) {
SubmissionState.unsubmitted, SubmissionState.pending -> TransferStatusState.pending
SubmissionState.transient_failure -> TransferStatusState.transient_failure
SubmissionState.permanent_failure -> TransferStatusState.permanent_failure
SubmissionState.success, SubmissionState.late_failure -> TransferStatusState.success
}
}
}
/** Collects database connection steps and any operation on the Nexus tables */
class Database(dbConfig: DatabaseConfig, val currency: String): DbPool(dbConfig, "libeufin_nexus") {
val payment = PaymentDAO(this)
val initiated = InitiatedDAO(this)
val exchange = ExchangeDAO(this)
val ebics = EbicsDAO(this)
val list = ListDAO(this)
val kv = KvDAO(this)
val transfer = TransferDAO(this)
private val outgoingTxFlows: MutableSharedFlow = MutableSharedFlow()
private val incomingTxFlows: MutableSharedFlow = MutableSharedFlow()
private val revenueTxFlows: MutableSharedFlow = MutableSharedFlow()
init {
watchNotifications(pgSource, "libeufin_nexus", LoggerFactory.getLogger("libeufin-nexus-db-watcher"), mapOf(
"nexus_revenue_tx" to {
val id = it.toLong()
revenueTxFlows.emit(id)
},
"nexus_outgoing_tx" to {
val id = it.toLong()
outgoingTxFlows.emit(id)
},
"nexus_incoming_tx" to {
val id = it.toLong()
incomingTxFlows.emit(id)
}
))
}
/** Listen for new taler outgoing transactions */
suspend fun listenOutgoing(lambda: suspend (Flow) -> R): R
= lambda(outgoingTxFlows)
/** Listen for new taler incoming transactions */
suspend fun listenIncoming(lambda: suspend (Flow) -> R): R
= lambda(incomingTxFlows)
/** Listen for new incoming transactions */
suspend fun listenRevenue(lambda: suspend (Flow) -> R): R
= lambda(revenueTxFlows)
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt 0000664 0001750 0001750 00000021600 15156463305 030377 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.db
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import java.time.Instant
/** Data access logic for exchange specific logic */
class ExchangeDAO(private val db: Database) {
/** Query history of taler incoming transactions */
suspend fun incomingHistory(
params: HistoryParams
): List
= db.poolHistoryGlobal(params, db::listenIncoming, """
SELECT
incoming_transaction_id
,execution_time
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,(credit_fee).val AS credit_fee_val
,(credit_fee).frac AS credit_fee_frac
,debit_payto
,type
,metadata
,authorization_pub
,authorization_sig
FROM talerable_incoming_transactions
JOIN incoming_transactions USING(incoming_transaction_id)
WHERE
""", "incoming_transaction_id") {
when (it.getEnum("type")) {
IncomingType.reserve -> IncomingReserveTransaction(
row_id = it.getLong("incoming_transaction_id"),
date = it.getTalerTimestamp("execution_time"),
amount = it.getAmount("amount", db.currency),
credit_fee = it.getAmount("credit_fee", db.currency).notZeroOrNull(),
debit_account = it.getString("debit_payto"),
reserve_pub = EddsaPublicKey(it.getBytes("metadata")),
authorization_pub = it.getOptKey("authorization_pub"),
authorization_sig = it.getOptSig("authorization_sig")
)
IncomingType.kyc -> IncomingKycAuthTransaction(
row_id = it.getLong("incoming_transaction_id"),
date = it.getTalerTimestamp("execution_time"),
amount = it.getAmount("amount", db.currency),
credit_fee = it.getAmount("credit_fee", db.currency).notZeroOrNull(),
debit_account = it.getString("debit_payto"),
account_pub = EddsaPublicKey(it.getBytes("metadata")),
authorization_pub = it.getOptKey("authorization_pub"),
authorization_sig = it.getOptSig("authorization_sig")
)
IncomingType.map -> throw UnsupportedOperationException()
}
}
/** Query exchange history of taler outgoing transactions */
suspend fun outgoingHistory(
params: HistoryParams
): List
= db.poolHistoryGlobal(params, db::listenOutgoing, """
SELECT
outgoing_transaction_id
,execution_time
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,(debit_fee).val AS debit_fee_val
,(debit_fee).frac AS debit_fee_frac
,credit_payto
,wtid
,exchange_base_url
,metadata
FROM talerable_outgoing_transactions
JOIN outgoing_transactions USING(outgoing_transaction_id)
WHERE
""", "outgoing_transaction_id") {
OutgoingTransaction(
row_id = it.getLong("outgoing_transaction_id"),
date = it.getTalerTimestamp("execution_time"),
amount = it.getAmount("amount", db.currency),
debit_fee = it.getAmount("debit_fee", db.currency).notZeroOrNull(),
credit_account = it.getString("credit_payto"),
wtid = ShortHashCode(it.getBytes("wtid")),
exchange_base_url = it.getString("exchange_base_url"),
metadata = it.getString("metadata"),
)
}
/** Result of taler transfer transaction creation */
sealed interface TransferResult {
/** Transaction [id] and wire transfer [timestamp] */
data class Success(val id: Long, val timestamp: TalerTimestamp): TransferResult
data object RequestUidReuse: TransferResult
data object WtidReuse: TransferResult
}
/** Perform a Taler transfer */
suspend fun transfer(
req: TransferRequest,
endToEndId: String,
timestamp: Instant
): TransferResult = db.serializable(
"""
SELECT
out_request_uid_reuse
,out_wtid_reuse
,out_tx_row_id
,out_timestamp
FROM taler_transfer (
?, ?, ?,
(?,?)::taler_amount,
?, ?, ?, ?, ?
)
"""
) {
val subject = fmtOutgoingSubject(req.wtid, req.exchange_base_url, req.metadata)
bind(req.request_uid)
bind(req.wtid)
bind(subject)
bind(req.amount)
bind(req.exchange_base_url.toString())
bind(req.metadata)
bind(req.credit_account.toString())
bind(endToEndId)
bind(timestamp)
one {
when {
it.getBoolean("out_request_uid_reuse") -> TransferResult.RequestUidReuse
it.getBoolean("out_wtid_reuse") -> TransferResult.WtidReuse
else -> TransferResult.Success(
id = it.getLong("out_tx_row_id"),
timestamp = it.getTalerTimestamp("out_timestamp")
)
}
}
}
/** Get status of transfer [id] */
suspend fun getTransfer(
id: Long
): TransferStatus? = db.serializable(
"""
SELECT
wtid
,exchange_base_url
,metadata
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,credit_payto
,initiation_time
,status
,status_msg
FROM transfer_operations
JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id)
WHERE initiated_outgoing_transaction_id=?
"""
) {
bind(id)
oneOrNull {
TransferStatus(
status = it.getEnum("status").toTransferStatus(),
status_msg = it.getString("status_msg"),
amount = it.getAmount("amount", db.currency),
origin_exchange_url = it.getString("exchange_base_url"),
metadata = it.getString("metadata"),
wtid = ShortHashCode(it.getBytes("wtid")),
credit_account = it.getString("credit_payto"),
timestamp = it.getTalerTimestamp("initiation_time"),
)
}
}
/** Get a page of transfers status */
suspend fun pageTransfer(
params: TransferParams
): List = db.page(
params.page,
"initiated_outgoing_transaction_id",
"""
SELECT
initiated_outgoing_transaction_id
,(amount).val AS amount_val
,(amount).frac AS amount_frac
,status
,credit_payto
,initiation_time
FROM transfer_operations
JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id)
WHERE ${
when (params.status) {
null -> ""
TransferStatusState.pending -> "(status=?::submission_state OR status=?::submission_state) AND"
else -> "status=?::submission_state AND"
}
}
""",
{
when (params.status) {
null -> {}
TransferStatusState.pending -> {
bind(SubmissionState.pending)
bind(SubmissionState.unsubmitted)
}
else -> {
bind(params.status)
}
}
}
) {
TransferListStatus(
row_id = it.getLong("initiated_outgoing_transaction_id"),
status = it.getEnum("status").toTransferStatus(),
amount = it.getAmount("amount", db.currency),
credit_account = it.getString("credit_payto"),
timestamp = it.getTalerTimestamp("initiation_time"),
)
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/TransferDAO.kt 0000664 0001750 0001750 00000005065 15221677432 030451 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.db
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import java.time.Instant
/** Data access logic for transfer specific logic */
class TransferDAO(private val db: Database) {
/** Result of prepared transfer registration */
sealed interface RegistrationResult {
data object Success: RegistrationResult
data object ReservePubReuse: RegistrationResult
data object SubjectReuse: RegistrationResult
}
/** Register a prepared transfer */
suspend fun register(
type: TransferType,
accountPub: EddsaPublicKey,
authPub: EddsaPublicKey,
authSig: EddsaSignature,
recurrent: Boolean,
referenceNumber: String?,
timestamp: Instant
): RegistrationResult = db.serializable(
"""
SELECT
out_subject_reuse
,out_reserve_pub_reuse
FROM register_prepared_transfers (
?::taler_incoming_type, ?, ?, ?, ?, ?, ?
)
"""
) {
bind(type)
bind(accountPub)
bind(authPub)
bind(authSig)
bind(recurrent)
bind(referenceNumber)
bind(timestamp)
one {
when {
it.getBoolean("out_subject_reuse") -> RegistrationResult.SubjectReuse
it.getBoolean("out_reserve_pub_reuse") -> RegistrationResult.ReservePubReuse
else -> RegistrationResult.Success
}
}
}
/** Unregister a prepared transfer */
suspend fun unregister(
authorizationPub: EddsaPublicKey,
timestamp: Instant
) = db.serializable(
"SELECT out_found FROM delete_prepared_transfers(?,?)",
{
bind(authorizationPub)
bind(timestamp)
one {
it.getBoolean(1)
}
}
)
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt 0000664 0001750 0001750 00000021112 15156463305 027566 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.db
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.nexus.iso20022.*
import java.sql.Types
import java.time.Instant
import java.util.UUID
/** Data access logic for metadata listing */
class ListDAO(private val db: Database) {
/** List incoming transaction metadata for debugging */
suspend fun incoming(incomplete: Boolean): List = db.serializable(
"""
SELECT
(incoming.amount).val AS amount_val
,(incoming.amount).frac AS amount_frac
,(credit_fee).val AS credit_fee_val
,(credit_fee).frac AS credit_fee_frac
,incoming.subject
,end_to_end_id AS bounced
,execution_time
,debit_payto
,type
,metadata
,uetr
,tx_id
,acct_svcr_ref
,talerable_incoming_transactions.authorization_pub as auth_pub
,pending_recurrent_incoming_transactions.authorization_pub as pending_pub
FROM incoming_transactions AS incoming
LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id)
LEFT JOIN bounced_transactions USING (incoming_transaction_id)
LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id)
LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id)
${if (incomplete) { "WHERE debit_payto IS NULL OR incoming.subject IS NULL" } else { ""}}
ORDER BY execution_time
"""
) {
all {
val type = it.getOptEnum("type")
val authPub = it.getOptKey("auth_pub")
val pendingPub = it.getOptKey("pending_pub")
val map = if (authPub != null) {
" mapped by ${authPub}"
} else {
""
}
IncomingTxMetadata(
id = IncomingId(
it.getObject("uetr") as UUID?,
it.getString("tx_id"),
it.getString("acct_svcr_ref"),
),
date = it.getLong("execution_time").asInstant(),
amount = it.getAmount("amount", db.currency),
creditFee = it.getDecimal("credit_fee"),
subject = it.getString("subject"),
debtor = it.getString("debit_payto"),
bounced = it.getString("bounced"),
talerable = when (type) {
null -> {
if (pendingPub != null) {
"pending mapped by $pendingPub"
} else {
null
}
}
IncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}$map"
IncomingType.kyc -> "kyc ${EddsaPublicKey(it.getBytes("metadata"))}$map"
IncomingType.map -> throw UnsupportedOperationException()
}
)
}
}
/** List outgoing transaction metadata for debugging */
suspend fun outgoing(): List = db.serializable(
"""
SELECT
(amount).val AS amount_val
,(amount).frac AS amount_frac
,subject
,execution_time
,credit_payto
,end_to_end_id
,acct_svcr_ref
,wtid
,exchange_base_url
FROM outgoing_transactions
LEFT JOIN talerable_outgoing_transactions using (outgoing_transaction_id)
ORDER BY execution_time
"""
) {
all {
OutgoingTxMetadata(
id = OutgoingId(
msgId = null,
endToEndId = it.getString("end_to_end_id"),
acctSvcrRef = it.getString("acct_svcr_ref"),
),
date = it.getLong("execution_time").asInstant(),
amount = it.getAmount("amount", db.currency),
subject = it.getString("subject"),
creditor = it.getString("credit_payto"),
wtid = it.getBytes("wtid")?.run { ShortHashCode(this) },
exchangeBaseUrl = it.getString("exchange_base_url")
)
}
}
/** List initiated transaction metadata for debugging */
suspend fun initiated(): List = db.serializable(
"""
SELECT
(amount).val AS amount_val
,(amount).frac AS amount_frac
,subject
,initiation_time
,submission_date
,submission_counter
,credit_payto
,end_to_end_id
,message_id
,order_id
,initiated_outgoing_transactions.status
,initiated_outgoing_transactions.status_msg
FROM initiated_outgoing_transactions
LEFT JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
ORDER BY initiation_time
"""
) {
all {
InitiatedTxMetadata(
date = it.getLong("initiation_time").asInstant(),
amount = it.getAmount("amount", db.currency),
subject = it.getString("subject"),
creditor = it.getString("credit_payto"),
id = it.getString("end_to_end_id"),
batch = it.getString("message_id"),
batchOrder = it.getString("order_id"),
status = it.getString("status"),
msg = it.getString("status_msg"),
submissionTime = it.getLong("submission_date").asInstant(),
submissionCounter = it.getInt("submission_counter")
)
}
}
/** List initiated transaction metadata pending acknowledgment for debugging */
suspend fun initiatedAck(): List = db.serializable(
"""
SELECT
(amount).val AS amount_val
,(amount).frac AS amount_frac
,subject
,initiation_time
,credit_payto
,end_to_end_id
,initiated_outgoing_transaction_id
FROM initiated_outgoing_transactions
WHERE initiated_outgoing_batch_id IS NULL AND NOT awaiting_ack
ORDER BY initiation_time
"""
) {
all {
InitiatedTxMetadataAck(
date = it.getLong("initiation_time").asInstant(),
amount = it.getAmount("amount", db.currency),
subject = it.getString("subject"),
creditor = it.getString("credit_payto"),
id = it.getString("end_to_end_id"),
dbId = it.getLong("initiated_outgoing_transaction_id"),
)
}
}
}
/** Incoming transaction metadata for debugging */
data class IncomingTxMetadata(
val id: IncomingId,
val date: Instant,
val amount: TalerAmount,
val creditFee: DecimalNumber,
val subject: String?,
val debtor: String?,
val talerable: String?,
val bounced: String?
)
/** Outgoing transaction metadata for debugging */
data class OutgoingTxMetadata(
val id: OutgoingId,
val date: Instant,
val amount: TalerAmount,
val subject: String?,
val creditor: String?,
val wtid: ShortHashCode?,
val exchangeBaseUrl: String?
)
/** Initiated metadata for debugging */
data class InitiatedTxMetadata(
val date: Instant,
val amount: TalerAmount,
val subject: String,
val creditor: String,
val id: String,
val batch: String?,
val batchOrder: String?,
val status: String,
val msg: String?,
val submissionTime: Instant,
val submissionCounter: Int
)
/** Initiated metadata for debugging */
data class InitiatedTxMetadataAck(
val date: Instant,
val amount: TalerAmount,
val subject: String,
val creditor: String,
val dbId: Long,
val id: String
) libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt 0000664 0001750 0001750 00000034136 15156463305 030577 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.db
import tech.libeufin.common.asInstant
import tech.libeufin.common.db.*
import tech.libeufin.common.micros
import tech.libeufin.nexus.iso20022.*
import java.time.Instant
/** Data access logic for initiated outgoing payments */
class InitiatedDAO(private val db: Database) {
private val UNSETTLED_FILTER =
"status NOT IN (${SubmissionState.SETTLED.joinToString(",") { "'$it'" }})"
private val PENDING_FILTER =
"status IN (${SubmissionState.PENDING.joinToString(",") { "'$it'" }})"
/** Outgoing payments initiation result */
sealed interface PaymentInitiationResult {
data class Success(val id: Long): PaymentInitiationResult
data object RequestUidReuse: PaymentInitiationResult
}
/** Initiate a new payment */
suspend fun create(payment: InitiatedPayment): PaymentInitiationResult = db.serializable(
"""
INSERT INTO initiated_outgoing_transactions (
amount
,subject
,credit_payto
,initiation_time
,end_to_end_id
) VALUES ((?,?)::taler_amount,?,?,?,?)
RETURNING initiated_outgoing_transaction_id
"""
) {
// TODO check payto uri
bind(payment.amount)
bind(payment.subject)
bind(payment.creditor.toString())
bind(payment.initiationTime)
bind(payment.endToEndId)
oneUniqueViolation(PaymentInitiationResult.RequestUidReuse) {
PaymentInitiationResult.Success(it.getLong("initiated_outgoing_transaction_id"))
}
}
/** Register submission success of order [orderId] for batch [id] at [timestamp] */
suspend fun batchSubmissionSuccess(
id: Long,
timestamp: Instant,
orderId: String?
) = db.serializableTransaction { tx ->
// Update batch status
val updated = tx.withStatement(
"""
UPDATE initiated_outgoing_batches
SET status = 'pending'
,submission_date = ?
,status_msg = NULL
,order_id = ?
,submission_counter = submission_counter + 1
WHERE initiated_outgoing_batch_id = ? AND order_id IS NULL
"""
) {
bind(timestamp)
bind(orderId)
bind(id)
executeUpdate()
}
if (updated > 0) {
// Update unsettled batch's transaction status
tx.withStatement(
"""
UPDATE initiated_outgoing_transactions
SET status = 'pending', status_msg = NULL
WHERE initiated_outgoing_batch_id = ? AND $UNSETTLED_FILTER
"""
) {
bind(id)
executeUpdate()
}
}
}
/** Register submission failure with [msg] for batch [id] at [timestamp]*/
suspend fun batchSubmissionFailure(
id: Long,
timestamp: Instant,
msg: String?,
permanent: Boolean = false
) = db.serializableTransaction { tx ->
// Update batch status
tx.withStatement(
"""
UPDATE initiated_outgoing_batches
SET status = ?::submission_state
,submission_date = ?
,status_msg = ?
,submission_counter = submission_counter + 1
WHERE initiated_outgoing_batch_id = ?
"""
) {
if (permanent) {
bind(StatusUpdate.permanent_failure)
} else {
bind(StatusUpdate.transient_failure)
}
bind(timestamp)
bind(msg)
bind(id)
executeUpdate()
}
// Update unsettled batch's transaction status
tx.withStatement(
"""
UPDATE initiated_outgoing_transactions
SET status = ?::submission_state, status_msg = ?
WHERE initiated_outgoing_batch_id = ? AND $UNSETTLED_FILTER
"""
) {
if (permanent) {
bind(StatusUpdate.permanent_failure)
} else {
bind(StatusUpdate.transient_failure)
}
bind(msg)
bind(id)
executeUpdate()
}
}
/** Register order step [msg] for [orderId] */
suspend fun orderStep(orderId: String, msg: String) = db.serializableTransaction { tx ->
// Update pending batch status
val batchId = tx.withStatement(
"""
UPDATE initiated_outgoing_batches
SET status = 'pending', status_msg = ?
WHERE order_id = ? AND $PENDING_FILTER
RETURNING initiated_outgoing_batch_id
"""
) {
bind(msg)
bind(orderId)
oneOrNull { it.getLong(1) }
}
if (batchId != null) {
// Update pending batch's transaction status
tx.withStatement(
"""
UPDATE initiated_outgoing_transactions
SET status = 'pending', status_msg = ?
WHERE initiated_outgoing_batch_id = ? AND $PENDING_FILTER
"""
) {
bind(msg)
bind(batchId)
executeUpdate()
}
}
}
/** Register order success for [orderId] and return message_id if found */
suspend fun orderSuccess(orderId: String): String? = db.serializableTransaction { tx ->
// Update batch status
val result = tx.withStatement(
"""
UPDATE initiated_outgoing_batches
SET status = 'success'
WHERE order_id = ?
RETURNING initiated_outgoing_batch_id, message_id
"""
) {
bind(orderId)
oneOrNull {
Pair(
it.getLong("initiated_outgoing_batch_id"),
it.getString("message_id")
)
}
}
if (result == null) return@serializableTransaction null
val (batchId, messageId) = result
// Update unsettled batch's transaction status
tx.withStatement(
"""
UPDATE initiated_outgoing_transactions
SET status = 'pending'
WHERE initiated_outgoing_batch_id = ? AND $UNSETTLED_FILTER
"""
) {
bind(batchId)
executeUpdate()
}
messageId
}
/** Register order failure for [orderId] and return message_id and previous status_msg if found */
suspend fun orderFailure(orderId: String): Pair? = db.serializableTransaction { tx ->
// Update batch status
val result = tx.withStatement(
"""
UPDATE initiated_outgoing_batches
SET status = 'permanent_failure'
WHERE order_id = ?
RETURNING initiated_outgoing_batch_id, message_id, status_msg
"""
) {
bind(orderId)
oneOrNull {
Triple(
it.getLong("initiated_outgoing_batch_id"),
it.getString("message_id"),
it.getString("status_msg")
)
}
}
if (result == null) return@serializableTransaction null
val (batchId, messageId, msg) = result
// Update batch's transaction status
tx.withStatement(
"""
UPDATE initiated_outgoing_transactions
SET status = 'permanent_failure'
WHERE initiated_outgoing_batch_id = ?
"""
) {
bind(batchId)
executeUpdate()
}
Pair(messageId, msg)
}
/** Register payment status [state] with [msg] for batch [msgId] */
suspend fun batchStatusUpdate(msgId: String, state: StatusUpdate, msg: String?) = db.serializable(
"SELECT out_ok FROM batch_status_update(?,?::submission_state,?)"
) {
bind(msgId)
bind(state)
bind(msg)
one {
it.getBoolean(1)
}
}
/** Register payment status [state] with [msg] for transaction [endToEndId] in batch [msgId] */
suspend fun txStatusUpdate(endToEndId: String, msgId: String?, state: StatusUpdate, msg: String?) = db.serializable(
"SELECT out_ok FROM tx_status_update(?,?,?::submission_state,?)"
) {
bind(endToEndId)
bind(msgId)
bind(state)
bind(msg)
one {
it.getBoolean(1)
}
}
/** Unsettled initiated payment in batch [msgId] */
suspend fun unsettledTxInBatch(msgId: String, executionTime: Instant) = db.serializable(
"""
SELECT end_to_end_id
,(amount).val as amount_val
,(amount).frac as amount_frac
,subject
,credit_payto
FROM initiated_outgoing_transactions
JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
WHERE message_id = ?
AND initiated_outgoing_transactions.$UNSETTLED_FILTER
"""
) {
bind(msgId)
all {
OutgoingPayment(
id = OutgoingId(
msgId = msgId,
endToEndId = it.getString("end_to_end_id"),
acctSvcrRef = null
),
amount = it.getAmount("amount", db.currency),
subject = it.getString("subject"),
executionTime = executionTime,
creditor = it.getIbanPayto("credit_payto")
)
}
}
suspend fun ack(id: Long): Boolean {
return db.serializable("UPDATE initiated_outgoing_transactions SET awaiting_ack=false WHERE initiated_outgoing_transaction_id = ?") {
bind(id)
executeUpdateCheck()
}
}
/** Group unbatched transaction into a single batch */
suspend fun batch(timestamp: Instant, ebicsId: String, requireAck: Boolean) {
db.serializable("SELECT FROM batch_outgoing_transactions(?, ?, ?)") {
bind(timestamp)
bind(ebicsId)
bind(requireAck)
executeQuery()
}
}
/** List every initiated payment pending submission in the order they should be submitted */
suspend fun submittable(): List {
val selectPart = """
SELECT initiated_outgoing_batch_id, message_id, creation_date, (sum).val as sum_val, (sum).frac as sum_frac
FROM initiated_outgoing_batches
"""
return db.serializableTransaction { tx ->
// We want to maximize the number of successfully submitted batches in the event
// of a malformed transaction or a persistent error classified as transient. We send
// the unsubmitted batches first, starting with the oldest by creation time.
// This is the happy path, giving every batch a chance while being fair on the
// basis of creation date.
// Then we retry the failed batches, starting with the oldest by submission time.
// This the bad path retrying each failed batch applying a rotation based on
// resubmission time.
val batches = tx.withStatement(
"""
($selectPart WHERE status='unsubmitted' ORDER BY creation_date)
UNION ALL
($selectPart WHERE status='transient_failure' ORDER BY submission_date)
"""
) {
all {
PaymentBatch(
id = it.getLong("initiated_outgoing_batch_id"),
messageId = it.getString("message_id"),
creationDate = it.getLong("creation_date").asInstant(),
sum = it.getAmount("sum", db.currency),
payments = emptyList()
)
}
}.associate { it.id to Pair(it, mutableListOf()) }
// Then load transactions
tx.withStatement(
"""
SELECT
initiated_outgoing_transaction_id
,(amount).val as amount_val
,(amount).frac as amount_frac
,subject
,credit_payto
,initiated_outgoing_transactions.initiation_time
,end_to_end_id
,initiated_outgoing_batch_id
FROM initiated_outgoing_transactions
JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
WHERE initiated_outgoing_batches.status IN ('unsubmitted', 'transient_failure')
"""
) {
all {
val payment = InitiatedPayment(
id = it.getLong("initiated_outgoing_transaction_id"),
amount = it.getAmount("amount", db.currency),
creditor = it.getIbanPayto("credit_payto"),
subject = it.getString("subject"),
initiationTime = it.getLong("initiation_time").asInstant(),
endToEndId = it.getString("end_to_end_id")
)
val batchId = it.getLong("initiated_outgoing_batch_id")
batches[batchId]!!.second.add(payment)
Unit
}
}
batches.values.map { (it, payments) -> it.copy(payments = payments) }
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Constants.kt 0000664 0001750 0001750 00000001561 15122266731 027721 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus
// KV
val CHECKPOINT_KEY = "checkpoint"
val SUBMIT_TASK_KEY = "submit_task"
val FETCH_TASK_KEY = "fetch_task" libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt 0000664 0001750 0001750 00000016221 15221677432 027155 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus
import tech.libeufin.common.*
import tech.libeufin.common.db.DatabaseConfig
import tech.libeufin.nexus.db.Database
import tech.libeufin.ebics.EbicsKeysConfig
import tech.libeufin.ebics.EbicsSetupConfig
import tech.libeufin.ebics.EbicsHostConfig
import java.nio.file.Path
import java.time.Instant
import org.slf4j.Logger
import org.slf4j.LoggerFactory
private val logger: Logger = LoggerFactory.getLogger("libeufin-config")
val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus")
data class NexusIngestConfig(
val accountType: AccountType,
val ignoreTransactionsBefore: Instant,
val ignoreBouncesBefore: Instant,
val restrictionPaytoRegex: Regex?,
val bounceDeduceFee: Boolean,
val bounceFee: TalerAmount
) {
companion object {
fun default(accountType: AccountType, currency: String = "KUDOS")
= NexusIngestConfig(accountType, Instant.MIN, Instant.MIN, null, false, TalerAmount.zero(currency))
}
}
class NexusFetchConfig(config: TalerConfig, currency: String) {
private val section = config.section("nexus-fetch")
val frequency = section.duration("frequency").require()
val frequencyRaw = section.string("frequency").require()
val checkpointTime = section.time("checkpoint_time_of_day").require()
val ignoreTransactionsBefore = section.date("ignore_transactions_before").default(Instant.MIN)
val ignoreBouncesBefore = section.date("ignore_bounces_before").default(Instant.MIN)
val restrictionPaytoRegex = section.regex("restriction_payto_regex").orNull()
val bounceDeduceFee = section.boolean("bounce_deduce_fee").default(false)
val bounceFee = section.amount("bounce_fee", currency).default(TalerAmount.zero(currency))
}
class NexusSubmitConfig(config: TalerConfig) {
private val section = config.section("nexus-submit")
val frequency = section.duration("frequency").require()
val frequencyRaw = section.string("frequency").require()
val requireAck = section.boolean("manual_ack").default(false)
}
class NexusSetupConfig(config: TalerConfig): EbicsSetupConfig {
private val section = config.section("nexus-setup")
override val bankAuthPubKey = section.hex("bank_authentication_pub_key_hash").orNull()
override val bankEncPubKey = section.hex("bank_encryption_pub_key_hash").orNull()
}
class NexusHostConfig(sect: TalerConfigSection): EbicsHostConfig {
/** The bank base URL */
override val baseUrl = sect.string("host_base_url").require()
/** The bank EBICS host ID */
override val hostId = sect.string("host_id").require()
/** EBICS user ID */
override val userId = sect.string("user_id").require()
/** EBICS partner ID */
override val partnerId = sect.string("partner_id").require()
}
class NexusEbicsConfig(
sect: TalerConfigSection,
): EbicsKeysConfig {
val host by lazy { NexusHostConfig(sect) }
/** Bank account metadata */
val account = IbanAccountMetadata(
iban = sect.iban("iban").require(),
bic = sect.string("bic").require(),
name = sect.string("name").require()
)
val qrIban = sect.iban("qr_iban").orNull()
/** Bank account payto */
val payto = IbanPayto.build(account.iban.toString(), account.bic, account.name)
val dialect = sect.map("bank_dialect", "bank dialect", mapOf(
"postfinance" to Dialect.postfinance,
"gls" to Dialect.gls,
"maerki_baumann" to Dialect.maerki_baumann,
"valiant" to Dialect.valiant,
"raiffeisen" to Dialect.raiffeisen,
)).require()
/** Path where we store the bank public keys */
override val bankPublicKeysPath = sect.path("bank_public_keys_file").require()
/** Path where we store our private keys */
override val clientPrivateKeysPath = sect.path("client_private_keys_file").require()
}
class ApiConfig(section: TalerConfigSection) {
val authMethod = section.requireAuthMethod()
}
/** Configuration for libeufin-nexus */
class NexusConfig internal constructor (val cfg: TalerConfig) {
private val sect = cfg.section("nexus-ebics")
val dbCfg by lazy { cfg.dbConfig() }
val serverCfg by lazy {
cfg.loadServerConfig("nexus-httpd")
}
/** The bank's currency */
val currency = sect.string("currency").require()
val accountType = sect.map("account_type", "account type", mapOf(
"normal" to AccountType.normal,
"exchange" to AccountType.exchange
)).require()
val fetch by lazy { NexusFetchConfig(cfg, currency) }
val submit by lazy { NexusSubmitConfig(cfg) }
val ebics by lazy { NexusEbicsConfig(sect) }
val setup by lazy { NexusSetupConfig(cfg) }
val ingest get() = NexusIngestConfig(
accountType,
fetch.ignoreTransactionsBefore,
fetch.ignoreBouncesBefore,
fetch.restrictionPaytoRegex,
fetch.bounceDeduceFee,
fetch.bounceFee
)
val wireGatewayApiCfg = cfg.section("nexus-httpd-wire-gateway-api").apiConf()
val revenueApiCfg = cfg.section("nexus-httpd-revenue-api").apiConf()
val observabilityApiCfg = cfg.section("nexus-httpd-observability-api").apiConf()
}
fun NexusConfig.checkCurrency(amount: TalerAmount) {
if (amount.currency != currency) throw badRequest(
"Wrong currency: expected $currency got ${amount.currency}",
TalerErrorCode.GENERIC_CURRENCY_MISMATCH
)
}
private fun TalerConfigSection.apiConf(): ApiConfig? {
val enabled = boolean("enabled").require()
return if (enabled) {
return ApiConfig(this)
} else {
null
}
}
enum class AccountType {
normal,
exchange
}
private fun TalerConfig.dbConfig(): DatabaseConfig {
val sect = section("libeufin-nexusdb-postgres")
val configOption = sect.string("config")
return DatabaseConfig(
dbConnStr = configOption.orNull() ?: section("nexus-postgres").string("config").orNull() ?: configOption.require(),
sqlDir = sect.path("sql_dir").require()
)
}
/** Load nexus config at [configPath] */
fun nexusConfig(configPath: Path?): NexusConfig {
val config = NEXUS_CONFIG_SOURCE.fromFile(configPath)
return NexusConfig(config)
}
/** Load nexus db config at [configPath] */
fun dbConfig(configPath: Path?): DatabaseConfig =
NEXUS_CONFIG_SOURCE.fromFile(configPath).dbConfig()
/** Run [lambda] with access to a database conn pool */
suspend fun NexusConfig.withDb(lambda: suspend (Database, NexusConfig) -> Unit) {
Database(dbCfg, currency).use { lambda(it, this) }
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/ 0000775 0001750 0001750 00000000000 15236145704 026153 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt 0000664 0001750 0001750 00000055536 15221677432 030532 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.enum
import kotlin.math.min
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Contextual
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.db.PaymentDAO.*
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.ebics.*
import java.io.IOException
import java.io.InputStream
import java.time.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.temporal.*
/** Register an outgoing [payment] into [db] */
suspend fun registerOutgoingPayment(
db: Database,
payment: OutgoingPayment
): OutgoingRegistrationResult {
val metadata: Triple? = payment.subject?.let {
runCatching { parseOutgoingSubject(it) }.getOrNull()
}
val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second, metadata?.third)
if (result.new) {
if (result.initiated)
logger.info("$payment")
else
logger.warn("$payment recovered")
} else {
logger.debug("{} already seen", payment)
}
return result
}
/** Register an outgoing [payment] into [db] */
suspend fun registerOutgoingBatch(
db: Database,
batch: OutgoingBatch
) {
logger.info("BATCH ${batch.executionTime.fmtDate()} ${batch.msgId}")
for (it in db.initiated.unsettledTxInBatch(batch.msgId, batch.executionTime)) {
registerOutgoingPayment(db, it)
}
}
/**
* Register an incoming [payment] into [db]
* Stores the payment into valid talerable ones or bounces it
*/
suspend fun registerIncomingPayment(
db: Database,
cfg: NexusIngestConfig,
payment: IncomingPayment,
) {
fun logRes(res: InResult, kind: String = "", suffix: String = "") {
val fmt = buildString {
append(payment)
if (kind != "") {
append(" ")
append(kind)
}
if (res.new) {
if (res.bounceId != null) {
append(" bounced in ${res.bounceId}")
}
} else {
if (res.completed) {
append(" completed")
if (res.bounceId != null) {
append(" bounced in ${res.bounceId}")
}
} else {
if (res.bounceId != null) {
append(" already bounced in ${res.bounceId}")
}
}
}
if (suffix != "") {
append(" ")
append(suffix)
}
}
if (res.completed || res.new) {
logger.info(fmt)
} else {
logger.debug(fmt)
}
}
suspend fun bounce(cause: String) {
if (payment.id == null) {
logger.debug("{} ignored: missing bank ID", payment)
return
}
when (cfg.accountType) {
AccountType.exchange -> {
if (payment.executionTime < cfg.ignoreBouncesBefore) {
val res = db.payment.registerIncoming(payment)
logRes(res, suffix = "ignored bounce: $cause")
} else {
var bounceAmount = payment.amount
if (payment.creditFee != null && cfg.bounceDeduceFee) {
if (payment.creditFee > bounceAmount) {
val res = db.payment.registerIncoming(payment)
logRes(res, suffix = "skip bounce (transfer fee higher than amount): $cause")
return
}
bounceAmount -= payment.creditFee
}
if (cfg.bounceFee > bounceAmount) {
val res = db.payment.registerIncoming(payment)
logRes(res, suffix = "skip bounce (bounce fee higher than amount): $cause")
return
}
bounceAmount -= cfg.bounceFee
val res = db.payment.registerMalformedIncoming(
payment,
bounceAmount,
randEbicsId(),
Instant.now(),
cause
)
when (res) {
IncomingBounceRegistrationResult.Talerable ->
logger.warn("{} tried to bounce a talerable transaction", payment)
is IncomingBounceRegistrationResult.Success ->
logRes(res, suffix=": $cause")
}
}
}
AccountType.normal -> {
val res = db.payment.registerIncoming(payment)
logRes(res)
}
}
}
// Check we have enough info to handle this transaction
if (payment.debtor == null || payment.debtor.receiverName == null) {
val res = db.payment.registerIncoming(payment)
logRes(res, kind = "incomplete")
return
}
if (cfg.restrictionPaytoRegex != null) {
if (!cfg.restrictionPaytoRegex.matches(payment.debtor.toString())) {
bounce("restricted account")
return
}
}
// Else we try to parse the incoming subject
if (payment.subject != null && subjectIsQrBill(payment.subject)) {
when (val res = db.payment.registerQrBillIncoming(payment, payment.subject)) {
IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse")
IncomingRegistrationResult.MappingReuse -> bounce("mapping reuse")
IncomingRegistrationResult.UnknownMapping -> bounce("unknown mapping")
is IncomingRegistrationResult.Success -> logRes(res)
}
} else {
runCatching { parseIncomingSubject(payment.subject) }.fold(
onSuccess = { metadata ->
if (metadata is IncomingSubject.AdminBalanceAdjust) {
val res = db.payment.registerIncoming(payment)
logRes(res, kind = "admin balance adjust")
} else {
when (val res = db.payment.registerTalerableIncoming(payment, metadata)) {
IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse")
IncomingRegistrationResult.MappingReuse -> bounce("mapping reuse")
IncomingRegistrationResult.UnknownMapping -> bounce("unknown mapping")
is IncomingRegistrationResult.Success -> logRes(res)
}
}
},
onFailure = { e -> bounce(e.fmt())}
)
}
}
/** Register a [tx] notification into [db] */
suspend fun registerTransaction(
db: Database,
cfg: NexusIngestConfig,
tx: TxNotification,
) {
if (tx.executionTime < cfg.ignoreTransactionsBefore) {
logger.debug("IGNORE {}", tx)
} else {
when (tx) {
is IncomingPayment -> registerIncomingPayment(db, cfg, tx)
is OutgoingPayment -> registerOutgoingPayment(db, tx)
is OutgoingBatch -> registerOutgoingBatch(db, tx)
is OutgoingReversal -> {
logger.error("{}", tx)
db.initiated.txStatusUpdate(tx.endToEndId, tx.msgId, StatusUpdate.permanent_failure, "Payment bounced: ${tx.reason}")
}
}
}
}
/** Register a single EBICS [xml] txs [document] into [db] */
suspend fun registerTxs(
db: Database,
cfg: NexusConfig,
xml: InputStream
): Int {
var nbTx: Int = 0
parseTx(xml).forEach { accountTx ->
if (accountTx.iban == cfg.ebics.account.iban.toString()) {
require(accountTx.currency == null || accountTx.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${accountTx.currency}" }
accountTx.txs.forEach { tx ->
when (tx) {
is IncomingPayment ->
require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" }
is OutgoingPayment ->
require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" }
is OutgoingBatch, is OutgoingReversal -> {}
}
registerTransaction(db, cfg.ingest, tx)
nbTx += 1
}
} else {
logger.debug("Skip transaction for unknown account ${accountTx.iban}")
}
}
return nbTx
}
/** Register a single EBICS [xml] [document] into [db] */
suspend fun registerFile(
db: Database,
cfg: NexusConfig,
xml: InputStream,
doc: OrderDoc
) {
when (doc) {
OrderDoc.report, OrderDoc.statement, OrderDoc.notification -> {
try {
registerTxs(db, cfg, xml)
} catch (e: Exception) {
throw Exception("Ingesting transactions files failed", e)
}
}
OrderDoc.acknowledgement -> {
val acks = parseCustomerAck(xml)
for (ack in acks) {
when (ack.actionType) {
HacAction.ORDER_HAC_FINAL_POS -> {
logger.debug("{}", ack)
db.initiated.orderSuccess(ack.orderId!!)?.let { messageId ->
logger.info("Batch $messageId order ${ack.orderId} accepted at ${ack.timestamp.fmtDateTime()}")
}
}
HacAction.ORDER_HAC_FINAL_NEG -> {
logger.debug("{}", ack)
db.initiated.orderFailure(ack.orderId!!)?.let { (messageId, msg) ->
logger.error("Batch $messageId order ${ack.orderId} refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}")
}
}
else -> {
logger.debug("{}", ack)
if (ack.orderId != null) {
db.initiated.orderStep(ack.orderId, ack.msg())
}
}
}
}
}
OrderDoc.status -> {
val msgStatus = parseCustomerPaymentStatusReport(xml)
logger.debug("{}", msgStatus)
if (msgStatus.code != null) {
val msg = msgStatus.msg()
val state = when (msgStatus.code) {
ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success
ExternalPaymentGroupStatusCode.RJCT -> {
logger.error("Batch ${msgStatus.id} failed: $msg")
StatusUpdate.permanent_failure
}
else -> StatusUpdate.pending
}
db.initiated.batchStatusUpdate(msgStatus.id, state, msg)
}
for (pmtStatus in msgStatus.payments) {
if (pmtStatus.id != "NOTPROVIDED") {
logger.warn("Unexpected payment status for ${msgStatus.id}.${pmtStatus.id}")
} else if (pmtStatus.code != null) {
val msg = pmtStatus.msg()
val state = when (pmtStatus.code) {
ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success
ExternalPaymentGroupStatusCode.RJCT -> {
logger.error("Batch ${msgStatus.id} failed: $msg")
StatusUpdate.permanent_failure
}
else -> StatusUpdate.pending
}
db.initiated.batchStatusUpdate(msgStatus.id, state, msg)
}
for (txStatus in pmtStatus.transactions) {
val msg = txStatus.msg()
val state = when (txStatus.code) {
ExternalPaymentTransactionStatusCode.RJCT,
ExternalPaymentTransactionStatusCode.BLCK -> {
logger.error("Transaction ${txStatus.endToEndId} failed: $msg")
StatusUpdate.permanent_failure
}
else -> StatusUpdate.pending
}
db.initiated.txStatusUpdate(txStatus.endToEndId, null, state, msg)
}
}
}
}
}
/** Register an EBICS [payload] of [doc] into [db] */
private suspend fun registerPayload(
db: Database,
cfg: NexusConfig,
payload: InputStream,
doc: OrderDoc
) {
// Unzip payload if necessary
when (doc) {
OrderDoc.status,
OrderDoc.report,
OrderDoc.statement,
OrderDoc.notification -> {
try {
payload.unzipEach { fileName, xml ->
logger.trace("parse $fileName")
registerFile(db, cfg, xml, doc)
}
} catch (e: IOException) {
throw Exception("Could not open any ZIP archive", e)
}
}
OrderDoc.acknowledgement -> registerFile(db, cfg, payload, doc)
}
}
/**
* Fetch and register banking records from [orders] using EBICS [client] starting from [pinnedStart]
*
* If [pinnedStart] is null fetch new records.
*/
private suspend fun fetchEbicsDocuments(
client: EbicsClient,
db: Database,
cfg: NexusConfig,
orders: Collection,
pinnedStart: Instant?,
peek: Boolean
): Boolean {
val lastExecutionTime: Instant? = pinnedStart
var success = true
for ((doc, orders) in orders.groupBy { it.doc() }) {
if (doc == null) {
logger.debug("Skip unsupported orders {}", orders)
} else {
if (lastExecutionTime == null) {
logger.info("Fetching new '${doc.fullDescription()}'")
} else {
logger.info("Fetching '${doc.fullDescription()}' from timestamp: $lastExecutionTime")
}
for (order in orders) {
try {
client.download(
order,
lastExecutionTime,
null,
peek
) { payload ->
registerPayload(db, cfg, payload, doc)
}
} catch (e: EbicsError.Code) {
when (e.bankCode) {
EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE -> continue
EbicsReturnCode.EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED -> {
e.fmtLog(logger)
success = false
continue
}
else -> throw e
}
}
}
}
}
return success
}
@Serializable
data class Checkpoint(
@Contextual
val last_successfull: Instant? = null,
@Contextual
val last_trial: Instant? = null
)
class EbicsFetch: EbicsCmd() {
override fun help(context: Context) = "Downloads and parse EBICS files from the bank and register them into the database"
private val documents: Set by argument(
help = "Which documents should be fetched? If none are specified, all supported documents will be fetched",
helpTags = OrderDoc.entries.associate { Pair(it.name, it.shortDescription()) },
).enum().multiple().unique()
private val pinnedStart by option(
help = "Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents",
metavar = "YYYY-MM-DD"
).convert { dateToInstant(it) }
private val peek by option("--peek",
help = "Only supported in --transient mode, do not consume fetched documents"
).flag()
private val transientCheckpoint by option("--checkpoint",
help = "Only supported in --transient mode, run a checkpoint"
).flag()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics)
val client = EbicsClient(
cfg.ebics.host,
httpClient(),
db.ebics,
EbicsLogger(ebicsLog),
clientKeys,
bankKeys
)
val docs = if (documents.isEmpty()) OrderDoc.entries else documents.toList()
// EBICS order than should be fetched
val selectedOrder = docs.flatMap { cfg.ebics.dialect.downloadDoc(it) }
// Try to obtain real-time notification channel if not transient
val wssNotification = if (transient) {
logger.info("Transient mode: fetching once and returning")
null
} else {
val tmp = listenForNotification(client)
logger.info("Running with a frequency of ${cfg.fetch.frequencyRaw}")
tmp
}
var lastFetch = Instant.EPOCH
while (true) {
val checkpoint = db.kv.get(CHECKPOINT_KEY) ?: TaskStatus()
var nextFetch = lastFetch + cfg.fetch.frequency
var nextCheckpoint = run {
// We never ran, we must checkpoint now
if (checkpoint.last_trial == null) {
Instant.EPOCH
} else {
// We run today at checkpointTime
val checkpointDate = OffsetDateTime.now().with(cfg.fetch.checkpointTime)
val checkpointToday = checkpointDate.toInstant()
// If we already ran today we ruAn tomorrow
if (checkpoint.last_trial > checkpointToday) {
checkpointDate.plusDays(1).toInstant()
} else {
checkpointToday
}
}
}
val now = Instant.now()
var success: Boolean = true
if (
// Run transient checkpoint at request
(transient && transientCheckpoint) ||
// Or run recurrent checkpoint
(!transient && now > nextCheckpoint)
) {
logger.info("Running checkpoint")
val since = if (transient && pinnedStart != null && (checkpoint.last_successfull == null || pinnedStart!!.isBefore(checkpoint.last_successfull))) {
pinnedStart
} else {
checkpoint.last_successfull
}
success = try {
/// We fetch HKD to only fetch supported EBICS orders and get the document versions
val orders = client.download(EbicsOrder.V3.HKD) { stream ->
val hkd = EbicsAdministrative.parseHKD(stream)
val supportedOrder = hkd.partner.orders.map { it.order }
logger.debug {
val fmt = supportedOrder.map(EbicsOrder::description).joinToString(" ")
"HKD: ${fmt}"
}
selectedOrder select supportedOrder
}
fetchEbicsDocuments(client, db, cfg, orders, since, transient && peek)
} catch (e: Exception) {
e.fmtLog(logger)
false
}
db.kv.updateTaskStatus(CHECKPOINT_KEY, now, success)
lastFetch = now
} else if (transient || now > nextFetch) {
if (!transient) logger.info("Running at frequency")
success = try {
/// We fetch HAA to only fetch pending & supported EBICS orders and get the document versions
val orders = client.download(EbicsOrder.V3.HAA) { stream ->
val haa = EbicsAdministrative.parseHAA(stream)
logger.debug {
val orders = haa.orders.map(EbicsOrder::description).joinToString(" ")
"HAA: ${orders}"
}
selectedOrder select haa.orders
}
fetchEbicsDocuments(client, db, cfg, orders, if (transient) pinnedStart else null, transient && peek)
} catch (e: Exception) {
e.fmtLog(logger)
false
}
lastFetch = now
}
db.kv.updateTaskStatus(SUBMIT_TASK_KEY, now, success)
if (transient) throw ProgramResult(if (!success) 1 else 0)
val delay = min(ChronoUnit.MILLIS.between(now, nextFetch), ChronoUnit.MILLIS.between(now, nextCheckpoint))
if (wssNotification == null) {
delay(delay)
} else {
val notifications = withTimeoutOrNull(delay) {
wssNotification.receive()
}
if (notifications != null) {
// Only fetch requested and supported orders
val orders = selectedOrder select notifications
if (orders.isNotEmpty()) {
logger.info("Running at real-time notifications reception")
fetchEbicsDocuments(client, db, cfg, notifications, null, false)
}
}
}
}
}
}
}
libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt 0000664 0001750 0001750 00000013253 15122266731 027732 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.*
import com.github.ajalt.mordant.terminal.*
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.*
import tech.libeufin.ebics.randEbicsId
import tech.libeufin.nexus.iso20022.*
import java.util.zip.*
import java.time.Instant
import java.io.*
class ExportCmt: TalerCmd("export") {
override fun help(context: Context) = "Export pending batches as pain001 messages"
private val out by argument().outputStream()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
// Create and get pending batches
db.initiated.batch(Instant.now(), randEbicsId(), cfg.submit.requireAck)
val batches = db.initiated.submittable()
var nbTx: Int = 0
ZipOutputStream(BufferedOutputStream(out)).use { zip ->
val ebicsCfg = cfg.ebics
val metadata = buildString {
append("Exported ${batches.size} pain.001 files:")
for (batch in batches) {
nbTx = batch.payments.size
val entry = ZipEntry("${batch.creationDate.toDateTimeFilePath()}-${batch.messageId}.xml")
zip.putNextEntry(entry)
val msg = batchToPain001Msg(ebicsCfg.account, batch)
val xml = createPain001(
msg = msg,
dialect = ebicsCfg.dialect,
instant = false
)
zip.write(xml)
append("\nbatch ${batch.messageId}:")
for (tx in batch.payments) {
append("\n- tx ${tx.endToEndId} ${tx.amount} ${tx.creditor.iban} '${tx.creditor.receiverName}'")
}
append("\n")
}
}
zip.putNextEntry(ZipEntry("README.txt"))
zip.write(metadata.toByteArray())
logger.info(metadata)
}
}
}
}
class ImportCmt: TalerCmd("import") {
override fun help(context: Context) = "Import EBICS camt files"
private val sources by argument().inputStream().multiple(required = true)
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
for (source in sources) {
var nbTx: Int = 0
source.use { xml ->
nbTx += registerTxs(db, cfg, xml)
}
logger.info("Imported $nbTx transactions from $source")
}
}
}
}
class StatusCmd: TalerCmd("status") {
override fun help(context: Context) = "Change batches or transactions status"
enum class Kind {
batch,
tx
}
private val kind by argument().enum()
private val id by argument()
private val status by argument().enum()
private val msg by argument().optional()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
when (kind) {
Kind.batch -> if (db.initiated.batchStatusUpdate(id, status, msg)) {
logger.info("Updated batch '${id}' to ${status}")
} else {
throw Exception("Unknown batch '$id'")
}
Kind.tx -> if (db.initiated.txStatusUpdate(id, null, status, msg)) {
logger.info("Updated tx '${id}' to ${status}")
} else {
throw Exception("Unknown tx '$id'")
}
}
}
}
}
class AckCmd: TalerCmd("ack") {
override fun help(context: Context) = "Manually acknowledge the outgoing transaction for submission"
private val ids by argument().long().multiple()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
for (id in ids) {
if (db.initiated.ack(id)) {
logger.info("Mark $id as acknowledge for submission")
} else {
logger.warn("Unknown transaction $id")
}
}
}
}
}
class ManualCmd : TalerCmd("manual") {
init {
subcommands(ExportCmt(), ImportCmt(), StatusCmd(), AckCmd())
}
override fun help(context: Context) = "Manual management commands"
override fun run() = Unit
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt 0000664 0001750 0001750 00000005635 15122266731 031626 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.convert
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.option
import tech.libeufin.common.*
import tech.libeufin.nexus.db.InitiatedPayment
import tech.libeufin.nexus.nexusConfig
import tech.libeufin.nexus.withDb
import tech.libeufin.ebics.randEbicsId
import java.time.Instant
class InitiatePayment: TalerCmd() {
override fun help(context: Context) = "Initiate an outgoing payment"
private val amount by option(
"--amount",
help = "The amount to transfer, payto 'amount' parameter takes the precedence"
).convert { TalerAmount(it) }
private val subject by option(
"--subject",
help = "The payment subject, payto 'message' parameter takes the precedence"
)
private val endToEndId by option(
"--end-to-end-id",
"--request-uid",
help = "The payment end-to-end UID"
)
private val payto by argument(
help = "The credited account IBAN payto URI"
).convert { Payto.parse(it).expectIban() }
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val subject = requireNotNull(payto.message ?: subject) { "Missing subject" }
val amount = requireNotNull(payto.amount ?: amount) { "Missing amount" }
requireNotNull(payto.receiverName) { "Missing receiver name in creditor payto" }
require(amount.currency == cfg.currency) {
"Wrong currency: expected ${cfg.currency} got ${amount.currency}"
}
db.initiated.create(
InitiatedPayment(
id = -1,
amount = amount,
subject = subject,
creditor = payto,
initiationTime = Instant.now(),
endToEndId = endToEndId ?: randEbicsId()
)
)
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt 0000664 0001750 0001750 00000004746 15122266731 027610 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.ProgramResult
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import tech.libeufin.common.TalerCmd
import tech.libeufin.common.api.serve
import tech.libeufin.nexus.nexusApi
import tech.libeufin.nexus.nexusConfig
import tech.libeufin.nexus.withDb
class Serve : TalerCmd("serve") {
override fun help(context: Context) = "Run libeufin-nexus HTTP server"
private val check by option(
help = "Check whether an API is in use (if it's useful to start the HTTP server). Exit with 0 if at least one API is enabled, otherwise 1"
).flag()
override fun run() = cliCmd(logger) {
val cfg = nexusConfig(config)
if (check) {
// Check if the server is to be started
val apis = listOf(
cfg.wireGatewayApiCfg to "Wire Gateway API",
cfg.revenueApiCfg to "Revenue API"
)
var startServer = false
for ((api, name) in apis) {
if (api != null) {
startServer = true
logger.info("$name is enabled: starting the server")
}
}
if (!startServer) {
logger.info("All APIs are disabled: not starting the server")
throw ProgramResult(1)
} else {
throw ProgramResult(0)
}
}
cfg.withDb { db, cfg ->
serve(cfg.serverCfg, logger) {
nexusApi(db, cfg)
}
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt 0000664 0001750 0001750 00000013531 15221677432 030731 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import kotlinx.coroutines.delay
import tech.libeufin.common.*
import tech.libeufin.ebics.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.iso20022.*
import java.time.Instant
import kotlin.time.toKotlinDuration
fun batchToPain001Msg(account: IbanAccountMetadata, batch: PaymentBatch): Pain001Msg {
return Pain001Msg(
messageId = batch.messageId,
timestamp = batch.creationDate,
debtor = account,
sum = batch.sum,
txs = batch.payments.map { payment ->
val payto = payment.creditor
if (payto.receiverName == null) {
logger.warn("Missing receiver-name for payto $payto")
}
Pain001Tx(
creditor = IbanAccountMetadata(
iban = payto.iban,
bic = payto.bic,
name = payto.receiverName ?: "Unknown"
),
amount = payment.amount,
subject = payment.subject,
endToEndId = payment.endToEndId
)
}
)
}
/**
* Submit an initiated payments [batch] using [client].
*
* Parse creditor IBAN account metadata then perform an EBICS direct credit
*
* Returns the orderID
*/
private suspend fun submitBatch(
client: EbicsClient,
order: EbicsOrder,
batch: PaymentBatch,
cfg: NexusConfig,
instant: Boolean,
): String {
val ebicsCfg = cfg.ebics
val msg = batchToPain001Msg(ebicsCfg.account, batch)
val xml = createPain001(
msg = msg,
dialect = ebicsCfg.dialect,
instant = instant
)
return client.upload(order, xml)
}
/** Submit all pending initiated payments using [client] */
private suspend fun submitAll(client: EbicsClient, requireAck: Boolean, cfg: NexusConfig, db: Database) {
// Find a supported debit order
var instantDebitOrder = cfg.ebics.dialect.instantDirectDebit()
val debitOrder = cfg.ebics.dialect.directDebit()
// Create batch if necessary
db.initiated.batch(Instant.now(), randEbicsId(), requireAck)
// Send submittable batches
db.initiated.submittable().forEach { batch ->
logger.debug("Submitting batch {}", batch.messageId)
runCatching {
if (instantDebitOrder != null) {
try {
return@runCatching submitBatch(client, instantDebitOrder!!, batch, cfg, true)
} catch (e: EbicsError.Code) {
// No longer try to submit using the instant method for now
logger.debug("Failed to submit using instant credit order ${e.fmt()}")
instantDebitOrder = null
}
}
submitBatch(client, debitOrder, batch, cfg, false)
}.fold(
onSuccess = { orderId ->
db.initiated.batchSubmissionSuccess(batch.id, Instant.now(), orderId)
val transactions = batch.payments.joinToString(",") { it.endToEndId }
if (instantDebitOrder == null) {
logger.info("Batch ${batch.messageId} submitted as order $orderId: $transactions")
} else {
logger.info("Instant batch ${batch.messageId} submitted as order $orderId: $transactions")
}
},
onFailure = { e ->
db.initiated.batchSubmissionFailure(batch.id, Instant.now(), e.message)
logger.error("Batch ${batch.messageId} submission failure: ${e.fmt()}")
throw e
}
)
}
}
class EbicsSubmit : EbicsCmd() {
override fun help(context: Context) = "Submits pending initiated payments found in the database"
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics)
val client = EbicsClient(
cfg.ebics.host,
httpClient(),
db.ebics,
EbicsLogger(ebicsLog),
clientKeys,
bankKeys
)
if (transient) {
logger.info("Transient mode: submitting what found and returning.")
submitAll(client, cfg.submit.requireAck, cfg, db)
} else {
logger.debug("Running with a frequency of ${cfg.submit.frequencyRaw}")
while (true) {
val now = Instant.now();
val success = try {
submitAll(client, cfg.submit.requireAck, cfg, db)
true
} catch (e: Exception) {
e.fmtLog(logger)
false
}
db.kv.updateTaskStatus(SUBMIT_TASK_KEY, now, success)
// TODO take submitBatch taken time in the delay
delay(cfg.submit.frequency.toKotlinDuration())
}
}
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt 0000664 0001750 0001750 00000021503 15221677432 030133 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import com.github.ajalt.clikt.parameters.types.*
import com.github.ajalt.mordant.terminal.*
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.ebics.*
import tech.libeufin.ebics.test.txCheck
import java.util.zip.*
import java.time.Instant
import java.io.*
class Wss : TalerCmd() {
override fun help(context: Context) = "Listen to EBICS instant notification over websocket"
private val ebicsLog by ebicsLogOption()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics)
val client = EbicsClient(
cfg.ebics.host,
httpClient(),
db.ebics,
EbicsLogger(ebicsLog),
clientKeys,
bankKeys
)
val wssNotifications = listenForNotification(client)
if (wssNotifications != null) {
while (true) {
wssNotifications.receive()
logger.debug("{}", wssNotifications)
}
}
}
}
}
class FakeIncoming : TalerCmd() {
override fun help(context: Context) = "Genere a fake incoming payment"
private val amount by option(
"--amount",
help = "The payment amount, credit-payto 'amount' parameter takes the precedence"
).convert { TalerAmount(it) }
private val creditFee by option(
"--credit-fee",
help = "The payment credit fee"
).convert { TalerAmount(it) }
private val subject by option(
"--subject",
help = "The payment subject, credit-payto 'message' parameter takes the precedence"
)
private val chQrr by option(
"--ch-qrr",
help = "The payment reference, credit-payto 'ch-qrr' parameter takes the precedence"
)
private val creditPayto by option(
"--credit-payto",
help = "The credited account IBAN payto URI"
).convert { Payto.parse(it).expectIban() }
private val debitPayto by option(
"--debit-payto",
help = "The debited account IBAN payto URI"
).convert { Payto.parse(it).expectIban() }
private val payto by argument(
name = "deprecated",
help = "deprecated"
).convert { Payto.parse(it).expectIban() }.optional()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val amount = requireNotNull(creditPayto?.amount ?: payto?.amount ?: amount) { "Missing amount" }
val subject = creditPayto?.message ?: payto?.message ?: subject
val reference = creditPayto?.chQrr ?: payto?.chQrr ?: chQrr
creditPayto?.let {
if (reference != null) {
require(it.iban == cfg.ebics.qrIban) {
"Creditor must be the exchange QRR account expected ${cfg.ebics.account.iban} got ${it.iban}"
}
} else {
require(it.iban == cfg.ebics.account.iban) {
"Creditor must be the exchange expected ${cfg.ebics.account.iban} got ${it.iban}"
}
}
}
require(amount.currency == cfg.currency) {
"Wrong currency: expected ${cfg.currency} got ${amount.currency}"
}
registerIncomingPayment(
db, cfg.ingest,
IncomingPayment(
amount = amount,
debtor = debitPayto ?: payto ?: IbanPayto.rand("Testing Account", Country.valueOf(cfg.ebics.account.iban.value.substring(0 until 2))),
subject = reference ?: subject ?: "",
creditFee = creditFee,
executionTime = Instant.now(),
id = IncomingId(null, randEbicsId(), null)
)
)
}
}
}
class TxCheck : TalerCmd() {
override fun help(context: Context) = "Check transaction semantic"
override fun run() = cliCmd(logger) {
val nexusCgf = nexusConfig(config)
val cfg = nexusCgf.ebics
val (clientKeys, bankKeys) = expectFullKeys(cfg)
val order = cfg.dialect.downloadDoc(OrderDoc.acknowledgement)
val client = httpClient()
val result = txCheck(client, cfg.host, clientKeys, bankKeys, order[0], cfg.dialect.directDebit())
println("$result")
}
}
enum class ListKind {
incoming,
outgoing,
initiated;
fun description(): String = when (this) {
incoming -> "Incoming transactions"
outgoing -> "Outgoing transactions"
initiated -> "Initiated transactions"
}
}
class EbicsDownload : TalerCmd("ebics-btd") {
override fun help(context: Context) = "Perform EBICS requests"
private val type by option().default("BTD")
private val name by option()
private val scope by option()
private val messageName by option()
private val messageVersion by option()
private val container by option()
private val option by option()
private val ebicsLog by ebicsLogOption()
private val pinnedStart by option(
help = "Constant YYYY-MM-DD date for the earliest document" +
" to download (only consumed in --transient mode). The" +
" latest document is always until the current time."
)
private val peek by option(
"--peek",
help = "Do not consume fetched documents"
).flag()
private val dryRun by option().flag()
class DryRun : Exception()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics)
val pinnedStartVal = pinnedStart
val pinnedStartArg = if (pinnedStartVal != null) {
logger.debug("Pinning start date to: $pinnedStartVal")
dateToInstant(pinnedStartVal)
} else null
val client = EbicsClient(
cfg.ebics.host,
httpClient(),
db.ebics,
EbicsLogger(ebicsLog),
clientKeys,
bankKeys
)
try {
client.download(
EbicsOrder.V3(type, name, scope, messageName, messageVersion, container, option),
pinnedStartArg,
null,
peek
) { stream ->
if (container == "ZIP") {
stream.unzipEach { fileName, xmlContent ->
println(fileName)
println(xmlContent.readText())
}
} else {
println(stream.readText())
}
if (dryRun) throw DryRun()
}
} catch (e: DryRun) {
// We throw DryRun to not consume files while testing
}
}
}
}
class IbanGen : CliktCommand("gen") {
override fun help(context: Context) = "Generate fake IBANs for testing"
private val country by option().enum().required()
override fun run() {
println(IBAN.rand(country))
}
}
class IbanCmd : CliktCommand("iban") {
init {
subcommands(IbanGen())
}
override fun run() = Unit
}
class TestingCmd : CliktCommand("testing") {
init {
subcommands(IbanCmd(), FakeIncoming(), ListCmd(), EbicsDownload(), TxCheck(), Wss())
}
override fun help(context: Context) = "Testing helper commands"
override fun run() = Unit
}
libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt 0000664 0001750 0001750 00000004034 15122266731 031272 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.versionOption
import com.github.ajalt.clikt.parameters.types.path
import tech.libeufin.common.*
import tech.libeufin.nexus.NEXUS_CONFIG_SOURCE
import org.slf4j.Logger
import org.slf4j.LoggerFactory
internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus")
fun CliktCommand.ebicsLogOption() = option(
"--debug-ebics",
help = "Log EBICS transactions steps and payload at log_dir",
metavar = "log_dir"
).path()
fun CliktCommand.transientOption() = option(
"--transient",
help = "Execute once and return, ignoring the 'FREQUENCY' configuration value"
).flag(default = false)
abstract class EbicsCmd(name: String? = null): TalerCmd(name) {
val ebicsLog by ebicsLogOption()
val transient by transientOption()
}
class LibeufinNexus : CliktCommand() {
init {
versionOption(VERSION)
subcommands(DbInit(), EbicsSetup(), EbicsSubmit(), EbicsFetch(), Serve(), InitiatePayment(), ManualCmd(), ListCmd(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd())
}
override fun run() = Unit
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt 0000664 0001750 0001750 00000003126 15122266731 027664 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import tech.libeufin.common.TalerCmd
import tech.libeufin.common.db.dbInit
import tech.libeufin.common.db.pgDataSource
import tech.libeufin.nexus.dbConfig
class DbInit : TalerCmd("dbinit") {
override fun help(context: Context) = "Initialize the libeufin-nexus database"
private val reset by option(
"--reset", "-r",
help = "Reset database (DANGEROUS: All existing data is lost)"
).flag()
override fun run() = cliCmd(logger) {
val cfg = dbConfig(config)
pgDataSource(cfg.dbConnStr).dbInit(cfg, "libeufin-nexus", reset)
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt 0000664 0001750 0001750 00000021012 15221677432 030557 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2023-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.ebics.ClientPrivateKeysFile
import tech.libeufin.ebics.BankPublicKeysFile
import tech.libeufin.ebics.ebicsSetup
import tech.libeufin.ebics.askUserToAcceptKeys
import tech.libeufin.ebics.EbicsLogger
import tech.libeufin.ebics.EbicsKeyMng
import tech.libeufin.nexus.*
import tech.libeufin.ebics.*
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.Instant
import kotlin.io.path.Path
import kotlin.io.path.writeBytes
fun expectFullKeys(cfg: EbicsKeysConfig): Pair =
expectFullKeys(cfg, "libeufin-nexus ebics-setup")
/**
* CLI class implementing the "ebics-setup" subcommand.
*/
class EbicsSetup: TalerCmd() {
override fun help(context: Context) = "Set up the EBICS subscriber"
private val forceKeysResubmission by option(
help = "Resubmits all the keys to the bank"
).flag(default = false)
private val autoAcceptKeys by option(
help = "Accepts the bank keys without interactively asking the user"
).flag(default = false)
private val generateRegistrationPdf by option(
help = "Generates the PDF with the client public keys to send to the bank"
).flag(default = false)
private val ebicsLog by ebicsLogOption()
/**
* This function collects the main steps of setting up an EBICS access.
*/
override fun run() = cliCmd(logger) {
val cfg = nexusConfig(config)
val setupCfg = cfg.setup
val client = httpClient()
val ebicsLogger = EbicsLogger(ebicsLog)
val ebics3 = when (cfg.ebics.dialect) {
// TODO GLS needs EBICS 2.5 for key management
Dialect.gls -> false
else -> true
}
val (clientKeys, bankKeys) = ebicsSetup(
client,
ebicsLogger,
cfg.ebics,
cfg.ebics.host,
cfg.setup,
forceKeysResubmission,
generateRegistrationPdf,
autoAcceptKeys,
ebics3
)
// Check account information
logger.info("Doing administrative request HKD")
cfg.withDb { db, _ ->
EbicsClient(
cfg.ebics.host,
client,
db.ebics,
ebicsLogger,
clientKeys,
bankKeys
).download(EbicsOrder.V3.HKD) { stream ->
val (partner, users) = EbicsAdministrative.parseHKD(stream)
val user = users.find { it -> it.id == cfg.ebics.host.userId }
// Debug logging
logger.debug {
buildString {
if (partner.name != null || partner.accounts.isNotEmpty()) {
append("Partner Info: ")
if (partner.name != null) {
append("'")
append(partner.name)
append("'")
}
for ((currency, iban, bic) in partner.accounts) {
append(' ')
append(currency)
append('-')
append(iban)
append('-')
append(bic)
}
append('\n')
}
append("Supported orders:\n")
for ((order, description) in partner.orders) {
append("- ")
append(order.description())
append(": ")
append(description)
append('\n')
}
if (user != null) {
append("Authorized orders:\n")
for ((order) in partner.orders) {
append("- ")
append(order.description())
append('\n')
}
}
}
}
// Check partner info match config
if (partner.name != null && partner.name != cfg.ebics.account.name)
logger.warn("Expected NAME '${cfg.ebics.account.name}' from config got '${partner.name}' from bank")
val account = partner.accounts.find { it.iban == cfg.ebics.account.iban.toString() }
if (account != null) {
if (account.currency != null && account.currency != cfg.currency)
logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank")
if (account.bic != cfg.ebics.account.bic)
logger.error("Expected BIC '${cfg.ebics.account.bic}' from config got '${account.bic}' from bank")
} else if (partner.accounts.isNotEmpty()) {
val ibans = partner.accounts.map { it.iban }.joinToString(" ")
logger.error("Expected IBAN ${cfg.ebics.account.iban} from config got $ibans from bank")
}
val instantDebitOrder = cfg.ebics.dialect.instantDirectDebit()
val debitOrder = cfg.ebics.dialect.directDebit()
val requireOrders = cfg.ebics.dialect.downloadOrders()
val partnerOrders = partner.orders.map { it.order }
// Check partner support for direct debit orders
if (instantDebitOrder != null && instantDebitOrder !in partnerOrders) {
logger.warn("Unsupported instant debit order: ${instantDebitOrder.description()}")
}
if (debitOrder !in partnerOrders) {
logger.warn("Unsupported debit order: ${debitOrder.description()}")
}
// Check partner support required orders
val unsupportedOrder = requireOrders subtract partnerOrders
if (unsupportedOrder.isNotEmpty()) {
logger.warn("Unsupported orders: {}", unsupportedOrder.map(EbicsOrder::description).joinToString(" "))
}
if (user != null) {
// Check user is authorized for direct debit orders
if (instantDebitOrder != null && instantDebitOrder in partnerOrders && instantDebitOrder !in user.permissions) {
logger.warn("Unauthorized instant debit order: ${instantDebitOrder.description()}")
}
if (debitOrder in partnerOrders && debitOrder !in user.permissions) {
logger.warn("Unauthorized debit order: ${debitOrder.description()}")
}
// Check user is authorized for required orders
val unauthorizedOrders = requireOrders subtract user.permissions subtract unsupportedOrder
if (unauthorizedOrders.isNotEmpty()) {
logger.warn("Unauthorized orders: {}", unauthorizedOrders.map(EbicsOrder::description).joinToString(" "))
}
logger.info("Subscriber status: {}", user.status.description)
}
}
}
println("setup ready")
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt 0000664 0001750 0001750 00000014056 15140725607 027434 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import com.github.ajalt.clikt.parameters.options.convert
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.*
import com.github.ajalt.mordant.terminal.*
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.iso20022.*
import java.util.zip.*
import java.time.Instant
import java.io.*
private fun fmtPayto(payto: String): String {
try {
val parsed = Payto.parse(payto).expectIban()
return buildString {
append(parsed.iban.toString())
if (parsed.bic != null) append(" ${parsed.bic}")
if (parsed.receiverName != null) append(" ${parsed.receiverName}")
}
} catch (e: Exception) {
return payto.removePrefix("payto://")
}
}
class ListIncoming: TalerCmd("incoming") {
override fun help(context: Context) = "List incoming transactions"
private val incomplete by option(
"--incomplete",
help = "Only list transactions that are incomplete",
).flag()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val txs = db.list.incoming(incomplete)
for (tx in txs) {
println(buildString{
if (tx.creditFee.isZero()) {
append("${tx.date} ${tx.id} ${tx.amount}\n")
} else {
append("${tx.date} ${tx.id} ${tx.amount}-${tx.creditFee}\n")
}
if (tx.debtor != null) {
append(" debtor: ${fmtPayto(tx.debtor)}\n")
}
if (tx.subject != null) {
append(" subject: ${tx.subject}\n")
}
if (tx.talerable != null) {
append(" talerable: ${tx.talerable}\n")
}
if (tx.bounced != null) {
append(" bounced: ${tx.bounced}\n")
}
})
}
}
}
}
class ListOutgoing: TalerCmd("outgoing") {
override fun help(context: Context) = "List outgoing transactions"
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
val txs = db.list.outgoing()
for (tx in txs) {
println(buildString{
append("${tx.date} ${tx.id} ${tx.amount}\n")
if (tx.creditor != null) {
append(" creditor: ${fmtPayto(tx.creditor)}\n")
}
append(" subject: ${tx.subject}\n")
if (tx.wtid != null) {
append(" talerable: ${tx.wtid} ${tx.exchangeBaseUrl}\n")
}
})
}
}
}
}
class ListInitiated: TalerCmd("initiated") {
override fun help(context: Context) = "List initiated transactions"
private val awaitingAck by option(
"--ack", "--awaiting-ack",
help = "Only list transactions awaiting manual acknowledgement",
).flag()
override fun run() = cliCmd(logger) {
nexusConfig(config).withDb { db, cfg ->
if (awaitingAck) {
val txs = db.list.initiatedAck()
for (tx in txs) {
println(buildString{
append("${tx.date} ${tx.id} ${tx.amount}\n")
append(" creditor: ${fmtPayto(tx.creditor)}\n")
append(" subject: ${tx.subject}\n")
append(" ack: ${tx.dbId}")
append('\n')
})
}
} else {
val txs = db.list.initiated()
for (tx in txs) {
println(buildString{
append("${tx.date} ${tx.id} ${tx.amount}\n")
append(" creditor: ${fmtPayto(tx.creditor)}\n")
append(" subject: ${tx.subject}\n")
if (tx.batch != null) {
append(" batch: ${tx.batch}")
if (tx.batchOrder != null)
append(" ${tx.batchOrder}")
append('\n')
}
if (tx.submissionCounter > 0) {
append(" submission: ${tx.submissionTime} ${tx.submissionCounter}\n")
}
append(" status: ${tx.status}")
if (tx.msg != null) {
append(" ${tx.msg}")
}
append('\n')
})
}
}
}
}
}
class ListCmd: CliktCommand("list") {
override fun help(context: Context) = "List nexus transactions"
init {
subcommands(ListIncoming(), ListOutgoing(), ListInitiated())
}
override fun run() = Unit
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt 0000664 0001750 0001750 00000006051 15221677432 026634 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2023-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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
/**
* This file collects all the CLI subcommands and runs
* them. The actual implementation of each subcommand is
* kept in their respective files.
*/
package tech.libeufin.nexus
import io.ktor.server.application.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlinx.serialization.Serializable
import kotlinx.serialization.Contextual
import tech.libeufin.common.api.OpenApiInfo
import tech.libeufin.common.api.talerApi
import tech.libeufin.common.setupSecurityProperties
import tech.libeufin.common.VERSION
import tech.libeufin.nexus.api.revenueApi
import tech.libeufin.nexus.api.preparedTransferAPI
import tech.libeufin.nexus.api.wireGatewayApi
import tech.libeufin.nexus.api.observabilityApi
import tech.libeufin.nexus.cli.LibeufinNexus
import tech.libeufin.nexus.db.Database
import com.github.ajalt.clikt.core.main
import tech.libeufin.common.IBAN
import java.time.Instant
/** Triple identifying one IBAN bank account */
data class IbanAccountMetadata(
val iban: IBAN,
val bic: String?,
val name: String
)
fun Application.nexusApi(db: Database, cfg: NexusConfig, serveSpec: Boolean = false) = talerApi(
LoggerFactory.getLogger("libeufin-nexus-api"),
OpenApiInfo(
title = "LibEuFin Nexus API",
version = VERSION,
description = "Taler wire gateway, wire transfer gateway, revenue, and observability APIs for LibEuFin Nexus",
securityConfig = {
securityScheme("bearerAuth") {
type = io.github.smiley4.ktoropenapi.config.AuthType.HTTP
scheme = io.github.smiley4.ktoropenapi.config.AuthScheme.BEARER
bearerFormat = "token"
}
securityScheme("basicAuth") {
type = io.github.smiley4.ktoropenapi.config.AuthType.HTTP
scheme = io.github.smiley4.ktoropenapi.config.AuthScheme.BASIC
description = "HTTP Basic authentication, supported only on compatibility-enabled endpoints"
}
}
),
serveSpec = serveSpec
) {
wireGatewayApi(db, cfg)
preparedTransferAPI(db, cfg)
revenueApi(db, cfg)
observabilityApi(db, cfg)
}
fun main(args: Array) {
setupSecurityProperties()
LibeufinNexus().main(args)
}
@Serializable
data class TaskStatus(
@Contextual
val last_successfull: Instant? = null,
@Contextual
val last_trial: Instant? = null
) libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/dialect.kt 0000664 0001750 0001750 00000007646 15161724132 027361 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus
import tech.libeufin.ebics.*
infix fun Collection.select(other: Collection): List
= this.flatMap { filter -> other.filter { order -> filter.match(order) } }
/** Supported EBICS standard */
enum class Standard {
/// Swiss Payment Standards
SIX,
/// German Banking Industry Committee
GBIC;
fun downloadDoc(doc: OrderDoc): List = when (this) {
SIX -> when (doc) {
OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC)
OrderDoc.status -> listOf(EbicsOrder.V3("BTD", "PSR", "CH", "pain.002", "10", "ZIP"))
OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "CH", "camt.052", "08", "ZIP"))
OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "CH", "camt.053", "08", "ZIP"))
OrderDoc.notification -> listOf(EbicsOrder.V3("BTD", "REP", "CH", "camt.054", "08", "ZIP"))
}
GBIC -> when (doc) {
OrderDoc.acknowledgement -> listOf(EbicsOrder.V3.HAC)
OrderDoc.status -> listOf(
EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCI"),
EbicsOrder.V3("BTD", "REP", "DE", "pain.002", null, "ZIP", "SCT")
)
OrderDoc.report -> listOf(EbicsOrder.V3("BTD", "STM", "DE", "camt.052", null, "ZIP"))
OrderDoc.statement -> listOf(EbicsOrder.V3("BTD", "EOP", "DE", "camt.053", null, "ZIP"))
OrderDoc.notification -> listOf(
EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP"),
EbicsOrder.V3("BTD", "STM", "DE", "camt.054", null, "ZIP", "SCI")
)
}
}
fun directDebit(): EbicsOrder = when (this) {
SIX -> EbicsOrder.V3("BTU", "MCT", "CH", "pain.001", "09")
GBIC -> EbicsOrder.V3("BTU", "SCT", null, "pain.001")
}
fun instantDirectDebit(): EbicsOrder? = when (this) {
SIX -> null
GBIC -> EbicsOrder.V3("BTU", "SCI", "DE", "pain.001")
}
}
/** Supported bank dialects */
enum class Dialect {
valiant,
raiffeisen,
postfinance,
gls,
maerki_baumann;
fun standard(): Standard = when (this) {
valiant, postfinance, raiffeisen, maerki_baumann -> Standard.SIX
gls -> Standard.GBIC
}
fun downloadDoc(doc: OrderDoc): List {
if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access")
return this.standard().downloadDoc(doc)
}
fun directDebit(): EbicsOrder {
if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access")
return this.standard().directDebit()
}
fun instantDirectDebit(): EbicsOrder? {
if (this == maerki_baumann) throw IllegalArgumentException("Maerki Baumann does not have EBICS access")
return this.standard().instantDirectDebit()
}
/** All orders required for a dialect implementation to work */
fun downloadOrders(): Set = (
// Administrative orders
sequenceOf(EbicsOrder.V3.HAA, EbicsOrder.V3.HKD)
// and documents orders
+ OrderDoc.entries.flatMap { downloadDoc(it) }
).toSet()
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt 0000664 0001750 0001750 00000002113 15122266731 027401 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
fun Instant.fmtDate(): String =
DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.of("UTC")).format(this)
fun Instant.fmtDateTime(): String =
DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.of("UTC")).format(this)
libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ 0000775 0001750 0001750 00000000000 15236145704 026564 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt 0000664 0001750 0001750 00000012752 15221677432 030305 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.iso20022
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.ebics.XmlBuilder
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/** String representation of a Taler [amount] compatible with EBICS */
fun getAmountNoCurrency(amount: TalerAmount): String {
if (amount.isSubCent()) {
throw Exception("Sub-cent amounts not supported")
}
return amount.number().toString()
}
/** pain.001 transaction metadata */
data class Pain001Tx(
val creditor: IbanAccountMetadata,
val amount: TalerAmount,
val subject: String,
val endToEndId: String
)
/** pain.001 message metadata */
data class Pain001Msg(
val messageId: String,
val timestamp: Instant,
val debtor: IbanAccountMetadata,
val sum: TalerAmount,
val txs: List
)
/** Create a pain.001 XML document [msg] valid for [dialect] */
fun createPain001(
msg: Pain001Msg,
dialect: Dialect,
instant: Boolean
): ByteArray {
val version = "09"
val suffix = when (dialect.standard()) {
Standard.SIX -> ".ch.03"
Standard.GBIC -> ""
}
val zonedTimestamp = ZonedDateTime.ofInstant(msg.timestamp, ZoneId.of("UTC"))
val totalAmount = getAmountNoCurrency(msg.sum)
return XmlBuilder.toBytes("Document") {
attr("xmlns", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version")
attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
attr("xsi:schemaLocation", "urn:iso:std:iso:20022:tech:xsd:pain.001.001.$version pain.001.001.$version$suffix.xsd")
el("CstmrCdtTrfInitn") {
el("GrpHdr") {
// Used for idempotency as banks will refuse to process EBICS request with the same MsgId for a pre-agreed period
// Used to uniquely identify batches of transactions in other files
el("MsgId", msg.messageId)
el("CreDtTm", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedTimestamp))
el("NbOfTxs", msg.txs.size.toString())
el("CtrlSum", totalAmount)
el("InitgPty/Nm", msg.debtor.name)
/* TODO fail with GLS: ES_VERIFICATION IncorrectFileStructure - 'Signature verification' 'The file format is incomplete or invalid'
el("InitnSrc") {
el("Nm", "LibEuFin")
el("Prvdr", "Taler Systems SA")
el("Vrsn", VERSION)
}*/
// LocalInstrument
}
el("PmtInf") {
el("PmtInfId", "NOTPROVIDED")
el("PmtMtd", "TRF")
el("BtchBookg", "false")
el("NbOfTxs", msg.txs.size.toString())
el("CtrlSum", totalAmount)
if (dialect == Dialect.gls) {
el("PmtTpInf") {
el("SvcLvl/Cd", "SEPA")
if (instant) {
el("LclInstrm/Cd", "INST")
}
}
}
el("ReqdExctnDt/Dt", DateTimeFormatter.ISO_DATE.format(zonedTimestamp))
el("Dbtr/Nm", msg.debtor.name)
el("DbtrAcct/Id/IBAN", msg.debtor.iban.toString())
el("DbtrAgt/FinInstnId") {
if (msg.debtor.bic != null) {
el("BICFI", msg.debtor.bic)
} else {
el("Othr/Id", "NOTPROVIDED")
}
}
el("ChrgBr", "SLEV")
for (tx in msg.txs) {
el("CdtTrfTxInf") {
el("PmtId") {
el("InstrId", tx.endToEndId)
// Used to uniquely identify transactions in other files
el("EndToEndId", tx.endToEndId)
}
el("Amt/InstdAmt") {
attr("Ccy", tx.amount.currency)
text(getAmountNoCurrency(tx.amount))
}
if (tx.creditor.bic != null) el("CdtrAgt/FinInstnId/BICFI", tx.creditor.bic)
el("Cdtr") {
el("Nm", tx.creditor.name)
// Addr might become a requirement in the future
/*el("PstlAdr") {
el("TwnNm", "Bochum")
el("Ctry", "DE")
}*/
}
el("CdtrAcct/Id/IBAN", tx.creditor.iban.toString())
el("RmtInf/Ustrd", tx.subject)
}
}
}
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt 0000664 0001750 0001750 00000060733 15122266731 030057 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.iso20022
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.ebics.XmlDestructor
import java.io.InputStream
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import org.slf4j.Logger
import org.slf4j.LoggerFactory
private val logger: Logger = LoggerFactory.getLogger("libeufin-iso20022")
sealed interface TxNotification {
val executionTime: Instant
}
/** ID for incoming transactions */
data class IncomingId(
/** ISO20022 UETR */
val uetr: UUID? = null,
/** ISO20022 TxID */
val txId: String? = null,
/** ISO20022 AcctSvcrRef */
val acctSvcrRef: String? = null,
) {
constructor(uetr: String, txId: String?, acctSvcrRef: String?) : this(UUID.fromString(uetr), txId, acctSvcrRef);
fun ref(): String = uetr?.toString() ?: txId ?: acctSvcrRef!!
override fun toString(): String = buildString {
append('(')
if (uetr != null) {
append("uetr=")
append(uetr.toString())
}
if (txId != null) {
if (length != 1) append(" ")
append("tx=")
append(txId)
}
if (acctSvcrRef != null) {
if (length != 1) append(" ")
append("ref=")
append(acctSvcrRef)
}
append(')')
}
}
sealed interface OutId {}
/** ID for outgoing transactions */
data class OutgoingId(
/**
* Unique msg ID generated by libeufin-nexus
* ISO20022 MessageId
**/
val msgId: String? = null,
/**
* Unique end-to-end ID generated by libeufin-nexus
* ISO20022 EndToEndId or MessageId (retrocompatibility)
**/
val endToEndId: String? = null,
/**
* Unique end-to-end ID generated by the bank
* ISO20022 AcctSvcrRef
**/
val acctSvcrRef: String? = null,
): OutId {
fun ref(): String = endToEndId ?: acctSvcrRef ?: msgId!!
override fun toString(): String = buildString {
append('(')
if (msgId != null && msgId != endToEndId) {
append("msg=")
append(msgId.toString())
}
if (endToEndId != null) {
if (length != 1) append(" ")
append("e2e=")
append(endToEndId)
}
if (acctSvcrRef != null) {
if (length != 1) append(" ")
append("ref=")
append(acctSvcrRef)
}
append(')')
}
}
/** ID for outgoing batches */
data class BatchId(
/**
* Unique msg ID generated by libeufin-nexus
* ISO20022 MessageId
**/
val msgId: String,
/**
* Unique end-to-end ID generated by the bank
* ISO20022 AcctSvcrRef
**/
val acctSvcrRef: String? = null,
): OutId {
fun ref(): String = msgId
override fun toString(): String = buildString {
append("(msg=")
append(msgId)
if (acctSvcrRef != null) {
if (length != 1) append(" ")
append("ref=")
append(acctSvcrRef)
}
append(')')
}
}
/** ISO20022 incoming payment */
data class IncomingPayment(
val id: IncomingId,
val amount: TalerAmount,
val creditFee: TalerAmount? = null,
val subject: String?,
override val executionTime: Instant,
val debtor: IbanPayto?
): TxNotification {
override fun toString(): String = buildString {
append("IN ")
append(executionTime.fmtDate())
append(" ")
append(amount)
if (creditFee != null) {
append("-")
append(creditFee)
}
append(" ")
append(id)
if (debtor != null) {
append(" debtor=")
append(debtor.fmt())
}
if (subject != null) {
append(" subject='")
append(subject)
append("'")
}
}
}
/** ISO20022 outgoing payment */
data class OutgoingPayment(
val id: OutgoingId,
val amount: TalerAmount,
val debitFee: TalerAmount? = null,
val subject: String?,
override val executionTime: Instant,
val creditor: IbanPayto?
): TxNotification {
override fun toString(): String = buildString {
append("OUT ")
append(executionTime.fmtDate())
append(" ")
append(amount)
if (debitFee != null) {
append("-")
append(debitFee)
}
append(" ")
append(id)
if (creditor != null) {
append(" creditor=")
append(creditor.fmt())
}
if (subject != null) {
append(" subject='")
append(subject)
append("'")
}
}
}
/** ISO20022 outgoing batch */
data class OutgoingBatch(
/** ISO20022 MessageId */
val msgId: String,
override val executionTime: Instant,
): TxNotification {
override fun toString(): String {
return "BATCH ${executionTime.fmtDate()} $msgId"
}
}
/** ISO20022 outgoing reversal */
data class OutgoingReversal(
/** ISO20022 EndToEndId */
val endToEndId: String,
/** ISO20022 MessageId */
val msgId: String? = null,
val reason: String?,
override val executionTime: Instant
): TxNotification {
override fun toString(): String {
val msgIdFmt = if (msgId == null) "" else "$msgId."
return "BOUNCE ${executionTime.fmtDate()} $msgIdFmt$endToEndId: $reason"
}
}
private class IncompleteTx(val msg: String): Exception(msg)
private enum class Kind {
CRDT,
DBIT
}
/** Parse a payto */
private fun XmlDestructor.payto(prefix: String): IbanPayto? {
return opt("RltdPties") {
val iban = opt("${prefix}Acct")?.one("Id")?.opt("IBAN")?.text()
if (iban != null) {
val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() }
// TODO more performant option
ibanPayto(iban, name)
} else {
null
}
}
}
/** Check if an entry status is BOOK */
private fun XmlDestructor.isBooked(): Boolean {
// We check at the Sts or Sts/Cd level for retrocompatibility
return one("Sts") {
val status = opt("Cd")?.text() ?: text()
status == "BOOK"
}
}
/** Parse the instruction execution date */
private fun XmlDestructor.executionDate(): Instant {
// Value date if present else booking date
val date = opt("ValDt") ?: one("BookgDt")
val parsed = date.opt("Dt") {
date().atStartOfDay()
} ?: date.one("DtTm") {
dateTime()
}
return parsed.toInstant(ZoneOffset.UTC)
}
/** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */
private fun XmlDestructor.outgoingId(ref: String?): OutId =
opt("Refs") {
val endToEndId = opt("EndToEndId")?.text()
val msgId = opt("MsgId")?.text()
val ref = if (ref != "NOTPROVIDED") ref else null
if (msgId != null && endToEndId == null) {
// This is a batch representation
BatchId(msgId, ref)
} else if (endToEndId == "NOTPROVIDED") {
// If not set use MsgId as end-to-end ID for retrocompatibility
OutgoingId(msgId, msgId, ref)
} else {
OutgoingId(msgId, endToEndId, ref)
}
} ?: OutgoingId(acctSvcrRef = ref)
/** Parse transaction ids as provided by bank*/
private fun XmlDestructor.incomingId(ref: String?): IncomingId =
opt("Refs") {
val uetr = opt("UETR")?.uuid()
val txId = opt("TxId")?.text()
IncomingId(uetr, txId, ref)
} ?: IncomingId(acctSvcrRef = ref)
/** Parse and format transaction return reasons */
private fun XmlDestructor.returnReason(): String = opt("RtrInf") {
val code = one("Rsn").one("Cd").enum()
val info = map("AddtlInf") { text() }.joinToString("")
buildString {
append("${code.isoCode} '${code.description}'")
if (info.isNotEmpty()) {
append(" - '$info'")
}
}
} ?: opt("RmtInf") {
map("Ustrd") { text() }.joinToString("")
} ?: ""
/** Parse amount */
private fun XmlDestructor.amount() = one("Amt") {
val currency = attr("Ccy")
val amount = text()
val concat = if (amount.startsWith('.')) {
"$currency:0$amount"
} else {
"$currency:$amount"
}
TalerAmount(concat)
}
data class ComplexAmount(
// Transaction amount
val amount: TalerAmount,
// The applied fee
private val fee: TalerAmount,
) {
/// The fees to register in database
fun fee(): TalerAmount? = if (fee.isZero()) { null } else { fee }
/// Check that entry and tx amount are compatible and return the result
fun resolve(child: ComplexAmount): ComplexAmount {
// Most time transaction will match
if (this.amount == child.amount && this.fee == child.fee) {
return this
}
// Or one of the level is missing the fee
if (
(child.amount > child.fee && child.amount - child.fee == this.amount) ||
this.amount - this.fee == child.amount
) {
if (child.fee.isZero()) {
return this
} else {
return child
}
}
// Or the conversion information are only present at the entry layer
if (child.amount.currency != this.amount.currency) {
return this
}
throw Error("Amount mismatch, got ${this} in the entry and ${child} in the tx")
}
}
private fun XmlDestructor.complexAmount(charges: List): ComplexAmount? {
// Amount before charges
var amount = opt("Amt") {
val currency = attr("Ccy")
// In case of fee overflow it's possible to have a negative amount here
// We ignore this as it will be handled elsewhere correctly
val amount = text().trimStart('-')
TalerAmount("$currency:0$amount")
} ?: return null
var fee: TalerAmount = TalerAmount.zero(amount.currency)
for (chr in charges) {
if (chr.included && !chr.amount.isZero()) {
fee += chr.amount
if (chr.kind == Kind.DBIT) {
if (chr.bearer == ChargeBearer.DEBT) {
if (chr.amount > amount) {
// This can happen when an incoming transaction fail because of debit fee
amount = chr.amount - amount
} else {
amount -= chr.amount
}
} else if (chr.bearer == ChargeBearer.CRED) {
amount += chr.amount
} else {
throw Error("Included charge ${chr.kind} with bearer ${chr.bearer}")
}
}
}
}
return ComplexAmount(amount, fee)
}
/** Parse bank transaction code */
private fun XmlDestructor.bankTransactionCode(): BankTransactionCode {
return one("BkTxCd").one("Domn") {
val domain = one("Cd").enum()
one("Fmly") {
val family = one("Cd").enum()
val subFamily = one("SubFmlyCd").enum()
BankTransactionCode(domain, family, subFamily)
}
}
}
/** Parse optional bank transaction code */
private fun XmlDestructor.optBankTransactionCode(): BankTransactionCode? {
return opt("BkTxCd")?.one("Domn") {
val domain = one("Cd").enum()
one("Fmly") {
val family = one("Cd").enum()
val subFamily = one("SubFmlyCd").enum()
BankTransactionCode(domain, family, subFamily)
}
}
}
/** Parse transaction wire transfer subject */
private fun XmlDestructor.wireTransferSubject(): String? = opt("RmtInf") {
map("Ustrd") { text() }.joinToString("").trim()
}
/** Parse account information */
private fun XmlDestructor.account(): Pair = one("Acct") {
Pair(
one("Id") {
(opt("IBAN") ?: one("Othr").one("Id")).text()
},
opt("Ccy")?.text()
)
}
private data class ChargeRecord(
val amount: TalerAmount,
val kind: Kind,
val included: Boolean,
val bearer: ChargeBearer
)
private fun XmlDestructor.charges(): List = opt("Chrgs")?.map("Rcrd") {
val amount = amount()
val kind = opt("CdtDbtInd")?.enum() ?: Kind.CRDT
val included = opt("ChrgInclInd")?.bool() ?: true // TODO not clear in spec
val bearer = opt("Br")?.enum() ?: ChargeBearer.SHAR
ChargeRecord(amount, kind, included, bearer)
} ?: emptyList()
data class AccountTransactions(
val iban: String?,
val currency: String?,
val txs: List
) {
companion object {
internal fun fromParts(iban: String?, currency: String?, txsInfos: List): AccountTransactions {
val txs = txsInfos.mapNotNull {
try {
it.parse()
} catch (e: IncompleteTx) {
// TODO: add more info in doc or in log message?
logger.warn("skip incomplete tx: ${e.msg}")
null
}
}
return AccountTransactions(iban, currency, txs)
}
}
}
/** Parse camt.054 or camt.053 file */
fun parseTx(notifXml: InputStream): List {
/*
In ISO 20022 specifications, most fields are optional and the same information
can be written several times in different places. For libeufin, we're only
interested in a subset of the available values that can be found in both camt.052,
camt.053 and camt.054. This function should not fail on legitimate files and should
simply warn when available information are insufficient.
EBICS and ISO20022 do not provide a perfect transaction identifier. The best is the
UETR (unique end-to-end transaction reference), which is a universally unique
identifier (UUID). However, it is not supplied by all banks. TxId (TransactionIdentification)
is a unique identification as assigned by the first instructing agent. As its format
is ambiguous, its uniqueness is not guaranteed by the standard, and it is only
supposed to be unique for a “pre-agreed period”, whatever that means. These two
identifiers are optional in the standard, but have the advantage of being unique
and can be used to track a transaction between banks so we use them when available.
It is also possible to use AccountServicerReference, which is a unique reference
assigned by the account servicing institution. They can be present at several levels
(batch level, transaction level, etc.) and are often optional. They also have the
disadvantage of being known only by the account servicing institution. They should
therefore only be used as a last resort.
*/
logger.trace("Parse transactions camt file")
val accountTxs = mutableListOf()
/** Common parsing logic for camt.052, camt.053 and camt.054 */
fun XmlDestructor.parseInner() {
val (iban, currency) = account()
val txInfos = mutableListOf()
val batches = each("Ntry") {
if (!isBooked()) return@each
val entryCode = bankTransactionCode()
val reversal = opt("RvslInd")?.text() == "true"
val entryKind = opt("CdtDbtInd")?.enum();
val entryRef = opt("AcctSvcrRef")?.text()
val bookDate = executionDate()
val entryCharges = charges()
val entryAmount = complexAmount(entryCharges)!!
// When an entry only contain a single transactions information will sometimes only be stored at the entry level
val tmp = opt("NtryDtls")?.map("TxDtls") { this } ?: return@each
val unique = tmp.size == 1
for (it in tmp) {it.run {
// Check information are present and coherent
val kind = requireNotNull(opt("CdtDbtInd")?.enum() ?: entryKind) { "WTF" }
// Sometimes the transaction level have a more precise bank transaction code
val code = optBankTransactionCode() ?: entryCode
// Amount
val amount = if (unique) {
// When unique the charges can be only at the entry level
val txCharges = charges()
val txAmount = complexAmount(if (txCharges.isEmpty()) entryCharges else txCharges)
// Check coherence
if (txAmount != null) entryAmount.resolve(txAmount) else entryAmount
} else {
// When many inner transaction the entry level is an aggregate of them
// We only use the transaction level information
requireNotNull(complexAmount(charges())) { "Missing tx amount" }
}
// We can only use the entry ref as the transaction ref if there is a single transaction in the batch
val ref = opt("Refs")?.opt("AcctSvcrRef")?.text() ?: if (unique) entryRef else null
if (code.isReversal() || reversal) {
val outgoingId = outgoingId(ref)
when (kind) {
Kind.CRDT -> {
val reason = returnReason()
txInfos.add(TxInfo.CreditReversal(
bookDate = bookDate,
id = outgoingId,
reason = reason,
code = code
))
}
Kind.DBIT -> {
val id = incomingId(ref)
val subject = wireTransferSubject()
val debtor = payto("Dbtr")
val fee = amount.fee()
txInfos.add(TxInfo.Credit(
bookDate = bookDate,
id = id,
amount = amount.amount,
subject = subject,
debtor = debtor,
code = code,
creditFee = fee
))
}
}
} else {
val subject = wireTransferSubject()
when (kind) {
Kind.CRDT -> {
val id = incomingId(ref)
val debtor = payto("Dbtr")
txInfos.add(TxInfo.Credit(
bookDate = bookDate,
id = id,
amount = amount.amount,
subject = subject,
debtor = debtor,
code = code,
creditFee = amount.fee()
))
}
Kind.DBIT -> {
val outgoingId = outgoingId(ref)
val creditor = payto("Cdtr")
txInfos.add(TxInfo.Debit(
bookDate = bookDate,
id = outgoingId,
amount = amount.amount,
subject = subject,
creditor = creditor,
code = code,
debitFee = amount.fee()
))
}
}
}
}}
}
accountTxs.add(AccountTransactions.fromParts(iban, currency, txInfos))
}
XmlDestructor.parse(notifXml, "Document") {
// Camt.053
opt("BkToCstmrStmt")?.each("Stmt") { parseInner() }
// Camt.052
opt("BkToCstmrAcctRpt")?.each("Rpt") { parseInner() }
// Camt.054
opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { parseInner() }
}
return accountTxs
}
sealed interface TxInfo {
data class CreditReversal(
val bookDate: Instant,
val code: BankTransactionCode,
val id: OutId,
val reason: String
): TxInfo
data class Credit(
val bookDate: Instant,
val code: BankTransactionCode,
val id: IncomingId,
val amount: TalerAmount,
val creditFee: TalerAmount?,
val subject: String?,
val debtor: IbanPayto?
): TxInfo
data class Debit(
val bookDate: Instant,
val code: BankTransactionCode,
val id: OutId,
val amount: TalerAmount,
val debitFee: TalerAmount?,
val subject: String?,
val creditor: IbanPayto?
): TxInfo
fun parse(): TxNotification {
return when (this) {
is TxInfo.CreditReversal -> {
if (id !is OutgoingId || id.endToEndId == null)
throw IncompleteTx("missing unique ID for Credit reversal $id")
OutgoingReversal(
endToEndId = id.endToEndId!!,
msgId = id.msgId,
reason = reason,
executionTime = bookDate
)
}
is TxInfo.Credit -> {
if (id.uetr == null && id.txId == null && id.acctSvcrRef == null)
throw IncompleteTx("missing unique ID for Credit $id")
IncomingPayment(
amount = amount,
creditFee = creditFee,
id = id,
debtor = debtor,
executionTime = bookDate,
subject = subject,
)
}
is TxInfo.Debit -> {
when (id) {
is OutgoingId -> {
if (id.endToEndId == null && id.msgId == null && id.acctSvcrRef == null) {
throw IncompleteTx("missing unique ID for Debit $id")
} else {
OutgoingPayment(
id = OutgoingId(
endToEndId = id.endToEndId,
acctSvcrRef = id.acctSvcrRef,
msgId = id.msgId,
),
amount = amount,
debitFee = debitFee,
executionTime = bookDate,
creditor = creditor,
subject = subject
)
}
}
is BatchId -> {
OutgoingBatch(
msgId = id.msgId,
executionTime = bookDate,
)
}
}
}
}
}
}
data class BankTransactionCode(
val domain: ExternalBankTransactionDomainCode,
val family: ExternalBankTransactionFamilyCode,
val subFamily: ExternalBankTransactionSubFamilyCode
) {
fun isReversal(): Boolean = REVERSAL_CODE.contains(subFamily)
fun isPayment(): Boolean = domain == ExternalBankTransactionDomainCode.PMNT || subFamily == ExternalBankTransactionSubFamilyCode.PSTE
override fun toString(): String =
"${domain.name} ${family.name} ${subFamily.name} - '${domain.description}' '${family.description}' '${subFamily.description}'"
companion object {
private val REVERSAL_CODE = setOf(
ExternalBankTransactionSubFamilyCode.RPCR,
ExternalBankTransactionSubFamilyCode.RRTN,
ExternalBankTransactionSubFamilyCode.PSTE,
)
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Constants.kt 0000664 0001750 0001750 00000003154 15122266731 031101 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.iso20022
enum class HacAction(val description: String) {
FILE_UPLOAD("File submitted to the bank"),
FILE_DOWNLOAD("File downloaded from the bank"),
ES_UPLOAD("Electronic signature submitted to the bank"),
ES_DOWNLOAD("Electronic signature downloaded from the bank"),
ES_VERIFICATION("Signature verification"),
VEU_FORWARDING("Forwarding to EDS"),
VEU_VERIFICATION("EDS signature verification"),
VEU_VERIFICATION_END("VEU_VERIFICATION_END"),
VEU_CANCEL_ORDER("Cancellation of EDS order"),
ADDITIONAL("Additional information"),
ORDER_HAC_FINAL_POS("HAC end of order (positive)"),
ORDER_HAC_FINAL_NEG("HAC end of order (negative)"),
// Not in the spec but Credit Suisse test suite use it
ORDER_HAC_FINAL("HAC end of order")
}
enum class ChargeBearer(val description: String) {
DEBT("BorneByDebtor"),
CRED("BorneByCreditor"),
SHAR("Shared"),
SLEV("SLEV")
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt 0000664 0001750 0001750 00000030231 15122266731 032775 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
// THIS FILE IS GENERATED, DO NOT EDIT
package tech.libeufin.nexus.iso20022
enum class ExternalBankTransactionDomainCode(val description: String) {
ACMT("Account Management"),
CAMT("Cash Management"),
CMDT("Commodities"),
DERV("Derivatives"),
FORX("Foreign Exchange"),
LDAS("Loans, Deposits & Syndications"),
PMET("Precious Metal"),
PMNT("Payments"),
SECU("Securities"),
TRAD("Trade Services"),
XTND("Extended Domain"),
}
enum class ExternalBankTransactionFamilyCode(val description: String) {
ACCB("Account Balancing"),
ACOP("Additional Miscellaneous Credit Operations"),
ADOP("Additional Miscellaneous Debit Operations"),
BLOC("Blocked Transactions"),
CAPL("Cash Pooling"),
CASH("Miscellaneous Securities Operations"),
CCRD("Customer Card Transactions"),
CLNC("Clean Collection"),
CNTR("Counter Transactions"),
COLC("Custody Collection"),
COLL("Collateral Management"),
CORP("Corporate Action"),
CSLN("Consumer Loans"),
CUST("Custody"),
DCCT("Documentary Credit"),
DLVR("Delivery"),
DOCC("Documentary Collection"),
DRFT("Drafts"),
FTDP("Fixed Term Deposits"),
FTLN("Fixed Term Loans"),
FTUR("Futures"),
FWRD("Forwards"),
GUAR("Guarantees"),
ICCN("Issued Cash Concentration Transactions"),
ICDT("Issued Credit Transfers"),
ICHQ("Issued Cheques"),
IDDT("Issued Direct Debits"),
IRCT("Issued Real-Time Credit Transfers"),
LACK("Lack"),
LBOX("Lockbox Transactions"),
LFUT("Listed Derivatives - Futures"),
LOCT("Stand-By Letter Of Credit"),
LOPT("Listed Derivatives - Options"),
MCOP("Miscellaneous Credit Operations"),
MCRD("Merchant Card Transactions"),
MDOP("Miscellaneous Debit Operations"),
MGLN("Mortgage Loans"),
NDFX("Non Deliverable"),
NSET("Non Settled"),
NTAV("Not Available"),
NTDP("Notice Deposits"),
NTLN("Notice Loans"),
OBND("OTC Derivatives - Bonds"),
OCRD("OTC Derivatives - Credit"),
OEQT("OTC Derivatives - Equity"),
OIRT("OTC Derivatives - Interest Rates"),
OPCL("Opening & Closing"),
OPTN("Options"),
OSED("OTC Derivatives - Structured Exotic Derivatives"),
OSWP("OTC Derivatives – Swaps"),
OTHB("CSD Blocked transactions"),
OTHR("Other"),
RCCN("Received Cash Concentration Transactions"),
RCDT("Received Credit Transfers"),
RCHQ("Received Cheques"),
RDDT("Received Direct Debits"),
RRCT("Received Real-Time Credit Transfers"),
SETT("Trade, Clearing and Settlement"),
SPOT("Spots"),
SWAP("Swaps"),
SYDN("Syndications"),
}
enum class ExternalBankTransactionSubFamilyCode(val description: String) {
ACCC("Account Closing"),
ACCO("Account Opening"),
ACCT("Account Transfer"),
ACDT("ACH Credit"),
ACOR("ACH Corporate Trade"),
ADBT("ACH Debit"),
ADJT("Adjustments (Generic)"),
APAC("ACH Pre-Authorised"),
ARET("ACH Return"),
AREV("ACH Reversal"),
ARPD("ARP Debit"),
ASET("ACH Settlement"),
ATXN("ACH Transaction"),
AUTT("Automatic Transfer"),
BBDD("SEPA B2B Direct Debit"),
BCDP("Branch Deposit"),
BCHQ("Bank Cheque"),
BCKV("Back Value"),
BCWD("Branch Withdrawl"),
BFWD("Bond Forward"),
BIDS("Repurchase Offer/Issuer Bid/Reverse Rights."),
BKFE("Bank Fees"),
BONU("Bonus Issue/Capitalisation Issue"),
BOOK("Internal Book Transfer"),
BPUT("Put Redemption"),
BROK("Brokerage Fee"),
BSBC("Sell Buy Back"),
BSBO("Buy Sell Back"),
CAJT("Credit Adjustments (Generic)"),
CAPG("Capital Gains Distribution"),
CASH("Cash Letter"),
CCCH("Certified Customer Cheque"),
CCHQ("Cheque"),
CCIR("Cross Currency IRS"),
CCPC("CCP Cleared Initial Margin"),
CCPM("CCP Cleared Variation Margin"),
CCSM("CCP Cleared Segregated Initial Margin"),
CDIS("Controlled Disbursement"),
CDPT("Cash Deposit"),
CHAR("Charge/Fees"),
CHKD("Check Deposit"),
CHRG("Charges (Generic)"),
CLAI("Compensation/Claims"),
CLCQ("Circular Cheque"),
CMBO("Corporate Mark Broker Owned"),
CMCO("Corporate Mark Client Owned"),
COME("Commission Excluding Taxes (Generic)"),
COMI("Commission Including Taxes (Generic)"),
COMM("Commission (Generic)"),
COMT("Non Taxable Commissions (Generic)"),
CONV("Conversion"),
COVE("Cover Transaction"),
CPEN("Cash Penalties"),
CPRB("Corporate Rebate"),
CQRV("Cheque Reversal"),
CRCQ("Crossed Cheque"),
CRDS("Credit DefaultSwap"),
CROS("Cross Trade"),
CRPR("Cross Product"),
CRSP("Credit Support"),
CRTL("Credit Line"),
CSHA("Cash Letter Adjustment"),
CSLI("Cash In Lieu"),
CWDL("Cash Withdrawal"),
DAJT("Debit Adjustments (Generic)"),
DDFT("Discounted Draft"),
DDWN("Drawdown"),
DECR("Decrease in Value"),
DMCG("Draft Maturity Change"),
DMCT("Domestic Credit Transfer"),
DPST("Deposit"),
DRAW("Drawing"),
DRIP("Dividend Reinvestment"),
DSBR("Controlled Disbursement"),
DTCH("Dutch Auction"),
DVCA("Cash Dividend"),
DVOP("Dividend Option"),
ENCT("Nordic Payment Council Credit Transfer"),
EQBO("Equity Mark Broker Owned"),
EQCO("Equity Mark Client Owned"),
EQPT("Equity Option"),
EQUS("Equity Swap"),
ERTA("Exchange Rate Adjustment"),
ERWA("Lending Income"),
ERWI("Borrowing Fee"),
ESCT("SEPA Credit Transfer"),
ESDD("SEPA Core Direct Debit"),
EXOF("Exchange"),
EXPT("Exotic Option"),
EXRI("Call On Intermediate Securities"),
EXTD("Exchange Traded Derivatives"),
EXWA("Warrant Exercise/Warrant Conversion"),
FCDP("Foreign Currencies Deposit"),
FCTA("Factor Update"),
FCWD("Foreign Currencies Withdrawal"),
FEES("Fees (Generic)"),
FICT("Financial Institution Credit Transfer"),
FIDD("Financial Institution Direct Debit Payment"),
FIOA("Financial Institution Own Account Transfer"),
FIXI("Fixed Income"),
FLTA("Float Adjustment"),
FRZF("Freeze Of Funds"),
FUCO("Futures Commission"),
FUTU("Future Variation Margin"),
FWBC("Forwards Broker Owned Collateral"),
FWCC("Forwards Client Owned Collateral"),
FWSB("MFA Segregated Broker Cash Collateral"),
FWSC("MFA Segregated Client Cash Collateral"),
GEN1("Withdrawal/Distribution"),
GEN2("Deposit/Contribution"),
IADD("Invoice Accepted with Differed Due Date"),
INFD("Fixed Deposit Interest Amount"),
INSP("Inspeci/Share Exchange"),
INTR("Interest Payment"),
ISSU("Depositary Receipt Issue"),
LBCA("Credit Adjustment"),
LBDP("Deposit"),
LIQU("Liquidation Dividend / Liquidation Payment"),
MARG("Margin Payments"),
MBSB("Mortgage Back Segregated Broker Cash Collateral"),
MBSC("Mortgage Back Segregated Client Cash Collateral"),
MCAL("Full Call / Early Redemption"),
MGCC("Margin Client Owned Cash Collateral"),
MGSC("Initial Futures Margin Segregated Client Cash Collateral"),
MIXD("Mixed Deposit"),
MNFE("Management Fees"),
MRGR("Merger"),
MSCD("Miscellaneous Deposit"),
NETT("Netting"),
NPCC("Non Presented Circular Cheques"),
NSYN("Non Syndicated"),
NTAV("Not Available"),
NWID("New issue distribution"),
OCCC("Client owned OCC pledged collateral"),
ODFT("Overdraft"),
ODLT("Odd Lot Sale/Purchase"),
OODD("One-Off Direct Debit"),
OPBC("Option Broker Owned Collateral"),
OPCC("Option Client Owned Collateral"),
OPCQ("Open Cheque"),
OPSB("OTC Option Segregated Broker Cash Collateral"),
OPSC("OTC Option Segregated Client Cash Collateral"),
OPTN("FX Option"),
ORCQ("Order Cheque"),
OTCC("OTC CCP"),
OTCD("OTC Derivatives"),
OTCG("OTC"),
OTCN("OTC Non-CCP"),
OTHR("Other"),
OVCH("Overdraft Charge"),
OWNE("External Account Transfer"),
OWNI("Internal Account Transfer"),
PADD("Pre-Authorised Direct Debit"),
PAIR("Pair-Off"),
PCAL("Partial Redemption With Reduction Of Nominal Value"),
PLAC("Placement"),
PMDD("Direct Debit"),
PORT("Portfolio Move"),
POSC("Credit Card Payment"),
POSD("Point-of-Sale (POS) Payment - Debit Card"),
PPAY("Principal Payment"),
PRCT("Priority Credit Transfer"),
PRDD("Reversal Due To Payment Reversal"),
PRED("Partial Redemption Without Reduction Of Nominal Value"),
PRII("Interest Payment with Principles"),
PRIN("Interest Payment with Principles"),
PRIO("Priority Issue"),
PRUD("Principal Pay-Down/Pay-Up"),
PSTE("Posting Error"),
RCDD("Reversal Due To Payment Cancellation Request"),
RCOV("Reversal due to a Cover Transaction Return"),
REAA("Redemption Asset Allocation"),
REDM("Final Maturity"),
REPU("Repo"),
RESI("Futures Residual Amount"),
RHTS("Rights Issue/Subscription Rights/Rights Offer"),
RIMB("Reimbursement (Generic)"),
RNEW("Renewal"),
RPBC("Bi-lateral repo broker owned collateral"),
RPCC("Repo client owned collateral"),
RPCR("Reversal Due To Payment Cancellation Request"),
RPMT("Repayment"),
RPSB("Bi-lateral Repo Segregated Broker Cash Collateral"),
RPSC("Bi-lateral Repo Segregated Client Cash Collateral"),
RRTN("Reversal Due To Payment Return"),
RVPO("Reverse Repo"),
RWPL("Redemption Withdrawing Plan"),
SABG("Settlement Against Bank Guarantee"),
SALA("Payroll/Salary Payment"),
SBSC("Securities Buy Sell Sell Buy Back"),
SCIE("Single Currency IRS Exotic"),
SCIR("Single Currency IRS"),
SCRP("Securities Cross Products"),
SDVA("Same Day Value Credit Transfer"),
SECB("Securities Borrowing"),
SECL("Securities Lending"),
SHBC("Broker owned collateral Short Sale"),
SHCC("Client owned collateral Short Sale"),
SHPR("Equity Premium Reserve"),
SHSL("Short Sell"),
SLBC("Lending Broker Owned Cash Collateral"),
SLCC("Lending Client Owned Cash Collateral"),
SLEB("Securities Lending And Borrowing"),
SLOA("SecuredLoan"),
SOSE("Settlement Of Sight Export Document"),
SOSI("Settlement Of Sight Import Document"),
SSPL("Subscription Savings Plan"),
STAC("Settlement After Collection"),
STAM("Settlement At Maturity"),
STDO("Standing Order"),
STLM("Settlement"),
STLR("Settlement Under Reserve"),
STOD("Bill of Exchange Settlement on Demand"),
SUAA("Subscription Asset Allocation"),
SUBS("Subscription"),
SWAP("Swap Payment"),
SWBC("Swap Broker Owned Collateral"),
SWCC("Client Owned Collateral"),
SWEP("Sweep"),
SWFP("Final Payment"),
SWIC("Switch"),
SWPP("Partial Payment"),
SWPT("Swaption"),
SWRS("Reset Payment"),
SWSB("ISDA/CSA Segregated Broker Cash Collateral"),
SWSC("ISDA/CSA Segregated Client Cash Collateral"),
SWUF("Upfront Payment"),
SYND("Syndicated"),
TAXE("Taxes (Generic)"),
TBAC("TBA Closing"),
TBAS("To Be Announced"),
TBBC("TBA Broker owned cash collateral"),
TBCC("TBA Client owned cash collateral"),
TCDP("Travellers Cheques Deposit"),
TCWD("Travellers Cheques Withdrawal"),
TEND("Tender"),
TOPG("Topping"),
TOUT("Transfer Out"),
TRAD("Trade"),
TRCP("Treasury Cross Product"),
TREC("Tax Reclaim"),
TRFE("Transaction Fees"),
TRIN("Transfer In"),
TRPO("Triparty Repo"),
TRVO("Triparty Reverse Repo"),
TTLS("Treasury Tax And Loan Service"),
TURN("Turnaround"),
UDFT("Dishonoured/Unpaid Draft"),
UNCO("Underwriting Commission"),
UPCQ("Unpaid Cheque"),
UPCT("Unpaid Card Transaction"),
UPDD("Reversal Due To Return/Unpaid Direct Debit"),
URCQ("Cheque Under Reserve"),
URDD("Direct Debit Under Reserve"),
VALD("Value Date"),
VCOM("Credit Transfer With Agreed Commercial Information"),
WITH("Withholding Tax"),
XBCP("Cross-Border Credit Card Payment"),
XBCQ("Foreign Cheque"),
XBCT("Cross-Border Credit Transfer"),
XBCW("Cross-Border Cash Withdrawal"),
XBRD("Cross-Border"),
XBSA("Cross-Border Payroll/Salary Payment"),
XBST("Cross-Border Standing Order"),
XCHC("Exchange Traded CCP"),
XCHG("Exchange Traded"),
XCHN("Exchange Traded Non-CCP"),
XICT("Cross-Border Intra Company Transfer"),
XPCQ("Unpaid Foreign Cheque"),
XRCQ("Foreign Cheque Under Reserve"),
XRTN("Cross Border Reversal Due to Payment Return"),
YTDA("YTD Adjustment"),
ZABA("Zero Balancing"),
ACON("ACH Concentration"),
BACT("Branch Account Transfer"),
COAT("Corporate Own Account Transfer"),
ICCT("Intra Company Transfer"),
LBDB("Debit"),
POSP("Point-of-Sale (POS) Payment"),
SMCD("Smart-Card Payment"),
SMRT("Smart-Card Payment"),
XBDD("Cross-Border Direct Debit"),
}
libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt 0000664 0001750 0001750 00000135033 15122266731 032343 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
// THIS FILE IS GENERATED, DO NOT EDIT
package tech.libeufin.nexus.iso20022
enum class ExternalStatusReasonCode(val isoCode: String, val description: String) {
AB01("AbortedClearingTimeout", "Clearing process aborted due to timeout."),
AB02("AbortedClearingFatalError", "Clearing process aborted due to a fatal error."),
AB03("AbortedSettlementTimeout", "Settlement aborted due to timeout."),
AB04("AbortedSettlementFatalError", "Settlement process aborted due to a fatal error."),
AB05("TimeoutCreditorAgent", "Transaction stopped due to timeout at the Creditor Agent."),
AB06("TimeoutInstructedAgent", "Transaction stopped due to timeout at the Instructed Agent."),
AB07("OfflineAgent", "Agent of message is not online."),
AB08("OfflineCreditorAgent", "Creditor Agent is not online."),
AB09("ErrorCreditorAgent", "Transaction stopped due to error at the Creditor Agent."),
AB10("ErrorInstructedAgent", "Transaction stopped due to error at the Instructed Agent."),
AB11("TimeoutDebtorAgent", "Transaction stopped due to timeout at the Debtor Agent."),
AB12("InvalidConcurrentBatch", "Duplicate Concurrent Batch Sequence number– for Settlement Instructions."),
AB13("InvalidRoutingCodeUtilised", "Wrong Message Routing Type for Return-of-Funds."),
AB15("InvalidAccountNumberForSettlementType", "Instruction may not be placed on the Continuous Processing Line settlement processor."),
AB21("InvalidSettlementAgreementNumberSpecified", "Agreement number not valid (beneficiary)."),
AB26("InvalidBatchSettlementInstruction", "Settlement Instruction does not exist."),
AC01("IncorrectAccountNumber", "Account number is invalid or missing."),
AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing"),
AC03("InvalidCreditorAccountNumber", "Creditor account number invalid or missing"),
AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books."),
AC05("ClosedDebtorAccountNumber", "Debtor account number closed"),
AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."),
AC07("ClosedCreditorAccountNumber", "Creditor account number closed"),
AC08("InvalidBranchCode", "Branch code is invalid or missing"),
AC09("InvalidAccountCurrency", "Account currency is invalid or missing"),
AC10("InvalidDebtorAccountCurrency", "Debtor account currency is invalid or missing"),
AC11("InvalidCreditorAccountCurrency", "Creditor account currency is invalid or missing"),
AC12("InvalidAccountType", "Account type missing or invalid."),
AC13("InvalidDebtorAccountType", "Debtor account type missing or invalid"),
AC14("InvalidCreditorAccountType", "Creditor account type missing or invalid"),
AC15("AccountDetailsChanged", "The account details for the counterparty have changed."),
AC16("CardNumberInvalid", "Credit or debit card number is invalid."),
AEXR("AlreadyExpiredRTP", "Request-to-pay Expiry Date and Time has already passed."),
AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"),
AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"),
AG03("TransactionNotSupported", "Transaction type not supported/authorized on this account"),
AG04("InvalidAgentCountry", "Agent country code is missing or invalid."),
AG05("InvalidDebtorAgentCountry", "Debtor agent country code is missing or invalid"),
AG06("InvalidCreditorAgentCountry", "Creditor agent country code is missing or invalid"),
AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."),
AG08("InvalidAccessRights", "Transaction failed due to invalid or missing user or access right"),
AG09("PaymentNotReceived", "Original payment never received."),
AG10("AgentSuspended", "Agent of message is suspended from the Real Time Payment system."),
AG11("CreditorAgentSuspended", "Creditor Agent of message is suspended from the Real Time Payment system."),
AG12("NotAllowedBookTransfer", "Payment orders made by transferring funds from one account to another at the same financial institution (bank or payment institution) are not allowed."),
AG13("ForbiddenReturnPayment", "Returned payments derived from previously returned transactions are not allowed."),
AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect"),
ALAC("AlreadyAcceptedRTP", "Request-to-pay has already been accepted by the Debtor."),
AM01("ZeroAmount", "Specified message amount is equal to zero"),
AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"),
AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"),
AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."),
AM05("Duplication", "Duplication"),
AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."),
AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."),
AM09("WrongAmount", "Amount received is not the amount agreed or expected"),
AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."),
AM11("InvalidTransactionCurrency", "Transaction currency is invalid or missing"),
AM12("InvalidAmount", "Amount is invalid or missing"),
AM13("AmountExceedsClearingSystemLimit", "Transaction amount exceeds limits set by clearing system"),
AM14("AmountExceedsAgreedLimit", "Transaction amount exceeds limits agreed between bank and client"),
AM15("AmountBelowClearingSystemMinimum", "Transaction amount below minimum set by clearing system"),
AM16("InvalidGroupControlSum", "Control Sum at the Group level is invalid"),
AM17("InvalidPaymentInfoControlSum", "Control Sum at the Payment Information level is invalid"),
AM18("InvalidNumberOfTransactions", "Number of transactions is invalid or missing."),
AM19("InvalidGroupNumberOfTransactions", "Number of transactions at the Group level is invalid or missing"),
AM20("InvalidPaymentInfoNumberOfTransactions", "Number of transactions at the Payment Information level is invalid"),
AM21("LimitExceeded", "Transaction amount exceeds limits agreed between bank and client."),
AM22("ZeroAmountNotApplied", "Unable to apply zero amount to designated account. For example, where the rules of a service allow the use of zero amount payments, however the back-office system is unable to apply the funds to the account. If the rules of a service prohibit the use of zero amount payments, then code AM01 is used to report the error condition."),
AM23("AmountExceedsSettlementLimit", "Transaction amount exceeds settlement limit."),
AMSE("AttachmentMaximumSize", "Size of the attachment exceeds the allowed maximum."),
APAR("AlreadyPaidRTP", "Request To Pay has already been paid by the Debtor."),
ARFR("AlreadyRefusedRTP", "Request-to-pay has already been refused by the Debtor."),
ARJR("AlreadyRejectedRTP", "Request-to-pay has already been rejected."),
ATNS("AttachementsNotSupported", "Attachments to the request-to-pay are not supported."),
BDAY("NotBusinessDay", "Settlement Cycle Day and Calendar day should be the same."),
BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number. (formerly CreditorConsistency)."),
BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."),
BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"),
BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"),
BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."),
BE08("MissingDebtorName", "Debtor name is missing"),
BE09("InvalidCountry", "Country code is missing or Invalid."),
BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid"),
BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid"),
BE12("InvalidCountryOfResidence", "Country code of residence is missing or Invalid."),
BE13("InvalidDebtorCountryOfResidence", "Country code of debtor's residence is missing or Invalid"),
BE14("InvalidCreditorCountryOfResidence", "Country code of creditor's residence is missing or Invalid"),
BE15("InvalidIdentificationCode", "Identification code missing or invalid."),
BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid"),
BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid"),
BE18("InvalidContactDetails", "Contact details missing or invalid"),
BE19("InvalidChargeBearerCode", "Charge bearer code for transaction type is invalid"),
BE20("InvalidNameLength", "Name length exceeds local rules for payment type."),
BE21("MissingName", "Name missing or invalid. Generic usage if cannot specifically identify debtor or creditor."),
BE22("MissingCreditorName", "Creditor name is missing"),
BE23("AccountProxyInvalid", "Phone number or email address, or any other proxy, used as the account proxy is unknown or invalid."),
CERI("CheckERI", "Credit transfer is not tagged as an Extended Remittance Information (ERI) transaction but contains ERI."),
CH03("RequestedExecutionDateOrRequestedCollectionDateTooFarInFuture", "Value in Requested Execution Date or Requested Collection Date is too far in the future"),
CH04("RequestedExecutionDateOrRequestedCollectionDateTooFarInPast", "Value in Requested Execution Date or Requested Collection Date is too far in the past"),
CH07("ElementIsNotToBeUsedAtB-andC-Level", "Element is not to be used at B- and C-Level"),
CH09("MandateChangesNotAllowed", "Mandate changes are not allowed"),
CH10("InformationOnMandateChangesMissing", "Information on mandate changes are missing"),
CH11("CreditorIdentifierIncorrect", "Value in Creditor Identifier is incorrect"),
CH12("CreditorIdentifierNotUnambiguouslyAtTransaction-Level", "Creditor Identifier is ambiguous at Transaction Level"),
CH13("OriginalDebtorAccountIsNotToBeUsed", "Original Debtor Account is not to be used"),
CH14("OriginalDebtorAgentIsNotToBeUsed", "Original Debtor Agent is not to be used"),
CH15("ElementContentIncludesMoreThan140Characters", "Content Remittance Information/Structured includes more than 140 characters"),
CH16("ElementContentFormallyIncorrect", "Content is incorrect"),
CH17("ElementNotAdmitted", "Element is not allowed"),
CH19("ValuesWillBeSetToNextTARGETday", "Values in Interbank Settlement Date or Requested Collection Date will be set to the next TARGET day"),
CH20("DecimalPointsNotCompatibleWithCurrency", "Number of decimal points not compatible with the currency"),
CH21("RequiredCompulsoryElementMissing", "Mandatory element is missing"),
CH22("COREandB2BwithinOnemessage", "SDD CORE and B2B not permitted within one message"),
CHCO("UnacceptedChargeCodeType", "Related to a Charge message to convey that the code in Charge Breakdown / Type / Code is not accepted by the receiving party."),
CHQC("ChequeSettledOnCreditorAccount", "Cheque has been presented in cheque clearing and settled on the creditor’s account."),
CHRG("UnderlyingChargeBearerWasNotDebt", "Related to a Charge message to convey that the charge bearer code used in the corresponding Payment message was not debt."),
CN01("AuthorisationCancelled", "Authorisation is cancelled."),
CNNS("CreditNotesNotSupported", "Credit notes are not supported."),
CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"),
CURR("IncorrectCurrency", "Currency of the payment is incorrect"),
CUST("RequestedByCustomer", "Cancellation requested by the Debtor"),
DC02("SettlementNotReceived", "Rejection of a payment due to covering FI settlement not being received."),
DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"),
DS01("ElectronicSignaturesCorrect", "The electronic signature(s) is/are correct"),
DS02("OrderCancelled", "An authorized user has cancelled the order"),
DS03("OrderNotCancelled", "The user’s attempt to cancel the order was not successful"),
DS04("OrderRejected", "The order was rejected by the bank side (for reasons concerning content)"),
DS05("OrderForwardedForPostprocessing", "The order was correct and could be forwarded for postprocessing"),
DS06("TransferOrder", "The order was transferred to VEU"),
DS07("ProcessingOK", "All actions concerning the order could be done by the EBICS bank server"),
DS08("DecompressionError", "The decompression of the file was not successful"),
DS09("DecryptionError", "The decryption of the file was not successful"),
DS0A("DataSignRequested", "Data signature is required."),
DS0B("UnknownDataSignFormat", "Data signature for the format is not available or invalid."),
DS0C("SignerCertificateRevoked", "The signer certificate is revoked."),
DS0D("SignerCertificateNotValid", "The signer certificate is not valid (revoked or not active)."),
DS0E("IncorrectSignerCertificate", "The signer certificate is not present."),
DS0F("SignerCertificationAuthoritySignerNotValid", "The authority of the signer certification sending the certificate is unknown."),
DS0G("NotAllowedPayment", "Signer is not allowed to sign this operation type."),
DS0H("NotAllowedAccount", "Signer is not allowed to sign for this account."),
DS0K("NotAllowedNumberOfTransaction", "The number of transaction is over the number allowed for this signer."),
DS10("Signer1CertificateRevoked", "The certificate is revoked for the first signer."),
DS11("Signer1CertificateNotValid", "The certificate is not valid (revoked or not active) for the first signer."),
DS12("IncorrectSigner1Certificate", "The certificate is not present for the first signer."),
DS13("SignerCertificationAuthoritySigner1NotValid", "The authority of signer certification sending the certificate is unknown for the first signer."),
DS14("UserDoesNotExist", "The user is unknown on the server"),
DS15("IdenticalSignatureFound", "The same signature has already been sent to the bank"),
DS16("PublicKeyVersionIncorrect", "The public key version is not correct. This code is returned when a customer sends signature files to the financial institution after conversion from an older program version (old ES format) to a new program version (new ES format) without having carried out re-initialisation with regard to a public key change."),
DS17("DifferentOrderDataInSignatures", "Order data and signatures don’t match"),
DS18("RepeatOrder", "File cannot be tested, the complete order has to be repeated. This code is returned in the event of a malfunction during the signature check, e.g. not enough storage space."),
DS19("ElectronicSignatureRightsInsufficient", "The user’s rights (concerning his signature) are insufficient to execute the order"),
DS20("Signer2CertificateRevoked", "The certificate is revoked for the second signer."),
DS21("Signer2CertificateNotValid", "The certificate is not valid (revoked or not active) for the second signer."),
DS22("IncorrectSigner2Certificate", "The certificate is not present for the second signer."),
DS23("SignerCertificationAuthoritySigner2NotValid", "The authority of signer certification sending the certificate is unknown for the second signer."),
DS24("WaitingTimeExpired", "Waiting time expired due to incomplete order"),
DS25("OrderFileDeleted", "The order file was deleted by the bank server"),
DS26("UserSignedMultipleTimes", "The same user has signed multiple times"),
DS27("UserNotYetActivated", "The user is not yet activated (technically)"),
DS28("ReturnForTechnicalReason", "Message routed to the wrong environment."),
DT01("InvalidDate", "Invalid date (eg, wrong or missing settlement date)"),
DT02("InvalidCreationDate", "Invalid creation date and time in Group Header (eg, historic date)"),
DT03("InvalidNonProcessingDate", "Invalid non bank processing date (eg, weekend or local public holiday)"),
DT04("FutureDateNotSupported", "Future date not supported"),
DT05("InvalidCutOffDate", "Associated message, payment information block or transaction was received after agreed processing cut-off date, i.e., date in the past."),
DT06("ExecutionDateChanged", "Execution Date has been modified in order for transaction to be processed"),
DU01("DuplicateMessageID", "Message Identification is not unique."),
DU02("DuplicatePaymentInformationID", "Payment Information Block is not unique."),
DU03("DuplicateTransaction", "Transaction is not unique."),
DU04("DuplicateEndToEndID", "End To End ID is not unique."),
DU05("DuplicateInstructionID", "Instruction ID is not unique."),
DUPL("DuplicatePaymentOrCharge", "Payment or charge is a duplicate of another payment or charge."),
ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."),
ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"),
ED05("SettlementFailed", "Settlement of the transaction has failed."),
ED06("SettlementSystemNotAvailable", "Interbank settlement system not available."),
EDNA("ExecutionDateNotAccepted", "Requested execution date of the payment is not accepted."),
EDTL("ExpiryDateTooLong", "Expiry date time of the request-to-pay is too far in the future."),
EDTR("ExpiryDateTimeReached", "Expiry date time of the request-to-pay is already reached."),
EOL1("EndOfLife", "Expiration of the payment authorisation due to no use for too long."),
ERIN("ERIOptionNotSupported", "Extended Remittance Information (ERI) option is not supported."),
FF01("InvalidFileFormat", "File Format incomplete or invalid"),
FF02("SyntaxError", "Syntax error reason is provided as narrative information in the additional reason information."),
FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."),
FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid"),
FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"),
FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid"),
FF07("InvalidPurpose", "Purpose is missing or invalid"),
FF08("InvalidEndToEndId", "End to End Id missing or invalid"),
FF09("InvalidChequeNumber", "Cheque number missing or invalid"),
FF10("BankSystemProcessingError", "File or transaction cannot be processed due to technical issues at the bank side"),
FF11("ClearingRequestAborted", "Clearing request rejected due it being subject to an abort operation."),
FF12("OriginalTransactionNotEligibleForRequestedReturn", "Original payment is not eligible to be returned given its current status."),
FF13("RequestForCancellationNotFound", "No record of request for cancellation found."),
FOCR("FollowingCancellationRequest", "Return following a cancellation request."),
FR01("Fraud", "Returned as a result of fraud."),
FRAD("FraudulentOrigin", "Cancellation requested following a transaction that was originated fraudulently. The use of the FraudulentOrigin code should be governed by jurisdictions."),
G000("PaymentTransferredAndTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is tracked. No further updates will follow from the Status Originator."),
G001("PaymentTransferredAndNotTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is not tracked. No further updates will follow from the Status Originator."),
G002("CreditDebitNotConfirmed", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account may not be confirmed same day. Update will follow from the Status Originator."),
G003("CreditPendingDocuments", "In a FIToFI Customer Credit Transfer: Credit to creditor’s account is pending receipt of required documents. The Status Originator has requested creditor to provide additional documentation. Update will follow from the Status Originator."),
G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."),
G005("DeliveredWithServiceLevel", "Payment has been delivered to creditor agent with service level."),
G006("DeliveredWIthoutServiceLevel", "Payment has been delivered to creditor agent without service level."),
ID01("CorrespondingOriginalFileStillNotSent", "Signature file was sent to the bank but the corresponding original file has not been sent yet."),
IEDT("IncorrectExpiryDateTime", "Expiry date time of the request-to-pay is incorrect."),
INAR("InvalidActivationReference", "Payer’s activation reference is invalid."),
INDT("InvalidDetails", "Details not valid for this field."),
IPNS("InstalmentPaymentsNotSupported", "Payments in instalments are not supported."),
IRNR("InitialRTPNeverReceived", "No initial request-to-pay has been received."),
ISWS("InvalidSettlementWindow", "Cannot schedule instruction for Night Window."),
MD01("NoMandate", "No Mandate"),
MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."),
MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit"),
MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"),
MD07("EndCustomerDeceased", "End customer is deceased."),
MINF("MissingInformation", "Information missing for the field or cannot be empty."),
MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"),
MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."),
NARR("Narrative", "Reason is provided as narrative information in the additional reason information."),
NERI("NoERI", "Credit transfer is tagged as an Extended Remittance Information (ERI) transaction but does not contain ERI."),
NOAR("NonAgreedRTP", "No existing agreement for receiving request-to-pay messages."),
NOAS("NoAnswerFromCustomer", "No response from Beneficiary."),
NOCM("NotCompliantGeneric", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."),
NOFR("OutstandingFundingForSettlement", "Continuous Processing Line on Hold Instruction."),
NOPG("NoPaymentGuarantee", "Requested payment guarantee (by Creditor) related to a request-to-pay cannot be provided."),
NRCH("PayerOrPayerRTPSPNotReachable", "Recipient side of the request-to-pay (payer or its request-to-pay service provider) is not reachable."),
OSNS("OptionalServiceNotSupported", "Requested optional service (for example instalment payments) is not supported."),
PINS("TypeOfPaymentInstrumentNotSupported", "Type of payment requested in the request-to-pay is not supported by the payer."),
RC01("BankIdentifierIncorrect", "Bank identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."),
RC02("InvalidBankIdentifier", "Bank identifier is invalid or missing."),
RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing"),
RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing"),
RC05("InvalidBICIdentifier", "BIC identifier is invalid or missing."),
RC06("InvalidDebtorBICIdentifier", "Debtor BIC identifier is invalid or missing"),
RC07("InvalidCreditorBICIdentifier", "Creditor BIC identifier is invalid or missing"),
RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."),
RC09("InvalidDebtorClearingSystemMemberIdentifier", "Debtor ClearingSystemMember identifier is invalid or missing"),
RC10("InvalidCreditorClearingSystemMemberIdentifier", "Creditor ClearingSystemMember identifier is invalid or missing"),
RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing"),
RC12("MissingCreditorSchemeId", "Creditor Scheme Id is invalid or missing"),
RC13("ParticipantNotAnActiveMemberofRTGS", "Originator not active any more."),
RC15("ParticipantNotActiveMemberSettlementType", "Settlement agreement required."),
RC16("ParticipantNotActiveMemberofSADCRTGS", "Participant blocked from SADC-RTGS."),
RCON("RMessageConflict", "Conflict with R-Message"),
RECI("ReceiverCustomerInformation", "Further information regarding the intended recipient."),
REPR("RTPReceivedCanBeProcessed", "Request-to-pay has been received and can be processed further."),
RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."),
RQNR("RequestNotRecognized", "Payer did not recognize the request from Payee Participant,"),
RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"),
RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."),
RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."),
RR04("RegulatoryReason", "Regulatory Reason"),
RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."),
RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."),
RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."),
RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."),
RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."),
RR10("InvalidCharacterSet", "Character set supplied not valid for the country and payment type."),
RR11("InvalidDebtorAgentServiceID", "Invalid or missing identification of a bank proprietary service."),
RR12("InvalidPartyID", "Invalid or missing identification required within a particular country or payment type."),
RTNS("RTPNotSupportedForDebtor", "Debtor does not support request-to-pay transactions."),
RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."),
S000("ValidRequestForCancellationAcknowledged", "Request for Cancellation is acknowledged following validation."),
S001("UETRFlaggedForCancellation", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been identified as being associated with a Request for Cancellation."),
S002("NetworkStopOfUETR", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been prevent from traveling across a messaging network."),
S003("RequestForCancellationForwarded", "Request for Cancellation has been forwarded to the payment processing/last payment processing agent."),
S004("RequestForCancellationDeliveryAcknowledgement", "Request for Cancellation has been acknowledged as delivered to payment processing/last payment processing agent."),
SBRN("SettlementBatchRemovalNotification", "Remove Concurrent Batch Processing Line on hold instruction."),
SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent."),
SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent."),
SL03("ServiceofClearingSystem", "Due to a specific service offered by the clearing system."),
SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."),
SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."),
SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."),
SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."),
SL15("MaximumNumberOfCreditTransactionsExceeded", "Maximum number of credit transactions allowed by the account servicer per service period exceeded."),
SL16("MaximumCreditTransactionsAmountExceeded", "Maximum total credit amount allowed by the account servicer per service period exceeded."),
SL17("DebtorNotOnWhitelistOfCreditorSide", "Whitelisting service offered by payment system operator or financial institution. Debtor is not included on the Creditor side whitelist."),
SL18("DebtorOnBlacklistOfCreditorSide", "Blacklisting service offered by payment system operator or financial institution. Debtor included on the Creditor side blacklist."),
SNRD("ServiceNotRendered", "Services are not yet rendered by the Payee Participant (Creditor)."),
SPII("RTPServiceProviderIdentifierIncorrect", "Identifier of the request-to-pay service provider is incorrect."),
TA01("TransmissonAborted", "The transmission of the file was not successful – it had to be aborted (for technical reasons)"),
TD01("NoDataAvailable", "There is no data available (for download)"),
TD02("FileNonReadable", "The file cannot be read (e.g. unknown format)"),
TD03("IncorrectFileStructure", "The file format is incomplete or invalid"),
TK01("TokenInvalid", "Token is invalid."),
TK02("SenderTokenNotFound", "Token used for the sender does not exist."),
TK03("ReceiverTokenNotFound", "Token used for the receiver does not exist."),
TK09("TokenMissing", "Token required for request is missing."),
TKCM("TokenCounterpartyMismatch", "Token found with counterparty mismatch."),
TKSG("TokenSingleUse", "Single Use Token already used."),
TKSP("TokenSuspended", "Token found with suspended status."),
TKVE("TokenValueLimitExceeded", "Token found with value limit rule violation."),
TKXP("TokenExpired", "Token expired."),
TM01("InvalidCutOffTime", "Associated message, payment information block, or transaction was received after agreed processing cut-off time."),
TS01("TransmissionSuccessful", "The (technical) transmission of the file was successful."),
TS04("TransferToSignByHand", "The order was transferred to pass by accompanying note signed by hand"),
UCRD("UnknownCreditor", "Unknown Creditor."),
UPAY("UnduePayment", "Payment is not justified."),
}
enum class ExternalPaymentGroupStatusCode(val isoCode: String, val description: String) {
ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."),
ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."),
ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement on the debtor's account has been completed."),
ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment initiation has been accepted for execution."),
ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"),
ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."),
PART("PartiallyAccepted", "A number of transactions have been accepted, whereas another number of transactions have not yet achieved"),
PDNG("Pending", "Payment initiation or individual transaction included in the payment initiation is pending. Further checks and status update will be performed."),
RCVC("ReceivedVerificationCompleted", "Verification of Payee check have been applied to received transactions stating to be complete without mismatching data."),
RCVD("Received", "Payment initiation has been received by the receiving agent"),
RJCT("Rejected", "Payment initiation or individual transaction included in the payment initiation has been rejected."),
RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transactions stating to be complete containing mismatching data."),
RVNC("ReceivedVerificationNotCompleted", "Verification of party check on transactions received is not yet completed."),
}
enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val description: String) {
ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."),
ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."),
ACFC("AcceptedFundsChecked", "Preceding check of technical validation and customer profile was successful and an automatic funds check was positive."),
ACFW("AcceptedFundsCheckedWaitingConfirmation", "Preceding check of technical validation and customer profile was successful, and an automatic funds check was positive, but an explicit confirmation by the initiating party is outstanding."),
ACIS("AcceptedandChequeIssued", "Payment instruction to issue a cheque has been accepted, and the cheque has been issued but not yet been deposited or cleared."),
ACPD("AcceptedClearingProcessed", "Status of transaction released from the Debtor Agent and accepted by the clearing."),
ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement completed."),
ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment instruction has been accepted for execution."),
ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"),
ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."),
ACWP("AcceptedWithoutPosting", "Payment instruction included in the credit transfer is accepted without being posted to the creditor customer’s account."),
BLCK("Blocked", "Payment transaction previously reported with status 'ACWP' is blocked, for example, funds will neither be posted to the Creditor's account, nor be returned to the Debtor."),
CANC("Cancelled", "Payment initiation has been successfully cancelled after having received a request for cancellation."),
CPUC("CashPickedUpByCreditor", "Cash has been picked up by the Creditor."),
PATC("PartiallyAcceptedTechnicalCorrect", "Payment initiation needs multiple authentications, where some but not yet all have been performed. Syntactical and semantical validations are successful."),
PDNG("Pending", "Payment instruction is pending. Further checks and status update will be performed."),
PRES("Presented", "Request for Payment has been presented to the Debtor."),
RCVC("ReceivedVerificationCompleted", "Verification of Payee check has been applied to received transaction stating to be complete without mismatching data."),
RCVD("Received", "Payment instruction has been received."),
RJCT("Rejected", "Payment instruction has been rejected."),
RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transaction stating to be completed containing mismatching data."),
RVMC("ReceivedVerificationCompletedMatchClosely", "Verification of Payee check has been applied to received transaction stating to be complete with data matching closely."),
RVNA("ReceivedVerificationCompletedNotApplicable", "Verification of Payee check has been applied to received transaction stating to be complete with not applicable data."),
RVNC("ReceivedVerificationNotCompleted", "Verification of party check on the transaction is not yet completed."),
RVNM("ReceivedVerificationCompletedNoMatch", "Verification of Payee check has been applied to received transaction stating to be complete with mismatching data."),
}
enum class ExternalReturnReasonCode(val isoCode: String, val description: String) {
AC01("IncorrectAccountNumber", "Format of the account number specified is not correct"),
AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing."),
AC03("InvalidCreditorAccountNumber", "Wrong IBAN in SCT"),
AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books"),
AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."),
AC07("ClosedCreditorAccountNumber", "Creditor account number closed."),
AC13("InvalidDebtorAccountType", "Debtor account type is missing or invalid"),
AC14("InvalidAgent", "An agent in the payment chain is invalid."),
AC15("AccountDetailsChanged", "Account details have changed."),
AC16("AccountInSequestration", "Account is in sequestration."),
AC17("AccountInLiquidation", "Account is in liquidation."),
AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"),
AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"),
AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."),
AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect."),
AM01("ZeroAmount", "Specified message amount is equal to zero"),
AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"),
AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"),
AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."),
AM05("Duplication", "Duplication"),
AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."),
AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."),
AM09("WrongAmount", "Amount received is not the amount agreed or expected"),
AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."),
ARDT("AlreadyReturnedTransaction", "Already returned original SCT"),
BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number, organisation ID or private ID."),
BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."),
BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"),
BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"),
BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."),
BE08("BankError", "Returned as a result of a bank error."),
BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid."),
BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid."),
BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid."),
BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid."),
CN01("AuthorisationCancelled", "Authorisation is cancelled."),
CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"),
CNPC("CashNotPickedUp", "Cash not picked up by Creditor or cash could not be delivered to Creditor"),
CURR("IncorrectCurrency", "Currency of the payment is incorrect"),
CUST("RequestedByCustomer", "Cancellation requested by the Debtor"),
DC04("NoCustomerCreditTransferReceived", "Return of Covering Settlement due to the underlying Credit Transfer details not being received."),
DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"),
DS28("ReturnForTechnicalReason", "Return following technical problems resulting in erroneous transaction."),
DT01("InvalidDate", "Invalid date (eg, wrong settlement date)"),
DT02("ChequeExpired", "Cheque has been issued but not deposited and is considered expired."),
DT04("FutureDateNotSupported", "Future date not supported."),
DUPL("DuplicatePayment", "Payment is a duplicate of another payment."),
ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."),
ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"),
ED05("SettlementFailed", "Settlement of the transaction has failed."),
EMVL("EMVLiabilityShift", "The card payment is fraudulent and was not processed with EMV technology for an EMV card."),
ERIN("ERIOptionNotSupported", "The Extended Remittance Information (ERI) option is not supported."),
FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."),
FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid."),
FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"),
FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid."),
FF07("InvalidPurpose", "Purpose is missing or invalid."),
FOCR("FollowingCancellationRequest", "Return following a cancellation request"),
FR01("Fraud", "Returned as a result of fraud."),
FRTR("FinalResponseMandateCancelled", "Final response/tracking is recalled as mandate is cancelled."),
G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."),
MD01("NoMandate", "No Mandate"),
MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."),
MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit."),
MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"),
MD07("EndCustomerDeceased", "End customer is deceased."),
MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"),
MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."),
NARR("Narrative", "Reason is provided as narrative information in the additional reason information."),
NOAS("NoAnswerFromCustomer", "No response from Beneficiary"),
NOCM("NotCompliant", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."),
NOOR("NoOriginalTransactionReceived", "Original SCT never received"),
PINL("PINLiabilityShift", "The card payment is fraudulent (lost and stolen fraud) and was processed as EMV transaction without PIN verification."),
RC01("BankIdentifierIncorrect", "Bank Identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."),
RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing."),
RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing."),
RC07("InvalidCreditorBICIdentifier", "Incorrrect BIC of the beneficiary Bank in the SCTR"),
RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."),
RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing."),
RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."),
RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"),
RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."),
RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."),
RR04("RegulatoryReason", "Regulatory Reason"),
RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."),
RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."),
RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."),
RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."),
RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."),
RR11("InvalidDebtorAgentServiceIdentification", "Invalid or missing identification of a bank proprietary service."),
RR12("InvalidPartyIdentification", "Invalid or missing identification required within a particular country or payment type."),
RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."),
SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent"),
SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent"),
SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."),
SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."),
SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."),
SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."),
SP01("PaymentStopped", "Payment is stopped by account holder."),
SP02("PreviouslyStopped", "Previously stopped by means of a stop payment advise."),
SVNR("ServiceNotRendered", "The card payment is returned since a cash amount rendered was not correct or goods or a service was not rendered to the customer, e.g. in an e-commerce situation."),
TM01("CutOffTime", "Associated message was received after agreed processing cut-off time."),
TRAC("RemovedFromTracking", "Return following direct debit being removed from tracking process."),
UPAY("UnduePayment", "Payment is not justified."),
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt 0000664 0001750 0001750 00000011270 15122266731 030274 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.iso20022
import tech.libeufin.nexus.*
import tech.libeufin.ebics.XmlDestructor
import java.io.InputStream
private fun fmtMsg(code: String?, description: String?, reasons: List) = buildString {
if (code != null) {
append(code)
append(" ")
if (description != null) {
append("'")
append(description)
append("'")
}
if (reasons.isNotEmpty()) {
append(":")
}
}
for (reason in reasons) {
append(" ")
if (reason.code != null) {
append(reason.code.isoCode)
append(" '")
append(reason.code.description)
append("'")
}
if (reason.information.isNotEmpty()) {
if (reason.code != null) append(" ")
append("'")
append(reason.information)
append("'")
}
}
}
data class MsgStatus(
val id: String,
val code: ExternalPaymentGroupStatusCode?,
val reasons: List,
val payments: List
) {
fun msg() = fmtMsg(code?.isoCode, code?.description, reasons)
override fun toString() = buildString {
append(id)
val msg = msg()
if (msg.isNotEmpty()) {
append(" ")
append(msg)
}
for (pmt in payments) {
append("\n>")
append(pmt.id)
val msg = pmt.msg()
if (msg.isNotEmpty()) {
append(" ")
append(msg)
}
for (tx in pmt.transactions) {
append("\n>>")
if (tx.id != tx.endToEndId) {
append(tx.id)
append(" ")
}
append(tx.endToEndId)
val msg = tx.msg()
if (msg.isNotEmpty()) {
append(" ")
append(msg)
}
}
}
}
}
data class PmtStatus(
val id: String,
val code: ExternalPaymentGroupStatusCode?,
val reasons: List,
val transactions: List
) {
fun msg() = fmtMsg(code?.isoCode, code?.description, reasons)
}
data class TxStatus(
val id: String,
val endToEndId: String,
val code: ExternalPaymentTransactionStatusCode,
val reasons: List
) {
fun msg() = fmtMsg(code.isoCode, code.description, reasons)
}
data class Reason (
val code: ExternalStatusReasonCode?,
val information: String
)
/** Parse pain.002 XML file */
fun parseCustomerPaymentStatusReport(xml: InputStream): MsgStatus {
fun XmlDestructor.reasons(): List {
return map("StsRsnInf") {
val code = opt("Rsn")?.one("Cd")?.enum()
val info = map("AddtlInf") { text() }.joinToString("")
Reason(code, info)
}
}
return XmlDestructor.parse(xml, "Document") {
one("CstmrPmtStsRpt") {
val (id, code, reasons) = one("OrgnlGrpInfAndSts") {
val id = one("OrgnlMsgId").text()
val code = opt("GrpSts")?.enum()
val reasons = reasons()
Triple(id, code, reasons)
}
val payments = map("OrgnlPmtInfAndSts") {
val id = one("OrgnlPmtInfId").text()
val code = opt("PmtInfSts")?.enum()
val reasons = reasons()
val transactions = map("TxInfAndSts") {
val id = one("OrgnlInstrId").text()
val endToEndId = one("OrgnlEndToEndId").text()
val code = one("TxSts").enum()
val reasons = reasons()
TxStatus(id, endToEndId, code, reasons)
}
PmtStatus(id, code, reasons, transactions)
}
MsgStatus(id, code, reasons, payments)
}
}
} libeufin-1.6.8/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt 0000664 0001750 0001750 00000005205 15122266731 027657 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
package tech.libeufin.nexus.iso20022
import tech.libeufin.nexus.*
import tech.libeufin.ebics.XmlDestructor
import java.io.InputStream
import java.time.Instant
import java.time.ZoneOffset
data class CustomerAck(
val actionType: HacAction,
val orderId: String?,
val code: ExternalStatusReasonCode?,
val info: String,
val timestamp: Instant
) {
fun msg(): String = buildString {
append("$actionType")
if (code != null) append(" ${code.isoCode}")
append(" - '${actionType.description}'")
if (code != null) append(" '${code.description}'")
if (info != "") append(" - '$info'")
}
override fun toString(): String = buildString {
append(timestamp.fmtDateTime())
if (orderId != null) append(" $orderId")
append(" ${msg()}")
}
}
/** Parse HAC pain.002 XML file */
fun parseCustomerAck(xml: InputStream): List {
return XmlDestructor.parse(xml, "Document") {
one("CstmrPmtStsRpt").map("OrgnlPmtInfAndSts") {
val actionType = one("OrgnlPmtInfId").enum()
one("StsRsnInf") {
var timestamp: Instant? = null
var orderId: String? = null
one("Orgtr").one("Id").one("OrgId").each("Othr") {
val value = one("Id")
val key = one("SchmeNm").one("Prtry").text()
when (key) {
"TimeStamp" -> {
timestamp = value.dateTime().toInstant(ZoneOffset.UTC)
}
"OrderID" -> orderId = value.text()
}
}
val code = opt("Rsn")?.one("Cd")?.enum()
val info = map("AddtlInf") { text() }.joinToString("")
CustomerAck(actionType, orderId, code, info, requireNotNull(timestamp))
}
}
}
} libeufin-1.6.8/libeufin-nexus/src/test/ 0000775 0001750 0001750 00000000000 15236145704 020255 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/test/kotlin/ 0000775 0001750 0001750 00000000000 15236145704 021555 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/src/test/kotlin/EbicsTest.kt 0000664 0001750 0001750 00000013700 15122266731 024001 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import org.w3c.dom.Document
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.testing.test
import kotlinx.coroutines.runBlocking
import io.ktor.http.*
import io.ktor.http.content.*
import org.junit.Test
import tech.libeufin.nexus.cli.LibeufinNexus
import tech.libeufin.nexus.*
import tech.libeufin.common.*
import tech.libeufin.common.test.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.ebics.test.*
import tech.libeufin.ebics.*
import kotlin.io.path.*
import kotlin.test.*
import java.time.LocalDate
@OptIn(kotlin.io.path.ExperimentalPathApi::class)
class EbicsTest {
private val nexusCmd = LibeufinNexus()
private val bank = EbicsState()
private val args = "-L TRACE -c conf/fetch.conf"
private fun ebicsSetup() {
// Reset current keys
val dir = Path("/tmp/ebics-test")
dir.deleteRecursively()
dir.createDirectories()
// Set setup mock
setMock(sequence {
yield(bank::hev)
yield(bank::ini)
yield(bank::hia)
yield(bank::hpb)
yield(bank::hkd)
yield(bank::receiptOk)
})
// Run setup
nexusCmd.succeed("ebics-setup $args --auto-accept-keys")
}
@Test
fun setup() {
ebicsSetup()
}
@Test
fun fetchPinnedDate() = setup { db, _ ->
ebicsSetup()
suspend fun resetCheckpoint() {
db.serializable("DELETE FROM kv WHERE key=?") {
bind(CHECKPOINT_KEY)
executeUpdate()
}
}
// Default transient
setMock(sequence {
yield(bank::haa)
yield(bank::receiptOk)
yield(bank::btdNoData)
})
nexusCmd.succeed("ebics-fetch $args --transient")
// Pinned transient
setMock(sequence {
yield(bank::haa)
yield(bank::receiptOk)
yield(bank::btdNoDataPinned)
})
nexusCmd.succeed("ebics-fetch $args --transient --pinned-start 2024-06-05")
// Init checkpoint
setMock(sequence {
yield(bank::hkd)
yield(bank::receiptOk)
yield(bank::btdNoData)
})
nexusCmd.succeed("ebics-fetch $args --transient --checkpoint")
// Default checkpoint
setMock(sequence {
yield(bank::hkd)
yield(bank::receiptOk)
yield(bank::btdNoDataNow)
})
nexusCmd.succeed("ebics-fetch $args --transient --checkpoint")
// Pinned checkpoint
setMock(sequence {
yield(bank::hkd)
yield(bank::receiptOk)
yield(bank::btdNoDataPinned)
})
nexusCmd.succeed("ebics-fetch $args --transient --checkpoint --pinned-start 2024-06-05")
// Reset checkpoint
resetCheckpoint()
setMock(sequence {
yield(bank::hkd)
yield(bank::receiptOk)
yield(bank::btdNoData)
})
nexusCmd.succeed("ebics-fetch $args --transient --checkpoint")
// Reset checkpoint pinned
resetCheckpoint()
setMock(sequence {
yield(bank::hkd)
yield(bank::receiptOk)
yield(bank::btdNoDataPinned)
})
nexusCmd.succeed("ebics-fetch $args --transient --checkpoint --pinned-start 2024-06-05")
}
@Test
fun closePendingTransaction() = setup { db, _ ->
ebicsSetup()
// Failure before first segment
setMock(sequence {
// Failure to perform download
yield(bank::failure)
// Then continue
yield(bank::haa)
yield(bank::receiptOk)
yield(bank::btdNoData)
})
nexusCmd.fail("ebics-fetch $args --transient")
nexusCmd.succeed("ebics-fetch $args --transient")
// Compliant server
setMock(sequence {
yield(bank::haa)
yield(bank::receiptOk)
// Failure to perform download
yield(bank::initializeTx)
yield(bank::failure)
// Retry fail once
yield(bank::failure)
// Retry fail twice
yield(bank::failure)
// Retry succeed
yield(bank::receiptErr)
// Then continue
yield(bank::haa)
yield(bank::receiptOk)
yield(bank::btdNoData)
})
nexusCmd.fail("ebics-fetch $args --transient")
nexusCmd.fail("ebics-fetch $args --transient")
nexusCmd.fail("ebics-fetch $args --transient")
nexusCmd.succeed("ebics-fetch $args --transient")
// Non compliant server
setMock(sequence {
yield(bank::haa)
yield(bank::receiptOk)
// Failure to perform download
yield(bank::initializeTx)
yield(bank::badRequest)
// Retry fail
yield(bank::failure)
// Retry succeed
yield(bank::badRequest)
// Then continue
yield(bank::haa)
yield(bank::receiptOk)
yield(bank::btdNoData)
})
nexusCmd.fail("ebics-fetch $args --transient")
nexusCmd.fail("ebics-fetch $args --transient")
nexusCmd.succeed("ebics-fetch $args --transient")
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/bench.kt 0000664 0001750 0001750 00000024232 15221677432 023201 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import org.junit.Test
import org.postgresql.jdbc.PgConnection
import io.ktor.client.request.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.common.test.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.cli.*
import kotlin.math.max
import java.util.UUID;
import java.time.Instant
class Bench {
/** Generate [amount] rows to fill the database */
fun genData(conn: PgConnection, amount: Int) {
val amount = max(amount, 10)
val token32 = ByteArray(32)
val token64 = ByteArray(64)
val accountPubs = List(amount*2) { EddsaPublicKey.randEdsaKey() }
conn.genData(amount, sequenceOf(
"incoming_transactions(amount, subject, execution_time, debit_payto, uetr, tx_id, acct_svcr_ref)" to {
val subject = if (it % 4 == 0) null else "subject ${it}"
val debtor = if (it % 3 == 0) null else "debit_payto"
if (it % 3 == 0) {
"(20,0)\t$subject\t0\t$debtor\t${UUID.randomUUID()}\t\\N\t\\N\n" +
"(21,0)\t$subject\t0\t$debtor\t\\N\tTX_ID${it*2}\t\\N\n" +
"(22,0)\t$subject\t0\t$debtor\t\\N\t\\N\tREF${it*2}\n"
} else if (it % 3 == 1) {
"(30,0)\t$subject\t0\t$debtor\t${UUID.randomUUID()}\tTX_ID${it*2}\t\\N\n" +
"(31,0)\t$subject\t0\t$debtor\t\\N\tTX_ID${it*2+1}\tREF${it*2}\n" +
"(32,0)\t$subject\t0\t$debtor\t${UUID.randomUUID()}\t\\N\tREF${it*2+1}\n"
} else {
"(40,0)\t$subject\t0\t$debtor\t${UUID.randomUUID()}\tTX_ID${it*2}\tREF${it*2}\n" +
"(41,0)\t$subject\t0\t$debtor\t${UUID.randomUUID()}\tTX_ID${it*2+1}\tREF${it*2+1}\n"
}
},
"outgoing_transactions(amount, subject, execution_time, credit_payto, end_to_end_id, acct_svcr_ref)" to {
val subject = if (it % 4 == 0) null else "subject ${it}"
val creditor = if (it % 3 == 0) null else "credit_payto"
if (it % 2 == 0) {
"(30,0)\t$subject\t0\t$creditor\t\\N\tREF${it*2}\n" +
"(31,0)\t$subject\t0\t$creditor\tE2E_ID${it*2}\t\\N\n"
} else {
"(30,0)\t$subject\t0\t$creditor\tE2E_ID${it*2}\tREF${it*2}\n" +
"(31,0)\t$subject\t0\t$creditor\tE2E_ID${it*2+1}\tREF${it*2+1}\n"
}
},
"initiated_outgoing_transactions(amount, subject, initiation_time, credit_payto, outgoing_transaction_id, end_to_end_id)" to {
"(42,0)\tsubject\t0\tcredit_payto\t${it*2}\tE2E_ID$it\n"
},
"prepared_transfers(type, account_pub, authorization_pub, authorization_sig, recurrent, reference_number, registered_at, incoming_transaction_id)" to {
val type = if (it % 2 == 0) "reserve" else "kyc"
val recurrent = if (it % 3 == 0) "true" else "false"
val incoming_transaction_id = if (it % 5 == 0) "\\N" else "${it*2}"
val reference_number = subjectFmtQrBill(accountPubs[it])
val pub = accountPubs[it].raw.encodeHex()
val sig = token64.rand().encodeHex()
"$type\t\\\\x$pub\t\\\\x$pub\t\\\\x$sig\t$recurrent\t$reference_number\t0\t$incoming_transaction_id\n"
},
"pending_recurrent_incoming_transactions(incoming_transaction_id, authorization_pub)" to {
val hex = accountPubs[it].raw.encodeHex()
"${it*2}\t\\\\x$hex\n"
},
"bounced_transactions(incoming_transaction_id, initiated_outgoing_transaction_id)" to {
if (it % 10 == 0) {
"${it/2}\t${it/3}\n"
} else {
""
}
},
"talerable_incoming_transactions(type, metadata, incoming_transaction_id)" to {
val hex = token32.rand().encodeHex()
if (it % 2 == 0) {
"reserve\t\\\\x$hex\t${it*2}\n"
} else {
"kyc\t\\\\x$hex\t${it*2}\n"
}
},
"talerable_outgoing_transactions(wtid, exchange_base_url, outgoing_transaction_id)" to {
val hex = token32.rand().encodeHex()
"\\\\x$hex\turl\t${it*2-1}\n"
},
"transfer_operations(initiated_outgoing_transaction_id, request_uid, wtid, exchange_base_url)" to {
val hex32 = token32.rand().encodeHex()
val hex64 = token64.rand().encodeHex()
"$it\t\\\\x$hex64\t\\\\x$hex32\turl\n"
}
))
}
@Test
fun benchDb() {
val ingestCfg = NexusIngestConfig.default(AccountType.exchange)
bench { AMOUNT -> serverSetup { db ->
// Generate data
db.conn { genData(it, AMOUNT) }
val accountPubs = List(AMOUNT) { EddsaPublicKey.randEdsaKeyPair() }
// Warm HTTP client
client.getA("/taler-revenue/config").assertOk()
// Register
measureAction("register_in") {
registerIn(db)
}
measureAction("register_incomplete_in") {
registerIncompleteIn(db)
}
measureAction("register_completed_in") {
registerCompletedIn(db)
}
measureAction("register_out") {
registerOut(db)
}
measureAction("register_incomplete_out") {
registerIncompleteOut(db)
}
measureAction("register_reserve") {
talerableIn(db)
}
measureAction("register_prepared_reserve") {
talerablePreparedIn(db)
}
measureAction("register_kyc") {
talerableKycIn(db)
}
// Revenue API
measureAction("transaction_revenue") {
client.getA("/taler-revenue/history").assertOk()
}
// Wire gateway
measureAction("wg_transfer") {
client.postA("/taler-wire-gateway/transfer") {
json {
"request_uid" to HashCode.rand()
"amount" to "CHF:0.0001"
"exchange_base_url" to "http://exchange.example.com/"
"wtid" to ShortHashCode.rand()
"credit_account" to grothoffPayto
}
}.assertOk()
}
measureAction("wg_transfer_get") {
client.getA("/taler-wire-gateway/transfers/42").assertOk()
}
measureAction("wg_transfer_page") {
client.getA("/taler-wire-gateway/transfers").assertOk()
}
measureAction("wg_transfer_page_filter") {
client.getA("/taler-wire-gateway/transfers?status=success").assertNoContent()
}
measureAction("wg_add") {
client.postA("/taler-wire-gateway/admin/add-incoming") {
json {
"amount" to "CHF:0.0001"
"reserve_pub" to EddsaPublicKey.randEdsaKey()
"debit_account" to grothoffPayto
}
}.assertOk()
}
measureAction("wg_incoming") {
client.getA("/taler-wire-gateway/history/incoming")
.assertOk()
}
measureAction("wg_outgoing") {
client.getA("/taler-wire-gateway/history/outgoing")
.assertOk()
}
// Wire transfer
/*measureAction("wt_register") {
val (priv, pub) = accountPubs[it]
val valid_req = obj {
"credit_account" to "payto://iban/CH7789144474425692816"
"credit_amount" to "KUDOS:55"
"type" to "reserve"
"alg" to "EdDSA"
"account_pub" to pub
"authorization_pub" to pub
"authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv)
"recurrent" to false
}
client.post("/taler-prepared-transfer/registration") {
json(valid_req)
}.assertOkJson()
client.post("/taler-prepared-transfer/registration") {
json(valid_req)
}.assertOkJson()
}
measureAction("wt_unregister") {
val (priv, pub) = accountPubs[it]
val now = Instant.now().toString()
val valid_req = obj {
"timestamp" to now
"authorization_pub" to pub
"authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv)
}
client.post("/taler-prepared-transfer/unregistration") {
json(valid_req)
}.assertNoContent()
client.post("/taler-prepared-transfer/unregistration") {
json(valid_req)
}.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
}*/
// Observability
/*measureAction("metrics") {
client.get("/taler-observability/metrics")
.assertOk()
}*/
}}
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/PreparedTransferApiTest.kt 0000664 0001750 0001750 00000016616 15221677432 026672 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import io.ktor.client.request.*
import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.nexus.*
import tech.libeufin.nexus.cli.*
import java.time.Instant
import kotlin.test.*
class PreparedTransferApiTest {
// GET /taler-prepared-transfer/config
@Test
fun config() = serverSetup {
client.get("/taler-prepared-transfer/config").assertOkJson()
}
// POST /taler-prepared-transfer/registration
@Test
fun registration() = serverSetup { db ->
val (priv, pub) = EddsaPublicKey.randEdsaKeyPair()
val amount = TalerAmount("KUDOS:55")
val valid_req = SubjectRequest(
Payto.parse("payto://iban/CH7789144474425692816"),
TransferType.reserve,
false,
amount,
PublicKeyAlg.EdDSA,
pub,
pub,
EddsaSignature.rand()
)
val simple = listOf(TransferSubject.Simple("Taler MAP:$pub", amount))
val qrs = listOf(TransferSubject.QrBill(subjectFmtQrBill(pub), amount))
// Valid simple
client.post("/taler-prepared-transfer/registration") {
json(valid_req.sign(priv))
}.assertOkJson {
assertEquals(it.subjects, simple)
}
// Idempotent simple
client.post("/taler-prepared-transfer/registration") {
json(valid_req.sign(priv))
}.assertOkJson {
assertEquals(it.subjects, simple)
}
// Valid qr
client.post("/taler-prepared-transfer/registration") {
json(valid_req.copy(credit_account=Payto.parse("payto://iban/CH4431999123000889012")).sign(priv))
}.assertOkJson {
assertEquals(it.subjects, qrs)
}
// Idempotent qr
client.post("/taler-prepared-transfer/registration") {
json(valid_req.copy(credit_account=Payto.parse("payto://iban/CH4431999123000889012")).sign(priv))
}.assertOkJson {
assertEquals(it.subjects, qrs)
}
// Bad signature
client.post("/taler-prepared-transfer/registration") {
json(valid_req)
}.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE)
// Unknown account
client.post("/taler-prepared-transfer/registration") {
json(valid_req.copy(credit_account=Payto.parse(grothoffPayto)).sign(priv))
}.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR)
// Check authorization field in incoming history
val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair()
val testKey = EddsaPublicKey.randEdsaKey()
val qr = subjectFmtQrBill(testAuth)
client.post("/taler-prepared-transfer/registration") {
json(valid_req.copy(
credit_account=Payto.parse("payto://iban/CH4431999123000889012"),
account_pub=testKey,
authorization_pub=testAuth,
recurrent=true
).sign(testPriv))
}.assertOkJson()
val cfg = NexusIngestConfig.default(AccountType.exchange)
registerIncomingPayment(db, cfg, genInPay(qr))
registerIncomingPayment(db, cfg, genInPay(qr))
registerIncomingPayment(db, cfg, genInPay(qr))
client.post("/taler-prepared-transfer/registration") {
json(valid_req.copy(
type=TransferType.kyc,
account_pub=testKey,
authorization_pub=testAuth,
recurrent=true
).sign(testPriv))
}.assertOkJson()
val otherPub = EddsaPublicKey.randEdsaKey()
client.post("/taler-prepared-transfer/registration") {
json(valid_req.copy(
account_pub=otherPub,
authorization_pub=testAuth,
recurrent=true
).sign(testPriv))
}.assertOkJson()
val lastPub = EddsaPublicKey.randEdsaKey()
talerableIn(db, reserve_pub=lastPub)
talerableKycIn(db, account_pub=lastPub)
val history = client.getA("/taler-wire-gateway/history/incoming?limit=-5")
.assertOkJson().incoming_transactions.map {
when (it) {
is IncomingKycAuthTransaction -> Pair(it.account_pub, it.authorization_pub)
is IncomingReserveTransaction -> Pair(it.reserve_pub, it.authorization_pub)
else -> throw UnsupportedOperationException()
}
}
assertContentEquals(history, listOf(
Pair(lastPub, null),
Pair(lastPub, null),
Pair(otherPub, testAuth),
Pair(testKey, testAuth),
Pair(testKey, testAuth)
))
}
// DELETE /taler-prepared-transfer/registration
@Test
fun unregistration() = serverSetup {
val (priv, pub) = EddsaPublicKey.randEdsaKeyPair()
val now = TalerTimestamp(Instant.now())
val req = Unregistration(
now,
pub,
EddsaSignature.rand()
).sign(priv)
// Unknown
client.post("/taler-prepared-transfer/unregistration") {
json(req)
}.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
// Know
client.post("/taler-prepared-transfer/registration") {
json(SubjectRequest(
Payto.parse("payto://iban/CH7789144474425692816"),
TransferType.reserve,
false,
TalerAmount("KUDOS:55"),
PublicKeyAlg.EdDSA,
pub,
pub,
EddsaSignature.rand()
).sign(priv))
}.assertOkJson()
client.post("/taler-prepared-transfer/unregistration") {
json(req)
}.assertNoContent()
// Idempotent
client.post("/taler-prepared-transfer/unregistration") {
json(req)
}.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
// Bad signature
client.post("/taler-prepared-transfer/unregistration") {
json(Unregistration(
now,
pub,
EddsaSignature.rand()
))
}.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE)
// Old timestamp
client.post("/taler-prepared-transfer/unregistration") {
json(
Unregistration(
TalerTimestamp(Instant.now().minusSeconds(1000000)),
pub,
EddsaSignature.rand()
).sign(priv)
)
}.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP)
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/RegistrationTest.kt 0000664 0001750 0001750 00000106011 15122266731 025424 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024-2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.cli.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.ebics.*
import java.nio.file.Files
import java.time.Instant
import java.util.UUID
import kotlin.io.path.*
import kotlin.test.*
/** End-to-end test for XML file registration */
class RegistrationTest {
/** Register batches of initiated payments for reconcciliation */
suspend fun Database.batches(batches: Map>): List {
val tmp = mutableListOf()
for ((name, txs) in batches) {
for (tx in txs) {
initiated.create(tx)
}
this.initiated.batch(Instant.now(), name, false)
val (batch) = this.initiated.submittable()
this.initiated.batchSubmissionSuccess(batch.id, Instant.now(), name.replace("BATCH", "ORDER"))
tmp.add(batch)
}
return tmp
}
/** Register an XML sample into the database */
suspend fun Database.register(
cfg: NexusConfig,
path: String,
doc: OrderDoc
) {
registerFile(this, cfg, Files.newInputStream(Path(path)), doc)
}
/** Check database content */
suspend fun Database.check(
status: Map>>,
incoming: List,
outgoing: List,
bounced: List
) {
// Check batch status
val batch_status = this.serializable(
"""
SELECT message_id, status FROM initiated_outgoing_batches ORDER BY initiated_outgoing_batch_id
"""
) {
all {
Pair(
it.getString("message_id"),
it.getEnum("status")
)
}
}
assertContentEquals(status.map { Pair(it.key, it.value.first) }, batch_status)
// Check transactions status
val batch_tx = this.serializable(
"""
SELECT message_id, end_to_end_id, initiated_outgoing_transactions.status
FROM initiated_outgoing_transactions
JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
ORDER BY initiated_outgoing_batch_id, initiated_outgoing_transaction_id
"""
) {
all {
Triple(
it.getString("message_id"),
it.getString("end_to_end_id"),
it.getEnum("status")
)
}.groupBy(
keySelector = { it.first },
valueTransform = { Pair(it.second, it.third) }
).mapValues { it.value.toMap() }
}
assertContentEquals(status.mapValues { it.value.second }.toList(), batch_tx.toList())
// Check incoming transactions
val incoming_tx = this.serializable(
"""
SELECT
uetr
,tx_id
,acct_svcr_ref
,(amount).val as amount_val
,(amount).frac AS amount_frac
,(credit_fee).val AS credit_fee_val
,(credit_fee).frac AS credit_fee_frac
,subject
,execution_time
,debit_payto
FROM incoming_transactions
ORDER BY incoming_transaction_id
"""
) {
all {
IncomingPayment(
id = IncomingId(
it.getObject("uetr") as UUID?,
it.getString("tx_id"),
it.getString("acct_svcr_ref"),
),
amount = it.getAmount("amount", this@check.currency),
creditFee = it.getAmount("credit_fee", this@check.currency).notZeroOrNull(),
subject = it.getString("subject"),
executionTime = it.getLong("execution_time").asInstant(),
debtor = it.getOptIbanPayto("debit_payto"),
)
}
}
assertContentEquals(incoming, incoming_tx)
// Check outgoing transactions
val outgoing_tx = this.serializable(
"""
SELECT end_to_end_id
,acct_svcr_ref
,(amount).val as amount_val
,(amount).frac AS amount_frac
,(debit_fee).val AS debit_fee_val
,(debit_fee).frac AS debit_fee_frac
,subject
,execution_time
,credit_payto
FROM outgoing_transactions
ORDER BY outgoing_transaction_id
"""
) {
all {
OutgoingPayment(
id = OutgoingId(null, it.getString("end_to_end_id"), it.getString("acct_svcr_ref")),
amount = it.getAmount("amount", this@check.currency),
debitFee = it.getAmount("debit_fee", this@check.currency).notZeroOrNull(),
subject = it.getString("subject"),
executionTime = it.getLong("execution_time").asInstant(),
creditor = it.getOptIbanPayto("credit_payto"),
)
}
}
assertContentEquals(outgoing, outgoing_tx)
// Check outgoing transactions
val bounced_tx = this.serializable(
"""
SELECT end_to_end_id
,(amount).val as amount_val
,(amount).frac as amount_frac
,subject
,credit_payto
FROM initiated_outgoing_transactions
JOIN bounced_transactions USING (initiated_outgoing_transaction_id)
"""
) {
all {
OutgoingPayment(
id = OutgoingId(null, null, null),
amount = it.getAmount("amount", this@check.currency),
subject = it.getString("subject"),
executionTime = Instant.EPOCH,
creditor = it.getOptIbanPayto("credit_payto"),
)
}
}
assertContentEquals(bounced, bounced_tx)
}
@Test
fun pain001() = setup { db, cfg ->
val (batch) = db.batches(mapOf(
"MESSAGE_ID" to listOf(
genInitPay(
endToEndId = "TX_FIRST",
amount = "EUR:42",
subject = "Test 42",
),
genInitPay(
endToEndId = "TX_SECOND",
amount = "EUR:5.11",
subject = "Test 5.11",
),
genInitPay(
endToEndId = "TX_THIRD",
amount = "EUR:0.21",
subject = "Test 0.21",
),
),
))
val msg = batchToPain001Msg(cfg.ebics.account, batch).copy(timestamp = dateToInstant("2024-09-09"),)
for (dialect in Dialect.entries) {
println(dialect)
assertEquals(
Path("sample/platform/${dialect}_pain001.xml").readText().replace("VERSION", VERSION),
createPain001(msg, dialect, false).asUtf8()
)
}
}
/** HAC order id test */
@Test
fun hac() = setup { db, cfg ->
db.batches(mapOf(
"BATCH_SUCCESS" to listOf(
genInitPay("BATCH_SUCCESS_0"),
genInitPay("BATCH_SUCCESS_1"),
),
"BATCH_FAILURE" to listOf(
genInitPay("BATCH_FAILURE_0"),
genInitPay("BATCH_FAILURE_1"),
)
))
// Register HAC files
db.register(cfg, "sample/platform/hac.xml", OrderDoc.acknowledgement)
// Check state
db.check(
status = mapOf(
"BATCH_SUCCESS" to Pair(SubmissionState.success, mapOf(
"BATCH_SUCCESS_0" to SubmissionState.pending,
"BATCH_SUCCESS_1" to SubmissionState.pending,
)),
"BATCH_FAILURE" to Pair(SubmissionState.permanent_failure, mapOf(
"BATCH_FAILURE_0" to SubmissionState.permanent_failure,
"BATCH_FAILURE_1" to SubmissionState.permanent_failure,
))
),
incoming = emptyList(),
outgoing = emptyList(),
bounced = emptyList()
)
}
/** CreditSuisse dialect test */
@Test
fun cs() = setup { db, cfg ->
db.batches(mapOf(
"05BD4C5B4A2649B5B08F6EF6A31F197A" to listOf(
genInitPay("AQCXNCPWD8PHW5JTN65Y5XTF7R"),
genInitPay("EE9SX76FC5YSC657EK3GMVZ9TC"),
genInitPay("V5B3MXPEWES9VQW1JDRD6VAET4"),
genInitPay("M9NGRCAC1FBX3ENX3XEDEPJ2JW"),
),
))
// Register pain files
db.register(cfg, "sample/platform/pain002_part.xml", OrderDoc.status)
// Check state
db.check(
status = mapOf(
"05BD4C5B4A2649B5B08F6EF6A31F197A" to Pair(SubmissionState.pending, mapOf(
"AQCXNCPWD8PHW5JTN65Y5XTF7R" to SubmissionState.permanent_failure,
"EE9SX76FC5YSC657EK3GMVZ9TC" to SubmissionState.permanent_failure,
"V5B3MXPEWES9VQW1JDRD6VAET4" to SubmissionState.permanent_failure,
"M9NGRCAC1FBX3ENX3XEDEPJ2JW" to SubmissionState.pending,
)),
),
incoming = emptyList(),
outgoing = emptyList(),
bounced = emptyList()
)
}
/** Valiant dialect test */
@Test
fun valiant() = setup("valiant.conf") { db, cfg ->
db.batches(mapOf(
"MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L" to listOf(
genInitPay("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE"),
),
"5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U" to listOf(
genInitPay("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"),
genInitPay("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"),
genInitPay("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"),
genInitPay("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"),
),
"X166701F6RV59LP71RVWVIW9SV2AFZYLG4" to listOf(
genInitPay("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX"),
),
"OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P" to listOf(
genInitPay("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7"),
),
"6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X" to listOf(
genInitPay("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"),
genInitPay("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"),
genInitPay("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE")
),
))
// Register camt files
db.register(cfg, "sample/platform/valiant_camt052.xml", OrderDoc.report)
// Check state
db.check(
status = mapOf(
"MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L" to Pair(SubmissionState.success, mapOf(
"4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE" to SubmissionState.success
)),
"5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U" to Pair(SubmissionState.success, mapOf(
"SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8" to SubmissionState.success,
"RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN" to SubmissionState.success,
"GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7" to SubmissionState.success,
"PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28" to SubmissionState.success
)),
"X166701F6RV59LP71RVWVIW9SV2AFZYLG4" to Pair(SubmissionState.success, mapOf(
"R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX" to SubmissionState.late_failure
)),
"OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P" to Pair(SubmissionState.success, mapOf(
"TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7" to SubmissionState.success
)),
"6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X" to Pair(SubmissionState.success, mapOf(
"02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ" to SubmissionState.success,
"XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y" to SubmissionState.late_failure,
"GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE" to SubmissionState.success
)),
),
incoming = listOf(
IncomingPayment(
id = IncomingId(null, "51030655601.0001", "ZV20251030/514778/1"),
amount = TalerAmount("CHF:0.85"),
subject = "fun stuff",
executionTime = dateToInstant("2025-10-30"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
),
IncomingPayment(
id = IncomingId(null, "51030655601.0002", "ZV20251030/514779/1"),
amount = TalerAmount("CHF:0.95"),
subject = "Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0",
executionTime = dateToInstant("2025-10-30"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
),
IncomingPayment(
id = IncomingId("7b76d488-05d5-44ab-9d77-31d4165ec158", "00204EQY370", "ZV20251118/685062/1"),
amount = TalerAmount("CHF:4.55"),
subject = "TEST",
executionTime = dateToInstant("2025-11-18"),
debtor = null
),
),
outgoing = listOf(
OutgoingPayment(
id = OutgoingId(null, "4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE", "ZV20251030/511372/1"),
amount = TalerAmount("CHF:0.1"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "single 2025-10-30T09:46:04.55293090 9Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8", "ZV20251030/511373/1"),
amount = TalerAmount("CHF:0.1"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 0 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN", "ZV20251030/511373/2"),
amount = TalerAmount("CHF:0.11"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 1 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7", "ZV20251030/511373/3"),
amount = TalerAmount("CHF:0.12"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 2 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28", "ZV20251030/511373/4"),
amount = TalerAmount("CHF:0.13"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 3 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", "ZV20251030/524078/1"),
amount = TalerAmount("CHF:0.21"),
creditor = ibanPayto("CH6208704048981247126", "John Smith"),
subject = "bad name 2025-10-30T12:03:24.997478 811Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ", "ZV20251030/524079/1"),
amount = TalerAmount("CHF:0.1"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "single 2025-10-30T12:04:00.37042083 6Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", "ZV20251030/524079/2"),
amount = TalerAmount("CHF:0.21"),
creditor = ibanPayto("CH6208704048981247126", "John Smith"),
subject = "bad name 2025-10-30T12:03:53.042190 686Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7", "ZV20251030/524077/1"),
amount = TalerAmount("CHF:0.23"),
debitFee = TalerAmount("CHF:5"),
creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"),
subject = "foreign iban 2025-10-30T12:03:44.0972 63765Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId(null, "GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE", "ZV20251030/524080/1"),
amount = TalerAmount("CHF:0.23"),
debitFee = TalerAmount("CHF:5"),
creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"),
subject = "foreign iban 2025-10-30T12:03:58.0046 73747Z",
executionTime = dateToInstant("2025-10-30")
),
),
bounced = listOf(
OutgoingPayment(
id = OutgoingId(null, null, null),
amount = TalerAmount("CHF:0.85"),
subject = "bounce 51030655601.0001: missing reserve public key",
executionTime = Instant.EPOCH,
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
)
)
}
/** GLS dialect test */
@Test
fun gls() = setup("gls.conf") { db, cfg ->
db.batches(mapOf(
"COMPAT_SUCCESS" to listOf(
genInitPay("COMPAT_SUCCESS")
),
"COMPAT_FAILURE" to listOf(
genInitPay("COMPAT_FAILURE")
),
"BATCH_SINGLE_SUCCESS" to listOf(
genInitPay("FD622SMXKT5QWSAHDY0H8NYG3G"),
),
// JEYMR3OYZTFM7505OWWENFPAH53LNOWJHS
"BATCH_SINGLE_FAILURE" to listOf(
genInitPay("DAFC3NEE4T48WVC560T76ABA2C"),
),
"BATCH_SINGLE_RETURN" to listOf(
genInitPay("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5"),
),
"BATCH_MANY_SUCCESS" to listOf(
genInitPay("IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V"),
genInitPay("CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW"),
genInitPay("35M1268GW5ZFHS5JCB41UKDQNPMD40T849"),
genInitPay("HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1"),
),
"BATCH_MANY_PART" to listOf(
genInitPay("27SK3166EG36SJ7VP7VFYP0MW8"),
genInitPay("KGTDBASWTJ6JM89WXD3Q5KFQC4"),
genInitPay("8XK8Z7RAX224FGWK832FD40GYC"),
),
// ZQOOPJC1DYBP52X119YGO6WMXU6NWIDPJK
"BATCH_MANY_FAILURE" to listOf(
genInitPay("4XTPKWE4A9V90PRQJCT8Z3MQZ8"),
genInitPay("3VZZHVYJ6XP2SNPKWF4D4YVHNG"),
)
))
// Register camt files
db.register(cfg, "sample/platform/gls_camt052.xml", OrderDoc.report)
db.register(cfg, "sample/platform/gls_camt053.xml", OrderDoc.statement)
// TODO camt054 with missing id before and after
// Check state
db.check(
status = mapOf(
"COMPAT_SUCCESS" to Pair(SubmissionState.success, mapOf(
"COMPAT_SUCCESS" to SubmissionState.success
)),
"COMPAT_FAILURE" to Pair(SubmissionState.pending, mapOf(
"COMPAT_FAILURE" to SubmissionState.permanent_failure
)),
"BATCH_SINGLE_SUCCESS" to Pair(SubmissionState.success, mapOf(
"FD622SMXKT5QWSAHDY0H8NYG3G" to SubmissionState.success
)),
"BATCH_SINGLE_FAILURE" to Pair(SubmissionState.pending, mapOf( // TODO success
"DAFC3NEE4T48WVC560T76ABA2C" to SubmissionState.pending, // TODO failure
)),
"BATCH_SINGLE_RETURN" to Pair(SubmissionState.success, mapOf(
"KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5" to SubmissionState.late_failure,
)),
"BATCH_MANY_SUCCESS" to Pair(SubmissionState.success, mapOf(
"IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V" to SubmissionState.success,
"CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW" to SubmissionState.success,
"35M1268GW5ZFHS5JCB41UKDQNPMD40T849" to SubmissionState.success,
"HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1" to SubmissionState.success,
)),
"BATCH_MANY_PART" to Pair(SubmissionState.success, mapOf(
"27SK3166EG36SJ7VP7VFYP0MW8" to SubmissionState.success,
"KGTDBASWTJ6JM89WXD3Q5KFQC4" to SubmissionState.permanent_failure,
"8XK8Z7RAX224FGWK832FD40GYC" to SubmissionState.permanent_failure,
)),
"BATCH_MANY_FAILURE" to Pair(SubmissionState.pending, mapOf( // TODO success
"4XTPKWE4A9V90PRQJCT8Z3MQZ8" to SubmissionState.pending, // TODO failure
"3VZZHVYJ6XP2SNPKWF4D4YVHNG" to SubmissionState.pending, // TODO failure
))
),
incoming = listOf(
IncomingPayment(
id = IncomingId(null, "BYLADEM1WOR-G2910276709458A2", "2024041210041357000"),
amount = TalerAmount("EUR:3"),
subject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-",
executionTime = dateToInstant("2024-04-12"),
debtor = ibanPayto("DE84500105177118117964", "John Smith")
),
),
outgoing = listOf(
OutgoingPayment(
id = OutgoingId(null, "COMPAT_SUCCESS", "2024041801514102000"),
amount = TalerAmount("EUR:2"),
subject = "TestABC123",
executionTime = dateToInstant("2024-04-18"),
creditor = ibanPayto("DE20500105172419259181", "John Smith")
),
OutgoingPayment(
id = OutgoingId(null, "FD622SMXKT5QWSAHDY0H8NYG3G", "2024090216552232000"),
amount = TalerAmount("EUR:1.1"),
subject = "single 2024-09-02T14:29:52.875253314Z",
executionTime = dateToInstant("2024-09-02"),
creditor = ibanPayto("DE89500105173198527518", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, "YF5QBARGQ0MNY0VK59S477VDG4", "2024041810552821000"),
amount = TalerAmount("EUR:1.1"),
subject = "Simple tx",
executionTime = dateToInstant("2024-04-18"),
creditor = ibanPayto("DE20500105172419259181", "John Smith")
),
OutgoingPayment(
id = OutgoingId(null, "IVMIGCUIE7Q7VOF73R8GU3KGRYBZPAYC5V", null),
amount = TalerAmount("EUR:44"),
subject = "init payment",
executionTime = dateToInstant("2024-09-20"),
creditor = ibanPayto("CH4189144589712575493", "Test")
),
OutgoingPayment(
id = OutgoingId(null, "CDFN7I4FVIZ848DGDQ35DZ2K49H9EWXGAW", null),
amount = TalerAmount("EUR:44"),
subject = "init payment",
executionTime = dateToInstant("2024-09-20"),
creditor = ibanPayto("CH4189144589712575493", "Test")
),
OutgoingPayment(
id = OutgoingId(null, "35M1268GW5ZFHS5JCB41UKDQNPMD40T849", null),
amount = TalerAmount("EUR:44"),
subject = "init payment",
executionTime = dateToInstant("2024-09-20"),
creditor = ibanPayto("CH4189144589712575493", "Test")
),
OutgoingPayment(
id = OutgoingId(null, "HPOMV7A4E3P1TK9UZJS1WTM94A9V3X2SR1", null),
amount = TalerAmount("EUR:44"),
subject = "init payment",
executionTime = dateToInstant("2024-09-20"),
creditor = ibanPayto("CH4189144589712575493", "Test")
),
OutgoingPayment(
id = OutgoingId(null, "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", "2024092100252498000"),
amount = TalerAmount("EUR:0.42"),
subject = "This should fail because bad iban",
executionTime = dateToInstant("2024-09-23"),
creditor = ibanPayto("DE18500105173385245163", "John Smith")
),
OutgoingPayment(
id = OutgoingId(null, "27SK3166EG36SJ7VP7VFYP0MW8", null),
amount = TalerAmount("EUR:44"),
subject = "init payment",
executionTime = dateToInstant("2024-09-04"),
creditor = ibanPayto("CH4189144589712575493", "Test")
),
),
bounced = emptyList()
)
}
/** Maerki Baumann dialect test */
@Test
fun maerki_baumann() = setup("maerki_baumann.conf") { db, cfg ->
db.batches(mapOf(
"BATCH_SINGLE_REPORTING" to listOf(
genInitPay("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"),
genInitPay("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"),
genInitPay("A09R35EW0359SZ51464E7TC37A0P2CBK04"),
genInitPay("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"),
),
))
// Register camt files
db.register(cfg, "sample/platform/maerki_baumann_camt053.xml", OrderDoc.statement)
// Check state
db.check(
status = mapOf(
"BATCH_SINGLE_REPORTING" to Pair(SubmissionState.success, mapOf(
"5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H" to SubmissionState.success,
"XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH" to SubmissionState.success,
"A09R35EW0359SZ51464E7TC37A0P2CBK04" to SubmissionState.success,
"UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT" to SubmissionState.success,
)),
),
incoming = listOf(
IncomingPayment(
id = IncomingId("adbe4a5a-6cea-4263-b259-8ab964561a32", "41103099704.0002", "ZV20241104/765446/1"),
amount = TalerAmount("CHF:1"),
creditFee = TalerAmount("CHF:0.2"),
subject = "SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G",
executionTime = dateToInstant("2024-11-04"),
debtor = ibanPayto("CH7389144832588726658", "Mr Test")
),
IncomingPayment(
id = IncomingId("7371795e-62fa-42dd-93b7-da89cc120faa", "41103099704.0003", "ZV20241104/765447/1"),
amount = TalerAmount("CHF:1"),
creditFee = TalerAmount("CHF:0.2"),
subject = "Random subject",
executionTime = dateToInstant("2024-11-04"),
debtor = ibanPayto("CH7389144832588726658", "Mr Test")
),
IncomingPayment(
id = IncomingId(null, "50523424675.0001", "ZV20250523/851716/1"),
amount = TalerAmount("CHF:0.5"),
creditFee = TalerAmount("CHF:0.2"),
subject = null,
executionTime = dateToInstant("2025-05-23"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
IncomingPayment(
id = IncomingId("f203fbb4-6e13-4c78-9b2a-d852fea6374a", "41202060702.0001", "ZV20241202/778108/1"),
amount = TalerAmount("CHF:0.05"),
creditFee = TalerAmount("CHF:0.2"),
subject = "mini",
executionTime = dateToInstant("2024-12-02"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
IncomingPayment(
id = IncomingId("81b0d8c6-a677-4577-b75e-a639dcc03681", "41120636093.0001", "ZV20241121/773118/1"),
amount = TalerAmount("CHF:0.1"),
creditFee = TalerAmount("CHF:0.2"),
subject = "small transfer test",
executionTime = dateToInstant("2024-11-21"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
IncomingPayment(
id = IncomingId(null, null, "ZV20250114/796191/1"),
amount = TalerAmount("CHF:3003"),
subject = "Fix bad payment by MB.",
executionTime = dateToInstant("2025-01-27"),
debtor = null
),
IncomingPayment(
id = IncomingId(null, "F000787951230001", "ZV20250526/852733/1"),
amount = TalerAmount("CHF:1.38"),
creditFee = TalerAmount("CHF:0.2"),
subject = "Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G",
executionTime = dateToInstant("2025-05-26"),
debtor = ibanPayto("DE20500105172419259181", "Mr German")
),
),
outgoing = listOf(
OutgoingPayment(
id = OutgoingId(null, "5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", "ZV20241121/773541/1"),
amount = TalerAmount("CHF:0.1"),
subject = "multi 0 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, "XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", "ZV20241121/773541/4"),
amount = TalerAmount("CHF:0.13"),
subject = "multi 3 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, "A09R35EW0359SZ51464E7TC37A0P2CBK04", "ZV20241121/773541/3"),
amount = TalerAmount("CHF:0.12"),
subject = "multi 2 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, "UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", "ZV20241121/773541/2"),
amount = TalerAmount("CHF:0.11"),
subject = "multi 1 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, null, "GB20241220/205792/1"),
amount = TalerAmount("CHF:3000"),
subject = null,
executionTime = dateToInstant("2024-12-20"),
creditor = null
),
),
bounced = listOf(
OutgoingPayment(
id = OutgoingId(null, null, null),
amount = TalerAmount("CHF:1"),
subject = "bounce 7371795e-62fa-42dd-93b7-da89cc120faa: missing reserve public key",
executionTime = Instant.EPOCH,
creditor = ibanPayto("CH7389144832588726658", "Mr Test")
),
OutgoingPayment(
id = OutgoingId(null, null, null),
amount = TalerAmount("CHF:0.5"),
subject = "bounce 50523424675.0001: missing subject",
executionTime = Instant.EPOCH,
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, null, null),
amount = TalerAmount("CHF:0.05"),
subject = "bounce f203fbb4-6e13-4c78-9b2a-d852fea6374a: missing reserve public key",
executionTime = Instant.EPOCH,
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, null, null),
amount = TalerAmount("CHF:0.1"),
subject = "bounce 81b0d8c6-a677-4577-b75e-a639dcc03681: missing reserve public key",
executionTime = Instant.EPOCH,
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, null, null),
amount = TalerAmount("CHF:1.38"),
subject = "bounce F000787951230001: restricted account",
executionTime = Instant.EPOCH,
creditor = ibanPayto("DE20500105172419259181", "Mr German")
)
)
)
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/Iso20022Test.kt 0000664 0001750 0001750 00000103464 15221677432 024107 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.ebics.*
import kotlin.io.path.*
import kotlin.test.*
import java.time.Instant
class Iso20022Test {
@Test
fun pain001() {
val creditor = IbanAccountMetadata(
iban = IBAN.parse("CH4189144589712575493"),
bic = null,
name = "Test"
)
val msg = Pain001Msg(
messageId = "MESSAGE_ID",
timestamp = dateToInstant("2024-09-09"),
debtor = IbanAccountMetadata(
iban = IBAN.parse("CH7789144474425692816"),
bic = "BIC",
name = "myname"
),
sum = TalerAmount("CHF:47.32"),
txs = listOf(
Pain001Tx(
creditor = creditor,
amount = TalerAmount("CHF:42"),
subject = "Test 42",
endToEndId = "TX_FIRST"
),
Pain001Tx(
creditor = creditor,
amount = TalerAmount("CHF:5.11"),
subject = "Test 5.11",
endToEndId = "TX_SECOND"
),
Pain001Tx(
creditor = creditor,
amount = TalerAmount("CHF:0.21"),
subject = "Test 0.21",
endToEndId = "TX_THIRD"
)
)
)
for (dialect in Dialect.entries) {
assertEquals(
Path("sample/platform/${dialect}_pain001.xml").readText().replace("VERSION", VERSION),
createPain001(msg, dialect, false).asUtf8()
)
}
}
@Test
fun hac() {
assertContentEquals(
parseCustomerAck(Path("sample/platform/hac.xml").inputStream()),
listOf(
CustomerAck(
actionType = HacAction.FILE_DOWNLOAD,
orderId = null,
code = ExternalStatusReasonCode.TS01,
info = "",
timestamp = dateTimeToInstant("2024-09-02T15:47:30.350Z")
),
CustomerAck(
actionType = HacAction.FILE_UPLOAD,
orderId = "ORDER_SUCCESS",
code = ExternalStatusReasonCode.TS01,
info = "",
timestamp = dateTimeToInstant("2024-09-02T20:48:43.153Z")
),
CustomerAck(
actionType = HacAction.ES_VERIFICATION,
orderId = "ORDER_SUCCESS",
code = ExternalStatusReasonCode.DS01,
info = "",
timestamp = dateTimeToInstant("2024-09-02T20:48:43.153Z")
),
CustomerAck(
actionType = HacAction.ORDER_HAC_FINAL_POS,
orderId = "ORDER_SUCCESS",
code = null,
info = "Some multiline info",
timestamp = dateTimeToInstant("2024-09-02T20:48:43.153Z")
),
CustomerAck(
actionType = HacAction.FILE_DOWNLOAD,
orderId = null,
code = ExternalStatusReasonCode.TD01,
info = "",
timestamp = dateTimeToInstant("2024-09-02T15:47:31.754Z")
),
CustomerAck(
actionType = HacAction.FILE_UPLOAD,
orderId = "ORDER_FAILURE",
code = ExternalStatusReasonCode.TS01,
info = "",
timestamp = dateTimeToInstant("2024-08-23T15:34:11.987")
),
CustomerAck(
actionType = HacAction.ES_VERIFICATION,
orderId = "ORDER_FAILURE",
code = ExternalStatusReasonCode.TD03,
info = "",
timestamp = dateTimeToInstant("2024-08-23T15:34:13.307")
),
CustomerAck(
actionType = HacAction.ORDER_HAC_FINAL_NEG,
orderId = "ORDER_FAILURE",
code = null,
info = "",
timestamp = dateTimeToInstant("2024-08-23T15:34:13.307")
),
)
)
}
@Test
fun postfinance_camt054() {
assertContentEquals(
parseTx(Path("sample/platform/postfinance_camt054.xml").inputStream()),
listOf(AccountTransactions(
iban = "CH9289144596463965762",
currency = "CHF",
txs = listOf(
OutgoingPayment(
id = OutgoingId("ZS1PGNTSV0ZNDFAJBBWWB8015G", "ZS1PGNTSV0ZNDFAJBBWWB8015G", null),
amount = TalerAmount("CHF:3.00"),
subject = null,
executionTime = dateToInstant("2024-01-15"),
creditor = null
),
IncomingPayment(
id = IncomingId("62e2b511-7313-4ccd-8d40-c9d8e612cd71", null, "231121CH0AZWCR9T"),
amount = TalerAmount("CHF:10"),
subject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG",
executionTime = dateToInstant("2023-12-19"),
debtor = ibanPayto("CH7389144832588726658", "Mr Test")
),
IncomingPayment(
id = IncomingId("62e2b511-7313-4ccd-8d40-c9d8e612cd71", null, "231121CH0AZWCVR1"),
amount = TalerAmount("CHF:2.53"),
subject = "G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB",
executionTime = dateToInstant("2023-12-19"),
debtor = ibanPayto("CH7389144832588726658", "Mr Test")
),
OutgoingReversal(
endToEndId = "50820f78-9024-44ff-978d-63a18c",
msgId = "50820f78-9024-44ff-978d-63a18c",
reason = "",
executionTime = dateToInstant("2024-01-15")
),
OutgoingBatch(
msgId = "ZS1PGNTSV0ZNDFAJBBWWB8015G",
executionTime = dateToInstant("2024-01-15")
)
)
))
)
}
@Test
fun postfinance_camt053() {
assertContentEquals(
parseTx(Path("sample/platform/postfinance_camt053.xml").inputStream()),
listOf(AccountTransactions(
iban = "CH9289144596463965762",
currency = "CHF",
txs = listOf(
OutgoingReversal(
endToEndId = "889d1a80-1267-49bd-8fcc-85701a",
msgId = "889d1a80-1267-49bd-8fcc-85701a",
reason = "InconsistenWithEndCustomer 'Identification of end customer is not consistent with associated account number, organisation ID or private ID.' - 'more info here ...'",
executionTime = dateToInstant("2023-11-22")
),
OutgoingReversal(
endToEndId = "4cc61cc7-6230-49c2-b5e2-b40bbb",
msgId = "4cc61cc7-6230-49c2-b5e2-b40bbb",
reason = "MissingCreditorNameOrAddress 'Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing.' - 'more info here ...'",
executionTime = dateToInstant("2023-11-22")
),
OutgoingBatch(
msgId = "EB4D22D428214261B2B3012D2A8CEC36",
executionTime = dateToInstant("2024-08-26")
)
)
))
)
}
@Test
fun valiant_camt052() {
assertContentEquals(
parseTx(Path("sample/platform/valiant_camt052.xml").inputStream()),
listOf(AccountTransactions(
iban = "CH7389144832588726658",
currency = "CHF",
txs = listOf(
OutgoingPayment(
id = OutgoingId("MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L", "4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE", "ZV20251030/511372/1"),
amount = TalerAmount("CHF:0.1"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "single 2025-10-30T09:46:04.55293090 9Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", "SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8", "ZV20251030/511373/1"),
amount = TalerAmount("CHF:0.1"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 0 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", "RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN", "ZV20251030/511373/2"),
amount = TalerAmount("CHF:0.11"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 1 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", "GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7", "ZV20251030/511373/3"),
amount = TalerAmount("CHF:0.12"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 2 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U", "PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28", "ZV20251030/511373/4"),
amount = TalerAmount("CHF:0.13"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "multi 3 2025-10-30T09:46:10.3877961 30Z",
executionTime = dateToInstant("2025-10-30")
),
IncomingPayment(
id = IncomingId(null, "51030655601.0001", "ZV20251030/514778/1"),
amount = TalerAmount("CHF:0.85"),
subject = "fun stuff",
executionTime = dateToInstant("2025-10-30"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
),
IncomingPayment(
id = IncomingId(null, "51030655601.0002", "ZV20251030/514779/1"),
amount = TalerAmount("CHF:0.95"),
subject = "Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0",
executionTime = dateToInstant("2025-10-30"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
),
OutgoingPayment(
id = OutgoingId("X166701F6RV59LP71RVWVIW9SV2AFZYLG4", "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", "ZV20251030/524078/1"),
amount = TalerAmount("CHF:0.21"),
creditor = ibanPayto("CH6208704048981247126", "John Smith"),
subject = "bad name 2025-10-30T12:03:24.997478 811Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", "02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ", "ZV20251030/524079/1"),
amount = TalerAmount("CHF:0.1"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans"),
subject = "single 2025-10-30T12:04:00.37042083 6Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", "ZV20251030/524079/2"),
amount = TalerAmount("CHF:0.21"),
creditor = ibanPayto("CH6208704048981247126", "John Smith"),
subject = "bad name 2025-10-30T12:03:53.042190 686Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingReversal(
endToEndId = "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y",
reason = "Error msg in german",
executionTime = dateToInstant("2025-10-30")
),
OutgoingReversal(
endToEndId = "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX",
reason = "Error msg in german",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P", "TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7", "ZV20251030/524077/1"),
amount = TalerAmount("CHF:0.23"),
debitFee = TalerAmount("CHF:5"),
creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"),
subject = "foreign iban 2025-10-30T12:03:44.0972 63765Z",
executionTime = dateToInstant("2025-10-30")
),
OutgoingPayment(
id = OutgoingId("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X", "GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE", "ZV20251030/524080/1"),
amount = TalerAmount("CHF:0.23"),
debitFee = TalerAmount("CHF:5"),
creditor = ibanPayto("DE48330605920000686018", "Christian Grothoff"),
subject = "foreign iban 2025-10-30T12:03:58.0046 73747Z",
executionTime = dateToInstant("2025-10-30")
),
IncomingPayment(
id = IncomingId("7b76d488-05d5-44ab-9d77-31d4165ec158", "00204EQY370", "ZV20251118/685062/1"),
amount = TalerAmount("CHF:4.55"),
subject = "TEST",
executionTime = dateToInstant("2025-11-18"),
debtor = null
),
)
))
)
}
@Test
fun raiffeisen_camt053() {
assertContentEquals(
parseTx(Path("sample/platform/raiffeisen_camt053.xml").inputStream()),
listOf(AccountTransactions(
iban = "CH7389144832588726658",
currency = null,
txs = listOf(
IncomingPayment(
id = IncomingId(null, null, "A200020494367552"),
amount = TalerAmount("CHF:20000"),
subject = "1. TZ 2025",
executionTime = dateToInstant("2025-12-23"),
debtor = ibanPayto("CH7389144832588726658", "KANTON BERN")
),
OutgoingPayment(
id = OutgoingId(null, null, "19868398389"),
amount = TalerAmount("CHF:15"),
executionTime = dateToInstant("2025-12-31"),
subject = null,
creditor = null
),
OutgoingPayment(
id = OutgoingId(null, null, "19890406743"),
amount = TalerAmount("CHF:2"),
executionTime = dateToInstant("2025-12-31"),
subject = null,
creditor = null
),
OutgoingPayment(
id = OutgoingId(null, null, "19885172770"),
amount = TalerAmount("CHF:3"),
executionTime = dateToInstant("2025-12-31"),
subject = null,
creditor = null
),
)
))
)
}
@Test
fun gls_camt052() {
assertContentEquals(
parseTx(Path("sample/platform/gls_camt052.xml").inputStream()),
listOf(AccountTransactions(
iban = "DE84500105177118117964",
currency = "EUR",
txs = listOf(
OutgoingPayment(
id = OutgoingId("COMPAT_SUCCESS", "COMPAT_SUCCESS", "2024041801514102000"),
amount = TalerAmount("EUR:2"),
subject = "TestABC123",
executionTime = dateToInstant("2024-04-18"),
creditor = ibanPayto("DE20500105172419259181", "John Smith")
),
OutgoingReversal(
endToEndId = "8XK8Z7RAX224FGWK832FD40GYC",
reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'",
executionTime = dateToInstant("2024-09-05")
),
IncomingPayment(
id = IncomingId(null, "BYLADEM1WOR-G2910276709458A2", "2024041210041357000"),
amount = TalerAmount("EUR:3"),
subject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-",
executionTime = dateToInstant("2024-04-12"),
debtor = ibanPayto("DE84500105177118117964", "John Smith")
),
OutgoingReversal(
endToEndId = "COMPAT_FAILURE",
reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'",
executionTime = dateToInstant("2024-04-12")
),
OutgoingPayment(
id = OutgoingId("BATCH_SINGLE_SUCCESS", "FD622SMXKT5QWSAHDY0H8NYG3G", "2024090216552232000"),
amount = TalerAmount("EUR:1.1"),
subject = "single 2024-09-02T14:29:52.875253314Z",
executionTime = dateToInstant("2024-09-02"),
creditor = ibanPayto("DE89500105173198527518", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId("YF5QBARGQ0MNY0VK59S477VDG4", "YF5QBARGQ0MNY0VK59S477VDG4", "2024041810552821000"),
amount = TalerAmount("EUR:1.1"),
subject = "Simple tx",
executionTime = dateToInstant("2024-04-18"),
creditor = ibanPayto("DE20500105172419259181", "John Smith")
),
OutgoingBatch(
msgId = "BATCH_MANY_SUCCESS",
executionTime = dateToInstant("2024-09-20"),
),
OutgoingPayment(
id = OutgoingId("BATCH_SINGLE_RETURN", "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", "2024092100252498000"),
amount = TalerAmount("EUR:0.42"),
subject = "This should fail because bad iban",
executionTime = dateToInstant("2024-09-23"),
creditor = ibanPayto("DE18500105173385245163", "John Smith")
),
OutgoingReversal(
endToEndId = "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5",
reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'",
executionTime = dateToInstant("2024-09-24")
),
)
))
)
}
@Test
fun gls_camt053() {
assertContentEquals(
parseTx(Path("sample/platform/gls_camt053.xml").inputStream()),
listOf(AccountTransactions(
iban = "DE84500105177118117964",
currency = "EUR",
txs = listOf(
OutgoingPayment(
id = OutgoingId("COMPAT_SUCCESS", "COMPAT_SUCCESS", "2024041801514102000"),
amount = TalerAmount("EUR:2"),
subject = "TestABC123",
executionTime = dateToInstant("2024-04-18"),
creditor = ibanPayto("DE20500105172419259181", "John Smith")
),
OutgoingReversal(
endToEndId = "KGTDBASWTJ6JM89WXD3Q5KFQC4",
reason = "Retoure aus SEPA Überweisung multi line",
executionTime = dateToInstant("2024-09-04")
),
OutgoingBatch(
msgId = "BATCH_MANY_PART",
executionTime = dateToInstant("2024-09-04")
),
IncomingPayment(
id = IncomingId(null, "BYLADEM1WOR-G2910276709458A2", "2024041210041357000"),
amount = TalerAmount("EUR:3"),
subject = "Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-",
executionTime = dateToInstant("2024-04-12"),
debtor = ibanPayto("DE84500105177118117964", "John Smith")
),
OutgoingReversal(
endToEndId = "COMPAT_FAILURE",
reason = "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'",
executionTime = dateToInstant("2024-04-12")
),
OutgoingPayment(
id = OutgoingId("BATCH_SINGLE_SUCCESS", "FD622SMXKT5QWSAHDY0H8NYG3G", "2024090216552232000"),
amount = TalerAmount("EUR:1.1"),
subject = "single 2024-09-02T14:29:52.875253314Z",
executionTime = dateToInstant("2024-09-02"),
creditor = ibanPayto("DE89500105173198527518", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId("YF5QBARGQ0MNY0VK59S477VDG4", "YF5QBARGQ0MNY0VK59S477VDG4", "2024041810552821000"),
amount = TalerAmount("EUR:1.1"),
subject = "Simple tx",
executionTime = dateToInstant("2024-04-18"),
creditor = ibanPayto("DE20500105172419259181", "John Smith")
),
))
)
)
}
@Test
fun gls_camt054() {
assertContentEquals(
parseTx(Path("sample/platform/gls_camt054.xml").inputStream()),
listOf(AccountTransactions(
iban = "DE84500105177118117964",
currency = "EUR",
txs = listOf(
IncomingPayment(
id = IncomingId(null, "IS11PGENODEFF2DA8899900378806", null),
amount = TalerAmount("EUR:2.5"),
subject = "Test ICT",
executionTime = dateToInstant("2024-05-05"),
debtor = ibanPayto("DE84500105177118117964", "Mr Test")
)
)
))
)
}
@Test
fun maerki_baumann_camt053() {
assertContentEquals(
parseTx(Path("sample/platform/maerki_baumann_camt053.xml").inputStream()),
listOf(AccountTransactions(
iban = "CH7389144832588726658",
currency = "CHF",
txs = listOf(
IncomingPayment(
id = IncomingId("adbe4a5a-6cea-4263-b259-8ab964561a32", "41103099704.0002", "ZV20241104/765446/1"),
amount = TalerAmount("CHF:1"),
creditFee = TalerAmount("CHF:0.2"),
subject = "SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G",
executionTime = dateToInstant("2024-11-04"),
debtor = ibanPayto("CH7389144832588726658", "Mr Test")
),
IncomingPayment(
id = IncomingId("7371795e-62fa-42dd-93b7-da89cc120faa", "41103099704.0003", "ZV20241104/765447/1"),
amount = TalerAmount("CHF:1"),
creditFee = TalerAmount("CHF:0.2"),
subject = "Random subject",
executionTime = dateToInstant("2024-11-04"),
debtor = ibanPayto("CH7389144832588726658", "Mr Test")
),
IncomingPayment(
id = IncomingId(null, "50523424675.0001", "ZV20250523/851716/1"),
amount = TalerAmount("CHF:0.5"),
creditFee = TalerAmount("CHF:0.2"),
subject = null,
executionTime = dateToInstant("2025-05-23"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId("BATCH_SINGLE_REPORTING", "5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H", "ZV20241121/773541/1"),
amount = TalerAmount("CHF:0.1"),
subject = "multi 0 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId("BATCH_SINGLE_REPORTING", "XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH", "ZV20241121/773541/4"),
amount = TalerAmount("CHF:0.13"),
subject = "multi 3 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId("BATCH_SINGLE_REPORTING", "A09R35EW0359SZ51464E7TC37A0P2CBK04", "ZV20241121/773541/3"),
amount = TalerAmount("CHF:0.12"),
subject = "multi 2 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId("BATCH_SINGLE_REPORTING", "UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT", "ZV20241121/773541/2"),
amount = TalerAmount("CHF:0.11"),
subject = "multi 1 2024-11-21T15:21:59.8859234 63Z",
executionTime = dateToInstant("2024-11-27"),
creditor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
IncomingPayment(
id = IncomingId("f203fbb4-6e13-4c78-9b2a-d852fea6374a", "41202060702.0001", "ZV20241202/778108/1"),
amount = TalerAmount("CHF:0.05"),
creditFee = TalerAmount("CHF:0.2"),
subject = "mini",
executionTime = dateToInstant("2024-12-02"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
IncomingPayment(
id = IncomingId("81b0d8c6-a677-4577-b75e-a639dcc03681", "41120636093.0001", "ZV20241121/773118/1"),
amount = TalerAmount("CHF:0.1"),
creditFee = TalerAmount("CHF:0.2"),
subject = "small transfer test",
executionTime = dateToInstant("2024-11-21"),
debtor = ibanPayto("CH7389144832588726658", "Grothoff Hans")
),
OutgoingPayment(
id = OutgoingId(null, null, "GB20241220/205792/1"),
amount = TalerAmount("CHF:3000"),
subject = null,
executionTime = dateToInstant("2024-12-20"),
creditor = null
),
IncomingPayment(
id = IncomingId(null, null, "ZV20250114/796191/1"),
amount = TalerAmount("CHF:3003"),
subject = "Fix bad payment by MB.",
executionTime = dateToInstant("2025-01-27"),
debtor = null
),
IncomingPayment(
id = IncomingId(null, "F000787951230001", "ZV20250526/852733/1"),
amount = TalerAmount("CHF:1.38"),
creditFee = TalerAmount("CHF:0.2"),
subject = "Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G",
executionTime = dateToInstant("2025-05-26"),
debtor = ibanPayto("DE20500105172419259181", "Mr German")
),
)
))
)
}
@Test
fun pain002() {
assertEquals(
parseCustomerPaymentStatusReport(Path("sample/platform/pain002_part.xml").inputStream()),
MsgStatus(
id = "05BD4C5B4A2649B5B08F6EF6A31F197A",
code = ExternalPaymentGroupStatusCode.PART,
reasons = emptyList(),
payments = listOf(
PmtStatus(
id = "NOTPROVIDED",
code = ExternalPaymentGroupStatusCode.PART,
reasons = listOf(
Reason(
code = ExternalStatusReasonCode.DT06,
information = "Due date is not a working day. Order will be executed on the next working day"
)
),
transactions = listOf(
TxStatus(
id = "AQCXNCPWD8PHW5JTN65Y5XTF7R",
endToEndId = "AQCXNCPWD8PHW5JTN65Y5XTF7R",
code = ExternalPaymentTransactionStatusCode.RJCT,
reasons = listOf(
Reason(
code = ExternalStatusReasonCode.AC04,
information = "Error message"
)
)
),
TxStatus(
id = "EE9SX76FC5YSC657EK3GMVZ9TC",
endToEndId = "EE9SX76FC5YSC657EK3GMVZ9TC",
code = ExternalPaymentTransactionStatusCode.RJCT,
reasons = listOf(
Reason(
code = ExternalStatusReasonCode.MS03,
information = "Error message"
)
)
),
TxStatus(
id = "V5B3MXPEWES9VQW1JDRD6VAET4",
endToEndId = "V5B3MXPEWES9VQW1JDRD6VAET4",
code = ExternalPaymentTransactionStatusCode.RJCT,
reasons = listOf(
Reason(
code = ExternalStatusReasonCode.RR02,
information = "Error message"
)
)
)
)
)
)
)
)
assertEquals(
parseCustomerPaymentStatusReport(Path("sample/platform/pain002_accp.xml").inputStream()),
MsgStatus(
id = "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U",
code = ExternalPaymentGroupStatusCode.ACCP,
reasons = listOf(
Reason(
code = null,
information = "PN10630020F0297329.20251030104613.EBTUAAAC.PN1.0002372"
)
),
payments = emptyList()
)
)
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/CliTest.kt 0000664 0001750 0001750 00000005576 15156463305 023502 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.testing.test
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.common.asUtf8
import tech.libeufin.nexus.*
import tech.libeufin.ebics.*
import tech.libeufin.nexus.cli.LibeufinNexus
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import kotlin.io.path.*
import kotlin.test.Test
import kotlin.test.assertEquals
val nexusCmd = LibeufinNexus()
fun CliktCommand.testErr(cmd: String, msg: String) {
val prevOut = System.err
val tmpOut = ByteArrayOutputStream()
System.setErr(PrintStream(tmpOut))
val result = test(cmd)
System.setErr(prevOut)
val tmpStr = tmpOut.asUtf8()
println(tmpStr)
assertEquals(1, result.statusCode, "'$cmd' should have failed")
val line = tmpStr.substringAfterLast(" - ").trimEnd('\n')
println(line)
assertEquals(msg, line)
}
class CliTest {
/** Test server check */
@Test
fun serveCheck() {
val confs = listOf(
"mini" to 1,
"test" to 0
)
for ((conf, statusCode) in confs) {
val result = nexusCmd.test("serve --check -c conf/$conf.conf")
assertEquals(statusCode, result.statusCode)
}
}
/** Test list cmds */
@Test
fun listCheck() = setup { db, _ ->
fun check() {
for (list in listOf("incoming", "outgoing", "initiated", "initiated --awaiting-ack")) {
val result = nexusCmd.test("list $list -c conf/test.conf")
assertEquals(0, result.statusCode)
}
}
// Check empty
check()
// Check with transactions
registerIn(db)
registerOut(db)
check()
// Check with taler transactions
talerableOut(db)
talerableIn(db)
talerableCompletedIn(db)
talerableKycIn(db)
talerablePreparedIn(db)
talerablePreparedCompletedIn(db)
check()
// Check with incomplete
registerIncompleteIn(db)
talerableIncompleteIn(db)
registerIncompleteOut(db)
talerablePreparedIncompleteIn(db)
check()
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/ObservabilityTest.kt 0000664 0001750 0001750 00000002451 15122266731 025573 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2025 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import io.ktor.http.*
import io.ktor.client.request.*
import org.junit.Test
import tech.libeufin.common.TalerObservabilityConfig
import tech.libeufin.common.assertOkJson
import tech.libeufin.common.assertOk
class ObservabilityApiTest {
// GET /taler-observability/config
@Test
fun config() = serverSetup {
client.get("/taler-observability/config").assertOkJson()
}
// GET /taler-observability/metrics
@Test
fun metrics() = serverSetup { db ->
client.getA("/taler-observability/metrics").assertOk()
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/DatabaseTest.kt 0000664 0001750 0001750 00000146002 15161724132 024457 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.common.db.*
import tech.libeufin.nexus.AccountType
import tech.libeufin.nexus.NexusIngestConfig
import tech.libeufin.nexus.iso20022.*
import tech.libeufin.nexus.cli.*
import tech.libeufin.nexus.db.*
import tech.libeufin.nexus.db.PaymentDAO.*
import tech.libeufin.nexus.db.InitiatedDAO.*
import tech.libeufin.nexus.db.TransferDAO.*
import tech.libeufin.ebics.*
import java.time.Instant
import java.util.UUID;
import kotlin.test.*
suspend fun Database.checkInCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) = serializable(
"""
SELECT (SELECT count(*) FROM incoming_transactions) AS incoming,
(SELECT count(*) FROM bounced_transactions) AS bounce,
(SELECT count(*) FROM talerable_incoming_transactions) AS talerable;
"""
) {
one {
assertEquals(
Triple(nbIncoming, nbBounce, nbTalerable),
Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable"))
)
}
}
suspend fun Database.checkOutCount(nbIncoming: Int, nbTalerable: Int) = serializable(
"""
SELECT (SELECT count(*) FROM outgoing_transactions) AS incoming,
(SELECT count(*) FROM talerable_outgoing_transactions) AS talerable;
"""
) {
one {
assertEquals(
Pair(nbIncoming, nbTalerable),
Pair(it.getInt("incoming"), it.getInt("talerable"))
)
}
}
sealed interface Status {
data object Simple : Status
data object Pending : Status
data object Bounced : Status
data object Incomplete : Status
data class Reserve(val key: EddsaPublicKey) : Status
data class Kyc(val key: EddsaPublicKey) : Status
}
suspend fun Database.checkIn(vararg expected: Status) {
val current = this.serializable(
"""
SELECT pending_recurrent_incoming_transactions.authorization_pub IS NOT NULL, initiated_outgoing_transaction_id IS NOT NULL, debit_payto IS NULL OR subject IS NULL, type, metadata
FROM incoming_transactions
LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id)
LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id)
LEFT JOIN bounced_transactions USING (incoming_transaction_id)
ORDER BY incoming_transaction_id
"""
) {
all {
if (it.getBoolean(1)) {
Status.Pending
} else if (it.getBoolean(2)) {
Status.Bounced
} else if (it.getBoolean(3)) {
Status.Incomplete
} else {
when (it.getOptEnum(4)) {
null -> Status.Simple
TransferType.reserve -> Status.Reserve(EddsaPublicKey(it.getBytes(5)))
TransferType.kyc -> Status.Kyc(EddsaPublicKey(it.getBytes(5)))
}
}
}.toList()
}
assertContentEquals(listOf(*expected), current)
}
class OutgoingPaymentsTest {
@Test
fun register() = setup { db, _ ->
// Register initiated transaction
for (subject in sequenceOf(
"initiated by nexus",
"${ShortHashCode.rand()} https://exchange.com/"
)) {
val pay = genOutPay(subject)
assertIs(
db.initiated.create(genInitPay(pay.id.endToEndId!!, subject))
)
val first = registerOutgoingPayment(db, pay)
assertEquals(OutgoingRegistrationResult(id = first.id, initiated = true, new = true), first)
assertEquals(
OutgoingRegistrationResult(id = first.id, initiated = true, new = false),
registerOutgoingPayment(db, pay)
)
val refOnly = pay.copy(id = OutgoingId(null, null, acctSvcrRef = pay.id.endToEndId))
val second = registerOutgoingPayment(db, refOnly)
assertEquals(OutgoingRegistrationResult(id = first.id + 1, initiated = false, new = true), second)
assertEquals(
OutgoingRegistrationResult(id = second.id, initiated = false, new = false),
registerOutgoingPayment(db, refOnly)
)
}
db.checkOutCount(nbIncoming = 4, nbTalerable = 1)
// Register unknown
for (subject in sequenceOf(
"not initiated by nexus",
"${ShortHashCode.rand()} https://exchange.com/"
)) {
val pay = genOutPay(subject)
val first = registerOutgoingPayment(db, pay)
assertEquals(OutgoingRegistrationResult(id = first.id, initiated = false, new = true), first)
assertEquals(
OutgoingRegistrationResult(id = first.id, initiated = false, new = false),
registerOutgoingPayment(db, pay)
)
}
db.checkOutCount(nbIncoming = 6, nbTalerable = 2)
// Register wtid reuse
val wtid = ShortHashCode.rand()
for (subject in sequenceOf(
"$wtid https://exchange.com/",
"$wtid https://exchange.com/"
)) {
val pay = genOutPay(subject)
val first = registerOutgoingPayment(db, pay)
assertEquals(OutgoingRegistrationResult(id = first.id, initiated = false, new = true), first)
assertEquals(
OutgoingRegistrationResult(id = first.id, initiated = false, new = false),
db.payment.registerOutgoing(pay, null, null, null)
)
}
db.checkOutCount(nbIncoming = 8, nbTalerable = 3)
}
@Test
fun registerBatch() = setup { db, _ ->
// Init batch
val wtid = ShortHashCode.rand()
for (subject in sequenceOf(
"initiated by nexus",
"${ShortHashCode.rand()} https://exchange.com/",
"$wtid https://exchange.com/",
"$wtid https://exchange.com/"
)) {
assertIs(
db.initiated.create(genInitPay(randEbicsId(), subject=subject))
)
}
db.initiated.batch(Instant.now(), "BATCH", false)
// Register batch
registerOutgoingBatch(db, OutgoingBatch("BATCH", Instant.now()));
db.checkOutCount(nbIncoming = 4, nbTalerable = 2)
// Test manual ack
val txs = List(3) { nb ->
assertIs(
db.initiated.create(genInitPay(randEbicsId(), subject="tx $nb"))
).id
}
// Check not sent without ack
db.initiated.batch(Instant.now(), "BATCH_MANUAL", true)
registerOutgoingBatch(db, OutgoingBatch("BATCH_MANUAL", Instant.now()));
db.checkOutCount(nbIncoming = 4, nbTalerable = 2)
// Check sent with ack
for (tx in txs) {
db.initiated.ack(tx)
}
db.initiated.batch(Instant.now(), "BATCH_MANUAL", true)
registerOutgoingBatch(db, OutgoingBatch("BATCH_MANUAL", Instant.now()));
db.checkOutCount(nbIncoming = 7, nbTalerable = 2)
}
}
class IncomingPaymentsTest {
// Tests creating and bouncing incoming payments in one DB transaction
@Test
fun bounce() = setup { db, _ ->
// creating and bouncing one incoming transaction.
val payment = genInPay("incoming and bounced")
val id = randEbicsId()
db.payment.registerMalformedIncoming(
payment,
TalerAmount("KUDOS:2.53"),
id,
Instant.now(),
"manual bounce"
).run {
assertIs(this)
assertTrue(new)
assertEquals(id, bounceId)
}
db.payment.registerMalformedIncoming(
payment,
TalerAmount("KUDOS:2.53"),
randEbicsId(),
Instant.now(),
"manual bounce"
).run {
assertIs(this)
assertFalse(new)
assertEquals(id, bounceId)
}
db.conn {
// Checking one incoming got created
val checkIncoming = it.talerStatement("""
SELECT (amount).val as amount_value, (amount).frac as amount_frac
FROM incoming_transactions WHERE incoming_transaction_id = 1
""").executeQuery()
assertTrue(checkIncoming.next())
assertEquals(payment.amount.value, checkIncoming.getLong("amount_value"))
assertEquals(payment.amount.frac, checkIncoming.getInt("amount_frac"))
// Checking the bounced table got its row.
val checkBounced = it.talerStatement("""
SELECT 1 FROM bounced_transactions
WHERE incoming_transaction_id = 1 AND initiated_outgoing_transaction_id = 1
""").executeQuery()
assertTrue(checkBounced.next())
// check the related initiated payment exists.
val checkInitiated = it.talerStatement("""
SELECT
(amount).val as amount_value
,(amount).frac as amount_frac
FROM initiated_outgoing_transactions
WHERE initiated_outgoing_transaction_id = 1
""").executeQuery()
assertTrue(checkInitiated.next())
assertEquals(
53000000,
checkInitiated.getInt("amount_frac")
)
assertEquals(
2,
checkInitiated.getInt("amount_value")
)
}
}
// Test creating an incoming reserve transaction without and ID and reconcile it later again
@Test
fun simple() = setup { db, _ ->
val cfg = NexusIngestConfig.default(AccountType.exchange)
val subject = "test"
// Register
val incoming = genInPay(subject)
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Bounced)
// Idempotent
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Bounced)
// No key reuse
registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9"))
registerIncomingPayment(db, cfg, genInPay("another $subject"))
db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced)
// Admin balance adjust is ignored
registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST"))
db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple)
val original = genInPay("test 2")
val incomplete = original.copy(subject = null, debtor = null)
// Register incomplete transaction
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete)
// Idempotent
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete)
// Recover info when complete
registerIncomingPayment(db, cfg, original)
db.checkIn(Status.Bounced, Status.Bounced, Status.Bounced, Status.Simple, Status.Bounced)
}
// Test creating an incoming reserve taler transaction without and ID and reconcile it later again
@Test
fun talerable() = setup { db, _ ->
val cfg = NexusIngestConfig.default(AccountType.exchange)
val key = EddsaPublicKey.randEdsaKey()
val subject = "test with $key reserve pub"
// Register
val incoming = genInPay(subject)
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Reserve(key))
// Idempotent
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Reserve(key))
// Key reuse is bounced
registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9"))
registerIncomingPayment(db, cfg, genInPay("another $subject"))
db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced)
// Admin balance adjust is ignored
registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST"))
db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple)
val newKey = EddsaPublicKey.randEdsaKey()
val original = genInPay("test 2 with $newKey reserve pub")
val incomplete = original.copy(subject = null, debtor = null)
// Register incomplete transaction
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete)
// Idempotent
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple, Status.Incomplete)
// Recover info when complete
registerIncomingPayment(db, cfg, original)
db.checkIn(Status.Reserve(key), Status.Bounced, Status.Bounced, Status.Simple, Status.Reserve(newKey))
}
// Test creating an mapped reserve taler transaction without and ID and reconcile it later again
@Test
fun mapping() = setup { db, _ ->
val cfg = NexusIngestConfig.default(AccountType.exchange)
val firstKey = EddsaPublicKey.randEdsaKey()
val authPub = EddsaPublicKey.randEdsaKey()
val sig = EddsaSignature.rand()
val referenceNumber = subjectFmtQrBill(authPub)
val subject = "test with MAP:$authPub auth pub"
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = firstKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = true
)
)
// Register
val incoming = genInPay(subject)
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Reserve(firstKey))
// Idempotent
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Reserve(firstKey))
// Admin balance adjust is ignored
registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST"))
db.checkIn(Status.Reserve(firstKey), Status.Simple)
val original = genInPay("test 2 for $subject")
val incomplete = original.copy(subject = null, debtor = null)
// Register incomplete transaction
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete)
// Idempotent
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete)
// Recover info when complete
registerIncomingPayment(db, cfg, original)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Pending)
val secondKey = EddsaPublicKey.randEdsaKey()
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = secondKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = true
)
)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey))
// Key reuse is pending
registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9"))
registerIncomingPayment(db, cfg, genInPay("another $subject"))
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Pending, Status.Pending)
// Finish pending
val thirdKey = EddsaPublicKey.randEdsaKey()
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = thirdKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = true
)
)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Reserve(thirdKey), Status.Pending)
}
// Test creating an mapped reserve taler transaction without and ID and reconcile it later again
@Test
fun reference() = setup { db, _ ->
val cfg = NexusIngestConfig.default(AccountType.exchange)
val firstKey = EddsaPublicKey.randEdsaKey()
val authPub = EddsaPublicKey.randEdsaKey()
val sig = EddsaSignature.rand()
val referenceNumber = subjectFmtQrBill(authPub)
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = firstKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = true
)
)
// Register
val incoming = genInPay(referenceNumber)
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Reserve(firstKey))
// Idempotent
registerIncomingPayment(db, cfg, incoming)
db.checkIn(Status.Reserve(firstKey))
// Admin balance adjust is ignored
registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST"))
db.checkIn(Status.Reserve(firstKey), Status.Simple)
val original = genInPay(referenceNumber)
val incomplete = original.copy(subject = null, debtor = null)
// Register incomplete transaction
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete)
// Idempotent
registerIncomingPayment(db, cfg, incomplete)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Incomplete)
// Recover info when complete
registerIncomingPayment(db, cfg, original)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Pending)
val secondKey = EddsaPublicKey.randEdsaKey()
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = secondKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = true
)
)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey))
// Key reuse is pending
registerIncomingPayment(db, cfg, genInPay(referenceNumber, "KUDOS:9"))
registerIncomingPayment(db, cfg, genInPay(referenceNumber))
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Pending, Status.Pending)
// Finish pending
val thirdKey = EddsaPublicKey.randEdsaKey()
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = thirdKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = true
)
)
db.checkIn(Status.Reserve(firstKey), Status.Simple, Status.Reserve(secondKey), Status.Reserve(thirdKey), Status.Pending)
}
@Test
fun recoverInfo() = setup { db, _ ->
val cfg = NexusIngestConfig.default(AccountType.exchange)
suspend fun Database.checkContent(payment: IncomingPayment) = serializable(
"""
SELECT
uetr IS NOT DISTINCT FROM ? AND
tx_id IS NOT DISTINCT FROM ? AND
acct_svcr_ref IS NOT DISTINCT FROM ? AND
subject IS NOT DISTINCT FROM ? AND
debit_payto IS NOT DISTINCT FROM ?
FROM incoming_transactions ORDER BY incoming_transaction_id DESC LIMIT 1
"""
) {
bind(payment.id.uetr)
bind(payment.id.txId)
bind(payment.id.acctSvcrRef)
bind(payment.subject)
bind(payment.debtor?.toString())
one {
assertTrue(it.getBoolean(1))
}
}
// Non talerable
for ((index, partialId) in sequenceOf(
IncomingId(UUID.randomUUID(), null, null),
IncomingId(null, randEbicsId(), null),
IncomingId(null, null, randEbicsId()),
).withIndex()) {
val payment = genInPay("subject")
// Register minimal
val partialPayment = payment.copy(id = partialId, subject = null, debtor = null)
registerIncomingPayment(db, cfg, partialPayment)
db.checkContent(partialPayment)
db.checkInCount(index + 1, index, 0)
// Recover ID
val fullId = IncomingId(
partialId.uetr ?: UUID.randomUUID(),
partialId.txId ?: randEbicsId(),
partialId.acctSvcrRef ?: randEbicsId()
)
val idPayment = partialPayment.copy(id = fullId)
registerIncomingPayment(db, cfg, idPayment)
db.checkContent(idPayment)
db.checkInCount(index + 1, index, 0)
// Recover subject & debtor
val fullPayment = payment.copy(id = fullId)
registerIncomingPayment(db, cfg, fullPayment)
db.checkContent(fullPayment)
db.checkInCount(index + 1, index + 1, 0)
}
// Talerable
for ((index, partialId) in sequenceOf(
IncomingId(UUID.randomUUID(), null, null),
IncomingId(null, randEbicsId(), null),
IncomingId(null, null, randEbicsId()),
).withIndex()) {
val payment = genInPay("test with ${EddsaPublicKey.randEdsaKey()} reserve pub")
// Register minimal
val partialPayment = payment.copy(id = partialId, subject = null, debtor = null)
registerIncomingPayment(db, cfg, partialPayment)
db.checkContent(partialPayment)
db.checkInCount(index + 4, 3, index)
// Recover ID
val fullId = IncomingId(
partialId.uetr ?: UUID.randomUUID(),
partialId.txId ?: randEbicsId(),
partialId.acctSvcrRef ?: randEbicsId()
)
val idPayment = partialPayment.copy(id = fullId)
registerIncomingPayment(db, cfg, idPayment)
db.checkContent(idPayment)
db.checkInCount(index + 4, 3, index)
// Recover subject & debtor
val fullPayment = payment.copy(id = fullId)
registerIncomingPayment(db, cfg, fullPayment)
db.checkContent(fullPayment)
db.checkInCount(index + 4, 3, index + 1)
}
}
@Test
fun horror() = setup { db, _ ->
val cfg = NexusIngestConfig.default(AccountType.exchange)
// Check we do not bounce already registered talerable transaction
val key = EddsaPublicKey.randEdsaKey()
val talerablePayment = genInPay("test with $key reserve pub")
registerIncomingPayment(db, cfg, talerablePayment)
db.payment.registerMalformedIncoming(
talerablePayment,
TalerAmount("KUDOS:2.53"),
randEbicsId(),
Instant.now(),
"manual bounce"
).run {
assertEquals(IncomingBounceRegistrationResult.Talerable, this)
}
registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null))
registerIncomingPayment(db, cfg, talerablePayment)
registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null))
db.checkIn(Status.Reserve(key))
// Check we do not register as talerable bounced transaction
val newKey = EddsaPublicKey.randEdsaKey()
val bouncedPayment = genInPay("bounced $key")
registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null))
registerIncomingPayment(db, cfg, bouncedPayment)
registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null))
registerIncomingPayment(db, cfg, bouncedPayment)
db.checkIn(Status.Reserve(key), Status.Bounced)
}
}
class PaymentInitiationsTest {
// Test skipping transaction based on config
@Test
fun skipping() = setup("skip.conf") { db, cfg ->
suspend fun checkCount(nbTxs: Int, nbBounce: Int) {
db.serializable(
"""
SELECT (SELECT count(*) FROM incoming_transactions) + (SELECT count(*) FROM outgoing_transactions) AS transactions,
(SELECT count(*) FROM bounced_transactions) AS bounce
"""
) {
one {
assertEquals(
Pair(nbTxs, nbBounce),
Pair(it.getInt("transactions"), it.getInt("bounce"))
)
}
}
}
suspend fun ingest(executionTime: Instant) {
for (tx in sequenceOf(
genInPay("test at $executionTime", executionTime = executionTime),
genOutPay("test at $executionTime", executionTime = executionTime)
)) {
registerTransaction(db, cfg.ingest, tx)
}
}
assertEquals(cfg.fetch.ignoreTransactionsBefore, dateToInstant("2024-04-04"))
assertEquals(cfg.fetch.ignoreBouncesBefore, dateToInstant("2024-06-12"))
// No transaction at the beginning
checkCount(0, 0)
// Skipped transactions
ingest(cfg.fetch.ignoreTransactionsBefore.minusMillis(10))
checkCount(0, 0)
// Skipped bounces
ingest(cfg.fetch.ignoreTransactionsBefore)
ingest(cfg.fetch.ignoreTransactionsBefore.plusMillis(10))
ingest(cfg.fetch.ignoreBouncesBefore.minusMillis(10))
checkCount(6, 0)
// Bounces
ingest(cfg.fetch.ignoreBouncesBefore)
ingest(cfg.fetch.ignoreBouncesBefore.plusMillis(10))
checkCount(10, 2)
}
@Test
fun status() = setup { db, _ ->
suspend fun checkPart(
batchId: Long,
batchStatus: SubmissionState,
batchMsg: String?,
txStatus: SubmissionState,
txMsg: String?,
settledStatus: SubmissionState,
settledMsg: String?,
) {
// Check batch status
val msgId = db.serializable(
"""
SELECT message_id, status, status_msg FROM initiated_outgoing_batches WHERE initiated_outgoing_batch_id=?
"""
) {
bind(batchId)
one {
val msgId = it.getString("message_id")
assertEquals(
batchStatus to batchMsg,
it.getEnum("status") to it.getString("status_msg"),
msgId
)
msgId
}
}
// Check tx status
db.serializable(
"""
SELECT end_to_end_id, status, status_msg FROM initiated_outgoing_transactions WHERE initiated_outgoing_batch_id=?
"""
) {
bind(batchId)
all {
val endToEndId = it.getString("end_to_end_id")
val expected = when (endToEndId) {
"TX" -> Pair(txStatus, txMsg)
"TX_SETTLED" -> Pair(settledStatus, settledMsg)
else -> throw Exception("Unexpected tx $endToEndId")
}
assertEquals(
expected,
it.getEnum("status") to it.getString("status_msg"),
"$msgId.$endToEndId"
)
}
}
}
suspend fun checkBatch(batchId: Long, status: SubmissionState, msg: String?, txStatus: SubmissionState? = null) {
val txStatus = txStatus ?: status
checkPart(batchId, status, msg, txStatus, msg, txStatus, msg)
}
suspend fun checkOrder(orderId: String, status: SubmissionState, msg: String?, txStatus: SubmissionState? = null) {
val batchId = db.serializable(
"SELECT initiated_outgoing_batch_id FROM initiated_outgoing_batches WHERE order_id=?"
) {
bind(orderId)
one {
it.getLong("initiated_outgoing_batch_id")
}
}
checkBatch(batchId, status, msg, txStatus)
}
suspend fun test(lambda: suspend (Long) -> Unit) {
// Reset DB
db.conn { conn ->
conn.execSQLUpdate("DELETE FROM initiated_outgoing_transactions");
conn.execSQLUpdate("DELETE FROM initiated_outgoing_batches");
}
// Create a test batch with three transactions
for (id in sequenceOf("TX", "TX_SETTLED")) {
assertIs(
db.initiated.create(genInitPay(id))
)
}
db.initiated.batch(Instant.now(), "BATCH", false)
// Create witness transactions and batch
for (id in sequenceOf("WITNESS_1", "WITNESS_2")) {
assertIs(
db.initiated.create(genInitPay(id))
)
}
db.initiated.batch(Instant.now(), "BATCH_WITNESS", false)
for (id in sequenceOf("WITNESS_3", "WITNESS_4")) {
assertIs(
db.initiated.create(genInitPay(id))
)
}
// Check everything is unsubmitted
db.serializable(
"""
SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches)
AND (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_transactions)
"""
) {
one { assertTrue(it.getBoolean(1)) }
}
// Run test
lambda(db.initiated.submittable().find { it.messageId == "BATCH" }!!.id)
// Check witness status is unaltered
db.serializable(
"""
SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches WHERE message_id != 'BATCH')
AND (SELECT bool_and(initiated_outgoing_transactions.status = 'unsubmitted')
FROM initiated_outgoing_transactions JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
WHERE message_id != 'BATCH')
"""
) {
one { assertTrue(it.getBoolean(1)) }
}
}
// Submission retry status
test { batchId ->
db.initiated.batchSubmissionFailure(batchId, Instant.now(), "First failure")
checkBatch(batchId, SubmissionState.transient_failure, "First failure")
db.initiated.batchSubmissionFailure(batchId, Instant.now(), "Second failure")
checkBatch(batchId, SubmissionState.transient_failure, "Second failure")
db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
checkOrder("ORDER", SubmissionState.pending, null)
db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
checkOrder("ORDER", SubmissionState.pending, null)
db.initiated.orderStep("ORDER", "step msg")
checkOrder("ORDER", SubmissionState.pending, "step msg")
db.initiated.orderStep("ORDER", "success msg")
checkOrder("ORDER", SubmissionState.pending, "success msg")
db.initiated.orderSuccess("ORDER")
checkOrder("ORDER", SubmissionState.success, "success msg", SubmissionState.pending)
db.initiated.orderStep("ORDER", "late msg")
checkOrder("ORDER", SubmissionState.success, "success msg", SubmissionState.pending)
}
// Order step message on failure
test { batchId ->
db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
checkOrder("ORDER", SubmissionState.pending, null)
db.initiated.orderStep("ORDER", "step msg")
checkOrder("ORDER", SubmissionState.pending, "step msg")
db.initiated.orderStep("ORDER", "failure msg")
checkOrder("ORDER", SubmissionState.pending, "failure msg")
assertEquals("failure msg", db.initiated.orderFailure("ORDER")!!.second)
checkOrder("ORDER", SubmissionState.permanent_failure, "failure msg")
db.initiated.orderStep("ORDER", "late msg")
checkOrder("ORDER", SubmissionState.permanent_failure, "failure msg")
}
// Payment & batch status
test { batchId ->
checkBatch(batchId, SubmissionState.unsubmitted, null)
db.initiated.batchStatusUpdate("BATCH", StatusUpdate.pending, "progress")
checkBatch(batchId, SubmissionState.pending, "progress")
db.initiated.txStatusUpdate("TX_SETTLED", null, StatusUpdate.success, "success")
checkPart(batchId, SubmissionState.pending, "progress", SubmissionState.pending, "progress", SubmissionState.success, "success")
db.initiated.batchStatusUpdate("BATCH", StatusUpdate.transient_failure, "waiting")
checkPart(batchId, SubmissionState.transient_failure, "waiting", SubmissionState.transient_failure, "waiting", SubmissionState.success, "success")
db.initiated.txStatusUpdate("TX", "BATCH", StatusUpdate.permanent_failure, "failure")
checkPart(batchId, SubmissionState.success, null, SubmissionState.permanent_failure, "failure", SubmissionState.success, "success")
db.initiated.txStatusUpdate("TX_SETTLED", "BATCH", StatusUpdate.permanent_failure, "late")
checkPart(batchId, SubmissionState.success, null, SubmissionState.permanent_failure, "failure", SubmissionState.late_failure, "late")
}
// Registration
test { batchId ->
checkBatch(batchId, SubmissionState.unsubmitted, null)
registerOutgoingPayment(db, genOutPay("", endToEndId = "TX_SETTLED"))
checkPart(batchId, SubmissionState.unsubmitted, null, SubmissionState.unsubmitted, null, SubmissionState.success, null)
registerOutgoingPayment(db, genOutPay("", endToEndId = "TX", msgId = "BATCH"))
checkPart(batchId, SubmissionState.success, null, SubmissionState.success, null, SubmissionState.success, null)
}
// Transaction failure take over batch failures
test { batchId ->
checkBatch(batchId, SubmissionState.unsubmitted, null)
db.initiated.batchStatusUpdate("BATCH", StatusUpdate.permanent_failure, "batch")
checkPart(batchId, SubmissionState.permanent_failure, "batch", SubmissionState.permanent_failure, "batch", SubmissionState.permanent_failure, "batch")
db.initiated.txStatusUpdate("TX", "BATCH", StatusUpdate.permanent_failure, "tx")
db.initiated.batchStatusUpdate("BATCH", StatusUpdate.permanent_failure, "batch2")
checkPart(batchId, SubmissionState.permanent_failure, "batch", SubmissionState.permanent_failure, "tx", SubmissionState.permanent_failure, "batch")
}
// Unknown order and batch
db.initiated.batchSubmissionSuccess(42, Instant.now(), "ORDER_X")
db.initiated.batchSubmissionFailure(42, Instant.now(), null)
db.initiated.orderStep("ORDER_X", "msg")
db.initiated.batchStatusUpdate("BATCH_X", StatusUpdate.success, null)
db.initiated.txStatusUpdate("TX_X", "BATCH_X", StatusUpdate.success, "msg")
assertNull(db.initiated.orderSuccess("ORDER_X"))
assertNull(db.initiated.orderFailure("ORDER_X"))
}
@Test
fun submittable() = setup { db, _ ->
repeat(6) {
assertIs(
db.initiated.create(genInitPay("PAY$it"))
)
db.initiated.batch(Instant.now(), "BATCH$it", false)
}
suspend fun checkIds(vararg ids: String) {
assertEquals(
listOf(*ids),
db.initiated.submittable().flatMap { it.payments.map { it.endToEndId } }
)
}
checkIds("PAY0", "PAY1", "PAY2", "PAY3", "PAY4", "PAY5")
// Check submitted not submitable
db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1")
checkIds("PAY1", "PAY2", "PAY3", "PAY4", "PAY5")
// Check transient failure submitable last
db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
checkIds("PAY2", "PAY3", "PAY4", "PAY5", "PAY1")
// Check persistent failure not submitable
db.initiated.batchSubmissionSuccess(4, Instant.now(), "ORDER3")
db.initiated.orderFailure("ORDER3")
checkIds("PAY2", "PAY4", "PAY5", "PAY1")
db.initiated.batchSubmissionSuccess(5, Instant.now(), "ORDER4")
db.initiated.orderFailure("ORDER4")
checkIds("PAY2", "PAY5", "PAY1")
// Check rotation
db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure")
checkIds("PAY5", "PAY1", "PAY2")
db.initiated.batchSubmissionFailure(6, Instant.now(), "Failure")
checkIds("PAY1", "PAY2", "PAY5")
db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
checkIds("PAY2", "PAY5", "PAY1")
}
// TODO test for unsettledTxInBatch
}
class EbicsTxTest {
// Test pending transaction's id
@Test
fun pending() = setup { db, _ ->
val ids = setOf("first", "second", "third")
for (id in ids) {
db.ebics.register(id)
}
repeat(ids.size) {
val id = db.ebics.first()
assert(ids.contains(id))
db.ebics.remove(id!!)
}
assertNull(db.ebics.first())
}
}
class TransferTest {
suspend fun Database.mapTx(authPub: EddsaPublicKey) = this.payment.registerTalerableIncoming(
genInPay("subject"), IncomingSubject.Map(authPub)
)
suspend fun Database.qrTx(reference: String) = this.payment.registerQrBillIncoming(
genInPay(reference), reference
)
@Test
fun registration() = setup { db, cfg ->
val now = Instant.now()
val accountPub = EddsaPublicKey.randEdsaKey()
val authPub = EddsaPublicKey.randEdsaKey()
val sig = EddsaSignature.rand()
val referenceNumber = subjectFmtQrBill(authPub)
// Register
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = accountPub,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = now,
recurrent = false
)
)
// Idempotent
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = accountPub,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = now,
recurrent = false
)
)
// Reference number reuse
assertEquals(
RegistrationResult.SubjectReuse,
db.transfer.register(
type = TransferType.reserve,
accountPub = accountPub,
authPub = EddsaPublicKey.randEdsaKey(),
authSig = sig,
referenceNumber = referenceNumber,
timestamp = now,
recurrent = false
)
)
// Auth pub reuse replace existing one
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = accountPub,
authPub = authPub,
authSig = sig,
referenceNumber = "032847109247158302947510329",
timestamp = now,
recurrent = false
)
)
// Reserve pub reuse
assertEquals(
RegistrationResult.ReservePubReuse,
db.transfer.register(
type = TransferType.reserve,
accountPub = accountPub,
authPub = EddsaPublicKey.randEdsaKey(),
authSig = sig,
referenceNumber = "032847109247158302947510330",
timestamp = now,
recurrent = true
)
)
// Non recurrent accept one then bounce
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = accountPub,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = now,
recurrent = false
)
)
assertEquals(
IncomingRegistrationResult.Success(1, true, false, null, false),
db.mapTx(authPub)
)
db.checkIn(
Status.Reserve(accountPub)
)
assertEquals(
IncomingRegistrationResult.MappingReuse,
db.mapTx(authPub)
)
// Recurrent accept one and delay
val newKey = EddsaPublicKey.randEdsaKey()
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = newKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = now,
recurrent = true
)
)
assertEquals(
IncomingRegistrationResult.Success(2, true, false, null, false),
db.mapTx(authPub)
)
assertEquals(
IncomingRegistrationResult.Success(3, true, false, null, true),
db.mapTx(authPub)
)
assertEquals(
IncomingRegistrationResult.Success(4, true, false, null, true),
db.mapTx(authPub)
)
assertEquals(
IncomingRegistrationResult.Success(5, true, false, null, true),
db.mapTx(authPub)
)
assertEquals(
IncomingRegistrationResult.Success(6, true, false, null, true),
db.mapTx(authPub)
)
db.checkIn(
Status.Reserve(accountPub),
Status.Reserve(newKey),
Status.Pending,
Status.Pending,
Status.Pending,
Status.Pending
)
// Complete pending on recurrent update
val kycKey = EddsaPublicKey.randEdsaKey()
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.kyc,
accountPub = kycKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = now,
recurrent = true
)
)
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = kycKey,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = now,
recurrent = true
)
)
db.checkIn(
Status.Reserve(accountPub),
Status.Reserve(newKey),
Status.Kyc(kycKey),
Status.Reserve(kycKey),
Status.Pending,
Status.Pending,
)
// Kyc key reuse keep pending ones
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), genInPay(fmtIncomingSubject(IncomingType.kyc, kycKey)))
db.checkIn(
Status.Reserve(accountPub),
Status.Reserve(newKey),
Status.Kyc(kycKey),
Status.Reserve(kycKey),
Status.Pending,
Status.Pending,
Status.Kyc(kycKey)
)
// Switching to non recurrent cancel pending
val lastAccountPub = EddsaPublicKey.randEdsaKey()
val lastAuthPub = EddsaPublicKey.randEdsaKey()
val lastReferenceNumber = subjectFmtQrBill(lastAuthPub)
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = lastAccountPub,
authPub = lastAuthPub,
authSig = sig,
referenceNumber = lastReferenceNumber,
timestamp = now,
recurrent = true
)
)
assertEquals(
IncomingRegistrationResult.Success(8, true, false, null, false),
db.mapTx(lastAuthPub)
)
assertEquals(
IncomingRegistrationResult.Success(9, true, false, null, true),
db.mapTx(lastAuthPub)
)
assertEquals(
IncomingRegistrationResult.Success(10, true, false, null, true),
db.mapTx(lastAuthPub)
)
db.checkIn(
Status.Reserve(accountPub),
Status.Reserve(newKey),
Status.Kyc(kycKey),
Status.Reserve(kycKey),
Status.Pending,
Status.Pending,
Status.Kyc(kycKey),
Status.Reserve(lastAccountPub),
Status.Pending,
Status.Pending,
)
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.kyc,
accountPub = lastAccountPub,
authPub = lastAuthPub,
authSig = sig,
referenceNumber = lastReferenceNumber,
timestamp = now,
recurrent = false
)
)
db.checkIn(
Status.Reserve(accountPub),
Status.Reserve(newKey),
Status.Kyc(kycKey),
Status.Reserve(kycKey),
Status.Pending,
Status.Pending,
Status.Kyc(kycKey),
Status.Reserve(lastAccountPub),
Status.Bounced,
Status.Bounced,
)
}
@Test
fun delete() = setup { db, _ ->
val authPub = EddsaPublicKey.randEdsaKey()
val sig = EddsaSignature.rand()
val referenceNumber = subjectFmtQrBill(authPub)
val payto = IbanPayto.rand("Sir Florian")
val amount = TalerAmount("KUDOS:2.53")
// Unknown
assertFalse(db.transfer.unregister(authPub, Instant.now()))
// Unused
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = authPub,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = false
)
)
assertTrue(db.transfer.unregister(authPub, Instant.now()))
assertFalse(db.transfer.unregister(authPub, Instant.now()))
assertEquals(
IncomingRegistrationResult.UnknownMapping,
db.mapTx(authPub)
)
assertEquals(
IncomingRegistrationResult.UnknownMapping,
db.qrTx(referenceNumber)
)
// Register after deletion is idempotent if already known
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = authPub,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = false
)
)
val cfg = NexusIngestConfig.default(AccountType.exchange)
val payment = genInPay(referenceNumber)
assertEquals(IncomingRegistrationResult.Success(1, true, false, null, false), db.payment.registerQrBillIncoming(payment, referenceNumber))
assertEquals(IncomingRegistrationResult.Success(1, false, false, null, false), db.payment.registerQrBillIncoming(payment, referenceNumber))
db.checkIn(Status.Reserve(authPub))
assertTrue(db.transfer.unregister(authPub, Instant.now()))
registerIncomingPayment(db, cfg, payment)
assertEquals(IncomingRegistrationResult.Success(1, false, false, null, false), db.payment.registerQrBillIncoming(payment, referenceNumber))
db.checkIn(Status.Reserve(authPub))
// Test mapped transfers behavior after deletion
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.kyc,
accountPub = authPub,
authPub = authPub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = true
)
)
// First is registered
assertEquals(
IncomingRegistrationResult.Success(2, true, false, null, false),
db.qrTx(referenceNumber)
)
// Other are pending
assertEquals(
IncomingRegistrationResult.Success(3, true, false, null, true),
db.qrTx(referenceNumber)
)
assertEquals(
IncomingRegistrationResult.Success(4, true, false, null, true),
db.mapTx(authPub)
)
db.checkIn(Status.Reserve(authPub), Status.Kyc(authPub), Status.Pending, Status.Pending)
assertTrue(db.transfer.unregister(authPub, Instant.now()))
db.checkIn(Status.Reserve(authPub), Status.Kyc(authPub), Status.Bounced, Status.Bounced)
assertEquals(
IncomingRegistrationResult.UnknownMapping,
db.mapTx(authPub)
)
assertEquals(
IncomingRegistrationResult.UnknownMapping,
db.qrTx(referenceNumber)
)
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/OpenApiTest.kt 0000664 0001750 0001750 00000004120 15204341712 024275 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.Test
import sun.misc.Unsafe
import tech.libeufin.common.*
import tech.libeufin.nexus.db.Database
import tech.libeufin.nexus.nexusApi
import tech.libeufin.nexus.nexusConfig
import java.io.File
import kotlin.io.path.Path
import kotlin.test.*
class OpenApiTest {
private fun fakeDatabase(): Database {
val field = Unsafe::class.java.getDeclaredField("theUnsafe")
field.isAccessible = true
val unsafe = field.get(null) as Unsafe
return unsafe.allocateInstance(Database::class.java) as Database
}
@Test
fun generateSpec() {
val cfg = nexusConfig(Path("conf/test.conf"))
testApplication {
application {
nexusApi(fakeDatabase(), cfg, serveSpec = true)
}
val resp = client.get("/openapi.yaml")
resp.assertOk()
val spec = resp.bodyAsText()
assertTrue(spec.contains("openapi: 3.1.0"), "Response should be a valid OpenAPI spec")
assertTrue(spec.contains("LibEuFin Nexus API"), "Spec should contain the API title")
val outFile = File("build/openapi.yaml")
outFile.writeText(spec)
println("OpenAPI spec written to ${outFile.absolutePath}")
}
}
}
libeufin-1.6.8/libeufin-nexus/src/test/kotlin/routines.kt 0000664 0001750 0001750 00000005346 15122266731 023773 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*
import tech.libeufin.common.TalerErrorCode
import tech.libeufin.common.assertBadRequest
import tech.libeufin.common.assertUnauthorized
import tech.libeufin.common.test.abstractHistoryRoutine
// Test endpoint is correctly authenticated
suspend fun ApplicationTestBuilder.authRoutine(
method: HttpMethod,
path: String,
token: Boolean = true
) {
// No header
client.request(path) {
this.method = method
}.assertUnauthorized(TalerErrorCode.GENERIC_PARAMETER_MISSING)
// Bad header
client.request(path) {
this.method = method
headers[HttpHeaders.Authorization] = "WTF"
}.assertBadRequest(TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED)
// Wrong scheme
if (token) {
client.request(path) {
this.method = method
headers[HttpHeaders.Authorization] = "Basic bad-token"
}.assertUnauthorized(TalerErrorCode.GENERIC_UNAUTHORIZED)
} else {
client.request(path) {
this.method = method
headers[HttpHeaders.Authorization] = "Bearer bad-token"
}.assertUnauthorized(TalerErrorCode.GENERIC_UNAUTHORIZED)
}
// Bad token
if (token) {
client.request(path) {
this.method = method
headers[HttpHeaders.Authorization] = "Bearer bad-token"
}.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
} else {
client.request(path) {
this.method = method
basicAuth("username", "bad-password")
}.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
}
}
suspend inline fun ApplicationTestBuilder.historyRoutine(
url: String,
crossinline ids: (B) -> List,
registered: List Unit>,
ignored: List Unit> = listOf(),
polling: Boolean = true
) {
abstractHistoryRoutine(ids, registered, ignored, polling) { params: String ->
client.getA("$url?$params")
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt 0000664 0001750 0001750 00000042676 15221677432 025660 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*
import org.junit.Test
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.nexus.cli.registerOutgoingPayment
import tech.libeufin.ebics.randEbicsId
import java.time.Instant
import kotlin.test.*
class WireGatewayApiTest {
// GET /taler-wire-gateway/config
@Test
fun config() = serverSetup {
client.get("/taler-wire-gateway/config").assertOk()
}
// POST /taler-wire-gateway/transfer
@Test
fun transfer() = serverSetup {
val valid_req = obj {
"request_uid" to HashCode.rand()
"amount" to "CHF:55"
"exchange_base_url" to "http://exchange.example.com/"
"wtid" to ShortHashCode.rand()
"credit_account" to grothoffPayto
}
authRoutine(HttpMethod.Post, "/taler-wire-gateway/transfer")
// Check OK
client.postA("/taler-wire-gateway/transfer") {
json(valid_req)
}.assertOk()
// check idempotency
client.postA("/taler-wire-gateway/transfer") {
json(valid_req)
}.assertOk()
val with_metadata = obj(valid_req) {
"request_uid" to HashCode.rand()
"metadata" to "ID"
"wtid" to ShortHashCode.rand()
}
client.postA("/taler-wire-gateway/transfer") {
json(with_metadata)
}.assertOk()
client.postA("/taler-wire-gateway/transfer") {
json(with_metadata)
}.assertOk()
// Malformed metadata
listOf("bad_id", "bad id", "bad@id.com", "A".repeat(41)).forEach {
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"request_uid" to HashCode.rand()
"metadata" to it
"wtid" to ShortHashCode.rand()
}
}.assertBadRequest()
}
// Trigger conflict due to reused request_uid
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"wtid" to ShortHashCode.rand()
"exchange_base_url" to "http://different-exchange.example.com/"
}
}.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
// Trigger conflict due to reused wtid
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"request_uid" to HashCode.rand()
}
}.assertConflict(TalerErrorCode.BANK_TRANSFER_WTID_REUSED)
// Currency mismatch
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"amount" to "EUR:33"
}
}.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
// Bad BASE32 wtid
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"wtid" to "I love chocolate"
}
}.assertBadRequest()
// Bad BASE32 len wtid
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"wtid" to Base32Crockford.encode(ByteArray(31).rand())
}
}.assertBadRequest()
// Bad BASE32 request_uid
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"request_uid" to "I love chocolate"
}
}.assertBadRequest()
// Bad BASE32 len wtid
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"request_uid" to Base32Crockford.encode(ByteArray(65).rand())
}
}.assertBadRequest()
// Missing receiver-name
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"credit_account" to "payto://iban/CH7389144832588726658"
}
}.assertBadRequest()
// Bad payto kind
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"credit_account" to "payto://x-taler-bank/bank.hostname.test/bar?receiver-name=Mr+Tom"
}
}.assertBadRequest()
// Bad baseURL
for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) {
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"exchange_base_url" to bad
}
}.assertBadRequest()
}
}
// GET /taler-wire-gateway/transfers/{ROW_ID}
@Test
fun transferById() = serverSetup {
val wtid = ShortHashCode.rand()
val valid_req = obj {
"request_uid" to HashCode.rand()
"amount" to "CHF:55"
"exchange_base_url" to "http://exchange.example.com/"
"wtid" to wtid
"credit_account" to grothoffPayto
}
authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers/1")
val resp = client.postA("/taler-wire-gateway/transfer") {
json(valid_req)
}.assertOkJson()
// Check OK
client.getA("/taler-wire-gateway/transfers/${resp.row_id}")
.assertOkJson { tx ->
assertEquals(TransferStatusState.pending, tx.status)
assertEquals(TalerAmount("CHF:55"), tx.amount)
assertEquals("http://exchange.example.com/", tx.origin_exchange_url)
assertNull(tx.metadata)
assertEquals(wtid, tx.wtid)
assertEquals(resp.timestamp, tx.timestamp)
}
client.postA("/taler-wire-gateway/transfer") {
json(valid_req) {
"request_uid" to HashCode.rand()
"metadata" to "ID"
"wtid" to ShortHashCode.rand()
}
}.assertOkJson {
client.getA("/taler-wire-gateway/transfers/${it.row_id}")
.assertOkJson { tx ->
assertEquals(tx.metadata, "ID")
}
}
// Check unknown transaction
client.getA("/taler-wire-gateway/transfers/42")
.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
}
// GET /accounts/{USERNAME}/taler-wire-gateway/transfers
@Test
fun transferPage() = serverSetup { db ->
authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers")
client.getA("/taler-wire-gateway/transfers").assertNoContent()
repeat(6) {
client.postA("/taler-wire-gateway/transfer") {
json {
"request_uid" to HashCode.rand()
"amount" to "CHF:55"
"exchange_base_url" to "http://exchange.example.com/"
"wtid" to ShortHashCode.rand()
"credit_account" to grothoffPayto
}
}.assertOkJson()
db.initiated.batch(Instant.now(), randEbicsId(), false)
}
client.getA("/taler-wire-gateway/transfers")
.assertOkJson {
assertEquals(6, it.transfers.size)
assertEquals(
it,
client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson()
)
}
client.getA("/taler-wire-gateway/transfers?status=success").assertNoContent()
db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1")
db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure")
client.getA("/taler-wire-gateway/transfers?status=transient_failure").assertOkJson {
assertEquals(2, it.transfers.size)
}
client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson {
assertEquals(4, it.transfers.size)
}
}
// GET /taler-wire-gateway/history/incoming
@Test
fun historyIncoming() = serverSetup { db ->
authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming")
historyRoutine(
url = "/taler-wire-gateway/history/incoming",
ids = { it.incoming_transactions.map { it.row_id } },
registered = listOf(
// Reserve transactions using clean add incoming logic
{ addIncoming("CHF:12") },
// Reserve transactions using raw bank transaction logic
{ talerableIn(db) },
{ talerableCompletedIn(db) },
{ talerablePreparedIn(db) },
{ talerablePreparedCompletedIn(db) },
// KYC transactions using clean add incoming logic
{ addKyc("CHF:12") },
// KYC transactions using raw bank transaction logic
{ talerableKycIn(db) },
),
ignored = listOf(
// Ignore malformed incoming transaction
{ registerIn(db) },
// Ignore malformed incomplete
{ registerIncompleteIn(db) },
// Ignore malformed completed
{ registerCompletedIn(db) },
// Ignore incompleted
{ talerableIncompleteIn(db) },
// Ignore outgoing transaction
{ talerableOut(db) },
// Ignore prepared incomplete
{ talerablePreparedIncompleteIn(db) },
)
)
}
// GET /taler-wire-gateway/history/outgoing
@Test
fun historyOutgoing() = serverSetup { db ->
authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing")
historyRoutine(
url = "/taler-wire-gateway/history/outgoing",
ids = { it.outgoing_transactions.map { it.row_id } },
registered = listOf(
// Transfer using raw bank transaction logic
{ talerableOut(db) },
// And with metadata
{ talerableOut(db, "CON.ID") }
),
ignored = listOf(
// Ignore pending transfers
{ transfer() },
// Ignore manual incoming transaction
{ talerableIn(db) },
// Ignore malformed incoming transaction
{ registerIn(db) },
// Ignore malformed outgoing transaction
{ registerOutgoingPayment(db, genOutPay("ignored")) },
)
)
println(client.getA("/taler-wire-gateway/history/outgoing?limit=2")
.assertOkJson()
.outgoing_transactions
.map { it.amount.toString() to it.metadata })
assertContentEquals(
client.getA("/taler-wire-gateway/history/outgoing?limit=2")
.assertOkJson()
.outgoing_transactions
.map { it.amount.toString() to it.metadata }
,listOf(
"CHF:44" to null,
"CHF:44" to "CON.ID",
)
)
}
suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) {
val (path, key) = when (type) {
IncomingType.reserve -> Pair("add-incoming", "reserve_pub")
IncomingType.kyc -> Pair("add-kycauth", "account_pub")
IncomingType.map -> Pair("add-mapped", "authorization_pub")
}
val (priv, pub) = EddsaPublicKey.randEdsaKeyPair()
client.post("/taler-prepared-transfer/registration") {
json(SubjectRequest(
Payto.parse("payto://iban/CH7789144474425692816"),
TransferType.reserve,
false,
TalerAmount("CHF:44"),
PublicKeyAlg.EdDSA,
pub,
pub,
EddsaSignature.rand()
).sign(priv))
}.assertOkJson()
val valid_req = obj {
"amount" to "CHF:44"
key to pub
"debit_account" to grothoffPayto
}
authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/$path")
// Check OK
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req)
}.assertOk()
when (type) {
IncomingType.reserve -> {
// Trigger conflict due to reused reserve_pub
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req)
}.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
}
IncomingType.kyc -> {
// Non conflict on reuse
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req)
}.assertOk()
}
IncomingType.map -> {
// Trigger conflict due to reused authorization_pub
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req)
}.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED)
// Trigger conflict due to unknown authorization_pub
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req) {
key to EddsaPublicKey.randEdsaKey()
}
}.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN)
}
}
// Currency mismatch
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req) { "amount" to "EUR:33" }
}.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
// Bad BASE32 reserve_pub
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req) {
key to "I love chocolate"
}
}.assertBadRequest()
// Bad BASE32 len reserve_pub
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req) {
key to Base32Crockford.encode(ByteArray(31).rand())
}
}.assertBadRequest()
// Bad payto kind
client.postA("/taler-wire-gateway/admin/$path") {
json(valid_req) {
"debit_account" to "payto://x-taler-bank/bank.hostname.test/bar"
}
}.assertBadRequest()
}
// POST /taler-wire-gateway/admin/add-incoming
@Test
fun addIncoming() = serverSetup {
talerAddIncomingRoutine(IncomingType.reserve)
}
// POST /taler-wire-gateway/admin/add-kycauth
@Test
fun addKycAuth() = serverSetup {
talerAddIncomingRoutine(IncomingType.kyc)
}
// POST /taler-wire-gateway/admin/add-mapped
@Test
fun addMapped() = serverSetup {
talerAddIncomingRoutine(IncomingType.map)
}
@Test
fun addIncomingMix() = serverSetup { db ->
addIncoming("CHF:1")
addKyc("CHF:2")
talerableIn(db, amount = "CHF:3")
talerableKycIn(db, amount = "CHF:4")
talerablePreparedIn(db, amount = "CHF:5")
client.getA("/taler-wire-gateway/history/incoming?limit=25").assertOkJson {
assertEquals(5, it.incoming_transactions.size)
it.incoming_transactions.forEachIndexed { i, tx ->
assertEquals(TalerAmount("CHF:${i+1}"), tx.amount)
if (i % 2 == 1) {
val tmp = assertIs(tx)
if (i < 4) {
assertNull(tmp.authorization_pub)
assertNull(tmp.authorization_sig)
} else {
assertNotNull(tmp.authorization_pub)
assertNotNull(tmp.authorization_sig)
}
} else {
val tmp = assertIs(tx)
if (i < 4) {
assertNull(tmp.authorization_pub)
assertNull(tmp.authorization_sig)
} else {
assertNotNull(tmp.authorization_pub)
assertNotNull(tmp.authorization_sig)
}
}
}
}
}
// POST /taler-wire-gateway/account/check
@Test
fun accountCheck() = serverSetup {
client.getA("/taler-wire-gateway/account/check").assertNotImplemented()
}
@Test
fun noApi() = serverSetup("mini.conf") {
client.get("/taler-wire-gateway/config").assertNotImplemented()
}
@Test
fun auth() = serverSetup("auth.conf") {
authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming", false)
client.get("/taler-wire-gateway/history/incoming") {
basicAuth("username", "password")
}.assertNoContent()
}
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/helpers.kt 0000664 0001750 0001750 00000024712 15156463305 023566 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.coroutines.runBlocking
import tech.libeufin.common.*
import tech.libeufin.common.db.dbInit
import tech.libeufin.common.db.pgDataSource
import tech.libeufin.ebics.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.cli.registerIncomingPayment
import tech.libeufin.nexus.cli.registerOutgoingPayment
import tech.libeufin.nexus.db.Database
import tech.libeufin.nexus.db.InitiatedPayment
import tech.libeufin.nexus.db.TransferDAO.RegistrationResult
import tech.libeufin.nexus.iso20022.*
import java.time.Instant
import kotlin.io.path.Path
import kotlin.test.assertEquals
fun conf(
conf: String = "test.conf",
lambda: suspend (NexusConfig) -> Unit
) = runBlocking {
val cfg = nexusConfig(Path("conf/$conf"))
lambda(cfg)
}
fun setup(
conf: String = "test.conf",
lambda: suspend (Database, NexusConfig) -> Unit
) = conf(conf) { cfg ->
pgDataSource(cfg.dbCfg.dbConnStr).dbInit(cfg.dbCfg, "libeufin-nexus", true)
cfg.withDb(lambda)
}
fun serverSetup(
conf: String = "test.conf",
lambda: suspend ApplicationTestBuilder.(Database) -> Unit
) = setup(conf) { db, cfg ->
testApplication {
application {
nexusApi(db, cfg)
}
lambda(db)
}
}
const val grothoffPayto = "payto://iban/CH4189144589712575493?receiver-name=Grothoff%20Hans"
val clientKeys = generateNewKeys()
/** Generates a payment initiation, given its subject */
fun genInitPay(
endToEndId: String,
subject: String = "init payment",
amount: String = "KUDOS:44",
creditor: IbanPayto = ibanPayto("CH4189144589712575493", "Test")
) = InitiatedPayment(
id = -1,
amount = TalerAmount(amount),
creditor = creditor,
subject = subject,
initiationTime = Instant.now(),
endToEndId = endToEndId
)
/** Generates an incoming payment, given its subject */
fun genInPay(
subject: String,
amount: String = "KUDOS:44",
executionTime: Instant = Instant.now()
) = IncomingPayment(
amount = TalerAmount(amount),
debtor = ibanPayto("DE84500105177118117964", "John Smith"),
subject = subject,
executionTime = executionTime,
id = IncomingId(null, randEbicsId(), null)
)
/** Generates an outgoing payment, given its subject and end-to-end ID */
fun genOutPay(
subject: String,
endToEndId: String? = null,
msgId: String? = null,
executionTime: Instant = Instant.now()
) = OutgoingPayment(
id = OutgoingId(msgId, endToEndId ?: randEbicsId(), null),
amount = TalerAmount(44, 0, "KUDOS"),
creditor = ibanPayto("CH4189144589712575493", "Test"),
subject = subject,
executionTime = executionTime,
)
/** Perform a taler outgoing transaction */
suspend fun ApplicationTestBuilder.transfer() {
client.postA("/taler-wire-gateway/transfer") {
json {
"request_uid" to HashCode.rand()
"amount" to "CHF:55"
"exchange_base_url" to "http://exchange.example.com/"
"wtid" to ShortHashCode.rand()
"credit_account" to grothoffPayto
}
}.assertOk()
}
/** Perform a taler incoming transaction of [amount] from merchant to exchange */
suspend fun ApplicationTestBuilder.addIncoming(amount: String) {
client.postA("/taler-wire-gateway/admin/add-incoming") {
json {
"amount" to TalerAmount(amount)
"reserve_pub" to EddsaPublicKey.randEdsaKey()
"debit_account" to grothoffPayto
}
}.assertOk()
}
/** Perform a taler kyc transaction of [amount] from merchant to exchange */
suspend fun ApplicationTestBuilder.addKyc(amount: String) {
client.postA("/taler-wire-gateway/admin/add-kycauth") {
json {
"amount" to TalerAmount(amount)
"account_pub" to EddsaPublicKey.randEdsaKey()
"debit_account" to grothoffPayto
}
}.assertOk()
}
/** Register a talerable outgoing transaction */
suspend fun talerableOut(db: Database, metadata: String? = null) {
val wtid = EddsaPublicKey.randEdsaKey()
registerOutgoingPayment(db, genOutPay(fmtOutgoingSubject(wtid, BaseURL.parse("http://exchange.example.com/"), metadata)))
}
/** Register a talerable reserve incoming transaction */
suspend fun talerableIn(
db: Database,
amount: String = "CHF:44",
reserve_pub: EddsaPublicKey = EddsaPublicKey.randEdsaKey()
) {
registerIncomingPayment(
db, NexusIngestConfig.default(AccountType.exchange),
genInPay("test with $reserve_pub reserve pub", amount)
)
}
private suspend fun prepare(db: Database): String {
val pub = EddsaPublicKey.randEdsaKey()
val sig = EddsaSignature.rand()
val referenceNumber = subjectFmtQrBill(pub)
assertEquals(
RegistrationResult.Success,
db.transfer.register(
type = TransferType.reserve,
accountPub = pub,
authPub = pub,
authSig = sig,
referenceNumber = referenceNumber,
timestamp = Instant.now(),
recurrent = false
)
)
return referenceNumber
}
/** Register a talerable reserve prepared incoming transaction */
suspend fun talerablePreparedIn(db: Database, amount: String = "CHF:44") {
val referenceNumber = prepare(db)
registerIncomingPayment(
db, NexusIngestConfig.default(AccountType.exchange),
genInPay(referenceNumber, amount)
)
}
/** Register an incomplete talerable reserve prepared incoming transaction */
suspend fun talerablePreparedIncompleteIn(db: Database, amount: String = "CHF:44") {
val referenceNumber = prepare(db)
val incomplete = genInPay(referenceNumber).copy(subject = null, debtor = null)
registerIncomingPayment(
db, NexusIngestConfig.default(AccountType.exchange), incomplete
)
}
/** Register a completed talerable reserve prepared incoming transaction */
suspend fun talerablePreparedCompletedIn(db: Database, amount: String = "CHF:44") {
val referenceNumber = prepare(db)
val original = genInPay(referenceNumber, amount)
val incomplete = original.copy(subject = null, debtor = null)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), original)
}
/** Register an incomplete talerable reserve incoming transaction */
suspend fun talerableIncompleteIn(db: Database) {
val reserve_pub = EddsaPublicKey.randEdsaKey()
val incomplete = genInPay("test with $reserve_pub reserve pub").copy(subject = null, debtor = null)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete)
}
/** Register a completed talerable reserve incoming transaction */
suspend fun talerableCompletedIn(db: Database) {
val reserve_pub = EddsaPublicKey.randEdsaKey()
val original = genInPay("test with $reserve_pub reserve pub")
val incomplete = original.copy(subject = null, debtor = null)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), original)
}
/** Register a talerable KYC incoming transaction */
suspend fun talerableKycIn(
db: Database,
amount: String = "CHF:44",
account_pub: EddsaPublicKey = EddsaPublicKey.randEdsaKey()
) {
registerIncomingPayment(
db, NexusIngestConfig.default(AccountType.exchange),
genInPay("test with KYC:$account_pub account pub", amount)
)
}
/** Register an incoming transaction */
suspend fun registerIn(db: Database) {
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), genInPay("ignored"))
}
/** Register an incomplete incoming transaction */
suspend fun registerIncompleteIn(db: Database) {
val incomplete = genInPay("ignored").copy(subject = null, debtor = null)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete)
}
/** Register a completed incoming transaction */
suspend fun registerCompletedIn(db: Database) {
val original = genInPay("ignored")
val incomplete = original.copy(subject = null, debtor = null)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete)
registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), original)
}
/** Register an outgoing transaction */
suspend fun registerOut(db: Database) {
registerOutgoingPayment(db, genOutPay("ignored"))
}
/** Register an incomplete outgoing transaction */
suspend fun registerIncompleteOut(db: Database) {
val original = genOutPay("ignored")
val incomplete = original.copy(id = OutgoingId(null, null, original.id.endToEndId), creditor = null)
registerOutgoingPayment(db, incomplete)
}
/* ----- Auth ----- */
/** Auto auth get request */
suspend inline fun HttpClient.getA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
return get(url) {
auth()
builder(this)
}
}
/** Auto auth post request */
suspend inline fun HttpClient.postA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
return post(url) {
auth()
builder(this)
}
}
/** Auto auth patch request */
suspend inline fun HttpClient.patchA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
return patch(url) {
auth()
builder(this)
}
}
/** Auto auth delete request */
suspend inline fun HttpClient.deleteA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse {
return delete(url) {
auth()
builder(this)
}
}
fun HttpRequestBuilder.auth() {
headers[HttpHeaders.Authorization] = "Bearer secret-token"
} libeufin-1.6.8/libeufin-nexus/src/test/kotlin/RevenueApiTest.kt 0000664 0001750 0001750 00000004603 15156463305 025024 0 ustar grothoff grothoff /*
* This file is part of LibEuFin.
* 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
import io.ktor.http.*
import io.ktor.client.request.*
import org.junit.Test
import tech.libeufin.common.RevenueIncomingHistory
import tech.libeufin.common.assertNotImplemented
import tech.libeufin.common.assertOk
class RevenueApiTest {
// GET /taler-revenue/config
@Test
fun config() = serverSetup {
client.get("/taler-revenue/config").assertOk()
}
// GET /taler-revenue/history
@Test
fun history() = serverSetup { db ->
authRoutine(HttpMethod.Get, "/taler-revenue/history")
historyRoutine(
url = "/taler-revenue/history",
ids = { it.incoming_transactions.map { it.row_id } },
registered = listOf(
// Transactions using clean transfer logic
{ talerableIn(db) },
{ talerableCompletedIn(db) },
{ talerablePreparedIn(db) },
{ talerablePreparedCompletedIn(db) },
// Common credit transactions
{ registerIn(db) },
{ registerCompletedIn(db) }
),
ignored = listOf(
// Ignore debit transactions
{ talerableOut(db) },
// Ignore incomplete
{ registerIncompleteIn(db) },
{ talerableIncompleteIn(db) },
{ talerablePreparedIncompleteIn(db) },
)
)
}
@Test
fun noApi() = serverSetup("mini.conf") {
client.getA("/taler-revenue/config").assertNotImplemented()
}
@Test
fun auth() = serverSetup("auth.conf") {
authRoutine(HttpMethod.Get, "/taler-revenue/history")
}
} libeufin-1.6.8/libeufin-nexus/build.gradle 0000664 0001750 0001750 00000005053 15204341712 020761 0 ustar grothoff grothoff plugins {
id("kotlin")
id("application")
id("com.gradleup.shadow") version "$shadow_version"
id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version"
}
version = rootProject.version
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
compileKotlin.kotlinOptions.jvmTarget = "17"
compileTestKotlin.kotlinOptions.jvmTarget = "17"
sourceSets.main.java.srcDirs = ["src/main/kotlin"]
dependencies {
// Core language libraries
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version")
implementation(project(":libeufin-common"))
implementation(project(":libeufin-ebics"))
// Metrics
implementation("io.prometheus:prometheus-metrics-core:$prometheus_version")
implementation("io.prometheus:prometheus-metrics-instrumentation-jvm:$prometheus_version")
implementation("io.prometheus:prometheus-metrics-exposition-formats:$prometheus_version")
// Command line parsing
implementation("com.github.ajalt.clikt:clikt:$clikt_version")
implementation("org.postgresql:postgresql:$postgres_version")
// Ktor client library
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-websockets:$ktor_version")
// UNIX domain sockets support (used to connect to PostgreSQL)
implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version")
// Serialization
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
// OpenAPI spec generation
implementation("io.github.smiley4:ktor-openapi:5.6.0")
implementation("io.github.smiley4:schema-kenerator-core:2.6.0")
// Unit testing
testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
testImplementation("io.ktor:ktor-server-test-host:$ktor_version")
testImplementation("io.ktor:ktor-server-cio:$ktor_version")
}
application {
mainClass = "tech.libeufin.nexus.MainKt"
}
shadowJar {
version = ""
minimize {
// Kotlin serialization
exclude(dependency("io.ktor:ktor-serialization-kotlinx-json:.*"))
// Postgres unix socket driver
exclude(dependency("com.kohlschutter.junixsocket:junixsocket-core:.*"))
// CIO engine
exclude(dependency("io.ktor:ktor-client-cio:.*"))
// Crypto
exclude(dependency("org.bouncycastle:.*"))
// CLI
exclude(dependency("com.github.ajalt.mordant:mordant:.*"))
// PDF
exclude(dependency("com.itextpdf:itext-core:.*"))
}
} libeufin-1.6.8/libeufin-nexus/codegen.py 0000775 0001750 0001750 00000011535 15122266731 020473 0 ustar grothoff grothoff #!/usr/bin/python3
# Update EBICS constants file using latest external code sets files
from io import BytesIO
from zipfile import ZipFile
import polars as pl
import requests
def iso20022codegenExternalCodeSet():
# Get XLSX zip file from server
r = requests.get(
"https://www.iso20022.org/sites/default/files/media/file/ExternalCodeSets_XLSX.zip"
)
assert r.status_code == 200
# Unzip the XLSX file
zip = ZipFile(BytesIO(r.content))
files = zip.namelist()
assert len(files) == 1
bytes = zip.read(files[0])
# Parse excel
df = pl.read_excel(bytes, sheet_name="AllCodeSets")
def extractCodeSet(setName: str, className: str) -> str:
out = f"enum class {className}(val isoCode: String, val description: String) {{"
for row in (
df.filter(pl.col("Code Set") == setName).sort("Code Value").rows(named=True)
):
(value, isoCode, description) = (
row["Code Value"],
row["Code Name"],
row["Code Definition"].split("\n", 1)[0].strip().replace("_x000D_", ""),
)
out += f'\n\t{value}("{isoCode}", "{description}"),'
out += "\n}"
return out
# Write kotlin file
kt = f"""/*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
// THIS FILE IS GENERATED, DO NOT EDIT
package tech.libeufin.nexus.iso20022
{extractCodeSet("ExternalStatusReason1Code", "ExternalStatusReasonCode")}
{extractCodeSet("ExternalPaymentGroupStatus1Code", "ExternalPaymentGroupStatusCode")}
{extractCodeSet("ExternalPaymentTransactionStatus1Code", "ExternalPaymentTransactionStatusCode")}
{extractCodeSet("ExternalReturnReason1Code", "ExternalReturnReasonCode")}
"""
with open(
"src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt", "w"
) as file1:
file1.write(kt)
def iso20022codegenBankTransactionCode():
# Get XLSX zip file from server
r = requests.get(
"https://www.iso20022.org/sites/default/files/media/file/BTC_Codification_21March2024.xlsx"
)
assert r.status_code == 200
# Get the XLSX file
bytes = r.content
# Parse excel
df = pl.read_excel(
bytes,
sheet_name="BTC_Codification",
read_options={"header_row": 2},
).rename(lambda name: name.splitlines()[0])
def extractCodeSet(setName: str, className: str) -> str:
out = f"enum class {className}(val description: String) {{"
codeName = f"{setName} Code"
for row in (
df.group_by(codeName)
.agg(pl.col(setName).unique().sort())
.sort(codeName)
.rows(named=True)
):
if len(row[setName]) > 1:
print(row)
(code, description) = (
row[codeName].strip().replace("\xa0", ""),
row[setName][0].strip(),
)
out += f'\n\t{code}("{description}"),'
out += "\n}"
return out
# Write kotlin file
kt = f"""/*
* This file is part of LibEuFin.
* Copyright (C) 2024 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
* published by the Free Software Foundation; either version 3, or
* (at your option) any later version.
* LibEuFin is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
* Public License for more details.
* You should have received a copy of the GNU Affero General Public
* License along with LibEuFin; see the file COPYING. If not, see
*
*/
// THIS FILE IS GENERATED, DO NOT EDIT
package tech.libeufin.nexus.iso20022
{extractCodeSet("Domain", "ExternalBankTransactionDomainCode")}
{extractCodeSet("Family", "ExternalBankTransactionFamilyCode")}
{extractCodeSet("SubFamily", "ExternalBankTransactionSubFamilyCode")}
"""
with open(
"src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt", "w"
) as file1:
file1.write(kt)
iso20022codegenExternalCodeSet()
iso20022codegenBankTransactionCode()
libeufin-1.6.8/libeufin-nexus/sample/ 0000775 0001750 0001750 00000000000 15236145704 017770 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/sample/platform/ 0000775 0001750 0001750 00000000000 15236145704 021614 5 ustar grothoff grothoff libeufin-1.6.8/libeufin-nexus/sample/platform/postfinance_camt053.xml 0000664 0001750 0001750 00000014074 15122266731 026107 0 ustar grothoff grothoff
CH9289144596463965762
CHF
1.00
CRDT
true
BOOK
2023-11-22
32632000B04CYPIK
PMNT
ICDT
RRTN
889d1a80-1267-49bd-8fcc-85701a
231122CH0B04CYPI
NOTPROVIDED
NOTPROVIDED
NOTPROVIDED
3e53516e-c0d3-450d-b6fc-69161ebe5942
1.00
CRDT
PMNT
ICDT
RRTN
BE01
more info here ...
1.00
CRDT
true
BOOK
2023-11-22
32632000B04KLEEK
PMNT
ICDT
RRTN
4cc61cc7-6230-49c2-b5e2-b40bbb
231122CH0B04KLEE
NOTPROVIDED
NOTPROVIDED
NOTPROVIDED
635d1493-cf6c-4ece-91b9-926daf2d4213
1.00
CRDT
PMNT
ICDT
RRTN
RR03
more info here ...
406.00
DBIT
false
BOOK
2024-08-26
PMNT
ICDT
AUTT
EB4D22D428214261B2B3012D2A8CEC36
NOTPROVIDED
fe4ba22a-8fc4-4f9b-80fc-12c4157a90bc
406.00
DBIT
PMNT
ICDT
AUTT
EZAG ISO 20022 BULK ORDER E-FINANCE NOTPROVIDED
EB4D22D428214261B2B3012D2A8CEC36