diff options
author | MS <ms@taler.net> | 2023-10-18 15:22:45 +0200 |
---|---|---|
committer | MS <ms@taler.net> | 2023-10-18 15:22:45 +0200 |
commit | c414263db597bae5a4c29019567b6299cd78553a (patch) | |
tree | b48f9c0ea8ff3a6bfcc3e4af52e0b1bd4d429328 | |
parent | 70379b90fb1e606fe5fc2f58c85b41c80a686e57 (diff) | |
download | libeufin-c414263db597bae5a4c29019567b6299cd78553a.tar.gz libeufin-c414263db597bae5a4c29019567b6299cd78553a.tar.bz2 libeufin-c414263db597bae5a4c29019567b6299cd78553a.zip |
obsolete nexus gone from master
24 files changed, 0 insertions, 8279 deletions
diff --git a/nexus/README b/nexus/README deleted file mode 100644 index 19af9703..00000000 --- a/nexus/README +++ /dev/null @@ -1,29 +0,0 @@ -Description -=========== - -The Libeufin Nexus implements a JSON API to let customers manages their -bank accounts. The Nexus will then convert those requests sent by customers -into one of more technical protocols actually implemented by banks; notably, -EBICS and FinTS. - -Running Nexus -============= - -Run the Nexus with the following command - -$ cd <top-level directory of this repository> -$ ./gradlew nexus:run --console=plain --args=serve [--db-name=<my-db>] - -Installing the Nexus start script along the project files -========================================================= - -$ cd <top-level directory of this repository> -$ ./gradlew -q -Pprefix=<installation prefix> nexus:installToPrefix - -If the previous step succeeded, the nexus can be launched by the -following file: "<installation prefix>/bin/libeufin-nexus". - -Documentation -============= - -See https://docs.libeufin.tech/ for the documentation. diff --git a/nexus/build.gradle b/nexus/build.gradle deleted file mode 100644 index 66ec7f98..00000000 --- a/nexus/build.gradle +++ /dev/null @@ -1,134 +0,0 @@ -plugins { - id 'kotlin' - id 'java' - id 'application' - id 'org.jetbrains.kotlin.jvm' - id "com.github.johnrengelman.shadow" version "5.2.0" -} - -sourceSets { - main.java.srcDirs = ['src/main/kotlin'] -} - -task installToPrefix(type: Copy) { - dependsOn(installShadowDist) - from("build/install/nexus-shadow") { - include("**/libeufin-nexus") - include("**/*.jar") - } - /** - * Reads from command line -Pkey=value options, - * with a default (/tmp) if the key is not found. - * - * project.findProperty('prefix') ?: '/tmp' - */ - into "${project.findProperty('prefix') ?: '/tmp'}" -} - -apply plugin: 'kotlin-kapt' - -sourceCompatibility = '11' -targetCompatibility = '11' -version = rootProject.version - -compileKotlin { - kotlinOptions { - jvmTarget = '11' - } -} - -compileTestKotlin { - kotlinOptions { - jvmTarget = '11' - } -} - -dependencies { - // Core language libraries - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt' - - // LibEuFin util library - implementation project(":util") - - // Logging - implementation 'ch.qos.logback:logback-classic:1.4.5' - - // XML parsing/binding and encryption - implementation "javax.xml.bind:jaxb-api:2.3.0" - implementation "org.glassfish.jaxb:jaxb-runtime:2.3.1" - implementation 'org.apache.santuario:xmlsec:2.2.2' - - // Compression - implementation group: 'org.apache.commons', name: 'commons-compress', version: '1.21' - - // Command line parsing - implementation('com.github.ajalt:clikt:2.8.0') - - // Exposed, an SQL library - implementation "org.jetbrains.exposed:exposed-dao:$exposed_version" - implementation "org.jetbrains.exposed:exposed-jdbc:$exposed_version" - - // Database connection driver - implementation group: 'org.xerial', name: 'sqlite-jdbc', version: '3.36.0.1' - implementation 'org.postgresql:postgresql:42.2.23.jre7' - - // Ktor, an HTTP client and server library - implementation "io.ktor:ktor-server-core:$ktor_version" - implementation "io.ktor:ktor-server-content-negotiation:$ktor_version" - implementation "io.ktor:ktor-server-status-pages:$ktor_version" - implementation "io.ktor:ktor-client-apache:$ktor_version" - implementation "io.ktor:ktor-client-auth:$ktor_version" - implementation "io.ktor:ktor-server-netty:$ktor_version" - - // Brings the call-logging library too. - implementation "io.ktor:ktor-server-test-host:$ktor_version" - implementation "io.ktor:ktor-auth:$ktor_auth_version" - implementation "io.ktor:ktor-serialization-jackson:$ktor_version" - - // PDF generation - implementation 'com.itextpdf:itext7-core:7.1.16' - - // Cron syntax - implementation 'com.cronutils:cron-utils:9.1.5' - - // UNIX domain sockets support (used to connect to PostgreSQL) - implementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2' - - // Unit testing - // testImplementation 'junit:junit:4.13.2' - // From https://docs.gradle.org/current/userguide/java_testing.html#sec:java_testing_basics: - testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' - testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21' - testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21' - testImplementation 'io.ktor:ktor-client-mock:2.2.4' - testImplementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2' -} - -test { - useJUnit() - failFast = true - testLogging.showStandardStreams = false - environment.put("LIBEUFIN_BANK_ADMIN_PASSWORD", "foo") - environment.put("LIBEUFIN_CASHOUT_TEST_TAN", "foo") -} - -application { - mainClassName = "tech.libeufin.nexus.MainKt" - applicationName = "libeufin-nexus" - applicationDefaultJvmArgs = ['-Djava.net.preferIPv6Addresses=true'] -} - -jar { - manifest { - attributes "Main-Class": "tech.libeufin.nexus.MainKt" - } -} - -run { - standardInput = System.in -} - -task pofi(type: JavaExec) { - classpath = sourceSets.test.runtimeClasspath - mainClass = "PostFinanceKt" -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt deleted file mode 100644 index 49e512fe..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt +++ /dev/null @@ -1,151 +0,0 @@ -package tech.libeufin.nexus - -import TransactionDetails -import io.ktor.http.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.server.PermissionQuery -import tech.libeufin.nexus.server.expectNonNull -import tech.libeufin.nexus.server.expectUrlParameter -import tech.libeufin.util.EbicsProtocolError -import kotlin.math.abs -import kotlin.math.min -import io.ktor.content.TextContent -import io.ktor.server.application.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import tech.libeufin.util.buildIbanPaytoUri -import tech.libeufin.util.internalServerError - -data class AnastasisIncomingBankTransaction( - val row_id: Long, - val date: GnunetTimestamp, // timestamp - val amount: String, - val debit_account: String, - val subject: String -) - -fun anastasisFilter(payment: NexusBankTransactionEntity, txDtls: TransactionDetails) { - val debtorName = txDtls.debtor?.name - if (debtorName == null) { - logger.warn("empty debtor name") - return - } - val debtorAcct = txDtls.debtorAccount - if (debtorAcct == null) { - // FIXME: Report payment, we can't even send it back - logger.warn("empty debtor account") - return - } - val debtorIban = debtorAcct.iban - if (debtorIban == null) { - // FIXME: Report payment, we can't even send it back - logger.warn("non-iban debtor account") - return - } - val debtorAgent = txDtls.debtorAgent - if (debtorAgent == null) { - // FIXME: Report payment, we can't even send it back - logger.warn("missing debtor agent") - return - } - /** - * This block either assigns a non-null BIC to the 'bic' - * variable, or causes this function (anastasisFilter()) - * to return. This last action ensures that the payment - * being processed won't show up in the Anastasis facade. - */ - val bic: String = debtorAgent.bic ?: run { - logger.warn("Not allowing transactions missing the BIC. IBAN and name: ${debtorIban}, $debtorName") - return - } - val paymentSubject = txDtls.unstructuredRemittanceInformation - if (paymentSubject == null) { - throw internalServerError("Nexus payment '${payment.accountTransactionId}' has no subject.") - } - AnastasisIncomingPaymentEntity.new { - this.payment = payment - subject = paymentSubject - timestampMs = System.currentTimeMillis() - debtorPaytoUri = buildIbanPaytoUri( - debtorIban, - bic, - debtorName, - ) - } -} - -data class AnastasisIncomingTransactions( - val credit_account: String, - val incoming_transactions: MutableList<AnastasisIncomingBankTransaction> -) - -// Handle a /taler-wire-gateway/history/incoming request. -private suspend fun historyIncoming(call: ApplicationCall) { - val facadeId = expectNonNull(call.parameters["fcid"]) - call.request.requirePermission( - PermissionQuery( - "facade", - facadeId, - "facade.anastasis.history" - ) - ) - val param = call.expectUrlParameter("delta") - val delta: Int = try { param.toInt() } catch (e: Exception) { - throw EbicsProtocolError(HttpStatusCode.BadRequest, "'${param}' is not Int") - } - val start: Long = handleStartArgument( - call.request.queryParameters["start"], - delta - ) - val history = object { - val incoming_transactions: MutableList<AnastasisIncomingBankTransaction> = mutableListOf() - - } - val startCmpOp = getComparisonOperator(delta, start, AnastasisIncomingPaymentsTable) - val incomingTransactionsResp = transaction { - val orderedPayments = AnastasisIncomingPaymentEntity.find { - startCmpOp - }.orderTaler(delta) // Taler and Anastasis have same ordering policy. Fixme: find better function's name? - if (orderedPayments.isNotEmpty()) { - val creditBankAccountObj = orderedPayments[0] - val ret = AnastasisIncomingTransactions( - credit_account = buildIbanPaytoUri( - creditBankAccountObj.payment.bankAccount.iban, - creditBankAccountObj.payment.bankAccount.bankCode, - creditBankAccountObj.payment.bankAccount.accountHolder, - ), - incoming_transactions = mutableListOf() - ) - orderedPayments.subList(0, min(abs(delta), orderedPayments.size)).forEach { - history.incoming_transactions.add( - AnastasisIncomingBankTransaction( - // Rounded timestamp - date = GnunetTimestamp(it.timestampMs / 1000L), - row_id = it.id.value, - amount = "${it.payment.currency}:${it.payment.amount}", - subject = it.subject, - debit_account = it.debtorPaytoUri - ) - ) - } - return@transaction ret - } else null - } - if (incomingTransactionsResp == null) { - call.respond(HttpStatusCode.NoContent) - return - } - return call.respond( - TextContent( - customConverter(incomingTransactionsResp), - ContentType.Application.Json - ) - ) -} - -fun anastasisFacadeRoutes(route: Route) { - route.get("/history/incoming") { - historyIncoming(call) - return@get - } -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Auth.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Auth.kt deleted file mode 100644 index 2048813e..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Auth.kt +++ /dev/null @@ -1,98 +0,0 @@ -package tech.libeufin.nexus - -import UtilError -import io.ktor.http.* -import io.ktor.server.request.* -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.server.Permission -import tech.libeufin.nexus.server.PermissionQuery -import tech.libeufin.util.* - -fun getNexusUser(username: String): NexusUserEntity = - transaction { - NexusUserEntity.find { - NexusUsersTable.username eq username - }.firstOrNull() ?: throw notFound("User $username not found.") - } - -/** - * HTTP basic auth. Throws error if password is wrong, - * and makes sure that the user exists in the system. - * - * @return user entity - */ -fun authenticateRequest(request: ApplicationRequest): NexusUserEntity { - return transaction { - val (username, password) = getHTTPBasicAuthCredentials(request) - val user = NexusUserEntity.find { - NexusUsersTable.username eq username - }.firstOrNull() - if (user == null) { - throw UtilError(HttpStatusCode.Unauthorized, - "Unknown user '$username'", - LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED - ) - } - CryptoUtil.checkPwOrThrow(password, user.passwordHash) - user - } -} - -fun requireSuperuser(request: ApplicationRequest): NexusUserEntity { - return transaction { - val user = authenticateRequest(request) - if (!user.superuser) { - throw NexusError(HttpStatusCode.Forbidden, "must be superuser") - } - user - } -} - -fun findPermission(p: Permission): NexusPermissionEntity? { - return transaction { - NexusPermissionEntity.find { - ((NexusPermissionsTable.subjectType eq p.subjectType) - and (NexusPermissionsTable.subjectId eq p.subjectId) - and (NexusPermissionsTable.resourceType eq p.resourceType) - and (NexusPermissionsTable.resourceId eq p.resourceId) - and (NexusPermissionsTable.permissionName eq p.permissionName.lowercase())) - - }.firstOrNull() - } -} - - -/** - * Require that the authenticated user has at least one of the listed permissions. - * - * Throws a NexusError if the authenticated user for the request doesn't have any of - * listed the permissions. It returns the username of the authorized user. - */ -fun ApplicationRequest.requirePermission(vararg perms: PermissionQuery): String { - val username = transaction { - val user = authenticateRequest(this@requirePermission) - if (user.superuser) { - return@transaction user.username - } - var foundPermission = false - for (pr in perms) { - val p = Permission("user", user.username, pr.resourceType, pr.resourceId, pr.permissionName.lowercase()) - val existingPerm = findPermission(p) - if (existingPerm != null) { - foundPermission = true - break - } - } - if (!foundPermission) { - val possiblePerms = - perms.joinToString(" | ") { "${it.resourceId} ${it.resourceType} ${it.permissionName}" } - throw NexusError( - HttpStatusCode.Forbidden, - "User ${user.username} has insufficient permissions (needs $possiblePerms)." - ) - } - user.username - } - return username -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt deleted file mode 100644 index 8607d3c9..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/BankConnectionProtocol.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus - -import com.fasterxml.jackson.databind.JsonNode -import io.ktor.client.HttpClient -import io.ktor.http.HttpStatusCode -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.server.BankConnectionType -import tech.libeufin.nexus.server.FetchSpecJson -import tech.libeufin.nexus.server.XLibeufinBankTransport -import tech.libeufin.nexus.xlibeufinbank.XlibeufinBankConnectionProtocol - -// 'const' allows only primitive types. -val bankConnectionRegistry: Map<BankConnectionType, BankConnectionProtocol> = mapOf( - BankConnectionType.EBICS to EbicsBankConnectionProtocol(), - BankConnectionType.X_LIBEUFIN_BANK to XlibeufinBankConnectionProtocol() -) - -interface BankConnectionProtocol { - // Get the bank URL in the same format that - // it was given when the connection was created. - // This helps the /admin/add-incoming handler. - fun getBankUrl(connId: String): String - // Initialize the connection. Usually uploads keys to the bank. - suspend fun connect(client: HttpClient, connId: String) - - // Downloads the list of bank accounts managed at the - // bank under one particular connection. - suspend fun fetchAccounts(client: HttpClient, connId: String) - - // Create a new connection from backup data. - fun createConnectionFromBackup(connId: String, user: NexusUserEntity, passphrase: String?, backup: JsonNode) - - // Create a new connection from an HTTP request. - fun createConnection(connId: String, user: NexusUserEntity, data: JsonNode) - - // Merely a formatter of connection details coming from - // the database. - fun getConnectionDetails(conn: NexusBankConnectionEntity): JsonNode - - // Returns the backup data. - fun exportBackup(bankConnectionId: String, passphrase: String): JsonNode - - // Export a printable format of the connection details. Useful - // to provide authentication via the traditional mail system. - fun exportAnalogDetails(conn: NexusBankConnectionEntity): ByteArray - - // Send to the bank a previously prepared payment instruction. - suspend fun submitPaymentInitiation(httpClient: HttpClient, paymentInitiationId: Long) - - /** - * Downloads transactions from the bank, according to the specification - * given in the arguments. - * - * This function returns a possibly empty list of exceptions. - * That helps not to stop fetching if ONE operation fails. Notably, - * C52 _and_ C53 may be asked along one invocation of this function, - * therefore storing the exception on C52 allows the C53 to still - * take place. The caller then decides how to handle the exceptions. - * - * More on multi requests: C52 and C53, or more generally 'reports' - * and 'statements' are tried to be downloaded together when the fetch - * level is set to ALL. - */ - suspend fun fetchTransactions( - fetchSpec: FetchSpecJson, - client: HttpClient, - bankConnectionId: String, - accountId: String - ): List<Exception>? -} - -fun getConnectionPlugin(connType: BankConnectionType): BankConnectionProtocol { - return bankConnectionRegistry[connType] ?: throw NexusError( - HttpStatusCode.NotFound, - "Connection type '${connType}' not available" - ) -} - -/** - * Adaptor helper to keep until all the connection type mentions will - * be passed as BankConnectionType instead of arbitrary easy-to-break - * string. - */ -fun getConnectionPlugin(connType: String): BankConnectionProtocol { - return getConnectionPlugin(BankConnectionType.parseBankConnectionType(connType)) -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt deleted file mode 100644 index c0d712fd..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt +++ /dev/null @@ -1,599 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus - -import EntryStatus -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.jetbrains.exposed.dao.* -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.TransactionManager -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.server.FetchLevel -import tech.libeufin.util.* - -/** - * This table holds the values that exchange gave to issue a payment, - * plus a reference to the prepared pain.001 version of. Note that - * whether a pain.001 document was sent or not to the bank is indicated - * in the PAIN-table. - */ -object TalerRequestedPaymentsTable : LongIdTable() { - val facade = reference("facade", FacadesTable) - val preparedPayment = reference("payment", PaymentInitiationsTable) - val requestUid = text("requestUid") - val amount = text("amount") - val exchangeBaseUrl = text("exchangeBaseUrl") - val wtid = text("wtid") - val creditAccount = text("creditAccount") -} - -class TalerRequestedPaymentEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<TalerRequestedPaymentEntity>(TalerRequestedPaymentsTable) - - var facade by FacadeEntity referencedOn TalerRequestedPaymentsTable.facade - var preparedPayment by PaymentInitiationEntity referencedOn TalerRequestedPaymentsTable.preparedPayment - var requestUid by TalerRequestedPaymentsTable.requestUid - var amount by TalerRequestedPaymentsTable.amount - var exchangeBaseUrl by TalerRequestedPaymentsTable.exchangeBaseUrl - var wtid by TalerRequestedPaymentsTable.wtid - var creditAccount by TalerRequestedPaymentsTable.creditAccount -} - -object TalerInvalidIncomingPaymentsTable : LongIdTable() { - val payment = reference("payment", NexusBankTransactionsTable) - val timestampMs = long("timestampMs") - val refunded = bool("refunded").default(false) -} - -class TalerInvalidIncomingPaymentEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<TalerInvalidIncomingPaymentEntity>(TalerInvalidIncomingPaymentsTable) - - var payment by NexusBankTransactionEntity referencedOn TalerInvalidIncomingPaymentsTable.payment - var timestampMs by TalerInvalidIncomingPaymentsTable.timestampMs - // FIXME: This should probably not be called refunded, and - // we should have a foreign key to the payment that sends the - // money back. - var refunded by TalerInvalidIncomingPaymentsTable.refunded -} - -object AnastasisIncomingPaymentsTable: LongIdTable() { - val payment = reference("payment", NexusBankTransactionsTable) - val subject = text("subject") - val timestampMs = long("timestampMs") - val debtorPaytoUri = text("incomingPaytoUri") -} - -class AnastasisIncomingPaymentEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<AnastasisIncomingPaymentEntity>(AnastasisIncomingPaymentsTable) - - var payment by NexusBankTransactionEntity referencedOn AnastasisIncomingPaymentsTable.payment - var subject by AnastasisIncomingPaymentsTable.subject - var timestampMs by AnastasisIncomingPaymentsTable.timestampMs - var debtorPaytoUri by AnastasisIncomingPaymentsTable.debtorPaytoUri -} - -/** - * This is the table of the incoming payments. Entries are merely "pointers" to the - * entries from the raw payments table. - */ -object TalerIncomingPaymentsTable : LongIdTable() { - val payment = reference("payment", NexusBankTransactionsTable) - val reservePublicKey = text("reservePublicKey") - val timestampMs = long("timestampMs") - val debtorPaytoUri = text("incomingPaytoUri") -} - -class TalerIncomingPaymentEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<TalerIncomingPaymentEntity>(TalerIncomingPaymentsTable) - - var payment by NexusBankTransactionEntity referencedOn TalerIncomingPaymentsTable.payment - var reservePublicKey by TalerIncomingPaymentsTable.reservePublicKey - var timestampMs by TalerIncomingPaymentsTable.timestampMs - var debtorPaytoUri by TalerIncomingPaymentsTable.debtorPaytoUri -} - -/** - * This table logs all the balances as returned by the bank for all the bank accounts. - */ -object NexusBankBalancesTable : LongIdTable() { - /** - * Balance mentioned in the bank message referenced below. NOTE: this is the - * CLOSING balance (a.k.a. CLBD), namely the one obtained by adding the transactions - * reported in the bank message to the _previous_ CLBD. - */ - val balance = text("balance") // $currency:x.y - val creditDebitIndicator = text("creditDebitIndicator") // CRDT or DBIT. - val bankAccount = reference("bankAccount", NexusBankAccountsTable) - val date = text("date") // in the YYYY-MM-DD format -} - -class NexusBankBalanceEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusBankBalanceEntity>(NexusBankBalancesTable) - var balance by NexusBankBalancesTable.balance - var creditDebitIndicator by NexusBankBalancesTable.creditDebitIndicator - var bankAccount by NexusBankAccountEntity referencedOn NexusBankBalancesTable.bankAccount - var date by NexusBankBalancesTable.date -} - -// This table holds the data to talk to Sandbox -// via the x-libeufin-bank protocol supplier. -object XLibeufinBankUsersTable : LongIdTable() { - val username = text("username") - val password = text("password") - val baseUrl = text("baseUrl") - val nexusBankConnection = reference("nexusBankConnection", NexusBankConnectionsTable) -} - -class XLibeufinBankUserEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<XLibeufinBankUserEntity>(XLibeufinBankUsersTable) - var username by XLibeufinBankUsersTable.username - var password by XLibeufinBankUsersTable.password - var baseUrl by XLibeufinBankUsersTable.baseUrl - var nexusBankConnection by NexusBankConnectionEntity referencedOn XLibeufinBankUsersTable.nexusBankConnection -} - -/** - * Table that stores all messages we receive from the bank. - * The nullable fields were introduced along the x-libeufin-bank - * connection, as those messages are plain JSON object unlike - * the more structured CaMt. - */ -object NexusBankMessagesTable : LongIdTable() { - val bankConnection = reference("bankConnection", NexusBankConnectionsTable) - val message = blob("message") - val messageId = text("messageId").nullable() - val fetchLevel = enumerationByName("fetchLevel", 16, FetchLevel::class) - // true when the parser could not ingest one message: - val errors = bool("errors").default(false) -} - -class NexusBankMessageEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusBankMessageEntity>(NexusBankMessagesTable) - var bankConnection by NexusBankConnectionEntity referencedOn NexusBankMessagesTable.bankConnection - var messageId by NexusBankMessagesTable.messageId - var fetchLevel by NexusBankMessagesTable.fetchLevel - var message by NexusBankMessagesTable.message - var errors by NexusBankMessagesTable.errors -} - -/** - * This table contains history "elements" as returned by the bank from a - * CAMT message. - */ -object NexusBankTransactionsTable : LongIdTable() { - /** - * Identifier for the transaction that is unique among all transactions of the account. - * The scheme for this identifier is the accounts transaction identification scheme. - * - * Note that this is *not* a unique ID per account, as the same underlying - * transaction can show up multiple times with a different status. - */ - val accountTransactionId = text("accountTransactionId") - val bankAccount = reference("bankAccount", NexusBankAccountsTable) - val creditDebitIndicator = text("creditDebitIndicator") - val currency = text("currency") - val amount = text("amount") - val status = enumerationByName("status", 16, EntryStatus::class) - // Another, later transaction that updates the status of the current transaction. - val updatedBy = optReference("updatedBy", NexusBankTransactionsTable) - val transactionJson = text("transactionJson") -} - -class NexusBankTransactionEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusBankTransactionEntity>(NexusBankTransactionsTable) { - override fun new(init: NexusBankTransactionEntity.() -> Unit): NexusBankTransactionEntity { - val ret = super.new(init) - if (isPostgres()) { - val channelName = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_NEXUS_TX, - ret.bankAccount.bankAccountName - ) - TransactionManager.current().postgresNotify(channelName, ret.creditDebitIndicator) - } - return ret - } - } - var currency by NexusBankTransactionsTable.currency - var amount by NexusBankTransactionsTable.amount - var status by NexusBankTransactionsTable.status - var creditDebitIndicator by NexusBankTransactionsTable.creditDebitIndicator - var bankAccount by NexusBankAccountEntity referencedOn NexusBankTransactionsTable.bankAccount - var transactionJson by NexusBankTransactionsTable.transactionJson - var accountTransactionId by NexusBankTransactionsTable.accountTransactionId - val updatedBy by NexusBankTransactionEntity optionalReferencedOn NexusBankTransactionsTable.updatedBy - - /** - * It is responsibility of the caller to insert only valid - * JSON into the database, and therefore provide error management - * when calling the two helpers below. - */ - - inline fun <reified T> parseDetailsIntoObject(): T { - val mapper = jacksonObjectMapper() - return mapper.readValue(this.transactionJson, T::class.java) - } - fun parseDetailsIntoObject(): JsonNode { - val mapper = jacksonObjectMapper() - return mapper.readTree(this.transactionJson) - } -} - -// Represents a prepared payment. -object PaymentInitiationsTable : LongIdTable() { - /** - * Bank account that wants to initiate the payment. - */ - val bankAccount = reference("bankAccount", NexusBankAccountsTable) - val preparationDate = long("preparationDate") - val submissionDate = long("submissionDate").nullable() - val sum = text("sum") // the amount to transfer. - val currency = text("currency") - val endToEndId = text("endToEndId") - val paymentInformationId = text("paymentInformationId") - val instructionId = text("instructionId") - val subject = text("subject") - val creditorIban = text("creditorIban") - val creditorBic = text("creditorBic").nullable() - val creditorName = text("creditorName") - val submitted = bool("submitted").default(false) - var invalid = bool("invalid").nullable() - val messageId = text("messageId") - /** - * Points at the raw transaction witnessing that this - * initiated payment was successfully performed. - */ - val confirmationTransaction = reference("rawConfirmation", NexusBankTransactionsTable).nullable() -} - -class PaymentInitiationEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<PaymentInitiationEntity>(PaymentInitiationsTable) - - var bankAccount by NexusBankAccountEntity referencedOn PaymentInitiationsTable.bankAccount - var preparationDate by PaymentInitiationsTable.preparationDate - var submissionDate by PaymentInitiationsTable.submissionDate - var sum by PaymentInitiationsTable.sum - var currency by PaymentInitiationsTable.currency - var endToEndId by PaymentInitiationsTable.endToEndId - var subject by PaymentInitiationsTable.subject - var creditorIban by PaymentInitiationsTable.creditorIban - var creditorBic by PaymentInitiationsTable.creditorBic - var creditorName by PaymentInitiationsTable.creditorName - var submitted by PaymentInitiationsTable.submitted - var invalid by PaymentInitiationsTable.invalid - var paymentInformationId by PaymentInitiationsTable.paymentInformationId - var instructionId by PaymentInitiationsTable.instructionId - var messageId by PaymentInitiationsTable.messageId - var confirmationTransaction by NexusBankTransactionEntity optionalReferencedOn PaymentInitiationsTable.confirmationTransaction -} - -/** - * This table contains the bank accounts that are offered by the bank. - * The bank account label (as assigned by the bank) is the primary key. - */ -object OfferedBankAccountsTable : LongIdTable() { - val offeredAccountId = text("offeredAccountId") - val bankConnection = reference("bankConnection", NexusBankConnectionsTable) - val iban = text("iban") - val bankCode = text("bankCode") - val accountHolder = text("holderName") - - // column below gets defined only WHEN the user imports the bank account. - val imported = reference("imported", NexusBankAccountsTable).nullable() - - init { - uniqueIndex(offeredAccountId, bankConnection) - } -} - -class OfferedBankAccountEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<OfferedBankAccountEntity>(OfferedBankAccountsTable) - - var offeredAccountId by OfferedBankAccountsTable.offeredAccountId - var bankConnection by NexusBankConnectionEntity referencedOn OfferedBankAccountsTable.bankConnection - var accountHolder by OfferedBankAccountsTable.accountHolder - var iban by OfferedBankAccountsTable.iban - var bankCode by OfferedBankAccountsTable.bankCode - var imported by NexusBankAccountEntity optionalReferencedOn OfferedBankAccountsTable.imported -} - -/** - * This table holds triples of <iban, bic, holder name>. - * FIXME(dold): Allow other account and bank identifications than IBAN and BIC - */ -object NexusBankAccountsTable : LongIdTable() { - val bankAccountName = text("bankAccountId").uniqueIndex() - val accountHolder = text("accountHolder") - val iban = text("iban") - val bankCode = text("bankCode") - val defaultBankConnection = reference("defaultBankConnection", NexusBankConnectionsTable).nullable() - val lastStatementCreationTimestamp = long("lastStatementCreationTimestamp").nullable() - val lastReportCreationTimestamp = long("lastReportCreationTimestamp").nullable() - val lastNotificationCreationTimestamp = long("lastNotificationCreationTimestamp").nullable() - // Highest bank message ID that this bank account is aware of. - val highestSeenBankMessageSerialId = long("highestSeenBankMessageSerialId") - val pain001Counter = long("pain001counter").default(1) -} - -class NexusBankAccountEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusBankAccountEntity>(NexusBankAccountsTable) { - fun findByName(name: String): NexusBankAccountEntity? { - return find { NexusBankAccountsTable.bankAccountName eq name }.firstOrNull() - } - } - - var bankAccountName by NexusBankAccountsTable.bankAccountName - var accountHolder by NexusBankAccountsTable.accountHolder - var iban by NexusBankAccountsTable.iban - var bankCode by NexusBankAccountsTable.bankCode - var defaultBankConnection by NexusBankConnectionEntity optionalReferencedOn NexusBankAccountsTable.defaultBankConnection - var highestSeenBankMessageSerialId by NexusBankAccountsTable.highestSeenBankMessageSerialId - var pain001Counter by NexusBankAccountsTable.pain001Counter - var lastStatementCreationTimestamp by NexusBankAccountsTable.lastStatementCreationTimestamp - var lastReportCreationTimestamp by NexusBankAccountsTable.lastReportCreationTimestamp - var lastNotificationCreationTimestamp by NexusBankAccountsTable.lastNotificationCreationTimestamp -} - -object NexusEbicsSubscribersTable : LongIdTable() { - val ebicsURL = text("ebicsURL") - val hostID = text("hostID") - val partnerID = text("partnerID") - val userID = text("userID") - val systemID = text("systemID").nullable() - val signaturePrivateKey = blob("signaturePrivateKey") - val encryptionPrivateKey = blob("encryptionPrivateKey") - val authenticationPrivateKey = blob("authenticationPrivateKey") - val bankEncryptionPublicKey = blob("bankEncryptionPublicKey").nullable() - val bankAuthenticationPublicKey = blob("bankAuthenticationPublicKey").nullable() - val nexusBankConnection = reference("nexusBankConnection", NexusBankConnectionsTable) - val ebicsIniState = enumerationByName("ebicsIniState", 16, EbicsInitState::class) - val ebicsHiaState = enumerationByName("ebicsHiaState", 16, EbicsInitState::class) -} - -class EbicsSubscriberEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<EbicsSubscriberEntity>(NexusEbicsSubscribersTable) - - var ebicsURL by NexusEbicsSubscribersTable.ebicsURL - var hostID by NexusEbicsSubscribersTable.hostID - var partnerID by NexusEbicsSubscribersTable.partnerID - var userID by NexusEbicsSubscribersTable.userID - var systemID by NexusEbicsSubscribersTable.systemID - var signaturePrivateKey by NexusEbicsSubscribersTable.signaturePrivateKey - var encryptionPrivateKey by NexusEbicsSubscribersTable.encryptionPrivateKey - var authenticationPrivateKey by NexusEbicsSubscribersTable.authenticationPrivateKey - var bankEncryptionPublicKey by NexusEbicsSubscribersTable.bankEncryptionPublicKey - var bankAuthenticationPublicKey by NexusEbicsSubscribersTable.bankAuthenticationPublicKey - var nexusBankConnection by NexusBankConnectionEntity referencedOn NexusEbicsSubscribersTable.nexusBankConnection - var ebicsIniState by NexusEbicsSubscribersTable.ebicsIniState - var ebicsHiaState by NexusEbicsSubscribersTable.ebicsHiaState -} - -object NexusUsersTable : LongIdTable() { - val username = text("username") - val passwordHash = text("password") - val superuser = bool("superuser") -} - -class NexusUserEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusUserEntity>(NexusUsersTable) - - var username by NexusUsersTable.username - var passwordHash by NexusUsersTable.passwordHash - var superuser by NexusUsersTable.superuser -} - -object NexusBankConnectionsTable : LongIdTable() { - val connectionId = text("connectionId") - val type = text("type") - val dialect = text("dialect").nullable() - val owner = reference("user", NexusUsersTable) -} - -class NexusBankConnectionEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusBankConnectionEntity>(NexusBankConnectionsTable) { - fun findByName(name: String): NexusBankConnectionEntity? { - return find { NexusBankConnectionsTable.connectionId eq name }.firstOrNull() - } - } - - var connectionId by NexusBankConnectionsTable.connectionId - var type by NexusBankConnectionsTable.type - var dialect by NexusBankConnectionsTable.dialect - var owner by NexusUserEntity referencedOn NexusBankConnectionsTable.owner -} - -object FacadesTable : LongIdTable() { - val facadeName = text("facadeName") - val type = text("type") - val creator = reference("creator", NexusUsersTable) - init { uniqueIndex(facadeName) } -} - -class FacadeEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<FacadeEntity>(FacadesTable) { - fun findByName(name: String): FacadeEntity? { - return find { FacadesTable.facadeName eq name}.firstOrNull() - } - } - var facadeName by FacadesTable.facadeName - var type by FacadesTable.type - var creator by NexusUserEntity referencedOn FacadesTable.creator -} - -object FacadeStateTable : LongIdTable() { - val bankAccount = text("bankAccount") - val bankConnection = text("bankConnection") - val currency = text("currency") - - // "statement", "report", "notification" - val reserveTransferLevel = text("reserveTransferLevel") - val facade = reference("facade", FacadesTable, onDelete = ReferenceOption.CASCADE) - - /** - * Highest ID seen in the raw transactions table. - */ - val highestSeenMsgSerialId = long("highestSeenMessageSerialId").default(0) -} - -class FacadeStateEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<FacadeStateEntity>(FacadeStateTable) - - var bankAccount by FacadeStateTable.bankAccount - var bankConnection by FacadeStateTable.bankConnection - var currency by FacadeStateTable.currency - - /** - * "statement", "report", "notification" - */ - var reserveTransferLevel by FacadeStateTable.reserveTransferLevel - var facade by FacadeEntity referencedOn FacadeStateTable.facade - var highestSeenMessageSerialId by FacadeStateTable.highestSeenMsgSerialId -} - -object NexusScheduledTasksTable : LongIdTable() { - val resourceType = text("resourceType") - val resourceId = text("resourceId") - val taskName = text("taskName") - val taskType = text("taskType") - val taskCronspec = text("taskCronspec") - val taskParams = text("taskParams") - val nextScheduledExecutionSec = long("nextScheduledExecutionSec").nullable() - val prevScheduledExecutionSec = long("lastScheduledExecutionSec").nullable() -} - -class NexusScheduledTaskEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusScheduledTaskEntity>(NexusScheduledTasksTable) - - var resourceType by NexusScheduledTasksTable.resourceType - var resourceId by NexusScheduledTasksTable.resourceId - var taskName by NexusScheduledTasksTable.taskName - var taskType by NexusScheduledTasksTable.taskType - var taskCronspec by NexusScheduledTasksTable.taskCronspec - var taskParams by NexusScheduledTasksTable.taskParams - var nextScheduledExecutionSec by NexusScheduledTasksTable.nextScheduledExecutionSec - var prevScheduledExecutionSec by NexusScheduledTasksTable.prevScheduledExecutionSec -} - -/** - * Generic permissions table that determines access of a subject - * identified by (subjectType, subjectName) to a resource (resourceType, resourceId). - * - * Subjects are typically of type "user", but this may change in the future. - */ -object NexusPermissionsTable : LongIdTable() { - val resourceType = text("resourceType") - val resourceId = text("resourceId") - val subjectType = text("subjectType") - val subjectId = text("subjectName") - val permissionName = text("permissionName") - - init { uniqueIndex(resourceType, resourceId, subjectType, subjectId, permissionName) } -} - -class NexusPermissionEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<NexusPermissionEntity>(NexusPermissionsTable) - - var resourceType by NexusPermissionsTable.resourceType - var resourceId by NexusPermissionsTable.resourceId - var subjectType by NexusPermissionsTable.subjectType - var subjectId by NexusPermissionsTable.subjectId - var permissionName by NexusPermissionsTable.permissionName -} - -fun dbDropTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - val ret = execCommand( - listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "nexus", - "-r" - ), - throwIfFails = false - ) - if (ret != 0) - logger.warn("Dropping the nexus tables failed. Was the DB filled before?") - return - } - transaction { - SchemaUtils.drop( - NexusUsersTable, - XLibeufinBankUsersTable, - PaymentInitiationsTable, - NexusEbicsSubscribersTable, - NexusBankAccountsTable, - NexusBankTransactionsTable, - TalerIncomingPaymentsTable, - TalerRequestedPaymentsTable, - TalerInvalidIncomingPaymentsTable, - NexusBankConnectionsTable, - NexusBankMessagesTable, - NexusBankBalancesTable, - FacadesTable, - FacadeStateTable, - NexusScheduledTasksTable, - OfferedBankAccountsTable, - NexusPermissionsTable, - AnastasisIncomingPaymentsTable - ) - } -} - -fun dbCreateTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - execCommand(listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "nexus" - )) - return - } - // Still using the legacy way for other DBMSs, like SQLite. - transaction { - SchemaUtils.create( - XLibeufinBankUsersTable, - NexusScheduledTasksTable, - NexusUsersTable, - PaymentInitiationsTable, - NexusEbicsSubscribersTable, - NexusBankAccountsTable, - NexusBankBalancesTable, - NexusBankTransactionsTable, - AnastasisIncomingPaymentsTable, - TalerIncomingPaymentsTable, - TalerRequestedPaymentsTable, - FacadeStateTable, - TalerInvalidIncomingPaymentsTable, - NexusBankConnectionsTable, - NexusBankMessagesTable, - FacadesTable, - OfferedBankAccountsTable, - NexusPermissionsTable - ) - } -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB_helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DB_helpers.kt deleted file mode 100644 index b39a05f2..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/DB_helpers.kt +++ /dev/null @@ -1,123 +0,0 @@ -package tech.libeufin.nexus - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.http.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.server.GetTransactionsParams -import tech.libeufin.nexus.server.Pain001Data -import tech.libeufin.util.notFound -import java.time.Instant - -fun getBankAccount(label: String): NexusBankAccountEntity { - val maybeBankAccount = transaction { - NexusBankAccountEntity.findByName(label) - } - return maybeBankAccount ?: - throw NexusError( - HttpStatusCode.NotFound, - "Account $label not found" - ) -} - -/** - * Queries the database according to the GET /transactions - * parameters. - */ -fun getIngestedTransactions(params: GetTransactionsParams): List<JsonNode> = - transaction { - val bankAccount = getBankAccount(params.bankAccountId) - val maybeResult = NexusBankTransactionEntity.find { - NexusBankTransactionsTable.bankAccount eq bankAccount.id.value and ( - NexusBankTransactionsTable.id greaterEq params.startIndex - ) - }.sortedBy { it.id.value }.take(params.resultSize.toInt()) // Smallest index (= earliest transaction) first - // Converting the result to the HTTP response type. - maybeResult.map { - val element: ObjectNode = jacksonObjectMapper().createObjectNode() - element.put("index", it.id.value.toString()) - val txObj: JsonNode = jacksonObjectMapper().readTree(it.transactionJson) - element.set<JsonNode>("camtData", txObj) - return@map element - } - } - -// Gets connection or throws. -fun getBankConnection(connId: String): NexusBankConnectionEntity { - val maybeConn = transaction { - NexusBankConnectionEntity.find { - NexusBankConnectionsTable.connectionId eq connId - }.firstOrNull() - } - if (maybeConn == null) throw notFound("Bank connection $connId not found") - return maybeConn -} - -/** - * Retrieve payment initiation from database, raising exception if not found. - */ -fun getPaymentInitiation(uuid: Long): PaymentInitiationEntity { - return transaction { - PaymentInitiationEntity.findById(uuid) - } ?: throw NexusError( - HttpStatusCode.NotFound, - "Payment '$uuid' not found" - ) -} - -/** - * Gets a prepared payment starting from its 'payment information id'. - * Note: although the terminology comes from CaMt, a 'payment information id' - * is indeed any UID that identifies the payment. For this reason, also - * the x-libeufin-bank logic uses this helper. - * - * Returns the prepared payment, or null if that's not found. Not throwing - * any exception because the null case is common: not every transaction being - * processed by Neuxs was prepared/initiated here; incoming transactions are - * one example. - */ -fun getPaymentInitiation(pmtInfId: String): PaymentInitiationEntity? = - transaction { - PaymentInitiationEntity.find( - PaymentInitiationsTable.paymentInformationId.eq(pmtInfId) - ).firstOrNull() - } - -/** - * Insert one row in the database, and leaves it marked as non-submitted. - * @param debtorAccount the mnemonic id assigned by the bank to one bank - * account of the subscriber that is creating the pain entity. In this case, - * it will be the account whose money will pay the wire transfer being defined - * by this pain document. - */ -fun addPaymentInitiation( - paymentData: Pain001Data, - debtorAccount: NexusBankAccountEntity -): PaymentInitiationEntity { - return transaction { - - val now = Instant.now().toEpochMilli() - val nowHex = now.toString(16) - val painCounter = debtorAccount.pain001Counter++ - val painHex = painCounter.toString(16) - val acctHex = debtorAccount.id.value.toString(16) - - PaymentInitiationEntity.new { - currency = paymentData.currency - bankAccount = debtorAccount - subject = paymentData.subject - sum = paymentData.sum - creditorName = paymentData.creditorName - creditorBic = paymentData.creditorBic - creditorIban = paymentData.creditorIban - preparationDate = now - endToEndId = paymentData.endToEndId ?: "leuf-e-$nowHex-$painHex-$acctHex" - messageId = "leuf-mp1-$nowHex-$painHex-$acctHex" - paymentInformationId = "leuf-p-$nowHex-$painHex-$acctHex" - instructionId = "leuf-i-$nowHex-$painHex-$acctHex" - } - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt deleted file mode 100644 index 0cc340e8..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Errors.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus - -import io.ktor.http.HttpStatusCode -import tech.libeufin.util.LibeufinErrorCode - -data class NexusError( - val statusCode: HttpStatusCode, - val reason: String, - val code: LibeufinErrorCode? = null - ) : - Exception("$reason (HTTP status $statusCode)") - -fun NexusAssert(condition: Boolean, errorMsg: String): Boolean { - if (! condition) throw NexusError(HttpStatusCode.InternalServerError, errorMsg) - return true -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt deleted file mode 100644 index 76234fbe..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt +++ /dev/null @@ -1,121 +0,0 @@ -package tech.libeufin.nexus - -import CamtBankAccountEntry -import EntryStatus -import TransactionDetails -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.http.* -import org.jetbrains.exposed.dao.flushCache -import org.jetbrains.exposed.sql.SortOrder -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.TransactionManager -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.server.NexusFacadeType - -fun getFacadeState(fcid: String): FacadeStateEntity { - return transaction { - val facade = FacadeEntity.find { - FacadesTable.facadeName eq fcid - }.firstOrNull() ?: throw NexusError( - HttpStatusCode.NotFound, - "Could not find facade '${fcid}'" - ) - FacadeStateEntity.find { - FacadeStateTable.facade eq facade.id.value - }.firstOrNull() ?: throw NexusError( - HttpStatusCode.NotFound, - "Could not find any state for facade: $fcid" - ) - } -} - -fun getFacadeBankAccount(fcid: String): NexusBankAccountEntity { - return transaction { - val facadeState = getFacadeState(fcid) - NexusBankAccountEntity.findByName(facadeState.bankAccount) ?: throw NexusError( - HttpStatusCode.NotFound, - "The facade: $fcid doesn't manage bank account: ${facadeState.bankAccount}" - ) - } -} - -/** - * Ingests transactions for those facades accounting for bankAccountId. - * 'incomingFilterCb' decides whether the facade accepts the payment; - * if not, refundCb prepares a refund. The 'txStatus' parameter decides - * at which state one transaction deserve to fuel Taler transactions. BOOK - * is conservative, and with some banks the delay can be significant. PNDG - * instead reacts faster, but risks that one transaction gets undone by the - * bank and never reach the BOOK state; this would mean a loss and/or admin - * burden. - */ -fun ingestFacadeTransactions( - bankAccountId: String, - facadeType: NexusFacadeType, - incomingFilterCb: ((NexusBankTransactionEntity, TransactionDetails) -> Unit)?, - refundCb: ((NexusBankAccountEntity, Long) -> Unit)?, - txStatus: EntryStatus = EntryStatus.BOOK -) { - fun ingest(bankAccount: NexusBankAccountEntity, facade: FacadeEntity) { - logger.debug( - "Ingesting transactions for $facadeType facade ${facade.id.value}," + - " and bank account: ${bankAccount.bankAccountName}" - ) - val facadeState = getFacadeState(facade.facadeName) - var lastId = facadeState.highestSeenMessageSerialId - NexusBankTransactionEntity.find { - /** Those with "our" bank account involved */ - NexusBankTransactionsTable.bankAccount eq bankAccount.id.value and - /** Those that are booked */ - (NexusBankTransactionsTable.status eq txStatus) and - /** Those that came later than the latest processed payment */ - (NexusBankTransactionsTable.id.greater(lastId)) - }.orderBy(Pair(NexusBankTransactionsTable.id, SortOrder.ASC)).forEach { - // Incoming payment. - val tx = jacksonObjectMapper().readValue( - it.transactionJson, - CamtBankAccountEntry::class.java - ) - /** - * Need transformer from "JSON tx" to TransactionDetails?. - */ - val details: TransactionDetails? = tx.batches?.get(0)?.batchTransactions?.get(0)?.details - if (details == null) { - logger.warn("A void money movement (${tx.accountServicerRef}) made it through the ingestion: VERY strange") - return@forEach - } - when (tx.creditDebitIndicator) { - CreditDebitIndicator.CRDT -> { - if (incomingFilterCb != null) { - incomingFilterCb( - it, // payment DB object - details // wire transfer details - ) - } - } - else -> Unit - } - lastId = it.id.value - } - try { - if (refundCb != null) { - refundCb( - bankAccount, - facadeState.highestSeenMessageSerialId - ) - } - } catch (e: Exception) { - logger.warn("Sending refund payment failed: ${e.message}") - } - facadeState.highestSeenMessageSerialId = lastId - } - // invoke ingestion for all the facades - transaction { - FacadeEntity.find { FacadesTable.type eq facadeType.facadeType }.forEach { - val facadeBankAccount = getFacadeBankAccount(it.facadeName) - if (facadeBankAccount.bankAccountName == bankAccountId) - ingest(facadeBankAccount, it) - flushCache() - } - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/JsonLiterals.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/JsonLiterals.kt deleted file mode 100644 index cd89e011..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/JsonLiterals.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2021 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus - -import com.fasterxml.jackson.databind.node.ObjectNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper - -class JsonObjectMaker(val obj: ObjectNode) { - fun prop(key: String, value: String?) { - obj.put(key, value) - } - fun prop(key: String, value: Long?) { - obj.put(key, value) - } - fun prop(key: String, value: Int?) { - obj.put(key, value) - } -} - -fun makeJsonObject(f: JsonObjectMaker.() -> Unit): ObjectNode { - val mapper = jacksonObjectMapper() - val obj = mapper.createObjectNode() - f(JsonObjectMaker(obj)) - return obj -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt deleted file mode 100644 index 5b453530..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus - -import com.github.ajalt.clikt.output.CliktHelpFormatter -import com.github.ajalt.clikt.parameters.arguments.argument -import org.jetbrains.exposed.sql.transactions.transaction -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.CryptoUtil.hashpw -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.github.ajalt.clikt.parameters.types.int -import execThrowableOrTerminate -import com.github.ajalt.clikt.core.* -import com.github.ajalt.clikt.parameters.options.* -import io.ktor.server.application.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import startServer -import tech.libeufin.nexus.iso20022.NexusPaymentInitiationData -import tech.libeufin.nexus.iso20022.createPain001document -import tech.libeufin.nexus.iso20022.parseCamtMessage -import tech.libeufin.nexus.server.EbicsDialects -import tech.libeufin.nexus.server.nexusApp -import tech.libeufin.util.* -import java.io.File -import kotlin.system.exitProcess - -val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") -const val NEXUS_DB_ENV_VAR_NAME = "LIBEUFIN_NEXUS_DB_CONNECTION" - -class NexusCommand : CliktCommand() { - init { versionOption(getVersion()) } - override fun run() = Unit -} - -class Serve : CliktCommand("Run nexus HTTP server") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - private val localhostOnly by option( - "--localhost-only", - help = "Bind only to localhost. On all interfaces otherwise" - ).flag("--no-localhost-only", default = true) - private val ipv4Only by option( - "--ipv4-only", - help = "Bind only to ipv4" - ).flag(default = false) - // Prevent IPv6 mode: - // private val host by option().default("127.0.0.1") - private val port by option().int().default(5001) - private val withUnixSocket by option( - help = "Bind the Sandbox to the Unix domain socket at PATH. Overrides" + - " --port, when both are given", metavar = "PATH" - ) - private val logLevel by option() - override fun run() { - setLogLevel(logLevel) - execThrowableOrTerminate { dbCreateTables(getDbConnFromEnv(NEXUS_DB_ENV_VAR_NAME)) } - CoroutineScope(Dispatchers.IO).launch(fallback) { whileTrueOperationScheduler() } - if (withUnixSocket != null) { - startServer( - withUnixSocket!!, - app = nexusApp - ) - exitProcess(0) - } - logger.info("Starting Nexus on port ${this.port}") - startServerWithIPv4Fallback( - options = StartServerOptions( - ipv4OnlyOpt = this.ipv4Only, - localhostOnlyOpt = this.localhostOnly, - portOpt = this.port - ), - app = nexusApp - ) - } -} - -/** - * This command purpose is to let the user then _manually_ - * tune the pain.001, to upload it to online verifiers. - */ -class GenPain : CliktCommand( - "Generate random pain.001 document for 'pf' dialect, printing to STDOUT." -) { - private val logLevel by option( - help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug', 'trace', 'all'" - ) - private val dialect by option( - help = "EBICS dialect using the pain.001 being generated. Defaults to 'pf' (PostFinance)", - ).default("pf") - override fun run() { - setLogLevel(logLevel) - val pain001 = createPain001document( - NexusPaymentInitiationData( - debtorIban = "CH0889144371988976754", - debtorBic = "POFICHBEXXX", - debtorName = "Sample Debtor Name", - currency = "CHF", - amount = "5.00", - creditorIban = "CH9789144829733648596", - creditorName = "Sample Creditor Name", - creditorBic = "POFICHBEXXX", - paymentInformationId = "8aae7a2ded2f", - preparationTimestamp = getNow().toInstant().toEpochMilli(), - subject = "Unstructured remittance information", - instructionId = "InstructionId", - endToEndId = "71cfbdaf901f", - messageId = "2a16b35ed69c" - ), - dialect = this.dialect - ) - println(pain001) - } -} -class ParseCamt : CliktCommand("Parse camt.05x file, outputs JSON in libEufin internal representation.") { - private val logLevel by option( - help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug', 'trace', 'all'" - ) - private val withPfDialect by option( - help = "Set the dialect to 'pf' (PostFinance). If not given, it defaults to GLS." - ).flag(default = false) - private val filename by argument("FILENAME", "File in CAMT format") - override fun run() { - setLogLevel(logLevel) - val camtText = File(filename).readText(Charsets.UTF_8) - val dialect = if (withPfDialect) EbicsDialects.POSTFINANCE.dialectName else null - val res = parseCamtMessage(XMLUtil.parseStringIntoDom(camtText), dialect) - println(jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(res)) - } -} - -class ResetTables : CliktCommand("Drop all the tables from the database") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - override fun run() { - val dbConnString = getDbConnFromEnv(NEXUS_DB_ENV_VAR_NAME) - execThrowableOrTerminate { - dbDropTables(dbConnString) - dbCreateTables(dbConnString) - } - } -} - -class Superuser : CliktCommand("Add superuser or change pw") { - private val username by argument("USERNAME", "User name of superuser") - private val password by option().prompt(requireConfirmation = true, hideInput = true) - override fun run() { - execThrowableOrTerminate { - dbCreateTables(getDbConnFromEnv(NEXUS_DB_ENV_VAR_NAME)) - } - transaction { - val hashedPw = hashpw(password) - val user = NexusUserEntity.find { NexusUsersTable.username eq username }.firstOrNull() - if (user == null) { - NexusUserEntity.new { - this.username = this@Superuser.username - this.passwordHash = hashedPw - this.superuser = true - } - } else { - if (!user.superuser) { - System.err.println("Can only change password for superuser with this command.") - throw ProgramResult(1) - } - user.passwordHash = hashedPw - } - } - } -} - -fun main(args: Array<String>) { - NexusCommand() - .subcommands(Serve(), Superuser(), ParseCamt(), ResetTables(), GenPain()) - .main(args) -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt deleted file mode 100644 index 2e67eb3b..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Scheduling.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus - -import com.cronutils.model.definition.CronDefinitionBuilder -import com.cronutils.model.time.ExecutionTime -import com.cronutils.parser.CronParser -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.HttpClient -import kotlinx.coroutines.* -import kotlinx.coroutines.time.delay -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions -import tech.libeufin.nexus.bankaccount.submitAllPaymentInitiations -import tech.libeufin.nexus.server.FetchSpecJson -import tech.libeufin.nexus.server.client -import java.lang.IllegalArgumentException -import java.time.Duration -import java.time.Instant -import java.time.ZonedDateTime -import kotlin.system.exitProcess - -private data class TaskSchedule( - val taskId: Long, - val name: String, - val type: String, - val resourceType: String, - val resourceId: String, - val params: String -) - -private suspend fun runTask(client: HttpClient, sched: TaskSchedule) { - logger.info("running task $sched") - try { - when (sched.resourceType) { - "bank-account" -> { - when (sched.type) { - // Downloads and ingests the payment records from the bank. - "fetch" -> { - val fetchSpec = jacksonObjectMapper().readValue(sched.params, FetchSpecJson::class.java) - fetchBankAccountTransactions(client, fetchSpec, sched.resourceId) - /** - * NOTE: the previous operation COULD have had problems but that - * is tolerated because the communication with the backend CAN be - * unreliable. As of logging: not doing it here twice, since every - * error should already have been logged when it originated. - */ - } - // Submits the payment preparations that are found in the database. - "submit" -> { - submitAllPaymentInitiations(client, sched.resourceId) - } - else -> { - logger.error("task type ${sched.type} not supported") - } - } - } - else -> logger.error("task on resource ${sched.resourceType} not supported") - } - } - catch (e: Exception) { - logger.error("Exception during task $sched: ${e.message})") - /** - * Not exiting the process since the error can be temporary: - * name resolution problem, Nexus connectivity problem, ... - */ - } - catch (so: StackOverflowError) { - logger.error(so.stackTraceToString()) - exitProcess(1) - } -} - -object NexusCron { - val parser = run { - val cronDefinition = - CronDefinitionBuilder.defineCron() - .withSeconds().and() - .withMinutes().and() - .withHours().and() - .withDayOfMonth().optional().and() - .withMonth().optional().and() - .withDayOfWeek().optional() - .and().instance() - CronParser(cronDefinition) - } -} - -// Fails whenever an unmanaged Throwable reaches the root coroutine. -val fallback = CoroutineExceptionHandler { _, err -> - logger.error(err.stackTraceToString()) - exitProcess(1) -} - -// Internal routine ultimately scheduling the tasks. -private suspend fun operationScheduler(httpClient: HttpClient) { - // First, assign next execution time stamps to all tasks that need them - transaction { - NexusScheduledTaskEntity.find { - NexusScheduledTasksTable.nextScheduledExecutionSec.isNull() - }.forEach { - val cron = try { NexusCron.parser.parse(it.taskCronspec) } - catch (e: IllegalArgumentException) { - logger.error("invalid cronspec in schedule ${it.resourceType}/${it.resourceId}/${it.taskName}") - return@forEach - } - val zonedNow = ZonedDateTime.now() - val parsedCron = ExecutionTime.forCron(cron) - val next = parsedCron.nextExecution(zonedNow) - logger.info("Scheduling task ${it.taskName} at $next (now is $zonedNow).") - it.nextScheduledExecutionSec = next.get().toEpochSecond() - } - } - val nowSec = Instant.now().epochSecond - // Second, find tasks that are due - val dueTasks = transaction { - NexusScheduledTaskEntity.find { - NexusScheduledTasksTable.nextScheduledExecutionSec lessEq nowSec - }.map { - TaskSchedule(it.id.value, it.taskName, it.taskType, it.resourceType, it.resourceId, it.taskParams) - } - } // Execute those due tasks and reset to null the next execution time. - dueTasks.forEach { - runTask(httpClient, it) - transaction { - val t = NexusScheduledTaskEntity.findById(it.taskId) - if (t != null) { - // Reset next scheduled execution - t.nextScheduledExecutionSec = null - t.prevScheduledExecutionSec = nowSec - } - } - } - -} - -// Alternative scheduler based on Java Timer, but same perf. as the while-true one. -/* -private val javaTimer = Timer() -suspend fun javaTimerOperationScheduler(httpClient: HttpClient) { - operationScheduler(httpClient) - javaTimer.schedule( - delay = 1000L, - action = { runBlocking { javaTimerOperationScheduler(httpClient) } } - ) -} -*/ - -suspend fun whileTrueOperationScheduler(httpClient: HttpClient = client) { - while (true) { - operationScheduler(httpClient) - // Wait the shortest period that the cron spec would allow. - delay(Duration.ofSeconds(1)) - } -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt deleted file mode 100644 index 19113985..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt +++ /dev/null @@ -1,710 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus - -import CamtBankAccountEntry -import TransactionDetails -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.call -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.content.TextContent -import io.ktor.http.* -import io.ktor.server.request.receive -import io.ktor.server.response.* -import io.ktor.server.routing.Route -import io.ktor.server.routing.get -import io.ktor.server.routing.post -import io.ktor.server.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import org.jetbrains.exposed.dao.Entity -import org.jetbrains.exposed.dao.id.IdTable -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.TransactionManager -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.server.* -import tech.libeufin.nexus.xlibeufinbank.ingestXLibeufinBankMessage -import tech.libeufin.util.* -import java.net.URL -import kotlin.math.abs -import kotlin.math.min - -/** - * Request body for "$TWG_BASE_URL/transfer". - */ -data class TalerTransferRequest( - val request_uid: String, - val amount: String, - val exchange_base_url: String, - val wtid: String, - val credit_account: String // payto://-format -) - -data class TalerTransferResponse( - /** - * Point in time when Nexus put the payment instruction into the database. - */ - val timestamp: GnunetTimestamp, - val row_id: Long -) - -/** - * History accounting data structures, typically - * used to build JSON responses. - */ -data class TalerIncomingBankTransaction( - val row_id: Long, - val date: GnunetTimestamp, // timestamp - val amount: String, - val debit_account: String, - val reserve_pub: String -) - -data class TalerIncomingHistory( - var incoming_transactions: MutableList<TalerIncomingBankTransaction> = mutableListOf(), - val credit_account: String -) - -data class TalerOutgoingBankTransaction( - val row_id: Long, - val date: GnunetTimestamp, // timestamp - val amount: String, - val credit_account: String, // payto form, - val debit_account: String, - val wtid: String, - val exchange_base_url: String -) - -data class TalerOutgoingHistory( - var outgoing_transactions: MutableList<TalerOutgoingBankTransaction> = mutableListOf() -) - -data class GnunetTimestamp(val t_s: Long) - -/** - * Sort query results in descending order for negative deltas, and ascending otherwise. - */ -fun <T : Entity<Long>> SizedIterable<T>.orderTaler(delta: Int): List<T> { - return if (delta < 0) { - this.sortedByDescending { it.id } - } else { - this.sortedBy { it.id } - } -} - -/** Builds the comparison operator for history entries based on the sign of 'delta' */ -fun getComparisonOperator(delta: Int, start: Long, table: IdTable<Long>): Op<Boolean> { - return if (delta < 0) { - Expression.build { - table.id less start - } - } else { - Expression.build { - table.id greater start - } - } -} - -fun expectLong(param: String?, allowNegative: Boolean = false): Long { - if (param == null) throw badRequest("'$param' is not Long") - val maybeLong = try { param.toLong() } catch (e: Exception) { - throw badRequest("'$param' is not Long") - } - if (!allowNegative && maybeLong < 0) - throw badRequest("Not expecting a negative: $param") - return maybeLong -} - -// Helper handling 'start' being optional and its dependence on 'delta'. -fun handleStartArgument(start: String?, delta: Int): Long { - if (start == null) { - if (delta >= 0) return -1 - return Long.MAX_VALUE - } - return expectLong(start) -} - -/** - * The Taler layer cannot rely on the ktor-internal JSON-converter/responder, - * because this one adds a "charset" extra information in the Content-Type header - * that makes the GNUnet JSON parser unhappy. - * - * The workaround is to explicitly convert the 'data class'-object into a JSON - * string (what this function does), and use the simpler respondText method. - */ -fun customConverter(body: Any): String { - return jacksonObjectMapper().writeValueAsString(body) -} - -// Handle a Taler Wire Gateway /transfer request. -private suspend fun talerTransfer(call: ApplicationCall) { - val transferRequest = call.receive<TalerTransferRequest>() - val amountObj = parseAmount(transferRequest.amount) - // FIXME: Right now we only parse the credit_account, should we also validate that it matches our account info? - // FIXME, another parse happens below; is this really useful here? - parsePayto(transferRequest.credit_account) - val facadeId = expectNonNull(call.parameters["fcid"]) - val opaqueRowId = transaction { - call.request.requirePermission(PermissionQuery("facade", facadeId, "facade.talerwiregateway.transfer")) - val facade = FacadeEntity.find { FacadesTable.facadeName eq facadeId }.firstOrNull() ?: throw NexusError( - HttpStatusCode.NotFound, - "Could not find facade '${facadeId}'" - ) - val creditorData = parsePayto(transferRequest.credit_account) - /** Checking the UID has the desired characteristics */ - TalerRequestedPaymentEntity.find { - TalerRequestedPaymentsTable.requestUid eq transferRequest.request_uid - }.forEach { - if ( - (it.amount != transferRequest.amount) or - (it.creditAccount != transferRequest.exchange_base_url) or - (it.wtid != transferRequest.wtid) - ) { - throw NexusError( - HttpStatusCode.Conflict, - "This uid (${transferRequest.request_uid}) belongs to a different payment already" - ) - } - } - val exchangeBankAccount = getFacadeBankAccount(facadeId) - val paymentSubject = "${transferRequest.wtid} ${transferRequest.exchange_base_url}" - val pain001 = addPaymentInitiation( - Pain001Data( - creditorIban = creditorData.iban, - creditorBic = creditorData.bic, - creditorName = creditorData.receiverName ?: throw NexusError( - HttpStatusCode.BadRequest, "Payto did not mention account owner" - ), - subject = paymentSubject, - sum = amountObj.amount, - currency = amountObj.currency - ), - exchangeBankAccount - ) - logger.debug("Taler requests payment: ${transferRequest.wtid}") - val row = TalerRequestedPaymentEntity.new { - this.facade = facade - preparedPayment = pain001 - exchangeBaseUrl = transferRequest.exchange_base_url - requestUid = transferRequest.request_uid - amount = transferRequest.amount - wtid = transferRequest.wtid - creditAccount = transferRequest.credit_account - } - row.id.value - } - return call.respond( - TextContent( - customConverter( - TalerTransferResponse( - /** - * Normally should point to the next round where the background - * routine will send new PAIN.001 data to the bank; work in progress.. - */ - timestamp = GnunetTimestamp(System.currentTimeMillis() / 1000L), - row_id = opaqueRowId - ) - ), - ContentType.Application.Json - ) - ) -} - -// Processes new transactions and stores TWG-specific data in -fun talerFilter( - payment: NexusBankTransactionEntity, - txDtls: TransactionDetails -) { - var isInvalid = false // True when pub is invalid or duplicate. - val subject = txDtls.unstructuredRemittanceInformation ?: throw - internalServerError("Payment '${payment.accountTransactionId}' has no subject, can't extract reserve pub.") - val debtorName = txDtls.debtor?.name - if (debtorName == null) { - logger.warn("empty debtor name") - return - } - val debtorAcct = txDtls.debtorAccount - if (debtorAcct == null) { - // FIXME: Report payment, we can't even send it back - logger.warn("empty debtor account") - return - } - val debtorIban = debtorAcct.iban - if (debtorIban == null) { - // FIXME: Report payment, we can't even send it back - logger.warn("non-iban debtor account") - return - } - val debtorBic = txDtls.debtorAgent?.bic - if (debtorBic == null) { - logger.warn("Not allowing transactions missing the BIC. IBAN and name: ${debtorIban}, $debtorName") - return - } - val reservePub = extractReservePubFromSubject(subject) - if (reservePub == null) { - logger.warn("could not find reserve pub in remittance information") - TalerInvalidIncomingPaymentEntity.new { - this.payment = payment - timestampMs = System.currentTimeMillis() - } - // Will be paid back by the refund handler. - return - } - // Check if reserve_pub was used already - val maybeExist = TalerIncomingPaymentEntity.find { - TalerIncomingPaymentsTable.reservePublicKey eq reservePub - }.firstOrNull() - if (maybeExist != null) { - val msg = "Reserve pub '$reservePub' was used already" - logger.info(msg) - isInvalid = true - } - - if (!CryptoUtil.checkValidEddsaPublicKey(reservePub)) { - logger.info("invalid public key detected") - isInvalid = true - } - if (isInvalid) { - TalerInvalidIncomingPaymentEntity.new { - this.payment = payment - timestampMs = System.currentTimeMillis() - } - // Will be paid back by the refund handler. - return - } - TalerIncomingPaymentEntity.new { - this.payment = payment - reservePublicKey = reservePub - timestampMs = System.currentTimeMillis() - debtorPaytoUri = buildIbanPaytoUri( - debtorIban, - debtorBic, - debtorName - ) - } - val dbTx = TransactionManager.currentOrNull() ?: throw NexusError( - HttpStatusCode.InternalServerError, - "talerFilter(): unexpected execution out of a DB transaction" - ) - // Only supporting Postgres' NOTIFY. - if (dbTx.isPostgres()) { - val channelName = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_TALER_INCOMING, - payment.bankAccount.iban - ) - logger.debug("NOTIFYing on domain" + - " ${NotificationsChannelDomains.LIBEUFIN_TALER_INCOMING}" + - " for IBAN: ${payment.bankAccount.iban}. Resulting channel" + - " name: $channelName.") - dbTx.postgresNotify(channelName) - } -} - -fun maybeTalerRefunds(bankAccount: NexusBankAccountEntity, lastSeenId: Long) { - logger.debug( - "Searching refundable payments of account: ${bankAccount.bankAccountName}," + - " after last seen transaction id: $lastSeenId" - ) - transaction { - TalerInvalidIncomingPaymentsTable.innerJoin( - NexusBankTransactionsTable, - { NexusBankTransactionsTable.id }, - { TalerInvalidIncomingPaymentsTable.payment } - ).select { - /** - * Finds Taler-invalid incoming payments that weren't refunded - * yet and are newer than those processed along the last round. - */ - TalerInvalidIncomingPaymentsTable.refunded eq false and - (NexusBankTransactionsTable.bankAccount eq bankAccount.id.value) and - (NexusBankTransactionsTable.id greater lastSeenId) - }.forEach { - // For each of them, extracts the wire details to reuse in the refund. - val paymentData = jacksonObjectMapper().readValue( - it[NexusBankTransactionsTable.transactionJson], - CamtBankAccountEntry::class.java - ) - val batches = paymentData.batches - if (batches == null) { - logger.error( - "Empty wire details encountered in transaction with" + - " AcctSvcrRef: ${paymentData.accountServicerRef}." + - " Taler can't refund." - ) - throw NexusError( - HttpStatusCode.InternalServerError, - "Unexpected void payment, cannot refund" - ) - } - val debtorIban = batches[0].batchTransactions[0].details.debtorAccount?.iban - if (debtorIban == null) { - logger.error("Could not find a IBAN to refund in transaction (AcctSvcrRef): ${paymentData.accountServicerRef}, aborting refund") - throw NexusError(HttpStatusCode.InternalServerError, "IBAN to refund not found") - } - val debtorAgent = batches[0].batchTransactions[0].details.debtorAgent - if (debtorAgent?.bic == null) { - logger.error("Could not find the BIC of refundable IBAN at transaction (AcctSvcrRef): ${paymentData.accountServicerRef}, aborting refund") - throw NexusError(HttpStatusCode.InternalServerError, "BIC to refund not found") - } - val debtorName = batches[0].batchTransactions[0].details.debtor?.name - if (debtorName == null) { - logger.error("Could not find the owner's name of refundable IBAN at transaction (AcctSvcrRef): ${paymentData.accountServicerRef}, aborting refund") - throw NexusError(HttpStatusCode.InternalServerError, "Name to refund not found") - } - // FIXME: investigate this amount! - val amount = batches[0].batchTransactions[0].amount - NexusAssert( - it[NexusBankTransactionsTable.creditDebitIndicator] == "CRDT" && - it[NexusBankTransactionsTable.bankAccount] == bankAccount.id, - "Cannot refund an _outgoing_ payment!" - ) - - // FIXME #7116 - addPaymentInitiation( - Pain001Data( - creditorIban = debtorIban, - creditorBic = debtorAgent.bic, - creditorName = debtorName, - subject = "Taler refund of: ${batches[0].batchTransactions[0].details.unstructuredRemittanceInformation}", - sum = amount.value, - currency = amount.currency - ), - bankAccount // the Exchange bank account. - ) - logger.debug("Refund of transaction (AcctSvcrRef): ${paymentData.accountServicerRef} got prepared") - it[TalerInvalidIncomingPaymentsTable.refunded] = true - } - } -} - -/** - * Handle a /taler/history/outgoing request. - */ -private suspend fun historyOutgoing(call: ApplicationCall) { - val facadeId = expectNonNull(call.parameters["fcid"]) - call.request.requirePermission(PermissionQuery("facade", facadeId, "facade.talerwiregateway.history")) - val param = call.expectUrlParameter("delta") - val delta: Int = try { - param.toInt() - } catch (e: Exception) { - throw EbicsProtocolError(HttpStatusCode.BadRequest, "'${param}' is not Int") - } - val start: Long = handleStartArgument(call.request.queryParameters["start"], delta) - val startCmpOp = getComparisonOperator(delta, start, TalerRequestedPaymentsTable) - /* retrieve database elements */ - val history = TalerOutgoingHistory() - transaction { - /** Retrieve all the outgoing payments from the _clean Taler outgoing table_ */ - val subscriberBankAccount = getFacadeBankAccount(facadeId) - val reqPayments = mutableListOf<TalerRequestedPaymentEntity>() - val reqPaymentsWithUnconfirmed = TalerRequestedPaymentEntity.find { - startCmpOp - }.orderTaler(delta) - reqPaymentsWithUnconfirmed.forEach { - if (it.preparedPayment.confirmationTransaction != null) { - reqPayments.add(it) - } - } - if (reqPayments.isNotEmpty()) { - reqPayments.subList(0, min(abs(delta), reqPayments.size)).forEach { - history.outgoing_transactions.add( - TalerOutgoingBankTransaction( - row_id = it.id.value, - amount = it.amount, - wtid = it.wtid, - date = GnunetTimestamp(it.preparedPayment.preparationDate / 1000L), - credit_account = it.creditAccount, - debit_account = buildIbanPaytoUri( - subscriberBankAccount.iban, - subscriberBankAccount.bankCode, - subscriberBankAccount.accountHolder, - ), - exchange_base_url = it.exchangeBaseUrl - ) - ) - } - } - } - if (history.outgoing_transactions.size == 0) { - call.respond(HttpStatusCode.NoContent) - return - } - call.respond( - status = HttpStatusCode.OK, - TextContent(customConverter(history), ContentType.Application.Json) - ) -} - -// Handle a /taler-wire-gateway/history/incoming request. -private suspend fun historyIncoming(call: ApplicationCall) { - val facadeId = expectNonNull(call.parameters["fcid"]) - call.request.requirePermission( - PermissionQuery( - "facade", - facadeId, - "facade.talerwiregateway.history" - ) - ) - val longPollTimeoutPar = call.parameters["long_poll_ms"] - val longPollTimeout = if (longPollTimeoutPar != null) { - val longPollTimeoutValue = try { longPollTimeoutPar.toLong() } - catch (e: Exception) { - throw badRequest("long_poll_ms value is invalid") - } - longPollTimeoutValue - } else null - val param = call.expectUrlParameter("delta") - val delta: Int = try { param.toInt() } catch (e: Exception) { - throw EbicsProtocolError(HttpStatusCode.BadRequest, "'${param}' is not Int") - } - val start: Long = handleStartArgument(call.request.queryParameters["start"], delta) - val facadeBankAccount = getFacadeBankAccount(facadeId) - val startCmpOp = getComparisonOperator(delta, start, TalerIncomingPaymentsTable) - val listenHandle: PostgresListenHandle? = if (isPostgres() && longPollTimeout != null) { - val notificationChannelName = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_TALER_INCOMING, - facadeBankAccount.iban - ) - val handle = PostgresListenHandle(channelName = notificationChannelName) - handle.postgresListen() - handle - } else null - - /** - * NOTE: the LISTEN command MAY also go inside this transaction, - * but LISTEN uses a connection other than the one provided by the - * transaction block. More facts on the consequences are needed. - */ - var result: List<TalerIncomingPaymentEntity> = transaction { - TalerIncomingPaymentEntity.find { startCmpOp }.orderTaler(delta) - } - // The request was lucky, unlisten then. - if (result.isNotEmpty() && listenHandle != null) - listenHandle.postgresUnlisten() - - // The request was NOT lucky, wait now. - if (result.isEmpty() && listenHandle != null && longPollTimeout != null) { - logger.debug("Waiting for NOTIFY on channel ${listenHandle.channelName}," + - " with timeout: $longPollTimeoutPar ms") - val notificationArrived = coroutineScope { - async(Dispatchers.IO) { - listenHandle.postgresGetNotifications(longPollTimeout) - }.await() - } - if (notificationArrived) { - /** - * NOTE: the query can still have zero results despite the - * notification. That happens when the 'start' URI param is - * higher than the ID of the new row in the database. Not - * an error. - */ - result = transaction { - // addLogger(StdOutSqlLogger) - TalerIncomingPaymentEntity.find { startCmpOp }.orderTaler(delta) - } - } - } - /** - * Whether because of a timeout or a notification or of never slept, here it - * proceeds to the response (== resultOrWait.first IS EFFECTIVE). - */ - val maybeNewPayments = result - val resp = if (maybeNewPayments.isNotEmpty()) { - val history = TalerIncomingHistory( - credit_account = buildIbanPaytoUri( - facadeBankAccount.iban, - facadeBankAccount.bankCode, - facadeBankAccount.accountHolder, - ) - ) - transaction { - maybeNewPayments.subList( - 0, - min(abs(delta), maybeNewPayments.size) - ).forEach { - history.incoming_transactions.add( - TalerIncomingBankTransaction( - // Rounded timestamp - date = GnunetTimestamp(it.timestampMs / 1000L), - row_id = it.id.value, - amount = "${it.payment.currency}:${it.payment.amount}", - reserve_pub = it.reservePublicKey, - debit_account = it.debtorPaytoUri - ) - ) - } - } - history - } else null - if (resp == null) { - call.respond(HttpStatusCode.NoContent) - return - } - return call.respond( - status = HttpStatusCode.OK, - TextContent(customConverter(resp), ContentType.Application.Json) - ) -} - -/** - * This call proxies /admin/add/incoming to the Sandbox, - * which is the service keeping the transaction ledger. - * The credentials are ASSUMED to be exchange/x (user/pass). - * - * In the future, a dedicated "add-incoming" facade should - * be provided, offering the mean to store the credentials - * at configuration time. - */ -private suspend fun addIncoming(call: ApplicationCall) { - val facadeId = ensureNonNull(call.parameters["fcid"]) - val currentBody = call.receive<String>() - val fromDb = transaction { - val f = FacadeEntity.findByName(facadeId) ?: throw notFound("facade $facadeId not found") - val facadeState = FacadeStateEntity.find { - FacadeStateTable.facade eq f.id - }.firstOrNull() ?: throw internalServerError("facade $facadeId has no state!") - val conn = NexusBankConnectionEntity.findByName(facadeState.bankConnection) ?: throw internalServerError( - "state of facade $facadeId has no bank connection!" - ) - val sandboxUrl = URL(getConnectionPlugin(conn.type).getBankUrl(conn.connectionId)) - // NOTE: the exchange username must be 'exchange', at the Sandbox. - return@transaction Pair( - url { - protocol = URLProtocol(sandboxUrl.protocol, 80) - host = sandboxUrl.host - if (sandboxUrl.port != 80) - port = sandboxUrl.port - path( - "demobanks", - "default", - "taler-wire-gateway", - "exchange", - "admin", - "add-incoming" - ) - }, // first - facadeState.bankAccount // second - ) - } - val client = HttpClient { followRedirects = true } - val resp = client.post(fromDb.first) { - setBody(currentBody) - basicAuth("exchange", "x") - contentType(ContentType.Application.Json) - expectSuccess = false - } - // Sandbox itself failed. Responding Bad Gateway because here is a proxy. - if (resp.status.value.toString().startsWith('5')) { - logger.error("Sandbox failed with status code: ${resp.status.description}") - throw badGateway("Sandbox failed at creating the 'admin/add-incoming' payment") - } - // Echo back whatever error is left, because that should be the client fault. - if (!resp.status.value.toString().startsWith('2')) { - logger.error("Client-side error for /admin/add-incoming. Sandbox says: ${resp.bodyAsText()}") - call.respond(resp.status, resp.bodyAsText()) - } - // x-libeufin-bank-ingest - val ingestionResult = ingestXLibeufinBankMessage( - fromDb.second, - resp.bodyAsText() - ) - if (ingestionResult.newTransactions != 1) - throw internalServerError("/admin/add-incoming was ingested into ${ingestionResult.newTransactions} new transactions, but it must have one.") - if (ingestionResult.errors != null) { - val errors = ingestionResult.errors - errors?.forEach { - logger.error(it.message) - } - throw internalServerError("/admin/add-incoming ingestion failed.") - } - // TWG ingest. - ingestFacadeTransactions( - bankAccountId = fromDb.second, - facadeType = NexusFacadeType.TALER, - incomingFilterCb = ::talerFilter, - refundCb = ::maybeTalerRefunds - ) - /** - * The latest incoming payment should now be found among - * the ingested ones. - */ - val lastIncomingPayment = transaction { - val allIncomingPayments = TalerIncomingPaymentEntity.all() - /** - * One payment must appear, since it was created BY this handler. - * If not, then respond 500. - */ - if (allIncomingPayments.empty()) - throw internalServerError("Incoming payment(s) not found AFTER /add-incoming") - val lastRecord = allIncomingPayments.last() - return@transaction Pair(lastRecord.id.value, lastRecord.timestampMs) - } - call.respond(object { - val row_id = lastIncomingPayment.first - val timestamp = GnunetTimestamp(lastIncomingPayment.second / 1000L) - }) -} - -private fun getCurrency(facadeName: String): String { - return transaction { - getFacadeState(facadeName).currency - } -} - -fun talerFacadeRoutes(route: Route) { - route.get("/config") { - val facadeId = ensureNonNull(call.parameters["fcid"]) - call.request.requirePermission( - PermissionQuery("facade", facadeId, "facade.talerwiregateway.transfer"), - PermissionQuery("facade", facadeId, "facade.talerwiregateway.history") - ) - call.respond(object { - val version = "0:0:0" - val name = "taler-wire-gateway" - val currency = getCurrency(facadeId) - }) - return@get - } - route.post("/transfer") { - talerTransfer(call) - return@post - } - route.get("/history/outgoing") { - historyOutgoing(call) - return@get - } - route.get("/history/incoming") { - historyIncoming(call) - return@get - } - route.post("/admin/add-incoming") { - addIncoming(call) - return@post - } - route.get("") { - call.respondText("Hello, this is a Taler Facade") - return@get - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt deleted file mode 100644 index 0be5f876..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt +++ /dev/null @@ -1,456 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus.bankaccount - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.server.application.ApplicationCall -import io.ktor.client.HttpClient -import io.ktor.http.HttpStatusCode -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.* -import tech.libeufin.nexus.iso20022.* -import tech.libeufin.nexus.server.* -import tech.libeufin.nexus.xlibeufinbank.ingestXLibeufinBankMessage -import tech.libeufin.util.XMLUtil -import tech.libeufin.util.internalServerError -import java.time.Instant -import java.time.ZoneOffset -import java.time.ZonedDateTime - -private val keepBankMessages: String? = System.getenv("LIBEUFIN_NEXUS_KEEP_BANK_MESSAGES") - -fun requireBankAccount(call: ApplicationCall, parameterKey: String): NexusBankAccountEntity { - val name = call.parameters[parameterKey] - if (name == null) - throw NexusError( - HttpStatusCode.InternalServerError, - "no parameter for bank account" - ) - val account = transaction { NexusBankAccountEntity.findByName(name) } - if (account == null) { - throw NexusError(HttpStatusCode.NotFound, "bank connection '$name' not found") - } - return account -} - -suspend fun submitPaymentInitiation(httpClient: HttpClient, paymentInitiationId: Long) { - val r = transaction { - val paymentInitiation = PaymentInitiationEntity.findById(paymentInitiationId) - if (paymentInitiation == null) { - throw NexusError(HttpStatusCode.NotFound, "prepared payment not found") - } - object { - val type = paymentInitiation.bankAccount.defaultBankConnection?.type - val submitted = paymentInitiation.submitted - } - } - // Skips, if the payment was sent once already. - if (r.submitted) { - return - } - if (r.type == null) - throw NexusError(HttpStatusCode.NotFound, "no default bank connection") - - getConnectionPlugin(r.type).submitPaymentInitiation(httpClient, paymentInitiationId) -} - -/** - * Submit all pending prepared payments. - */ -suspend fun submitAllPaymentInitiations( - httpClient: HttpClient, - accountid: String -) { - data class Submission(val id: Long) - val workQueue = mutableListOf<Submission>() - transaction { - val account = NexusBankAccountEntity.findByName(accountid) ?: throw NexusError( - HttpStatusCode.NotFound, - "account not found" - ) - /** - * Skip submitted and invalid preparations. - */ - PaymentInitiationEntity.find { - // Not submitted. - (PaymentInitiationsTable.submitted eq false) and - // From the correct bank account. - (PaymentInitiationsTable.bankAccount eq account.id) - }.forEach { - if (it.invalid == true) return@forEach - val defaultBankConnectionId = it.bankAccount.defaultBankConnection?.id ?: throw NexusError( - HttpStatusCode.NotFound, - "Default bank connection not found. Can't submit Pain document" - ) - // Rare, but filter out bank accounts without a bank connection. - val bankConnection = NexusBankConnectionEntity.findById(defaultBankConnectionId) ?: throw NexusError( - HttpStatusCode.InternalServerError, - "Bank connection '$defaultBankConnectionId' " + - "(pointed by bank account '${it.bankAccount.bankAccountName}')" + - " not found in the database." - ) - try { BankConnectionType.parseBankConnectionType(bankConnection.type) } - catch (e: Exception) { - logger.info("Skipping non-implemented bank connection '${bankConnection.type}'") - return@forEach - } - workQueue.add(Submission(it.id.value)) - } - } - workQueue.forEach { submitPaymentInitiation(httpClient, it.id) } -} - -/** - * NOTE: this type can be used BOTH for one Camt document OR - * for a set of those. - */ -data class IngestedTransactionsCount( - /** - * Number of transactions that are new to the database. - * Note that transaction T can be downloaded multiple times; - * for example, once in a C52 and once - maybe a day later - - * in a C53. The second time, the transaction is not considered - * 'new'. - */ - val newTransactions: Int, - - /** - * Total number of transactions that were included in a report - * or a statement. - */ - val downloadedTransactions: Int, - /** - * Exceptions occurred while fetching transactions. Fetching - * transactions can be done via multiple EBICS messages, therefore - * a failing one should not prevent other messages to be fetched. - * This list collects all the exceptions that happened while fetching - * multiple messages. - */ - var errors: List<Exception>? = null -) - -/** - * Causes new Nexus transactions to be stored into the database. Note: - * this function does NOT parse itself the banking data but relies on the - * dedicated helpers. This function is mostly responsible for _iterating_ - * over the new downloaded messages and update the local bank account about - * the new data. - */ -fun ingestBankMessagesIntoAccount( - bankConnectionId: String, - bankAccountId: String -): IngestedTransactionsCount { - var totalNew = 0 - var downloadedTransactions = 0 - transaction { - val conn = - NexusBankConnectionEntity.find { - NexusBankConnectionsTable.connectionId eq bankConnectionId - }.firstOrNull() - if (conn == null) { - throw NexusError(HttpStatusCode.InternalServerError, "connection not found") - } - val acct = NexusBankAccountEntity.findByName(bankAccountId) - if (acct == null) { - throw NexusError(HttpStatusCode.InternalServerError, "account not found") - } - var lastId = acct.highestSeenBankMessageSerialId - /** - * This block picks all the new messages that were downloaded - * from the bank and passes them to the deeper banking data handlers - * according to the connection type. Such handlers are then responsible - * to extract the interesting values and insert them into the database. - */ - NexusBankMessageEntity.find { - (NexusBankMessagesTable.bankConnection eq conn.id) and - (NexusBankMessagesTable.id greater acct.highestSeenBankMessageSerialId) and - not(NexusBankMessagesTable.errors) - }.orderBy( - Pair(NexusBankMessagesTable.id, SortOrder.ASC) - ).forEach { - val ingestionResult: IngestedTransactionsCount = when(BankConnectionType.parseBankConnectionType(conn.type)) { - BankConnectionType.EBICS -> { - val camtString = it.message.bytes.toString(Charsets.UTF_8) - /** - * NOT validating _again_ the camt document because it was - * already validate before being stored into the database. - */ - val doc = XMLUtil.parseStringIntoDom(camtString) - /** - * Calling the CaMt handler. After its return, all the Neuxs-meaningful - * payment data got stored into the database and is ready to being further - * processed by any facade OR simply be communicated to the CLI via JSON. - */ - try { - ingestCamtMessageIntoAccount( - bankAccountId, - doc, - it.fetchLevel, - conn.dialect - ) - } - catch (e: Exception) { - logger.error("Could not parse the following camt document:\n${camtString}") - // rethrowing. Here just to log the failing document - throw e - } - } - BankConnectionType.X_LIBEUFIN_BANK -> { - val jMessage = try { jacksonObjectMapper().readTree(it.message.bytes) } - catch (e: Exception) { - logger.error("Bank message ${it.id}/${it.messageId} could not" + - " be parsed into JSON by the x-libeufin-bank ingestion.") - throw internalServerError("Could not ingest x-libeufin-bank messages.") - } - ingestXLibeufinBankMessage( - bankAccountId, - jMessage - ) - } - } - /** - * Checking for errors. Note: errors do NOT stop this loop as - * they mean that ONE message has errors. Erroneous messages gets - * (1) flagged, (2) skipped when this function will run again, and (3) - * NEVER deleted from the database. - */ - if (ingestionResult.newTransactions == -1) { - it.errors = true - lastId = it.id.value - return@forEach - } - totalNew += ingestionResult.newTransactions - downloadedTransactions += ingestionResult.downloadedTransactions - /** - * Disk-space conservative check: only store if "yes" was - * explicitly set into the environment variable. Any other - * value or non given falls back to deletion. - */ - if (keepBankMessages == null || keepBankMessages != "yes") { - it.delete() - return@forEach - } - /** - * Updating the highest seen message ID with the serial ID of - * the row that's being currently iterated over. Note: this - * number is ever-growing REGARDLESS of the row being kept into - * the database. - */ - lastId = it.id.value - } - // Causing the lastId to be stored into the database: - acct.highestSeenBankMessageSerialId = lastId - } - return IngestedTransactionsCount( - newTransactions = totalNew, - downloadedTransactions = downloadedTransactions - ) -} - -data class LastMessagesTimes( - val lastStatement: ZonedDateTime?, - val lastReport: ZonedDateTime?, - val lastNotification: ZonedDateTime? -) -/** - * Get the last timestamps where a report and - * a statement were received for the bank account - * given as argument. - */ -fun getLastMessagesTimes(bankAccountId: String): LastMessagesTimes { - val acct = getBankAccount(bankAccountId) - return getLastMessagesTimes(acct) -} - -fun getLastMessagesTimes(acct: NexusBankAccountEntity): LastMessagesTimes { - return LastMessagesTimes( - lastReport = acct.lastReportCreationTimestamp?.let { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) - }, - lastStatement = acct.lastStatementCreationTimestamp?.let { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) - }, - lastNotification = acct.lastNotificationCreationTimestamp?.let { - ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) - } - ) -} - -fun addPaymentInitiation(paymentData: Pain001Data, debtorAccount: String): PaymentInitiationEntity { - val bankAccount = getBankAccount(debtorAccount) - return addPaymentInitiation(paymentData, bankAccount) -} - -suspend fun fetchBankAccountTransactions( - client: HttpClient, - fetchSpec: FetchSpecJson, - accountId: String -): IngestedTransactionsCount { - val connectionDetails = transaction { - val acct = NexusBankAccountEntity.findByName(accountId) - if (acct == null) { - throw NexusError( - HttpStatusCode.NotFound, - "Account '$accountId' not found" - ) - } - val conn = acct.defaultBankConnection - if (conn == null) { - throw NexusError( - HttpStatusCode.BadRequest, - "No default bank connection (explicit connection not yet supported)" - ) - } - return@transaction object { - /** - * The connection type _as enum_ should eventually come - * directly from the database, instead of being parsed by - * parseBankConnectionType(). - */ - val connectionType = BankConnectionType.parseBankConnectionType(conn.type) - val connectionName = conn.connectionId - } - } - /** - * Collects transactions from the bank and stores the (camt) - * document into the database. This function tries to download - * both reports AND statements even if the first one fails. - */ - val errors: List<Exception>? = getConnectionPlugin(connectionDetails.connectionType).fetchTransactions( - fetchSpec, - client, - connectionDetails.connectionName, - accountId - ) - /** - * Here it MIGHT just return in case of errors, but sometimes the - * fetcher asks for multiple results (e.g. C52 and C53), and what - * went through SHOULD be ingested. - */ - - /** - * This block causes new NexusBankAccountTransactions rows to be - * INSERTed into the database, according to the banking data that - * was recently downloaded. - */ - val ingestionResult: IngestedTransactionsCount = ingestBankMessagesIntoAccount( - connectionDetails.connectionName, - accountId - ) - /** - * The following two functions further process the banking data - * that was recently downloaded, according to the particular facade - * being honored. - */ - ingestFacadeTransactions( - bankAccountId = accountId, - facadeType = NexusFacadeType.TALER, - incomingFilterCb = ::talerFilter, - refundCb = ::maybeTalerRefunds - ) - ingestFacadeTransactions( - bankAccountId = accountId, - facadeType = NexusFacadeType.ANASTASIS, - incomingFilterCb = ::anastasisFilter, - refundCb = null - ) - - ingestionResult.errors = errors - return ingestionResult -} - -fun importBankAccount(call: ApplicationCall, offeredBankAccountId: String, nexusBankAccountId: String) { - transaction { - val conn = requireBankConnection(call, "connid") - // first get handle of the offered bank account - val offeredAccount = OfferedBankAccountsTable.select { - OfferedBankAccountsTable.offeredAccountId eq offeredBankAccountId and - (OfferedBankAccountsTable.bankConnection eq conn.id.value) - }.firstOrNull() ?: throw NexusError( - HttpStatusCode.NotFound, "Could not find offered bank account '${offeredBankAccountId}'" - ) - // detect name collisions first. - NexusBankAccountEntity.findByName(nexusBankAccountId).run { - // This variable will either host a new, or a found imported bank account. - val importedAccount = when (this) { - is NexusBankAccountEntity -> { - if (this.iban != offeredAccount[OfferedBankAccountsTable.iban]) { - throw NexusError( - HttpStatusCode.Conflict, - "$nexusBankAccountId exists already and its IBAN is different from $offeredBankAccountId" - ) - } - // an imported bank account already exists and - // the user tried to import the same IBAN to it. Do nothing - this - } - // such named imported account didn't exist. Make it - else -> { - val newImportedAccount = NexusBankAccountEntity.new { - bankAccountName = nexusBankAccountId - iban = offeredAccount[OfferedBankAccountsTable.iban] - bankCode = offeredAccount[OfferedBankAccountsTable.bankCode] - defaultBankConnection = conn - highestSeenBankMessageSerialId = 0 - accountHolder = offeredAccount[OfferedBankAccountsTable.accountHolder] - } - logger.info("Account ${newImportedAccount.id} gets imported") - newImportedAccount - } - } - // Associate the bank account as named by the bank (the 'offered') - // with the imported/local one (the 'imported'). Rewrites are acceptable. - OfferedBankAccountsTable.update( - { - OfferedBankAccountsTable.offeredAccountId eq offeredBankAccountId and - (OfferedBankAccountsTable.bankConnection eq conn.id.value) - } - ) { - it[imported] = importedAccount.id - } - } - } -} - - -/** - * Check if the transaction is already found in the database. - * This function works as long as the caller provides the appropriate - * 'uid' parameter. For CaMt messages this value is carried along - * the AcctSvcrRef node, whereas for x-libeufin-bank connections - * that's the 'uid' field of the XLibeufinBankTransaction type. - * - * Returns the transaction that's already in the database, in case - * the 'uid' is from a duplicate. - */ -fun findDuplicate( - bankAccountId: String, - uid: String -): NexusBankTransactionEntity? { - return transaction { - val account = NexusBankAccountEntity.findByName((bankAccountId)) ?: - return@transaction null - NexusBankTransactionEntity.find { - (NexusBankTransactionsTable.accountTransactionId eq uid) and - (NexusBankTransactionsTable.bankAccount eq account.id) - }.firstOrNull() - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt deleted file mode 100644 index d77a8b38..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsClient.kt +++ /dev/null @@ -1,437 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -/** - * High-level interface for the EBICS protocol. - */ -package tech.libeufin.nexus.ebics - -import io.ktor.client.HttpClient -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.nexus.NexusError -import tech.libeufin.util.* -import java.util.* - -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.util") - -private suspend inline fun HttpClient.postToBank(url: String, body: String): String { - if (!XMLUtil.validateFromString(body)) throw NexusError( - HttpStatusCode.InternalServerError, - "EBICS (outgoing) document is invalid" - ) - val response: HttpResponse = try { - this.post(urlString = url) { - setBody(body) - } - } catch (e: ClientRequestException) { - logger.error("Exception during request to $url: ${e.message}") - val returnStatus = if (e.response.status.value == HttpStatusCode.RequestTimeout.value) - HttpStatusCode.GatewayTimeout - else HttpStatusCode.BadGateway - throw NexusError( - returnStatus, - e.message - ) - } - catch (e: Exception) { - logger.error("Exception during request to $url: ${e.message}") - throw NexusError( - HttpStatusCode.BadGateway, - e.message ?: "Could not reach the bank" - ) - } - /** - * EBICS should be expected only after a 200 OK response - * (including problematic ones); throw exception in all the other cases, - * by echoing what the bank said. - */ - if (response.status.value != HttpStatusCode.OK.value) - throw NexusError( - HttpStatusCode.BadGateway, - "bank says: ${response.bodyAsText()}" - ) - return response.bodyAsText() -} - -sealed class EbicsDownloadResult - -class EbicsDownloadSuccessResult( - val orderData: ByteArray, - /** - * This value points at the EBICS transaction that carried - * the order data contained in this structure. That makes - * possible to log the EBICS transaction that carried one - * invalid order data, for example. - */ - val transactionID: String? = null -) : EbicsDownloadResult() - -class EbicsDownloadEmptyResult( - val orderData: ByteArray = ByteArray(0) -) : EbicsDownloadResult() - - -/** - * A bank-technical error occurred. - */ -class EbicsDownloadBankErrorResult( - val returnCode: EbicsReturnCode -) : EbicsDownloadResult() - -/** - * Do an EBICS download transaction. This includes - * the initialization phase, transaction phase and receipt phase. - */ -suspend fun doEbicsDownloadTransaction( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails, - fetchSpec: EbicsFetchSpec -): EbicsDownloadResult { - - // Initialization phase - val initDownloadRequestStr = if (fetchSpec.isEbics3) { - if (fetchSpec.ebics3Service == null) - throw internalServerError("Expected EBICS 3 fetch spec but null was found.") - createEbicsRequestForDownloadInitialization( - subscriberDetails, - fetchSpec.ebics3Service, - fetchSpec.orderParams - ) - } - else { - if (fetchSpec.orderType == null) - throw internalServerError("Expected EBICS 2.5 order type but null was found.") - createEbicsRequestForDownloadInitialization( - subscriberDetails, - fetchSpec.orderType, - fetchSpec.orderParams - ) - } - val payloadChunks = LinkedList<String>() - val initResponseStr = client.postToBank(subscriberDetails.ebicsUrl, initDownloadRequestStr) - val initResponse = parseAndValidateEbicsResponse( - subscriberDetails, - initResponseStr, - withEbics3 = fetchSpec.isEbics3 - ) - val transactionID: String? = initResponse.transactionID - // Checking for EBICS communication problems. - when (initResponse.technicalReturnCode) { - EbicsReturnCode.EBICS_OK -> { - /** - * The EBICS communication succeeded, but business problems - * may be reported along the 'bank technical' code; this check - * takes place later. - */ - } - else -> { - // The bank gave a valid XML response but EBICS had problems. - throw EbicsProtocolError( - HttpStatusCode.UnprocessableEntity, - "EBICS-technical error at init phase: " + - "${initResponse.technicalReturnCode} ${initResponse.reportText}," + - " for fetching level ${fetchSpec.originalLevel} and transaction ID: $transactionID.", - initResponse.technicalReturnCode - ) - } - } - // Checking the 'bank technical' code. - when (initResponse.bankReturnCode) { - EbicsReturnCode.EBICS_OK -> { - // Success, nothing to do! - } - EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE -> { - // The 'pf' dialect might respond this value here (at init phase), - // in contrast to what the default dialect does (waiting the transfer phase) - return EbicsDownloadEmptyResult() - } - else -> { - println("Bank raw response: $initResponseStr") - logger.error( - "Bank-technical error at init phase: ${initResponse.bankReturnCode}" + - ", for fetching level ${fetchSpec.originalLevel} and transaction ID $transactionID." - ) - return EbicsDownloadBankErrorResult(initResponse.bankReturnCode) - } - } - logger.debug("Bank acknowledges EBICS download initialization." + - " Transaction ID: $transactionID.") - val encryptionInfo = initResponse.dataEncryptionInfo - ?: throw NexusError( - HttpStatusCode.BadGateway, - "Initial response did not contain encryption info. " + - "Fetching level ${fetchSpec.originalLevel} , transaction ID $transactionID" - ) - - val initOrderDataEncChunk = initResponse.orderDataEncChunk - ?: throw NexusError( - HttpStatusCode.BadGateway, - "Initial response for download transaction does not " + - "contain data transfer. Fetching level ${fetchSpec.originalLevel}, " + - "transaction ID $transactionID." - ) - payloadChunks.add(initOrderDataEncChunk) - - val numSegments = initResponse.numSegments - ?: throw NexusError( - HttpStatusCode.FailedDependency, - "Missing segment number in EBICS download init response." + - " Fetching level ${fetchSpec.originalLevel}, transaction ID $transactionID" - ) - // Transfer phase - for (x in 2 .. numSegments) { - val transferReqStr = - createEbicsRequestForDownloadTransferPhase( - subscriberDetails, - transactionID, - x, - numSegments, - fetchSpec.isEbics3 - ) - logger.debug("EBICS download transfer phase of ${transactionID}: sending segment $x") - val transferResponseStr = client.postToBank(subscriberDetails.ebicsUrl, transferReqStr) - val transferResponse = parseAndValidateEbicsResponse(subscriberDetails, transferResponseStr) - when (transferResponse.technicalReturnCode) { - EbicsReturnCode.EBICS_OK -> { - // Success, nothing to do! - } - else -> { - throw NexusError( - HttpStatusCode.FailedDependency, - "EBICS-technical error at transfer phase: " + - "${transferResponse.technicalReturnCode} ${transferResponse.reportText}." + - " Fetching level ${fetchSpec.originalLevel}, transaction ID $transactionID" - ) - } - } - when (transferResponse.bankReturnCode) { - EbicsReturnCode.EBICS_OK -> { - // Success, nothing to do! - } - else -> { - logger.error("Bank-technical error at transfer phase: " + - "${transferResponse.bankReturnCode}." + - " Fetching level ${fetchSpec.originalLevel}, transaction ID $transactionID") - return EbicsDownloadBankErrorResult(transferResponse.bankReturnCode) - } - } - val transferOrderDataEncChunk = transferResponse.orderDataEncChunk - ?: throw NexusError( - HttpStatusCode.BadGateway, - "transfer response for download transaction " + - "does not contain data transfer. Fetching level ${fetchSpec.originalLevel}, transaction ID $transactionID" - ) - payloadChunks.add(transferOrderDataEncChunk) - logger.debug("Download transfer phase of ${transactionID}: bank acknowledges $x") - } - - val respPayload = decryptAndDecompressResponse(subscriberDetails, encryptionInfo, payloadChunks) - - // Acknowledgement phase - val ackRequest = createEbicsRequestForDownloadReceipt( - subscriberDetails, - transactionID, - fetchSpec.isEbics3 - ) - val ackResponseStr = client.postToBank( - subscriberDetails.ebicsUrl, - ackRequest - ) - val ackResponse = parseAndValidateEbicsResponse( - subscriberDetails, - ackResponseStr, - withEbics3 = fetchSpec.isEbics3 - ) - when (ackResponse.technicalReturnCode) { - EbicsReturnCode.EBICS_DOWNLOAD_POSTPROCESS_DONE -> { - } - else -> { - throw NexusError( - HttpStatusCode.InternalServerError, - "Unexpected EBICS return code" + - " at acknowledgement phase: ${ackResponse.technicalReturnCode.name}." + - " Fetching level ${fetchSpec.originalLevel}, transaction ID $transactionID" - ) - } - } - logger.debug("Bank acknowledges EBICS download receipt. Transaction ID: $transactionID.") - return EbicsDownloadSuccessResult(respPayload, transactionID) -} - -// Currently only 1-segment requests. -suspend fun doEbicsUploadTransaction( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails, - uploadSpec: EbicsUploadSpec, - payload: ByteArray -) { - if (subscriberDetails.bankEncPub == null) { - throw NexusError(HttpStatusCode.BadRequest, - "bank encryption key unknown, request HPB first" - ) - } - val preparedUploadData = prepareUploadPayload( - subscriberDetails, - payload, - isEbics3 = uploadSpec.isEbics3 - ) - val req: String = if (uploadSpec.isEbics3) { - if (uploadSpec.ebics3Service == null) - throw internalServerError("EBICS 3 service data was expected, but null was found.") - createEbicsRequestForUploadInitialization( - subscriberDetails, - uploadSpec.ebics3Service, - uploadSpec.orderParams, - preparedUploadData - ) - } else { - if (uploadSpec.orderType == null) - throw internalServerError("EBICS 2.5 order type was expected, but null was found.") - createEbicsRequestForUploadInitialization( - subscriberDetails, - uploadSpec.orderType, - uploadSpec.orderParams ?: EbicsStandardOrderParams(), - preparedUploadData - ) - } - logger.debug("EBICS upload message to: ${subscriberDetails.ebicsUrl}") - val responseStr = client.postToBank(subscriberDetails.ebicsUrl, req) - - val initResponse = parseAndValidateEbicsResponse( - subscriberDetails, - responseStr, - withEbics3 = uploadSpec.isEbics3 - ) - // The bank indicated one error, hence Nexus sent invalid data. - if (initResponse.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - throw NexusError( - HttpStatusCode.InternalServerError, - reason = "EBICS-technical error at init phase:" + - " ${initResponse.technicalReturnCode} ${initResponse.reportText}" - ) - } - // The bank did NOT indicate any error, but the response - // lacks required information, blame the bank. - val transactionID = initResponse.transactionID - if (initResponse.bankReturnCode != EbicsReturnCode.EBICS_OK) { - throw NexusError( - HttpStatusCode.InternalServerError, - reason = "Bank-technical error at init phase:" + - " ${initResponse.bankReturnCode}" - ) - } - logger.debug("Bank acknowledges EBICS upload initialization. " + - " Transaction ID: $transactionID.") - - /* now send actual payload */ - val ebicsPayload = createEbicsRequestForUploadTransferPhase( - subscriberDetails, - transactionID, - preparedUploadData, - 0, - withEbics3 = uploadSpec.isEbics3 - ) - val txRespStr = client.postToBank( - subscriberDetails.ebicsUrl, - ebicsPayload - ) - val txResp = parseAndValidateEbicsResponse( - subscriberDetails, - txRespStr, - withEbics3 = uploadSpec.isEbics3 - ) - when (txResp.technicalReturnCode) { - EbicsReturnCode.EBICS_OK -> {/* do nothing */} - else -> { - // EBICS failed, blame Nexus. - throw EbicsProtocolError( - httpStatusCode = HttpStatusCode.InternalServerError, - reason = txResp.reportText, - ebicsTechnicalCode = txResp.technicalReturnCode - ) - } - } - when (txResp.bankReturnCode) { - EbicsReturnCode.EBICS_OK -> {/* do nothing */} - else -> { - /** - * Although EBICS went fine, the bank complained about - * the communication content. - */ - throw EbicsProtocolError( - httpStatusCode = HttpStatusCode.UnprocessableEntity, - reason = "bank-technical error: ${txResp.reportText}" - ) - } - } - logger.debug("Bank acknowledges EBICS upload transfer. Transaction ID: $transactionID") -} - -suspend fun doEbicsHostVersionQuery(client: HttpClient, ebicsBaseUrl: String, ebicsHostId: String): EbicsHevDetails { - val ebicsHevRequest = makeEbicsHEVRequestRaw(ebicsHostId) - val resp = client.postToBank(ebicsBaseUrl, ebicsHevRequest) - return parseEbicsHEVResponse(resp) -} - -suspend fun doEbicsIniRequest( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails -): EbicsKeyManagementResponseContent { - val request = makeEbicsIniRequest(subscriberDetails) - val respStr = client.postToBank( - subscriberDetails.ebicsUrl, - request - ) - return parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) -} - -suspend fun doEbicsHiaRequest( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails -): EbicsKeyManagementResponseContent { - val request = makeEbicsHiaRequest(subscriberDetails) - val respStr = client.postToBank( - subscriberDetails.ebicsUrl, - request - ) - return parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) -} - - -suspend fun doEbicsHpbRequest( - client: HttpClient, - subscriberDetails: EbicsClientSubscriberDetails -): HpbResponseData { - val request = makeEbicsHpbRequest(subscriberDetails) - val respStr = client.postToBank( - subscriberDetails.ebicsUrl, - request - ) - val parsedResponse = parseAndDecryptEbicsKeyManagementResponse(subscriberDetails, respStr) - val orderData = parsedResponse.orderData ?: throw EbicsProtocolError( - HttpStatusCode.BadGateway, - "Cannot find data in a HPB response" - ) - return parseEbicsHpbOrder(orderData) -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt deleted file mode 100644 index 1548efce..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsNexus.kt +++ /dev/null @@ -1,1247 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -/** - * Handlers for EBICS-related endpoints offered by the nexus for EBICS - * connections. - */ -package tech.libeufin.nexus.ebics - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.itextpdf.kernel.pdf.PdfDocument -import com.itextpdf.kernel.pdf.PdfWriter -import com.itextpdf.layout.Document -import com.itextpdf.layout.element.AreaBreak -import com.itextpdf.layout.element.Paragraph -import io.ktor.server.application.call -import io.ktor.client.HttpClient -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.server.request.* -import io.ktor.server.response.respond -import io.ktor.server.response.respondText -import io.ktor.server.routing.Route -import io.ktor.server.routing.post -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.select -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.* -import tech.libeufin.nexus.bankaccount.getLastMessagesTimes -import tech.libeufin.nexus.iso20022.NexusPaymentInitiationData -import tech.libeufin.nexus.iso20022.createPain001document -import tech.libeufin.nexus.logger -import tech.libeufin.nexus.server.* -import tech.libeufin.util.* -import tech.libeufin.util.ebics_h004.EbicsTypes -import tech.libeufin.util.ebics_h004.HTDResponseOrderData -import tech.libeufin.util.ebics_h005.Ebics3Request -import java.io.ByteArrayOutputStream -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey -import java.time.* -import java.time.format.DateTimeFormatter -import java.util.* -import javax.crypto.EncryptedPrivateKeyInfo - - -/** - * This type maps the abstract fetch specifications -- as for example - * they were given via the Nexus JSON API -- to the specific EBICS type. - */ -data class EbicsFetchSpec( - val orderType: String? = null, // unused for 3.0 - val orderParams: EbicsOrderParams, - val ebics3Service: Ebics3Request.OrderDetails.Service? = null, // unused for 2.5 - // Not always available, for example at raw POST /download/${ebicsMessageName} calls. - // It helps to trace back the original level. - val originalLevel: FetchLevel? = null, - val isEbics3: Boolean = false -) - -/** - * Collects EBICS 2.5 and/or 3.0 parameters for a unified - * way of passing parameters. Individual helpers will then - * act according to the EBICS version. - */ -data class EbicsUploadSpec( - val isEbics3: Boolean = false, - val ebics3Service: Ebics3Request.OrderDetails.Service? = null, // unused for 2.5 - val orderType: String? = null, - val orderParams: EbicsOrderParams? = null -) - -// Validate and store the received document for later ingestion. -private fun validateAndStoreCamt( - bankConnectionId: String, - camt: String, - fetchLevel: FetchLevel, - transactionID: String? = null, // the EBICS transaction that carried this camt. - validateBankContent: Boolean = false -) { - val camtDoc = try { - XMLUtil.parseStringIntoDom(camt) - } - catch (e: Exception) { - throw badGateway("Could not parse camt document from EBICS transaction $transactionID") - } - if (validateBankContent && !XMLUtil.validateFromDom(camtDoc)) { - logger.error("This document didn't validate: $camt") - throw badGateway("Camt document from EBICS transaction $transactionID is invalid") - } - - val msgId = camtDoc.pickStringWithRootNs("/*[1]/*[1]/root:GrpHdr/root:MsgId") - logger.info("Camt document '$msgId' received via $fetchLevel.") - transaction { - val conn = NexusBankConnectionEntity.findByName(bankConnectionId) - if (conn == null) { - throw NexusError( - HttpStatusCode.InternalServerError, - "bank connection missing" - ) - } - val oldMsg = NexusBankMessageEntity.find { NexusBankMessagesTable.messageId eq msgId }.firstOrNull() - if (oldMsg == null) { - NexusBankMessageEntity.new { - this.bankConnection = conn - this.fetchLevel = fetchLevel - this.messageId = msgId - this.message = ExposedBlob(camt.toByteArray(Charsets.UTF_8)) - } - } - } -} - -private fun handleEbicsDownloadResult( - bankResponse: EbicsDownloadResult, - bankConnectionId: String, - fetchLevel: FetchLevel -) { - when (bankResponse) { - is EbicsDownloadSuccessResult -> { - bankResponse.orderData.unzipWithLambda { - // logger.debug("Camt entry (filename (in the Zip archive): ${it.first}): ${it.second}") - validateAndStoreCamt( - bankConnectionId, - it.second, - fetchLevel, - transactionID = bankResponse.transactionID - ) - } - } - is EbicsDownloadBankErrorResult -> { - throw NexusError( - HttpStatusCode.BadGateway, - bankResponse.returnCode.errorCode - ) - } - is EbicsDownloadEmptyResult -> { - // no-op - } - } -} - -// Fetch EBICS transactions according to the specifications -// (fetchSpec) it finds in the parameters. -private suspend fun fetchEbicsTransactions( - fetchSpec: EbicsFetchSpec, - client: HttpClient, - bankConnectionId: String, - subscriberDetails: EbicsClientSubscriberDetails, -) { - /** - * In this case Nexus will not be able to associate the future - * EBICS response with the fetch level originally requested by - * the caller, and therefore refuses to continue the execution. - * This condition is however in some cases allowed: for example - * along the "POST /download/$ebicsMessageType" call, where the result - * is not supposed to be stored in the database and therefore doesn't - * need its original level. - */ - if (fetchSpec.originalLevel == null) { - throw internalServerError( - "Original fetch level missing, won't download from EBICS" - ) - } - val response: EbicsDownloadResult = try { - doEbicsDownloadTransaction( - client, - subscriberDetails, - fetchSpec - ) - } catch (e: EbicsProtocolError) { - /** - * Although given a error type, an empty transactions list does - * not mean anything wrong. - */ - if (e.ebicsTechnicalCode == EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE) { - logger.debug("EBICS had no new data") - return - } - // re-throw in any other error case. - throw e - } - handleEbicsDownloadResult( - response, - bankConnectionId, - fetchSpec.originalLevel - ) -} - -/** - * Prepares key material and other EBICS details and - * returns them along a convenient object. - */ -private fun getEbicsSubscriberDetailsInternal(subscriber: EbicsSubscriberEntity): EbicsClientSubscriberDetails { - var bankAuthPubValue: RSAPublicKey? = null - if (subscriber.bankAuthenticationPublicKey != null) { - bankAuthPubValue = CryptoUtil.loadRsaPublicKey( - subscriber.bankAuthenticationPublicKey?.bytes!! - ) - } - var bankEncPubValue: RSAPublicKey? = null - if (subscriber.bankEncryptionPublicKey != null) { - bankEncPubValue = CryptoUtil.loadRsaPublicKey( - subscriber.bankEncryptionPublicKey?.bytes!! - ) - } - return EbicsClientSubscriberDetails( - bankAuthPub = bankAuthPubValue, - bankEncPub = bankEncPubValue, - - ebicsUrl = subscriber.ebicsURL, - hostId = subscriber.hostID, - userId = subscriber.userID, - partnerId = subscriber.partnerID, - - customerSignPriv = CryptoUtil.loadRsaPrivateKey(subscriber.signaturePrivateKey.bytes), - customerAuthPriv = CryptoUtil.loadRsaPrivateKey(subscriber.authenticationPrivateKey.bytes), - customerEncPriv = CryptoUtil.loadRsaPrivateKey(subscriber.encryptionPrivateKey.bytes), - ebicsIniState = subscriber.ebicsIniState, - ebicsHiaState = subscriber.ebicsHiaState - ) -} -private fun getSubscriberFromConnection(connectionEntity: NexusBankConnectionEntity): EbicsSubscriberEntity = - transaction { - EbicsSubscriberEntity.find { - NexusEbicsSubscribersTable.nexusBankConnection eq connectionEntity.id - }.firstOrNull() ?: throw internalServerError("ebics bank connection '${connectionEntity.connectionId}' has no subscriber.") - } -/** - * Retrieve Ebics subscriber details given a bank connection. - */ -fun getEbicsSubscriberDetails(bankConnectionId: String): EbicsClientSubscriberDetails { - val transport = getBankConnection(bankConnectionId) - val subscriber = getSubscriberFromConnection(transport) - - // transport exists and belongs to caller. - val ret = getEbicsSubscriberDetailsInternal(subscriber) - if (transport.dialect != null) - ret.dialect = transport.dialect - return ret -} - -fun Route.ebicsBankProtocolRoutes(client: HttpClient) { - post("test-host") { - val r = call.receive<EbicsHostTestRequest>() - val qr = doEbicsHostVersionQuery(client, r.ebicsBaseUrl, r.ebicsHostId) - call.respond(qr) - return@post - } -} - -fun Route.ebicsBankConnectionRoutes(client: HttpClient) { - post("/send-ini") { - requireSuperuser(call.request) - val subscriber = transaction { - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError( - HttpStatusCode.BadRequest, - "bank connection is not of type 'ebics' (but '${conn.type}')" - ) - } - getEbicsSubscriberDetails(conn.connectionId) - } - val resp = doEbicsIniRequest(client, subscriber) - call.respond(resp) - } - - post("/send-hia") { - requireSuperuser(call.request) - val subscriber = transaction { - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.connectionId) - } - val resp = doEbicsHiaRequest(client, subscriber) - call.respond(resp) - } - - post("/send-hev") { - requireSuperuser(call.request) - val subscriber = transaction { - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.connectionId) - } - val resp = doEbicsHostVersionQuery(client, subscriber.ebicsUrl, subscriber.hostId) - call.respond(resp) - } - - post("/send-hpb") { - requireSuperuser(call.request) - val subscriberDetails = transaction { - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.connectionId) - } - val hpbData = doEbicsHpbRequest(client, subscriberDetails) - transaction { - val conn = requireBankConnection(call, "connid") - val subscriber = - EbicsSubscriberEntity.find { NexusEbicsSubscribersTable.nexusBankConnection eq conn.id }.first() - subscriber.bankAuthenticationPublicKey = ExposedBlob((hpbData.authenticationPubKey.encoded)) - subscriber.bankEncryptionPublicKey = ExposedBlob((hpbData.encryptionPubKey.encoded)) - } - call.respond(object {}) - } - - // Directly import accounts. Used for testing. - post("/import-accounts") { - requireSuperuser(call.request) - val subscriberDetails = transaction { - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.connectionId) - } - val response = doEbicsDownloadTransaction( - client, - subscriberDetails, - EbicsFetchSpec( - orderType = "HTD", - orderParams = EbicsStandardOrderParams() - ) - ) - when (response) { - is EbicsDownloadEmptyResult -> { - // no-op - logger.warn("HTD response was empty.") - } - is EbicsDownloadBankErrorResult -> { - throw NexusError( - HttpStatusCode.BadGateway, - response.returnCode.errorCode - ) - } - is EbicsDownloadSuccessResult -> { - val payload = XMLUtil.convertStringToJaxb<HTDResponseOrderData>( - response.orderData.toString(Charsets.UTF_8) - ) - transaction { - val conn = requireBankConnection(call, "connid") - payload.value.partnerInfo.accountInfoList?.forEach { - NexusBankAccountEntity.new { - bankAccountName = it.id - accountHolder = it.accountHolder ?: "NOT-GIVEN" - iban = it.accountNumberList?.filterIsInstance<EbicsTypes.GeneralAccountNumber>() - ?.find { it.international }?.value - ?: throw NexusError(HttpStatusCode.NotFound, reason = "bank gave no IBAN") - bankCode = it.bankCodeList?.filterIsInstance<EbicsTypes.GeneralBankCode>() - ?.find { it.international }?.value - ?: throw NexusError( - HttpStatusCode.NotFound, - reason = "bank gave no BIC" - ) - defaultBankConnection = conn - highestSeenBankMessageSerialId = 0 - } - } - } - response.orderData.toString(Charsets.UTF_8) - } - } - call.respond(object {}) - } - - post("/download/{msgtype}") { - requireSuperuser(call.request) - val orderType = requireNotNull(call.parameters["msgtype"]).uppercase(Locale.ROOT) - if (orderType.length != 3) { - throw NexusError(HttpStatusCode.BadRequest, "ebics order type must be three characters") - } - val paramsJson = call.receiveNullable<EbicsStandardOrderParamsEmptyJson>() - val orderParams = paramsJson?.toOrderParams() ?: EbicsStandardOrderParams() - val subscriberDetails = transaction { - val conn = requireBankConnection(call, "connid") - if (conn.type != "ebics") { - throw NexusError(HttpStatusCode.BadRequest, "bank connection is not of type 'ebics'") - } - getEbicsSubscriberDetails(conn.connectionId) - } - val response = doEbicsDownloadTransaction( - client, - subscriberDetails, - EbicsFetchSpec( - orderType = orderType, - orderParams = orderParams, - ebics3Service = null, - originalLevel = null - ) - ) - when (response) { - is EbicsDownloadEmptyResult -> { - logger.info(orderType + " response was empty.") // no op - } - is EbicsDownloadSuccessResult -> { - call.respondText( - response.orderData.toString(Charsets.UTF_8), - ContentType.Text.Plain, - HttpStatusCode.OK - ) - } - is EbicsDownloadBankErrorResult -> { - call.respond( - HttpStatusCode.BadGateway, - NexusErrorJson( - error = NexusErrorDetailJson( - type = "bank-error", - description = response.returnCode.errorCode - ) - ) - ) - } - } - } -} - -/** - * Do the Hpb request when we don't know whether our keys have been submitted or not. - * - * Return true when the tentative HPB request succeeded, and thus key initialization is done. - */ -private suspend fun tentativeHpb(client: HttpClient, connId: String): Boolean { - val subscriber = transaction { getEbicsSubscriberDetails(connId) } - val hpbData = try { - doEbicsHpbRequest(client, subscriber) - } catch (e: EbicsProtocolError) { - logger.info("failed tentative hpb request", e) - return false - } - transaction { - val conn = NexusBankConnectionEntity.findByName(connId) - if (conn == null) { - throw NexusError(HttpStatusCode.NotFound, "bank connection '$connId' not found") - } - val subscriberEntity = - EbicsSubscriberEntity.find { NexusEbicsSubscribersTable.nexusBankConnection eq conn.id }.first() - subscriberEntity.ebicsIniState = EbicsInitState.SENT - subscriberEntity.ebicsHiaState = EbicsInitState.SENT - subscriberEntity.bankAuthenticationPublicKey = - ExposedBlob((hpbData.authenticationPubKey.encoded)) - subscriberEntity.bankEncryptionPublicKey = ExposedBlob((hpbData.encryptionPubKey.encoded)) - } - return true -} - -fun formatHex(ba: ByteArray): String { - var out = "" - for (i in ba.indices) { - val b = ba[i] - if (i > 0 && i % 16 == 0) { - out += "\n" - } - out += java.lang.String.format("%02X", b) - out += " " - } - return out -} - -// A null return value indicates that the connection uses EBICS 3.0 -private fun getSubmissionTypeAfterDialect(dialect: String? = null): String? { - return when (dialect) { - "pf" -> null // "XE2" - else -> "CCT" - } -} - -private fun getStatementSpecAfterDialect(dialect: String? = null, p: EbicsOrderParams): EbicsFetchSpec { - return when (dialect) { - "pf" -> EbicsFetchSpec( - orderType = "Z53", - orderParams = p, - ebics3Service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "EOP" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.053" - version = "04" - } - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - }, - originalLevel = FetchLevel.STATEMENT - ) - else -> EbicsFetchSpec( - orderType = "C53", - orderParams = p, - ebics3Service = null, - originalLevel = FetchLevel.STATEMENT - ) - } -} - -private fun getNotificationSpecAfterDialect(dialect: String? = null, p: EbicsOrderParams): EbicsFetchSpec { - return when (dialect) { - "pf" -> EbicsFetchSpec( - orderType = null, // triggers 3.0 - orderParams = p, - ebics3Service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "REP" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.054" - version = "08" - } - scope = "CH" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - }, - originalLevel = FetchLevel.NOTIFICATION, - isEbics3 = true - ) - else -> EbicsFetchSpec( - orderType = "C54", - orderParams = p, - ebics3Service = null, - originalLevel = FetchLevel.NOTIFICATION - ) - } -} -private fun getReportSpecAfterDialect(dialect: String? = null, p: EbicsOrderParams): EbicsFetchSpec { - return when (dialect) { - "pf" -> EbicsFetchSpec( - orderType = "Z52", - orderParams = p, - ebics3Service = null, - originalLevel = FetchLevel.REPORT - ) - else -> EbicsFetchSpec( - orderType = "C52", - orderParams = p, - ebics3Service = null, - originalLevel = FetchLevel.REPORT - ) - } -} - -/** - * This function returns a possibly empty list of Exception. - * That helps not to stop fetching if ONE operation fails. Notably, - * C52 and C53 may be asked along one invocation of this function, - * therefore storing the exception on C52 allows the C53 to still - * take place. The caller then decides how to handle the exceptions. - */ -class EbicsBankConnectionProtocol: BankConnectionProtocol { - /** - * Downloads the pain.002 that informs about previous - * payments submissions. Not all the banks offer this - * service; some may use analog channels. - */ - suspend fun fetchPaymentReceipt( - fetchSpec: FetchSpecJson, - client: HttpClient, - bankConnectionId: String, - accountId: String - ) { - val subscriberDetails = transaction { getEbicsSubscriberDetails(bankConnectionId) } - // Typically a date range. - if (fetchSpec.level != FetchLevel.RECEIPT) { - logger.error("This method accepts only RECEIPT as the fetch level, not '${fetchSpec.level}'.") - throw badRequest("Invalid params to get payments receipts: use fetch level RECEIPT.") - } - val ebicsOrderInfo = when(fetchSpec) { - is FetchSpecLatestJson -> { - EbicsFetchSpec( - orderType = "Z01", // PoFi specific. - orderParams = EbicsStandardOrderParams(), - originalLevel = fetchSpec.level - ) - } - else -> throw NotImplementedError("Fetch spec '${fetchSpec::class}' not supported for payment receipts.") - } - // Proceeding to download now. - val response = try { - doEbicsDownloadTransaction( - client, - subscriberDetails, - EbicsFetchSpec( - orderType = ebicsOrderInfo.orderType, - orderParams = ebicsOrderInfo.orderParams - ) - ) - } catch (e: EbicsProtocolError) { - if (e.ebicsTechnicalCode == EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE) { - logger.debug("EBICS had no new data") - return - } - // re-throw in any other error case. - throw e - } - when(response) { - is EbicsDownloadEmptyResult -> { - // no-op - } - is EbicsDownloadBankErrorResult -> { - logger.error("Bank technical code: ${response.returnCode}") - } - is EbicsDownloadSuccessResult -> { - // Extracting the content (pain.002) and parsing it. - val orderData: String = response.orderData.toString() - logger.debug(orderData) - val doc = XMLUtil.parseStringIntoDom(orderData) - - } - } - } - - override fun getBankUrl(connId: String): String { - val subscriberDetails = transaction { getEbicsSubscriberDetails(connId) } - return subscriberDetails.ebicsUrl - } - override suspend fun fetchTransactions( - fetchSpec: FetchSpecJson, - client: HttpClient, - bankConnectionId: String, - accountId: String - ): List<Exception>? { - val subscriberDetails = transaction { getEbicsSubscriberDetails(bankConnectionId) } - val lastTimes = getLastMessagesTimes(accountId) - /** - * Will be filled with fetch instructions, according - * to the parameters received from the client. - */ - val specs = mutableListOf<EbicsFetchSpec>() - /** - * 'level' indicates whether to fetch statements and/or reports, - * whereas 'p' usually carries a date range. - */ - fun addForLevel(l: FetchLevel, p: EbicsOrderParams) { - when (l) { - FetchLevel.ALL -> { - specs.add(getReportSpecAfterDialect(subscriberDetails.dialect, p)) - specs.add(getStatementSpecAfterDialect(subscriberDetails.dialect, p)) - } - FetchLevel.REPORT -> { - specs.add(getReportSpecAfterDialect(subscriberDetails.dialect, p)) - } - FetchLevel.STATEMENT -> { - specs.add(getStatementSpecAfterDialect(subscriberDetails.dialect, p)) - } - FetchLevel.NOTIFICATION -> { - specs.add(getNotificationSpecAfterDialect(subscriberDetails.dialect, p)) - } - else -> { - logger.error("fetch level wrong in addForLevel() helper: ${fetchSpec.level}.") - throw badRequest("Fetch level ${fetchSpec.level} not supported") - } - } - } - // Figuring out what time range to put in the fetch instructions. - when (fetchSpec) { - is FetchSpecTimeRangeJson -> { - // the parse() method defaults to the YYYY-MM-DD format. - // If parsing fails, the global catcher intervenes. - - val start: LocalDate = parseDashedDate(fetchSpec.start) - val end: LocalDate = parseDashedDate(fetchSpec.end) - val p = EbicsStandardOrderParams( - EbicsDateRange( - start = start.atStartOfDay().atZone(ZoneId.systemDefault()), - end = end.atStartOfDay().atZone(ZoneId.systemDefault()) - ) - ) - addForLevel(fetchSpec.level, p) - } - is FetchSpecLatestJson -> { - val p = EbicsStandardOrderParams() - addForLevel(fetchSpec.level, p) - } - /** - * This spec wants _all_ the records, therefore the - * largest time frame possible needs to be specified. - * Rarely employed in production, but useful for tests. - */ - is FetchSpecAllJson -> { - val start = ZonedDateTime.ofInstant( - Instant.EPOCH, - ZoneOffset.UTC - ) - val end = ZonedDateTime.ofInstant( - /** - * XML date sets the time to 'start of the day'. By - * adding 24 hours, we make sure today's transactions - * are included in the response. - */ - Instant.now().plusSeconds(60 * 60 * 24), - ZoneOffset.systemDefault() - ) - val p = EbicsStandardOrderParams( - EbicsDateRange(start, end) - ) - addForLevel(fetchSpec.level, p) - } - /** - * This branch differentiates the last date of reports and - * statements and builds the fetch instructions for each of - * them. For this reason, it does not use the "addForLevel()" - * helper, since that uses the same date for all the messages - * falling in the ALL level. - */ - is FetchSpecSinceLastJson -> { - val pRep = EbicsStandardOrderParams( - EbicsDateRange( - lastTimes.lastReport ?: ZonedDateTime.ofInstant( - Instant.EPOCH, - ZoneOffset.UTC - ), ZonedDateTime.now(ZoneOffset.UTC) - ) - ) - val pStmt = EbicsStandardOrderParams( - EbicsDateRange( - lastTimes.lastStatement ?: ZonedDateTime.ofInstant( - Instant.EPOCH, - ZoneOffset.UTC - ), ZonedDateTime.now(ZoneOffset.UTC) - ) - ) - val pNtfn = EbicsStandardOrderParams( - EbicsDateRange( - lastTimes.lastNotification ?: ZonedDateTime.ofInstant( - Instant.EPOCH, - ZoneOffset.UTC - ), ZonedDateTime.now(ZoneOffset.UTC) - ) - ) - when (fetchSpec.level) { - FetchLevel.ALL -> { - specs.add(getReportSpecAfterDialect(subscriberDetails.dialect, pRep)) - specs.add(getStatementSpecAfterDialect(subscriberDetails.dialect, pRep)) - } - FetchLevel.REPORT -> { - specs.add(getReportSpecAfterDialect(subscriberDetails.dialect, pRep)) - } - FetchLevel.STATEMENT -> { - specs.add(getStatementSpecAfterDialect(subscriberDetails.dialect, pStmt)) - } - FetchLevel.NOTIFICATION -> { - specs.add(getNotificationSpecAfterDialect(subscriberDetails.dialect, pNtfn)) - } - else -> throw badRequest("Fetch level ${fetchSpec.level} " + - "not supported in the 'since last' EBICS time range.") - } - } - } - // Downloads and stores the bank message into the database. No ingestion. - val errors = mutableListOf<Exception>() - for (spec in specs) { - try { - fetchEbicsTransactions( - spec, - client, - bankConnectionId, - subscriberDetails - ) - } catch (e: Exception) { - logger.warn("Fetching transactions (${spec.originalLevel}) excepted: ${e.message}.") - e.printStackTrace() - errors.add(e) - } - } - if (errors.size > 0) - return errors - return null - } - - // Submit one Pain.001 for one payment initiations. - override suspend fun submitPaymentInitiation( - httpClient: HttpClient, - paymentInitiationId: Long - ) { - val dbData = transaction { - val preparedPayment = getPaymentInitiation(paymentInitiationId) - val conn = preparedPayment.bankAccount.defaultBankConnection ?: throw NexusError( - HttpStatusCode.NotFound, - "no default bank connection available for submission" - ) - val subscriberDetails = getEbicsSubscriberDetails(conn.connectionId) - val painMessage = createPain001document( - NexusPaymentInitiationData( - debtorIban = preparedPayment.bankAccount.iban, - debtorBic = preparedPayment.bankAccount.bankCode, - debtorName = preparedPayment.bankAccount.accountHolder, - currency = preparedPayment.currency, - amount = preparedPayment.sum, - creditorIban = preparedPayment.creditorIban, - creditorName = preparedPayment.creditorName, - creditorBic = preparedPayment.creditorBic, - paymentInformationId = preparedPayment.paymentInformationId, - preparationTimestamp = preparedPayment.preparationDate, - subject = preparedPayment.subject, - instructionId = preparedPayment.instructionId, - endToEndId = preparedPayment.endToEndId, - messageId = preparedPayment.messageId - ), - dialect = subscriberDetails.dialect - ) - object { - val painXml = painMessage - val subscriberDetails = subscriberDetails - } - } - val isPoFi = dbData.subscriberDetails.dialect == "pf" - val uploadSpec = EbicsUploadSpec( - isEbics3 = isPoFi, - orderType = if (!isPoFi) getSubmissionTypeAfterDialect(dbData.subscriberDetails.dialect) else null, - orderParams = EbicsStandardOrderParams(), - ebics3Service = if (isPoFi) - Ebics3Request.OrderDetails.Service().apply { - serviceName = "MCT" - scope = "CH" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "pain.001" - version = "09" - } - } - else null - ) - doEbicsUploadTransaction( - httpClient, - dbData.subscriberDetails, - uploadSpec, - dbData.painXml.toByteArray(Charsets.UTF_8) - ) - transaction { - val payment = getPaymentInitiation(paymentInitiationId) - payment.submitted = true - payment.submissionDate = LocalDateTime.now().millis() - } - } - - override fun exportAnalogDetails(conn: NexusBankConnectionEntity): ByteArray { - val ebicsSubscriber = transaction { getEbicsSubscriberDetails(conn.connectionId) } - val po = ByteArrayOutputStream() - val pdfWriter = PdfWriter(po) - val pdfDoc = PdfDocument(pdfWriter) - val date = LocalDateTime.now() - val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) - - fun writeCommon(doc: Document) { - doc.add( - Paragraph( - """ - Datum: $dateStr - Teilnehmer: ${conn.id.value} - Host-ID: ${ebicsSubscriber.hostId} - User-ID: ${ebicsSubscriber.userId} - Partner-ID: ${ebicsSubscriber.partnerId} - ES version: A006 - """.trimIndent() - ) - ) - } - - fun writeKey(doc: Document, priv: RSAPrivateCrtKey) { - val pub = CryptoUtil.getRsaPublicFromPrivate(priv) - val hash = CryptoUtil.getEbicsPublicKeyHash(pub) - doc.add(Paragraph("Exponent:\n${formatHex(pub.publicExponent.toByteArray())}")) - doc.add(Paragraph("Modulus:\n${formatHex(pub.modulus.toByteArray())}")) - doc.add(Paragraph("SHA-256 hash:\n${formatHex(hash)}")) - } - - fun writeSigLine(doc: Document) { - doc.add(Paragraph("Ort / Datum: ________________")) - doc.add(Paragraph("Firma / Name: ________________")) - doc.add(Paragraph("Unterschrift: ________________")) - } - - Document(pdfDoc).use { - it.add(Paragraph("Signaturschlüssel").setFontSize(24f)) - writeCommon(it) - it.add(Paragraph("Öffentlicher Schlüssel (Public key for the electronic signature)")) - writeKey(it, ebicsSubscriber.customerSignPriv) - it.add(Paragraph("\n")) - writeSigLine(it) - it.add(AreaBreak()) - - it.add(Paragraph("Authentifikationsschlüssel").setFontSize(24f)) - writeCommon(it) - it.add(Paragraph("Öffentlicher Schlüssel (Public key for the identification and authentication signature)")) - writeKey(it, ebicsSubscriber.customerAuthPriv) - it.add(Paragraph("\n")) - writeSigLine(it) - it.add(AreaBreak()) - - it.add(Paragraph("Verschlüsselungsschlüssel").setFontSize(24f)) - writeCommon(it) - it.add(Paragraph("Öffentlicher Schlüssel (Public encryption key)")) - writeKey(it, ebicsSubscriber.customerEncPriv) - it.add(Paragraph("\n")) - writeSigLine(it) - } - pdfWriter.flush() - return po.toByteArray() - } - - override fun exportBackup(bankConnectionId: String, passphrase: String): JsonNode { - val subscriber = transaction { getEbicsSubscriberDetails(bankConnectionId) } - val ret = EbicsKeysBackupJson( - type = "ebics", - dialect = subscriber.dialect, - userID = subscriber.userId, - hostID = subscriber.hostId, - partnerID = subscriber.partnerId, - ebicsURL = subscriber.ebicsUrl, - authBlob = bytesToBase64( - CryptoUtil.encryptKey( - subscriber.customerAuthPriv.encoded, - passphrase - ) - ), - encBlob = bytesToBase64( - CryptoUtil.encryptKey( - subscriber.customerEncPriv.encoded, - passphrase - ) - ), - sigBlob = bytesToBase64( - CryptoUtil.encryptKey( - subscriber.customerSignPriv.encoded, - passphrase - ) - ), - bankAuthBlob = run { - val maybeBankAuthPub = subscriber.bankAuthPub - if (maybeBankAuthPub != null) - return@run bytesToBase64(maybeBankAuthPub.encoded) - null - }, - bankEncBlob = run { - val maybeBankEncPub = subscriber.bankEncPub - if (maybeBankEncPub != null) - return@run bytesToBase64(maybeBankEncPub.encoded) - null - } - ) - val mapper = ObjectMapper() - return mapper.valueToTree(ret) - } - - override fun getConnectionDetails(conn: NexusBankConnectionEntity): JsonNode { - val ebicsSubscriber = transaction { getEbicsSubscriberDetails(conn.connectionId) } - val mapper = ObjectMapper() - val details = mapper.createObjectNode() - details.put("ebicsUrl", ebicsSubscriber.ebicsUrl) - details.put("ebicsHostId", ebicsSubscriber.hostId) - details.put("partnerId", ebicsSubscriber.partnerId) - details.put("userId", ebicsSubscriber.userId) - details.put( - "customerAuthKeyHash", - CryptoUtil.getEbicsPublicKeyHash( - CryptoUtil.getRsaPublicFromPrivate(ebicsSubscriber.customerAuthPriv) - ).toHexString() - ) - details.put( - "customerEncKeyHash", - CryptoUtil.getEbicsPublicKeyHash( - CryptoUtil.getRsaPublicFromPrivate(ebicsSubscriber.customerEncPriv) - ).toHexString() - ) - val bankAuthPubImmutable = ebicsSubscriber.bankAuthPub - if (bankAuthPubImmutable != null) { - details.put( - "bankAuthKeyHash", - CryptoUtil.getEbicsPublicKeyHash(bankAuthPubImmutable).toHexString() - ) - } - val bankEncPubImmutable = ebicsSubscriber.bankEncPub - if (bankEncPubImmutable != null) { - details.put( - "bankEncKeyHash", - CryptoUtil.getEbicsPublicKeyHash(bankEncPubImmutable).toHexString() - ) - } - val node = mapper.createObjectNode() - node.put("type", conn.type) - node.put("owner", conn.owner.username) - node.put("ready", true) // test with #6715 needed. - node.set<JsonNode>("details", details) - return node - } - override fun createConnection( - connId: String, - user: NexusUserEntity, - data: JsonNode - ) { - val newTransportData = jacksonObjectMapper() - .treeToValue(data, EbicsNewTransport::class.java) ?: throw NexusError( - HttpStatusCode.BadRequest, - "Ebics details not found in request" - ) - val bankConn = NexusBankConnectionEntity.new { - this.connectionId = connId - owner = user - type = "ebics" - this.dialect = newTransportData.dialect - } - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - EbicsSubscriberEntity.new { - ebicsURL = newTransportData.ebicsURL - hostID = newTransportData.hostID - partnerID = newTransportData.partnerID - userID = newTransportData.userID - systemID = newTransportData.systemID - signaturePrivateKey = ExposedBlob((pairA.private.encoded)) - encryptionPrivateKey = ExposedBlob((pairB.private.encoded)) - authenticationPrivateKey = ExposedBlob((pairC.private.encoded)) - nexusBankConnection = bankConn - ebicsIniState = EbicsInitState.NOT_SENT - ebicsHiaState = EbicsInitState.NOT_SENT - } - } - - override fun createConnectionFromBackup( - connId: String, - user: NexusUserEntity, - passphrase: String?, - backup: JsonNode - ) { - if (passphrase === null) { - throw NexusError( - HttpStatusCode.BadRequest, - "EBICS backup needs passphrase" - ) - } - val ebicsBackup = jacksonObjectMapper().treeToValue(backup, EbicsKeysBackupJson::class.java) - val bankConn = NexusBankConnectionEntity.new { - connectionId = connId - owner = user - type = "ebics" - this.dialect = ebicsBackup.dialect - } - val (authKey, encKey, sigKey) = try { - Triple( - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.authBlob)), - passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.encBlob)), - passphrase - ), - CryptoUtil.decryptKey( - EncryptedPrivateKeyInfo(base64ToBytes(ebicsBackup.sigBlob)), - passphrase - ) - ) - } catch (e: Exception) { - e.printStackTrace() - logger.info("Restoring keys failed, probably due to wrong passphrase") - throw NexusError( - HttpStatusCode.BadRequest, - "Bad backup given" - ) - } - try { - EbicsSubscriberEntity.new { - ebicsURL = ebicsBackup.ebicsURL - hostID = ebicsBackup.hostID - partnerID = ebicsBackup.partnerID - userID = ebicsBackup.userID - signaturePrivateKey = ExposedBlob(sigKey.encoded) - encryptionPrivateKey = ExposedBlob((encKey.encoded)) - authenticationPrivateKey = ExposedBlob((authKey.encoded)) - nexusBankConnection = bankConn - ebicsIniState = EbicsInitState.UNKNOWN - ebicsHiaState = EbicsInitState.UNKNOWN - if (ebicsBackup.bankAuthBlob != null) { - val keyBlob = base64ToBytes(ebicsBackup.bankAuthBlob) - try { CryptoUtil.loadRsaPublicKey(keyBlob) } - catch (e: Exception) { - logger.error("Could not restore bank's auth public key") - throw NexusError( - HttpStatusCode.BadRequest, - "Bad bank's auth pub" - ) - } - bankAuthenticationPublicKey = ExposedBlob(keyBlob) - } - if (ebicsBackup.bankEncBlob != null) { - val keyBlob = base64ToBytes(ebicsBackup.bankEncBlob) - try { CryptoUtil.loadRsaPublicKey(keyBlob) } - catch (e: Exception) { - logger.error("Could not restore bank's enc public key") - throw NexusError( - HttpStatusCode.BadRequest, - "Bad bank's enc pub" - ) - } - bankEncryptionPublicKey = ExposedBlob(keyBlob) - } - } - } catch (e: Exception) { - throw NexusError( - HttpStatusCode.BadRequest, - "exception: $e" - ) - } - return - } - - override suspend fun fetchAccounts(client: HttpClient, connId: String) { - val subscriberDetails = transaction { getEbicsSubscriberDetails(connId) } - val response = doEbicsDownloadTransaction( - client, - subscriberDetails, - EbicsFetchSpec( - orderType = "HTD", - orderParams = EbicsStandardOrderParams() - ) - ) - when (response) { - is EbicsDownloadEmptyResult -> { - // no-op - logger.warn("HTD response was empty.") - } - is EbicsDownloadBankErrorResult -> { - throw NexusError( - HttpStatusCode.BadGateway, - response.returnCode.errorCode - ) - } - is EbicsDownloadSuccessResult -> { - val payload = XMLUtil.convertStringToJaxb<HTDResponseOrderData>( - response.orderData.toString(Charsets.UTF_8) - ) - transaction { - payload.value.partnerInfo.accountInfoList?.forEach { accountInfo -> - val conn = NexusBankConnectionEntity.findByName(connId) ?: throw NexusError( - HttpStatusCode.NotFound, - "bank connection not found" - ) - // Avoiding to store twice one downloaded bank account. - val isDuplicate = OfferedBankAccountsTable.select { - OfferedBankAccountsTable.bankConnection eq conn.id and ( - OfferedBankAccountsTable.offeredAccountId eq accountInfo.id) - }.firstOrNull() - if (isDuplicate != null) return@forEach - // Storing every new bank account. - OfferedBankAccountsTable.insert { newRow -> - newRow[accountHolder] = accountInfo.accountHolder ?: "NOT GIVEN" - newRow[iban] = - accountInfo.accountNumberList?.filterIsInstance<EbicsTypes.GeneralAccountNumber>() - ?.find { it.international }?.value - ?: throw NexusError(HttpStatusCode.NotFound, reason = "bank gave no IBAN") - newRow[bankCode] = accountInfo.bankCodeList?.filterIsInstance<EbicsTypes.GeneralBankCode>() - ?.find { it.international }?.value - ?: throw NexusError( - HttpStatusCode.NotFound, - reason = "bank gave no BIC" - ) - newRow[bankConnection] = requireBankConnectionInternal(connId).id - newRow[offeredAccountId] = accountInfo.id - } - } - } - } - } - } - - override suspend fun connect(client: HttpClient, connId: String) { - val subscriber = transaction { getEbicsSubscriberDetails(connId) } - if (subscriber.bankAuthPub != null && subscriber.bankEncPub != null) { - return - } - if (subscriber.ebicsIniState == EbicsInitState.UNKNOWN || subscriber.ebicsHiaState == EbicsInitState.UNKNOWN) { - if (tentativeHpb(client, connId)) { - /** - * NOTE/FIXME: in case the HIA/INI did succeed (state is UNKNOWN but Sandbox - * has somehow the keys), here the state should be set to SENT, because later - - * when the Sandbox will respond to the INI/HIA requests - we'll get a - * EBICS_INVALID_USER_OR_USER_STATE. Hence, the state will never switch to - * SENT again. - */ - return - } - } - val iniDone = when (subscriber.ebicsIniState) { - EbicsInitState.NOT_SENT, EbicsInitState.UNKNOWN -> { - val iniResp = doEbicsIniRequest(client, subscriber) - iniResp.bankReturnCode == EbicsReturnCode.EBICS_OK && iniResp.technicalReturnCode == EbicsReturnCode.EBICS_OK - } - EbicsInitState.SENT -> true - } - val hiaDone = when (subscriber.ebicsHiaState) { - EbicsInitState.NOT_SENT, EbicsInitState.UNKNOWN -> { - val hiaResp = doEbicsHiaRequest(client, subscriber) - hiaResp.bankReturnCode == EbicsReturnCode.EBICS_OK && hiaResp.technicalReturnCode == EbicsReturnCode.EBICS_OK - } - EbicsInitState.SENT -> true - } - val hpbData = try { - doEbicsHpbRequest(client, subscriber) - } catch (e: EbicsProtocolError) { - logger.warn("failed HPB request", e) - null - } - transaction { - val conn = NexusBankConnectionEntity.findByName(connId) - if (conn == null) { - throw NexusError(HttpStatusCode.NotFound, "bank connection '$connId' not found") - } - val subscriberEntity = - EbicsSubscriberEntity.find { NexusEbicsSubscribersTable.nexusBankConnection eq conn.id }.first() - if (iniDone) { - subscriberEntity.ebicsIniState = EbicsInitState.SENT - } - if (hiaDone) { - subscriberEntity.ebicsHiaState = EbicsInitState.SENT - } - if (hpbData != null) { - subscriberEntity.bankAuthenticationPublicKey = - ExposedBlob((hpbData.authenticationPubKey.encoded)) - subscriberEntity.bankEncryptionPublicKey = ExposedBlob((hpbData.encryptionPubKey.encoded)) - } - } - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt deleted file mode 100644 index 2a83e847..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt +++ /dev/null @@ -1,286 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus.iso20022 -import CreditDebitIndicator - -/** - * Extra rules for German Banking Industry Committee (GBIC) for ISO 20022. - */ -object GbicRules { - /** - * Map credit/debit indicator and the German GVC code to a ISO 20022 bank transaction code. - * When multiple alternatives are available, we always choose the least specific one. - * - * Mapping taken from "Anhang1 zu Anlage 3 - Datenformatstandards-Version 3.3 Final Version-2019-04-11" - */ - @Suppress("SpellCheckingInspection") - fun getBtcFromGvc(c: CreditDebitIndicator, s: String): String { - val cd = when (c) { - CreditDebitIndicator.CRDT -> "C" - CreditDebitIndicator.DBIT -> "D" - } - return when ("${cd}-${s}") { - "D-006" -> "PMNT-CCRD-POSC" - "C-058" -> "PMNT-RCDT-FICT" - "C-072" -> "PMNT-DRFT-STLR" - "D-073" -> "PMNT-DRFT-STAM" - "C-079" -> "PMNT-MCOP-OTHR" - "D-079" -> "PMNT-MDOP-OTHR" - "C-082" -> "PMNT-CNTR-CDPT" - "D-083" -> "PMNT-CNTR-CWDL" - "D-084" -> "PMNT-RDDT-OODD" - "D-087" -> "PMNT-ICDT-SDVA" - "C-088" -> "PMNT-RCDT-SDVA" - "C-093" -> "PMNT-DRFT-DDFT" - "C-095" -> "TRAD-GUAR-OTHR" - "D-095" -> "TRAD-GUAR-OTHR" - "C-098" -> "PMNT-MCRD-SMCD" - "D-101" -> "PMNT-ICHQ-CCHQ" - "D-102" -> "PMNT-ICHQ-ORCQ" - "D-103" -> "PMNT-ICHQ-CCHQ" - "D-104" -> "PMNT-RDDT-BBDD" - "D-105" -> "PMNT-RDDT-ESDD" - // Alternatives: - // "D-106" -> "PMNT-CCRD-CWDL" - // "D-106" -> "PMNT-CCRD-SMRT" - // "D-106" -> "PMNT-CCRD-POSD" - // "D-106" -> "PMNT-MCRD-CHRG" - "D-106" -> "PMNT-CCRD-OTHR" - "D-107" -> "PMNT-CCRD-OTHR" - "D-108" -> "PMNT-IDDT-UPDD" - "D-109" -> "PMNT-IDDT-UPDD" - "D-110" -> "PMNT-MCRD-UPCT" - "D-111" -> "PMNT-ICHQ-UPCQ" - "D-112" -> "PMNT-ICHQ-OTHR" - "C-112" -> "PMNT-RCHQ-OTHR" - "D-116" -> "PMNT-ICDT-ESCT" - "D-118" -> "PMNT-IRCT-ESCT" - "D-117" -> "PMNT-ICDT-STDO" - "D-119" -> "PMNT-ICDT-ESCT" - "D-122" -> "PMNT-ICHQ-CCHQ" - "C-152" -> "PMNT-RCDT-STDO" - "C-153" -> "PMNT-RCDT-SALA" - "C-154" -> "PMNT-RCDT-ESCT" - "C-155" -> "PMNT-RCDT-ESCT" - "C-156" -> "PMNT-RCDT-ESCT" - "C-157" -> "PMNT-RRCT-SALA" - "C-159" -> "PMNT-ICDT-RRTN" - "D-159" -> "PMNT-RCDT-RRTN" - "C-160" -> "PMNT-IRCT-RRTN" - "D-160" -> "PMNT-RRCT-RRTN" - "C-161" -> "PMNT-RRCT-ESCT" - "C-162" -> "PMNT-RRCT-ESCT" - "C-163" -> "PMNT-RRCT-ESCT" - "C-164" -> "PMNT-RRCT-ESCT" - "C-165" -> "PMNT-RRCT-ESCT" - "C-166" -> "PMNT-RCDT-ESCT" - "C-167" -> "PMNT-RCDT-ESCT" - "C-168" -> "PMNT-RRCT-ESCT" - "C-169" -> "PMNT-RCDT-ESCT" - "C-170" -> "PMNT-RCHQ-URCQ" - "C-171" -> "PMNT-IDDT-ESDD" - "C-174" -> "PMNT-IDDT-BBDD" - "D-177" -> "PMNT-ICDT-ESCT" - "C-181" -> "PMNT-RDDT-UPDD" - "C-182" -> "PMNT-CCRD-RIMB" - "C-183" -> "PMNT-RCHQ-UPCQ" - "C-184" -> "PMNT-RDDT-UPDD" - "D-185" -> "PMNT-ICHQ-CCHQ" - "D-188" -> "PMNT-IRCT-ESCT" - "C-189" -> "PMNT-RRCT-ESCT" - "D-190" -> "PMNT-CCRD-OTHR" - "D-191" -> "PMNT-ICDT-ESCT" - "C-192" -> "PMNT-IDDT-ESDD" - "D-193" -> "PMNT-IDDT-RCDD" - "C-194" -> "PMNT-RCDT-ESCT" - "D-195" -> "PMNT-RDDT-ESDD" - "C-196" -> "PMNT-IDDT-BBDD" - "D-197" -> "PMNT-RDDT-BBDD" - "C-198" -> "PMNT-MCRD-POSP" - "D-199" -> "PMNT-MCRD-DAJT" - "D-201" -> "PMNT-ICDT-XBCT" - "C-202" -> "PMNT-RCDT-XBCT" - "C-203" -> "TRAD-CLNC-OTHR" - "D-203" -> "TRAD-CLNC-OTHR" - "C-204" -> "TRAD-DCCT-OTHR" - "D-204" -> "TRAD-DCCT-OTHR" - "C-205" -> "TRAD-GUAR-OTHR" - "D-205" -> "TRAD-GUAR-OTHR" - "C-206" -> "PMNT-RCDT-XBCT" - "C-208" -> "TRAD-MCOP-OTHR" - "D-208" -> "TRAD-MDOP-OTHR" - "D-209" -> "PMNT-ICHQ-XBCQ" - "D-210" -> "PMNT-ICDT-XBCT" - "C-211" -> "PMNT-RCDT-XBCT" - "D-212" -> "PMNT-ICDT-XBST" - "D-213" -> "PMNT-RDDT-XBDD" - "D-214" -> "TRAD-DOCC-OTHR" - "C-215" -> "TRAD-DOCC-OTHR" - "D-216" -> "PMNT-DRFT-STAM" - "C-217" -> "PMNT-DRFT-STAM" - // Alternative: - // "C-217" -> "PMNT-DRFT-STLR" - "D-218" -> "TRAD-DCCT-OTHR" - "C-219" -> "TRAD-DCCT-OTHR" - "C-220" -> "PMNT-RCHQ-XRCQ" - "C-221" -> "PMNT-RCHQ-XBCQ" - "D-222" -> "PMNT-ICHQ-XBCQ" - "D-223" -> "PMNT-ICHQ-XBCQ" - "C-224" -> "PMNT-CNTR-FCDP" - "D-225" -> "PMNT-CNTR-FCWD" - "C-301" -> "SECU-CUST-REDM" - "C-302" -> "SECU-CUST-DVCA" - "C-303" -> "SECU-SETT-TRAD" - "D-303" -> "SECU-SETT-TRAD" - "C-304" -> "SECU-OTHR-OTHR" - "D-304" -> "SECU-OTHR-OTHR" - "D-305" -> "SECU-OTHR-OTHR" - "D-306" -> "SECU-OTHR-OTHR" - "D-307" -> "SECU-SETT-SUBS" - "C-308" -> "SECU-CORP-EXWA" - "D-308" -> "SECU-CORP-EXWA" - "C-309" -> "SECU-CORP-BONU" - "D-309" -> "SECU-CORP-BONU" - "C-310" -> "SECU-MCOP-OTHR" - "D-310" -> "SECU-MDOP-OTHR" - "C-311" -> "DERV-OTHR-OTHR" - "D-311" -> "DERV-OTHR-OTHR" - "D-320" -> "SECU-CASH-TRFE" - "D-321" -> "SECU-CUST-CHRG" - "C-321" -> "SECU-CUST-CHRG" - "C-330" -> "SECU-CUST-INTR" - "C-340" -> "SECU-CUST-REDM" - "C-399" -> "ACMT-ACOP-PSTE" - "D-399" -> "ACMT-ADOP-PSTE" - "C-401" -> "FORX-SPOT-OTHR" - "D-401" -> "FORX-SPOT-OTHR" - "C-402" -> "FORX-FWRD-OTHR" - "D-402" -> "FORX-FWRD-OTHR" - "D-403" -> "FORX-MDOP-OTHR" - "D-404" -> "FORX-OTHR-OTHR" - "D-405" -> "FORX-OTHR-OTHR" - "C-406" -> "FORX-SPOT-OTHR" - "D-406" -> "FORX-SPOT-OTHR" - "C-407" -> "FORX-OTHR-OTHR" - "D-407" -> "FORX-OTHR-OTHR" - "C-408" -> "FORX-OTHR-OTHR" - "C-409" -> "FORX-OTHR-OTHR" - "D-411" -> "FORX-SPOT-OTHR" - "C-412" -> "FORX-SPOT-OTHR" - "D-413" -> "FORX-FWRD-OTHR" - "C-414" -> "FORX-FWRD-OTHR" - "D-415" -> "FORX-OTHR-OTHR" - "C-416" -> "FORX-OTHR-OTHR" - "D-417" -> "FORX-OTHR-OTHR" - "C-418" -> "FORX-OTHR-OTHR" - "D-419" -> "FORX-OTHR-OTHR" - "C-420" -> "FORX-OTHR-OTHR" - "C-421" -> "FORX-OTHR-OTHR" - "D-421" -> "FORX-OTHR-OTHR" - "C-422" -> "FORX-SWAP-OTHR" - "D-422" -> "FORX-SWAP-OTHR" - "C-423" -> "PMET-SPOT-OTHR" - "D-424" -> "PMET-SPOT-OTHR" - "D-601" -> "LDAS-FTLN-OTHR" - "C-602" -> "LDAS-FTLN-OTHR" - "D-603" -> "LDAS-FTLN-PPAY" - "D-604" -> "LDAS-MDOP-INTR" - "D-605" -> "LDAS-MDOP-INTR" - "C-606" -> "LDAS-FTLN-DDWN" - "D-606" -> "LDAS-FTLN-DDWN" - "D-607" -> "LDAS-OTHR-OTHR" - "D-801" -> "ACMT-MDOP-CHRG" - "D-802" -> "ACMT-MDOP-CHRG" - "D-803" -> "SECU-CUST-CHRG" - "D-804" -> "PMNT-MDOP-CHRG" - "C-804" -> "PMNT-MCOP-CHRG" - "C-805" -> "ACMT-OPCL-ACCC" - "D-805" -> "ACMT-OPCL-ACCC" - "C-806" -> "ACMT-MCOP-CHRG" - "D-806" -> "ACMT-MDOP-CHRG" - "C-807" -> "ACMT-MCOP-CHRG" - "D-807" -> "ACMT-MDOP-CHRG" - "C-808" -> "PMNT-MCOP-CHRG" - "D-808" -> "PMNT-MDOP-CHRG" - // Alternatives: - // "C-808" -> "TRAD-MCOP-CHRG" - // "D-808" -> "TRAD-MDOP-CHRG" - // "C-808" -> "ACMT-MCOP-CHRG" - // "D-808" -> "ACMT-MDOP-CHRG" - "D-809" -> "PMNT-MDOP-COMM" - "C-809" -> "PMNT-MCOP-COMM" - // Alternatives: - // "D-809" -> "ACMT-MDOP-COMM" - // "C-809" -> "ACMT-MCOP-COMM" - // "D-809" -> "TRAD-MDOP-COMM" - // "C-809" -> "TRAD-MCOP-COMM" - // "D-809" -> "LDAS-MDOP-COMM" - // "C-809" -> "LDAS-MCOP-COMM" - "D-810" -> "ACMT-MDOP-CHRG" - "C-810" -> "ACMT-MCOP-CHRG" - "D-811" -> "LDAS-MDOP-CHRG" - "C-811" -> "LDAS-MCOP-CHRG" - "D-812" -> "LDAS-MDOP-INTR" - "C-812" -> "LDAS-MCOP-INTR" - "D-813" -> "LDAS-MDOP-INTR" - "C-814" -> "ACMT-MCOP-INTR" - "D-814" -> "ACMT-MDOP-INTR" - "C-815" -> "ACMT-OTHR-OTHR" - "C-816" -> "ACMT-OTHR-OTHR" - "C-817" -> "ACMT-OTHR-OTHR" - "D-818" -> "PMNT-OTHR-OTHR" - "C-819" -> "PMNT-OTHR-OTHR" - "C-820" -> "PMNT-RCDT-BOOK" - "D-820" -> "PMNT-ICDT-BOOK" - "D-821" -> "PMNT-OTHR-OTHR" - "C-822" -> "PMNT-OTHR-OTHR" - "C-823" -> "LDAS-FTDP-RPMT" - "D-823" -> "LDAS-FTDP-DPST" - "D-824" -> "LDAS-OTHR-OTHR" - "D-825" -> "LDAS-OTHR-OTHR" - "D-826" -> "LDAS-OTHR-OTHR" - "D-827" -> "LDAS-OTHR-OTHR" - "C-828" -> "LDAS-FTDP-RPMT" - "D-828" -> "LDAS-FTDP-DPST" - "C-829" -> "LDAS-FTDP-RPMT" - "D-829" -> "LDAS-FTDP-DPST" - "C-830" -> "LDAS-FTDP-INTR" - "D-831" -> "XTND-NTAV-NTAV" - "D-832" -> "LDAS-OTHR-OTHR" - "C-833" -> "CAMT-ACCB-OTHR" - "D-833" -> "CAMT-ACCB-OTHR" - "C-834" -> "CAMT-ACCB-OTHR" - "D-834" -> "CAMT-ACCB-OTHR" - "C-835" -> "XTND-NTAV-NTAV" - "D-835" -> "XTND-NTAV-NTAV" - "C-836" -> "ACMT-MCOP-ADJT" - "D-836" -> "ACMT-MDOP-ADJT" - "D-837" -> "ACMT-MDOP-TAXE" - "C-888" -> "XTND-NTAV-NTAV" - "D-888" -> "XTND-NTAV-NTAV" - "C-899" -> "ACMT-ACOP-PSTE" - "D-899" -> "ACMT-ADOP-PSTE" - "D-997" -> "XTND-NTAV-NTAV" - "C-999" -> "XTND-NTAV-NTAV" - "D-999" -> "XTND-NTAV-NTAV" - else -> "XTND-NTAV-NTAV" - } - } -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt deleted file mode 100644 index 128b7a1b..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt +++ /dev/null @@ -1,1023 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -/** - * Parse and generate ISO 20022 messages - */ -package tech.libeufin.nexus.iso20022 - -import AgentIdentification -import Batch -import BatchTransaction -import CamtBankAccountEntry -import CashAccount -import CreditDebitIndicator -import CurrencyAmount -import CurrencyExchange -import EntryStatus -import GenericId -import OrganizationIdentification -import PartyIdentification -import PostalAddress -import PrivateIdentification -import ReturnInfo -import TransactionDetails -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.annotation.JsonValue -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.http.* -import io.ktor.util.reflect.* -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import org.w3c.dom.Document -import tech.libeufin.nexus.* -import tech.libeufin.nexus.bankaccount.IngestedTransactionsCount -import tech.libeufin.nexus.bankaccount.findDuplicate -import tech.libeufin.nexus.server.EbicsDialects -import tech.libeufin.nexus.server.FetchLevel -import tech.libeufin.nexus.server.PaymentUidQualifiers -import tech.libeufin.util.* -import toPlainString -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import tech.libeufin.nexus.logger - - -enum class CashManagementResponseType(@get:JsonValue val jsonName: String) { - Report("report"), - Statement("statement"), - Notification("notification") -} - -@JsonInclude(JsonInclude.Include.NON_NULL) -data class CamtReport( - val id: String, - val creationDateTime: String?, - val legalSequenceNumber: Int?, - val electronicSequenceNumber: Int?, - val fromDate: String?, - val toDate: String?, - val reportingSource: String?, - val proprietaryReportingSource: String?, - val account: CashAccount, - val balances: List<Balance>, - val entries: List<CamtBankAccountEntry> -) - -@JsonInclude(JsonInclude.Include.NON_NULL) -data class Balance( - val type: String?, - val subtype: String?, - val proprietaryType: String?, - val proprietarySubtype: String?, - val date: String, - val creditDebitIndicator: CreditDebitIndicator, - val amount: CurrencyAmount -) - -data class CamtParseResult( - /** - * Message type in form of the ISO 20022 message name. - */ - val messageType: CashManagementResponseType, - val messageId: String, - val creationDateTime: String, - /** - * One Camt document can contain multiple reports/statements - * for each account being owned by the requester. - */ - val reports: List<CamtReport> -) - -class CamtParsingError(msg: String) : Exception(msg) - -/** - * Data that the LibEuFin nexus uses for payment initiation. - * Subset of what ISO 20022 allows. - */ -data class NexusPaymentInitiationData( - val debtorIban: String, - val debtorBic: String, - val debtorName: String, - val messageId: String, - val paymentInformationId: String, - val endToEndId: String? = null, - val amount: String, - val currency: String, - val subject: String, - val preparationTimestamp: Long, - val creditorName: String, - val creditorIban: String, - val creditorBic: String? = null, - val instructionId: String? = null -) - -data class Pain001Namespaces( - val fullNamespace: String, - val xsdFilename: String -) - -fun XmlElementBuilder.setBicAfterDialect(dialect: String?, bic: String) { - if (dialect == EbicsDialects.POSTFINANCE.dialectName) - element("BICFI") { - text(bic) - } - else element("BIC") { - text(bic) - } -} -/** - * Create a PAIN.001 XML document according to the input data. - * Needs to be called within a transaction block. - */ -fun createPain001document( - paymentData: NexusPaymentInitiationData, - dialect: String? = null -): String { - - val namespace: Pain001Namespaces = if (dialect == "pf") - // The 2019 version of pain.001. - Pain001Namespaces( - fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.09", - xsdFilename = "pain.001.001.09.ch.03.xsd" - ) - else Pain001Namespaces( - fullNamespace = "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03", - xsdFilename = "pain.001.001.03.xsd" - ) - - val paymentMethod = if (dialect == "pf") - "SDVA" else "SEPA" - - val s = constructXml(indent = true) { - root("Document") { - attribute("xmlns", namespace.fullNamespace) - attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") - attribute( - "xsi:schemaLocation", - "${namespace.fullNamespace} ${namespace.xsdFilename}" - ) - element("CstmrCdtTrfInitn") { - element("GrpHdr") { - element("MsgId") { - text(paymentData.messageId) - } - element("CreDtTm") { - val dateMillis = paymentData.preparationTimestamp - val dateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME - val instant = Instant.ofEpochSecond(dateMillis / 1000) - val zoned = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()) - text(dateFormatter.format(zoned)) - } - element("NbOfTxs") { - text("1") - } - element("CtrlSum") { - text(paymentData.amount) - } - element("InitgPty/Nm") { - text(paymentData.debtorName) - } - } - element("PmtInf") { - element("PmtInfId") { - text(paymentData.paymentInformationId) - } - element("PmtMtd") { - text("TRF") - } - element("BtchBookg") { - text("true") - } - element("NbOfTxs") { - text("1") - } - element("CtrlSum") { - text(paymentData.amount) - } - element("PmtTpInf/SvcLvl/Cd") { - text(paymentMethod) - } - element("ReqdExctnDt") { - val dateMillis = paymentData.preparationTimestamp - if (dialect == EbicsDialects.POSTFINANCE.dialectName) - element("Dt") { - text(importDateFromMillis(dateMillis).toDashedDate()) - } - else - text(importDateFromMillis(dateMillis).toDashedDate()) - } - element("Dbtr/Nm") { - text(paymentData.debtorName) - } - element("DbtrAcct/Id/IBAN") { - text(paymentData.debtorIban) - } - element("DbtrAgt/FinInstnId") { - setBicAfterDialect(dialect, paymentData.debtorBic) - } - element("ChrgBr") { - text("SLEV") - } - element("CdtTrfTxInf") { - element("PmtId") { - paymentData.instructionId?.let { - element("InstrId") { text(it) } - } - when (val eeid = paymentData.endToEndId) { - null -> element("EndToEndId") { text("NOTPROVIDED") } - else -> element("EndToEndId") { text(eeid) } - } - } - element("Amt/InstdAmt") { - attribute("Ccy", paymentData.currency) - text(paymentData.amount) - } - val creditorBic = paymentData.creditorBic - if (creditorBic != null) { - element("CdtrAgt/FinInstnId") { - setBicAfterDialect(dialect, creditorBic) - } - } - element("Cdtr/Nm") { - text(paymentData.creditorName) - } - element("CdtrAcct/Id/IBAN") { - text(paymentData.creditorIban) - } - element("RmtInf/Ustrd") { - text(paymentData.subject) - } - } - } - } - } - } - return s -} - -private fun XmlElementDestructor.extractDateOrDateTime(): String { - return requireOnlyChild { - when (focusElement.localName) { - "Dt" -> focusElement.textContent - "DtTm" -> focusElement.textContent - else -> throw Exception("Invalid date / time: ${focusElement.localName}") - } - } -} - -private fun XmlElementDestructor.extractInnerPostalAddress(): PostalAddress { - return PostalAddress( - addressCode = maybeUniqueChildNamed("AdrTp") { maybeUniqueChildNamed("Cd") { focusElement.textContent } }, - addressProprietaryIssuer = maybeUniqueChildNamed("AdrTp") { - maybeUniqueChildNamed("Prtry") { - maybeUniqueChildNamed("Issr") { focusElement.textContent } - } - }, - addressProprietarySchemeName = maybeUniqueChildNamed("AdrTp") { - maybeUniqueChildNamed("Prtry") { - maybeUniqueChildNamed("SchmeNm") { focusElement.textContent } - } - }, - addressProprietaryId = maybeUniqueChildNamed("AdrTp") { - maybeUniqueChildNamed("Prtry") { - maybeUniqueChildNamed("Id") { focusElement.textContent } - } - }, - buildingName = maybeUniqueChildNamed("BldgNm") { focusElement.textContent }, - buildingNumber = maybeUniqueChildNamed("BldgNb") { focusElement.textContent }, - country = maybeUniqueChildNamed("Ctry") { focusElement.textContent }, - countrySubDivision = maybeUniqueChildNamed("CtrySubDvsn") { focusElement.textContent }, - department = maybeUniqueChildNamed("Dept") { focusElement.textContent }, - districtName = maybeUniqueChildNamed("DstrctNm") { focusElement.textContent }, - floor = maybeUniqueChildNamed("Flr") { focusElement.textContent }, - postBox = maybeUniqueChildNamed("PstBx") { focusElement.textContent }, - postCode = maybeUniqueChildNamed("PstCd") { focusElement.textContent }, - room = maybeUniqueChildNamed("Room") { focusElement.textContent }, - streetName = maybeUniqueChildNamed("StrtNm") { focusElement.textContent }, - subDepartment = maybeUniqueChildNamed("SubDept") { focusElement.textContent }, - townLocationName = maybeUniqueChildNamed("TwnLctnNm") { focusElement.textContent }, - townName = maybeUniqueChildNamed("TwnNm") { focusElement.textContent }, - addressLines = mapEachChildNamed("AdrLine") { focusElement.textContent } - ) -} - -private fun XmlElementDestructor.extractAgent(): AgentIdentification { - return AgentIdentification( - name = maybeUniqueChildNamed("FinInstnId") { - maybeUniqueChildNamed("Nm") { focusElement.textContent } - }, - bic = requireUniqueChildNamed("FinInstnId") { - maybeUniqueChildNamed("BIC") { focusElement.textContent } - }, - lei = requireUniqueChildNamed("FinInstnId") { - maybeUniqueChildNamed("LEI") { focusElement.textContent } - }, - clearingSystemCode = requireUniqueChildNamed("FinInstnId") { - maybeUniqueChildNamed("ClrSysMmbId") { - maybeUniqueChildNamed("ClrSysId") { - maybeUniqueChildNamed("Cd") { focusElement.textContent } - } - } - }, - proprietaryClearingSystemCode = requireUniqueChildNamed("FinInstnId") { - maybeUniqueChildNamed("ClrSysMmbId") { - maybeUniqueChildNamed("ClrSysId") { - maybeUniqueChildNamed("Prtry") { focusElement.textContent } - } - } - }, - clearingSystemMemberId = requireUniqueChildNamed("FinInstnId") { - maybeUniqueChildNamed("ClrSysMmbId") { - maybeUniqueChildNamed("MmbId") { focusElement.textContent } - } - }, - otherId = requireUniqueChildNamed("FinInstnId") { maybeUniqueChildNamed("Othr") { extractGenericId() } }, - postalAddress = requireUniqueChildNamed("FinInstnId") { maybeUniqueChildNamed("PstlAdr") { extractInnerPostalAddress() } } - ) -} - -private fun XmlElementDestructor.extractGenericId(): GenericId { - return GenericId( - id = requireUniqueChildNamed("Id") { focusElement.textContent }, - schemeName = maybeUniqueChildNamed("SchmeNm") { - maybeUniqueChildNamed("Cd") { focusElement.textContent } - }, - issuer = maybeUniqueChildNamed("Issr") { focusElement.textContent }, - proprietarySchemeName = maybeUniqueChildNamed("SchmeNm") { - maybeUniqueChildNamed("Prtry") { focusElement.textContent } - } - ) -} - -private fun XmlElementDestructor.extractAccount(): CashAccount { - var iban: String? = null - var otherId: GenericId? = null - val currency: String? = maybeUniqueChildNamed("Ccy") { focusElement.textContent } - val name: String? = maybeUniqueChildNamed("Nm") { focusElement.textContent } - requireUniqueChildNamed("Id") { - requireOnlyChild { - when (focusElement.localName) { - "IBAN" -> { - iban = focusElement.textContent - } - "Othr" -> { - otherId = extractGenericId() - } - else -> throw Error("invalid account identification") - } - } - } - return CashAccount(name, currency, iban, otherId) -} - -private fun XmlElementDestructor.extractParty(): PartyIdentification { - val otherId: GenericId? = maybeUniqueChildNamed("Id") { - (maybeUniqueChildNamed("PrvtId") { focusElement } ?: maybeUniqueChildNamed("OrgId") { focusElement })?.run { - maybeUniqueChildNamed("Othr") { - extractGenericId() - } - } - } - - val privateId = maybeUniqueChildNamed("Id") { - maybeUniqueChildNamed("PrvtId") { - maybeUniqueChildNamed("DtAndPlcOfBirth") { - PrivateIdentification( - birthDate = maybeUniqueChildNamed("BirthDt") { focusElement.textContent }, - cityOfBirth = maybeUniqueChildNamed("CityOfBirth") { focusElement.textContent }, - countryOfBirth = maybeUniqueChildNamed("CtryOfBirth") { focusElement.textContent }, - provinceOfBirth = maybeUniqueChildNamed("PrvcOfBirth") { focusElement.textContent } - ) - } - } - } - - val organizationId = maybeUniqueChildNamed("Id") { - maybeUniqueChildNamed("OrgId") { - OrganizationIdentification( - bic = maybeUniqueChildNamed("BICOrBEI") { focusElement.textContent } - ?: maybeUniqueChildNamed("AnyBIC") { focusElement.textContent }, - lei = maybeUniqueChildNamed("LEI") { focusElement.textContent } - ) - } - } - - return PartyIdentification( - name = maybeUniqueChildNamed("Nm") { focusElement.textContent }, - otherId = otherId, - privateId = privateId, - organizationId = organizationId, - countryOfResidence = maybeUniqueChildNamed("CtryOfRes") { focusElement.textContent }, - postalAddress = maybeUniqueChildNamed("PstlAdr") { extractInnerPostalAddress() } - ) -} - -private fun XmlElementDestructor.extractCurrencyAmount(): CurrencyAmount { - return CurrencyAmount( - value = requireUniqueChildNamed("Amt") { focusElement.textContent }, - currency = requireUniqueChildNamed("Amt") { focusElement.getAttribute("Ccy") } - ) -} - -private fun XmlElementDestructor.maybeExtractCurrencyAmount(): CurrencyAmount? { - return maybeUniqueChildNamed("Amt") { - CurrencyAmount( - focusElement.getAttribute("Ccy"), - focusElement.textContent - ) - } -} - -private fun XmlElementDestructor.extractMaybeCurrencyExchange(): CurrencyExchange? { - return maybeUniqueChildNamed("CcyXchg") { - CurrencyExchange( - sourceCurrency = requireUniqueChildNamed("SrcCcy") { focusElement.textContent }, - targetCurrency = requireUniqueChildNamed("TrgtCcy") { focusElement.textContent }, - contractId = maybeUniqueChildNamed("CtrctId") { focusElement.textContent }, - exchangeRate = requireUniqueChildNamed("XchgRate") { focusElement.textContent }, - quotationDate = maybeUniqueChildNamed("QtnDt") { focusElement.textContent }, - unitCurrency = maybeUniqueChildNamed("UnitCcy") { focusElement.textContent } - ) - } -} - -private fun XmlElementDestructor.extractBatches( - inheritableAmount: CurrencyAmount, - outerCreditDebitIndicator: CreditDebitIndicator, - acctSvcrRef: String -): List<Batch> { - if (mapEachChildNamed("NtryDtls") {}.size != 1) throw CamtParsingError( - "This money movement (AcctSvcrRef: $acctSvcrRef) is not a singleton #0" - ) - val txs = requireUniqueChildNamed("NtryDtls") { - if (mapEachChildNamed("TxDtls") {}.size != 1) { - throw CamtParsingError("This money movement (AcctSvcrRef: $acctSvcrRef) is not a singleton #1") - } - requireUniqueChildNamed("TxDtls") { - val details = extractTransactionDetails(outerCreditDebitIndicator) - mutableListOf( - BatchTransaction( - inheritableAmount, - outerCreditDebitIndicator, - details - ) - ) - } - } - return mutableListOf( - Batch(messageId = null, paymentInformationId = null, batchTransactions = txs) - ) -} - -private fun XmlElementDestructor.maybeExtractCreditDebitIndicator(): CreditDebitIndicator? { - return maybeUniqueChildNamed("CdtDbtInd") { focusElement.textContent }?.let { - CreditDebitIndicator.valueOf(it) - } -} - -private fun XmlElementDestructor.extractTransactionDetails( - outerCreditDebitIndicator: CreditDebitIndicator -): TransactionDetails { - val instructedAmount = maybeUniqueChildNamed("AmtDtls") { - maybeUniqueChildNamed("InstdAmt") { extractCurrencyAmount() } - } - - val creditDebitIndicator = maybeExtractCreditDebitIndicator() ?: outerCreditDebitIndicator - val currencyExchange = maybeUniqueChildNamed("AmtDtls") { - val cxCntrVal = maybeUniqueChildNamed("CntrValAmt") { extractMaybeCurrencyExchange() } - val cxTx = maybeUniqueChildNamed("TxAmt") { extractMaybeCurrencyExchange() } - val cxInstr = maybeUniqueChildNamed("InstdAmt") { extractMaybeCurrencyExchange() } - cxCntrVal ?: cxTx ?: cxInstr - } - - return TransactionDetails( - instructedAmount = instructedAmount, - counterValueAmount = maybeUniqueChildNamed("AmtDtls") { - maybeUniqueChildNamed("CntrValAmt") { extractCurrencyAmount() } - }, - currencyExchange = currencyExchange, - interBankSettlementAmount = null, - endToEndId = maybeUniqueChildNamed("Refs") { - maybeUniqueChildNamed("EndToEndId") { focusElement.textContent } - }, - paymentInformationId = maybeUniqueChildNamed("Refs") { - maybeUniqueChildNamed("PmtInfId") { focusElement.textContent } - }, - accountServicerRef = maybeUniqueChildNamed("Refs") { - maybeUniqueChildNamed("AcctSvcrRef") { focusElement.textContent } - }, - unstructuredRemittanceInformation = maybeUniqueChildNamed("RmtInf") { - val chunks = mapEachChildNamed("Ustrd") { focusElement.textContent } - if (chunks.isEmpty()) { - null - } else { - chunks.joinToString(separator = "") - } - }, - creditorAgent = maybeUniqueChildNamed("RltdAgts") { maybeUniqueChildNamed("CdtrAgt") { extractAgent() } }, - debtorAgent = maybeUniqueChildNamed("RltdAgts") { maybeUniqueChildNamed("DbtrAgt") { extractAgent() } }, - debtorAccount = maybeUniqueChildNamed("RltdPties") { maybeUniqueChildNamed("DbtrAcct") { extractAccount() } }, - creditorAccount = maybeUniqueChildNamed("RltdPties") { maybeUniqueChildNamed("CdtrAcct") { extractAccount() } }, - debtor = maybeUniqueChildNamed("RltdPties") { maybeUniqueChildNamed("Dbtr") { extractParty() } }, - creditor = maybeUniqueChildNamed("RltdPties") { maybeUniqueChildNamed("Cdtr") { extractParty() } }, - proprietaryPurpose = maybeUniqueChildNamed("Purp") { maybeUniqueChildNamed("Prtry") { focusElement.textContent } }, - purpose = maybeUniqueChildNamed("Purp") { maybeUniqueChildNamed("Cd") { focusElement.textContent } }, - ultimateCreditor = maybeUniqueChildNamed("RltdPties") { maybeUniqueChildNamed("UltmtCdtr") { extractParty() } }, - ultimateDebtor = maybeUniqueChildNamed("RltdPties") { maybeUniqueChildNamed("UltmtDbtr") { extractParty() } }, - returnInfo = maybeUniqueChildNamed("RtrInf") { - ReturnInfo( - originalBankTransactionCode = maybeUniqueChildNamed("OrgnlBkTxCd") { - extractInnerBkTxCd( - when (creditDebitIndicator) { - CreditDebitIndicator.DBIT -> CreditDebitIndicator.CRDT - CreditDebitIndicator.CRDT -> CreditDebitIndicator.DBIT - } - ) - }, - originator = maybeUniqueChildNamed("Orgtr") { extractParty() }, - reason = maybeUniqueChildNamed("Rsn") { maybeUniqueChildNamed("Cd") { focusElement.textContent } }, - proprietaryReason = maybeUniqueChildNamed("Rsn") { maybeUniqueChildNamed("Prtry") { focusElement.textContent } }, - additionalInfo = maybeUniqueChildNamed("AddtlInf") { focusElement.textContent } - ) - } - ) -} - -private fun XmlElementDestructor.extractInnerBkTxCd(creditDebitIndicator: CreditDebitIndicator): String { - - val domain = maybeUniqueChildNamed("Domn") { maybeUniqueChildNamed("Cd") { focusElement.textContent } } - val family = maybeUniqueChildNamed("Domn") { - maybeUniqueChildNamed("Fmly") { - maybeUniqueChildNamed("Cd") { focusElement.textContent } - } - } - val subfamily = maybeUniqueChildNamed("Domn") { - maybeUniqueChildNamed("Fmly") { - maybeUniqueChildNamed("SubFmlyCd") { focusElement.textContent } - } - } - val proprietaryCode = maybeUniqueChildNamed("Prtry") { - maybeUniqueChildNamed("Cd") { focusElement.textContent } - } - val proprietaryIssuer = maybeUniqueChildNamed("Prtry") { - maybeUniqueChildNamed("Issr") { focusElement.textContent } - } - - if (domain != null && family != null && subfamily != null) { - return "$domain-$family-$subfamily" - } - if (proprietaryIssuer == "DK" && proprietaryCode != null) { - val components = proprietaryCode.split("+") - if (components.size == 1) { - return GbicRules.getBtcFromGvc(creditDebitIndicator, components[0]) - } else { - return GbicRules.getBtcFromGvc(creditDebitIndicator, components[1]) - } - } - // FIXME: log/raise this somewhere? - return "XTND-NTAV-NTAV" -} - -private fun XmlElementDestructor.extractInnerTransactions(dialect: String? = null): CamtReport { - val account = requireUniqueChildNamed("Acct") { extractAccount() } - - val balances = mapEachChildNamed("Bal") { - Balance( - type = maybeUniqueChildNamed("Tp") { - maybeUniqueChildNamed("CdOrPrtry") { - maybeUniqueChildNamed("Cd") { focusElement.textContent } - } - }, - proprietaryType = maybeUniqueChildNamed("Tp") { - maybeUniqueChildNamed("CdOrPrtry") { - maybeUniqueChildNamed("Prtry") { focusElement.textContent } - } - }, - date = requireUniqueChildNamed("Dt") { extractDateOrDateTime() }, - creditDebitIndicator = requireUniqueChildNamed("CdtDbtInd") { focusElement.textContent }.let { - CreditDebitIndicator.valueOf(it) - }, - subtype = maybeUniqueChildNamed("Tp") { - maybeUniqueChildNamed("SubTp") { maybeUniqueChildNamed("Cd") { focusElement.textContent } } - }, - proprietarySubtype = maybeUniqueChildNamed("Tp") { - maybeUniqueChildNamed("SubTp") { maybeUniqueChildNamed("Prtry") { focusElement.textContent } } - }, - amount = extractCurrencyAmount() - ) - } - // Note: multiple Ntry's *are* allowed. What is not allowed is - // multiple money transactions *within* one Ntry element. - val entries = mapEachChildNamed("Ntry") { - val amount = extractCurrencyAmount() - val status = requireUniqueChildNamed("Sts") { - val textContent = if (dialect == EbicsDialects.POSTFINANCE.dialectName) { - requireUniqueChildNamed("Cd") { - focusElement.textContent - } - } else - focusElement.textContent - textContent.let { - EntryStatus.valueOf(it) - } - } - val creditDebitIndicator = requireUniqueChildNamed("CdtDbtInd") { focusElement.textContent }.let { - CreditDebitIndicator.valueOf(it) - } - val btc = requireUniqueChildNamed("BkTxCd") { - extractInnerBkTxCd(creditDebitIndicator) - } - val acctSvcrRef = maybeUniqueChildNamed("AcctSvcrRef") { focusElement.textContent } - val entryRef = maybeUniqueChildNamed("NtryRef") { focusElement.textContent } - - val currencyExchange = maybeUniqueChildNamed("AmtDtls") { - val cxCntrVal = maybeUniqueChildNamed("CntrValAmt") { extractMaybeCurrencyExchange() } - val cxTx = maybeUniqueChildNamed("TxAmt") { extractMaybeCurrencyExchange() } - val cxInstr = maybeUniqueChildNamed("InstrAmt") { extractMaybeCurrencyExchange() } - cxCntrVal ?: cxTx ?: cxInstr - } - - val counterValueAmount = maybeUniqueChildNamed("AmtDtls") { - maybeUniqueChildNamed("CntrValAmt") { extractCurrencyAmount() } - } - - val instructedAmount = maybeUniqueChildNamed("AmtDtls") { - maybeUniqueChildNamed("InstdAmt") { extractCurrencyAmount() } - } - - CamtBankAccountEntry( - amount = amount, - status = status, - currencyExchange = currencyExchange, - counterValueAmount = counterValueAmount, - instructedAmount = instructedAmount, - creditDebitIndicator = creditDebitIndicator, - bankTransactionCode = btc, - batches = extractBatches( - amount, - creditDebitIndicator, - acctSvcrRef ?: "AcctSvcrRef not given/found"), - bookingDate = maybeUniqueChildNamed("BookgDt") { extractDateOrDateTime() }, - valueDate = maybeUniqueChildNamed("ValDt") { extractDateOrDateTime() }, - accountServicerRef = acctSvcrRef, - entryRef = entryRef - ) - } - return CamtReport( - account = account, - entries = entries, - creationDateTime = maybeUniqueChildNamed("CreDtTm") { focusElement.textContent }, - balances = balances, - electronicSequenceNumber = maybeUniqueChildNamed("ElctrncSeqNb") { focusElement.textContent.toInt() }, - legalSequenceNumber = maybeUniqueChildNamed("LglSeqNb") { focusElement.textContent.toInt() }, - fromDate = maybeUniqueChildNamed("FrToDt") { maybeUniqueChildNamed("FrDtTm") { focusElement.textContent } }, - toDate = maybeUniqueChildNamed("FrToDt") { maybeUniqueChildNamed("ToDtTm") { focusElement.textContent } }, - id = requireUniqueChildNamed("Id") { focusElement.textContent }, - proprietaryReportingSource = maybeUniqueChildNamed("RptgSrc") { maybeUniqueChildNamed("Prtry") { focusElement.textContent } }, - reportingSource = maybeUniqueChildNamed("RptgSrc") { maybeUniqueChildNamed("Cd") { focusElement.textContent } } - ) -} - -/** - * Extract a list of transactions from - * an ISO20022 camt.052 / camt.053 message. - */ -fun parseCamtMessage(doc: Document, dialect: String? = null): CamtParseResult { - return destructXml(doc) { - requireRootElement("Document") { - // Either bank to customer statement or report - val reports = requireOnlyChild { - when (focusElement.localName) { - "BkToCstmrAcctRpt" -> { - mapEachChildNamed("Rpt") { - extractInnerTransactions(dialect) - } - } - "BkToCstmrStmt" -> { - mapEachChildNamed("Stmt") { - extractInnerTransactions(dialect) - } - } - "BkToCstmrDbtCdtNtfctn" -> { - mapEachChildNamed("Ntfctn") { - extractInnerTransactions(dialect) - } - } - else -> { - throw CamtParsingError("expected statement or report") - } - } - } - val messageId = requireOnlyChild { - requireUniqueChildNamed("GrpHdr") { - requireUniqueChildNamed("MsgId") { focusElement.textContent } - } - } - val creationDateTime = requireOnlyChild { - requireUniqueChildNamed("GrpHdr") { - requireUniqueChildNamed("CreDtTm") { focusElement.textContent } - } - } - val messageType = requireOnlyChild { - when (focusElement.localName) { - "BkToCstmrAcctRpt" -> CashManagementResponseType.Report - "BkToCstmrStmt" -> CashManagementResponseType.Statement - "BkToCstmrDbtCdtNtfctn" -> CashManagementResponseType.Notification - else -> { - throw CamtParsingError("expected statement or report") - } - } - } - CamtParseResult( - reports = reports, - messageId = messageId, - messageType = messageType, - creationDateTime = creationDateTime - ) - } - } -} - -// Get timestamp in milliseconds, according to the EBICS+camt dialect. -fun getTimestampInMillis( - dateTimeFromCamt: String, - dialect: String? = null -): Long { - return when(dialect) { - EbicsDialects.POSTFINANCE.dialectName -> { - val withoutTimezone = LocalDateTime.parse( - dateTimeFromCamt, - DateTimeFormatter.ISO_LOCAL_DATE_TIME - ) - ZonedDateTime.of( - withoutTimezone, - ZoneId.of("Europe/Zurich")).toInstant().toEpochMilli() - } - else -> { - ZonedDateTime.parse( - dateTimeFromCamt, - DateTimeFormatter.ISO_DATE_TIME - ).toInstant().toEpochMilli() - } - } -} - -/** - * Extracts the UID from the payment, according to dialect - * and direction. It returns the _qualified_ string from such - * ID. A qualified string has the format "$qualifier:$extracted_id". - * $qualifier is a constant that gives more context about the - * actual $extracted_id; for example, it may indicate that the - * ID was assigned by the bank, or by Nexus when it uploaded - * the payment initiation in the first place. - * - * NOTE: this version _still_ expect only singleton transactions - * in the input. That means _only one_ element is expected at the - * lowest level of the camt.05x report. This may/should change in - * future versions. - */ -fun extractPaymentUidFromSingleton( - ntry: CamtBankAccountEntry, - camtMessageId: String, // used to print errors. - dialect: String? - ): String { - // First check if the input is a singleton. - val batchTransactions: List<BatchTransaction>? = ntry.batches?.get(0)?.batchTransactions - val tx: BatchTransaction = if (ntry.batches?.size != 1 || batchTransactions?.size != 1) { - logger.error("camt message ${camtMessageId} has non singleton transactions.") - throw internalServerError("Dialect $dialect sent camt with non singleton transactions.") - } else - batchTransactions[0] - - when(dialect) { - EbicsDialects.POSTFINANCE.dialectName -> { - if (tx.creditDebitIndicator == CreditDebitIndicator.DBIT) { - val expectedEndToEndId = tx.details.endToEndId - /** - * Because this is an outgoing transaction, and because - * Nexus should have included the EndToEndId in the original - * pain.001, this transaction must have it (recall: EndToEndId - * is mandatory in the pain.001). A null value means therefore - * that the payment was done via another mean than pain.001. - */ - if (expectedEndToEndId == null) { - logger.error("Camt '$camtMessageId' shows outgoing payment _without_ the EndToEndId." + - " This likely wasn't initiated via pain.001" - ) - throw internalServerError("Internal reconciliation error (no EndToEndId)") - } - return "${PaymentUidQualifiers.USER_GIVEN}:$expectedEndToEndId" - } - // Didn't return/throw before, it must be an incoming payment. - val maybeAcctSvcrRef = tx.details.accountServicerRef - // Expecting this value to be at the lowest level, as observed on the test platform. - val expectedAcctSvcrRef = tx.details.accountServicerRef - if (expectedAcctSvcrRef == null) { - logger.error("AcctSvcrRef was expected at the lowest tx level for dialect: $dialect, but wasn't found") - throw internalServerError("Internal reconciliation error (no AcctSvcrRef at lowest tx level)") - } - return "${PaymentUidQualifiers.BANK_GIVEN}:$expectedAcctSvcrRef" - } - // This is the default dialect, the one tested with GLS. - null -> { - /** - * This dialect has shown the AcctSvcrRef to be always given - * at the level that _contains_ the (singleton) transaction(s). - * This occurs _regardless_ of the payment direction. - */ - val expectedAcctSvcrRef = ntry.accountServicerRef - if (expectedAcctSvcrRef == null) { - logger.error("AcctSvcrRef was expected at the outer tx level for dialect: GLS, but wasn't found.") - throw internalServerError("Internal reconciliation error: AcctSvcrRef not found at outer level.") - } - return "${PaymentUidQualifiers.BANK_GIVEN}:$expectedAcctSvcrRef" - } - else -> throw internalServerError("Dialect $dialect is not supported.") - } -} - -/** - * Given that every CaMt is a collection of reports/statements - * where each of them carries the bank account balance and a list - * of transactions, this function: - * - * - extracts the balance (storing a NexusBankBalanceEntity) - * - updates timestamps in NexusBankAccountEntity to the last seen - * report/statement. - * - finds which transactions were already downloaded. - * - stores a new NexusBankTransactionEntity for each new tx - * accounted in the report/statement. - * - tries to link the new transaction with a submitted one, in - * case of DBIT transaction. - * - returns a IngestedTransactionCount object. - */ -fun ingestCamtMessageIntoAccount( - bankAccountId: String, - camtDoc: Document, - fetchLevel: FetchLevel, - dialect: String? = null -): IngestedTransactionsCount { - /** - * Ensure that the level is not ALL, as the parser expects - * the exact type for the one message being parsed. - */ - if (fetchLevel == FetchLevel.ALL) - throw internalServerError("Parser needs exact camt type (ALL not permitted).") - - var newTransactions = 0 - var downloadedTransactions = 0 - transaction { - val acct = NexusBankAccountEntity.findByName(bankAccountId) - if (acct == null) { - throw NexusError(HttpStatusCode.NotFound, "user not found") - } - val res = try { parseCamtMessage(camtDoc, dialect) } catch (e: CamtParsingError) { - logger.warn("Invalid CAMT received from bank: ${e.message}") - newTransactions = -1 - return@transaction - } - res.reports.forEach { - NexusAssert( - it.account.iban == acct.iban, - "Nexus hit a report or statement of a wrong IBAN!" - ) - it.balances.forEach { b -> - if (b.type == "CLBD") { - val lastBalance = NexusBankBalanceEntity.all().lastOrNull() - /** - * Store balances different from the one that came from the bank, - * or the very first balance. This approach has the following inconvenience: - * the 'balance' held at Nexus does not differentiate between one - * coming from a statement and one coming from a report. As a consequence, - * the two types of balances may override each other without notice. - */ - if ((lastBalance == null) || - (b.amount.toPlainString() != lastBalance.balance)) { - NexusBankBalanceEntity.new { - bankAccount = acct - balance = b.amount.toPlainString() - creditDebitIndicator = b.creditDebitIndicator.name - date = b.date - } - } - } - } - } - // Updating the local bank account state timestamps according to the current document. - val stamp = getTimestampInMillis(res.creationDateTime, dialect = dialect) - when (fetchLevel) { - FetchLevel.REPORT -> { - val s = acct.lastReportCreationTimestamp - if (s == null || stamp > s) { - acct.lastReportCreationTimestamp = stamp - } - } - FetchLevel.STATEMENT -> { - val s = acct.lastStatementCreationTimestamp - if (s == null || stamp > s) { - acct.lastStatementCreationTimestamp = stamp - } - } - FetchLevel.NOTIFICATION -> { - val s = acct.lastNotificationCreationTimestamp - if (s == null || stamp > s) { - acct.lastNotificationCreationTimestamp = stamp - } - } - // Silencing the compiler: the 'ALL' case was checked at the top of this function. - else -> {} - } - val entries: List<CamtBankAccountEntry> = res.reports.map { it.entries }.flatten() - var newPaymentsLog = "" - downloadedTransactions = entries.size - txloop@ for (entry: CamtBankAccountEntry in entries) { - val singletonBatchedTransaction: BatchTransaction = entry.batches?.get(0)?.batchTransactions?.get(0) - ?: throw NexusError( - HttpStatusCode.InternalServerError, - "Singleton money movements policy wasn't respected" - ) - if (entry.status != EntryStatus.BOOK) { - logger.info("camt message '${res.messageId}' has a " + - "non-BOOK transaction, ignoring it." - ) - continue - } - val paymentUid = extractPaymentUidFromSingleton( - ntry = entry, - camtMessageId = res.messageId, - dialect = dialect - ) - val duplicate = findDuplicate(bankAccountId, paymentUid) - if (duplicate != null) { - logger.info("Found a duplicate, UID is $paymentUid") - // https://bugs.gnunet.org/view.php?id=6381 - continue@txloop - } - /* Checking for the unstructured remittance information before - storing the payment in the database. */ - val paymentSubject = entry.getSingletonSubject() // throws if not found. - val rawEntity = NexusBankTransactionEntity.new { - bankAccount = acct - accountTransactionId = paymentUid - amount = singletonBatchedTransaction.amount.value - currency = singletonBatchedTransaction.amount.currency - transactionJson = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(entry) - creditDebitIndicator = singletonBatchedTransaction.creditDebitIndicator.name - status = entry.status - } - rawEntity.flush() - newTransactions++ - newPaymentsLog += "\n- $paymentSubject" - - // This block tries to acknowledge a former outgoing payment as booked. - if (singletonBatchedTransaction.creditDebitIndicator == CreditDebitIndicator.DBIT) { - val t0 = singletonBatchedTransaction.details - val endToEndId = t0.endToEndId - if (endToEndId != null) { - logger.debug("Reconciling outgoing payment with EndToEndId: $endToEndId") - val paymentInitiation = PaymentInitiationEntity.find { - PaymentInitiationsTable.bankAccount eq acct.id and ( - // pmtInfId is a value that the payment submitter - // asked the bank to associate with the payment to be made. - PaymentInitiationsTable.endToEndId eq endToEndId) - - }.firstOrNull() - if (paymentInitiation != null) { - logger.info("Could confirm one initiated payment: $endToEndId") - paymentInitiation.confirmationTransaction = rawEntity - } - } - // Every payment initiated by Nexus has EndToEndId. Warn if not found. - else - logger.warn("Camt ${res.messageId} has outgoing payment without EndToEndId..") - } - } - if (newTransactions > 0) - logger.debug("Camt $fetchLevel '${res.messageId}' has new payments:${newPaymentsLog}") - } - - return IngestedTransactionsCount( - newTransactions = newTransactions, - downloadedTransactions = downloadedTransactions - ) -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt deleted file mode 100644 index 175509cd..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt +++ /dev/null @@ -1,79 +0,0 @@ -package tech.libeufin.nexus.server - -import io.ktor.http.* -import tech.libeufin.nexus.* -import tech.libeufin.util.internalServerError - -// Type holding parameters of GET /transactions. -data class GetTransactionsParams( - val bankAccountId: String, - val startIndex: Long, - val resultSize: Long -) - -fun unknownBankAccount(bankAccountLabel: String): NexusError { - return NexusError( - HttpStatusCode.NotFound, - "Bank account $bankAccountLabel was not found" - ) -} - -/** - * FIXME: - * enum type names were introduced after 0.9.2 and need to - * be employed wherever now type names are passed as plain - * strings. - */ -enum class EbicsDialects(val dialectName: String) { - POSTFINANCE("pf") -} - -/** - * Nexus needs to uniquely identify a payment, in order - * to spot the same payment to be ingested more than once. - * For example, payment X may have been already ingested - * (and possibly led to a Taler withdrawal) via a EBICS C52 - * order, and might be later again downloaded via another - * EBICS order (e.g. C53). The second time this payment - * reaches Nexus, it must NOT be considered new, therefore - * Nexus needs a UID to check its database for the presence - * of known payments. Every bank assigns UIDs in a different - * fashion, sometimes even differentiating between incoming and - * outgoing payments; Nexus therefore classifies those UIDs - * by assigning them one of the names defined in the following - * enum class. This way, Nexus has more control when it tries - * to locally reconcile payments. - */ -enum class PaymentUidQualifiers { - BANK_GIVEN, - USER_GIVEN -} - -// Valid connection types. -enum class BankConnectionType(val typeName: String) { - EBICS("ebics"), - X_LIBEUFIN_BANK("x-libeufin-bank"); - companion object { - /** - * This method takes legacy bank connection type names as input - * and _tries_ to return the correspondent enum type. This - * fixes the cases where bank connection types are passed as - * easy-to-break arbitrary strings; eventually this method should - * be discarded and only enum types be passed as connection type names. - */ - fun parseBankConnectionType(typeName: String): BankConnectionType { - return when(typeName) { - "ebics" -> EBICS - "x-libeufin-bank" -> X_LIBEUFIN_BANK - else -> throw internalServerError( - "Cannot extract ${this::class.java.typeName}' instance from name: $typeName'" - ) - } - } - } -} -// Valid facade types -enum class NexusFacadeType(val facadeType: String) { - TALER("taler-wire-gateway"), - ANASTASIS("anastasis") -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt deleted file mode 100644 index 39955778..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt +++ /dev/null @@ -1,444 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus.server - -import CamtBankAccountEntry -import EntryStatus -import com.fasterxml.jackson.annotation.JsonSubTypes -import com.fasterxml.jackson.annotation.JsonTypeInfo -import com.fasterxml.jackson.annotation.JsonTypeName -import com.fasterxml.jackson.annotation.JsonValue -import com.fasterxml.jackson.databind.JsonNode -import tech.libeufin.util.* -import java.time.Instant -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeFormatterBuilder -import java.time.temporal.ChronoField - - -data class BackupRequestJson( - val passphrase: String -) - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "paramType" -) -@JsonSubTypes( - JsonSubTypes.Type(value = EbicsStandardOrderParamsDateJson::class, name = "standard-date-range"), - JsonSubTypes.Type(value = EbicsStandardOrderParamsEmptyJson::class, name = "standard-empty"), - JsonSubTypes.Type(value = EbicsGenericOrderParamsJson::class, name = "generic") -) -abstract class EbicsOrderParamsJson { - abstract fun toOrderParams(): EbicsOrderParams -} - -@JsonTypeName("generic") -class EbicsGenericOrderParamsJson( - val params: Map<String, String> -) : EbicsOrderParamsJson() { - override fun toOrderParams(): EbicsOrderParams { - return EbicsGenericOrderParams(params) - } -} - -@JsonTypeName("standard-empty") -class EbicsStandardOrderParamsEmptyJson : EbicsOrderParamsJson() { - override fun toOrderParams(): EbicsOrderParams { - return EbicsStandardOrderParams(null) - } -} - -object EbicsDateFormat { - var fmt = DateTimeFormatterBuilder() - .append(DateTimeFormatter.ISO_DATE) - .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) - .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) - .parseDefaulting(ChronoField.OFFSET_SECONDS, ZoneId.systemDefault().rules.getOffset(Instant.now()).totalSeconds.toLong()) - .toFormatter()!! -} - -@JsonTypeName("standard-date-range") -class EbicsStandardOrderParamsDateJson( - private val start: String, - private val end: String -) : EbicsOrderParamsJson() { - override fun toOrderParams(): EbicsOrderParams { - val dateRange = - EbicsDateRange( - ZonedDateTime.parse(this.start, EbicsDateFormat.fmt), - ZonedDateTime.parse(this.end, EbicsDateFormat.fmt) - ) - return EbicsStandardOrderParams(dateRange) - } -} - -data class NexusErrorDetailJson( - val type: String, - val description: String -) -data class NexusErrorJson( - val error: NexusErrorDetailJson -) -data class NexusMessage( - val message: String -) - -data class ErrorResponse( - val code: Int, - val hint: String, - val detail: String, -) - -data class BankConnectionInfo( - val name: String, - val type: String -) - -data class BankConnectionsList( - val bankConnections: MutableList<BankConnectionInfo> = mutableListOf() -) - -data class BankConnectionDeletion( - val bankConnectionId: String -) - -data class EbicsHostTestRequest( - val ebicsBaseUrl: String, - val ebicsHostId: String -) - -/** - * This object is used twice: as a response to the backup request, - * and as a request to the backup restore. Note: in the second case - * the client must provide the passphrase. - */ -data class EbicsKeysBackupJson( - // Always "ebics" - val type: String, - val userID: String, - val partnerID: String, - val hostID: String, - val ebicsURL: String, - val authBlob: String, - val encBlob: String, - val sigBlob: String, - val bankAuthBlob: String?, - val bankEncBlob: String?, - val dialect: String? = null -) - -enum class PermissionChangeAction(@get:JsonValue val jsonName: String) { - GRANT("grant"), REVOKE("revoke") -} - -data class Permission( - val subjectType: String, - val subjectId: String, - val resourceType: String, - val resourceId: String, - val permissionName: String -) - -data class PermissionQuery( - val resourceType: String, - val resourceId: String, - val permissionName: String, -) - -data class ChangePermissionsRequest( - val action: PermissionChangeAction, - val permission: Permission -) - -enum class FetchLevel(@get:JsonValue val jsonName: String) { - REPORT("report"), - STATEMENT("statement"), - NOTIFICATION("notification"), - /** - * Although not strictly used to get camt documents - typically - * gets pain.002 documents, this level still participates in downloading - * bank account activity, so placing it here. - */ - RECEIPT("receipt"), - /** - * Uses of ALL do NOT include RECEIPT, in the current version. - */ - ALL("all"); -} - -/** - * Instructions on what range to fetch from the bank, - * and which source(s) to use. - * - * Intended to be convenient to specify. - */ -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "rangeType" -) -@JsonSubTypes( - JsonSubTypes.Type(value = FetchSpecLatestJson::class, name = "latest"), - JsonSubTypes.Type(value = FetchSpecAllJson::class, name = "all"), - JsonSubTypes.Type(value = FetchSpecPreviousDaysJson::class, name = "previous-days"), - JsonSubTypes.Type(value = FetchSpecSinceLastJson::class, name = "since-last"), - JsonSubTypes.Type(value = FetchSpecTimeRangeJson::class, name = "time-range") -) -abstract class FetchSpecJson( - val level: FetchLevel, - val bankConnection: String?, - val start: String? = null, - val end: String? = null -) - -@JsonTypeName("latest") -class FetchSpecLatestJson(level: FetchLevel, bankConnection: String?) : FetchSpecJson(level, bankConnection) - -@JsonTypeName("all") -class FetchSpecAllJson(level: FetchLevel, bankConnection: String?) : FetchSpecJson(level, bankConnection) - -@JsonTypeName("since-last") -class FetchSpecSinceLastJson(level: FetchLevel, bankConnection: String?) : FetchSpecJson(level, bankConnection) - -@JsonTypeName("time-range") -class FetchSpecTimeRangeJson( - level: FetchLevel, - start: String, - end: String, - bankConnection: String? -) : FetchSpecJson(level, bankConnection, start, end) - -@JsonTypeName("previous-days") -class FetchSpecPreviousDaysJson(level: FetchLevel, bankConnection: String?, val number: Int) : - FetchSpecJson(level, bankConnection) - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "source" -) -@JsonSubTypes( - JsonSubTypes.Type(value = CreateBankConnectionFromBackupRequestJson::class, name = "backup"), - JsonSubTypes.Type(value = CreateBankConnectionFromNewRequestJson::class, name = "new") -) -abstract class CreateBankConnectionRequestJson( - val name: String -) - -@JsonTypeName("backup") -class CreateBankConnectionFromBackupRequestJson( - name: String, - val passphrase: String?, - val data: JsonNode -) : CreateBankConnectionRequestJson(name) - -@JsonTypeName("new") -class CreateBankConnectionFromNewRequestJson( - name: String, - val type: String, - val dialect: String? = null, - val data: JsonNode -) : CreateBankConnectionRequestJson(name) - -data class EbicsNewTransport( - val userID: String, - val partnerID: String, - val hostID: String, - val ebicsURL: String, - val systemID: String?, - val dialect: String? = null -) - -/** - * Credentials and URL to access Sandbox and talk JSON to it. - * See https://docs.taler.net/design-documents/038-demobanks-protocol-suppliers.html#static-x-libeufin-bank-with-dynamic-demobank - * for an introduction on x-libeufin-bank. - */ -data class XLibeufinBankTransport( - val username: String, - val password: String, - val baseUrl: String -) - -/** Response type of "GET /prepared-payments/{uuid}" */ -data class PaymentStatus( - val paymentInitiationId: String, - val submitted: Boolean, - val creditorIban: String, - val creditorBic: String?, - val creditorName: String, - val amount: String, - val subject: String, - val submissionDate: String?, - val preparationDate: String, - val status: EntryStatus? -) - -data class Transactions( - val transactions: MutableList<CamtBankAccountEntry> = mutableListOf() -) - -data class BankProtocolsResponse( - val protocols: List<String> -) - -/** Request type of "POST /prepared-payments" */ -data class CreatePaymentInitiationRequest( - val iban: String, - val bic: String, - val name: String, - val amount: String, - val subject: String, - // When it's null, the client doesn't expect/need idempotence. - val uid: String? = null -) - -/** Response type of "POST /prepared-payments" */ -data class PaymentInitiationResponse( - val uuid: String -) - -/** Response type of "GET /user" */ -data class UserResponse( - val username: String, - val superuser: Boolean, -) - -/** Request type of "POST /users" */ -data class CreateUserRequest( - val username: String, - val password: String -) - -data class ChangeUserPassword( - val newPassword: String -) - -data class UserInfo( - val username: String, - val superuser: Boolean -) - -data class UsersResponse( - val users: List<UserInfo> -) - -/** Response (list's element) type of "GET /bank-accounts" */ -data class BankAccount( - var ownerName: String, - var iban: String, - var bic: String, - var nexusBankAccountId: String -) - -data class OfferedBankAccount( - var ownerName: String, - var iban: String, - var bic: String, - var offeredAccountId: String, - var nexusBankAccountId: String? -) - -data class OfferedBankAccounts( - val accounts: MutableList<OfferedBankAccount> = mutableListOf() -) - -/** Response type of "GET /bank-accounts" */ -data class BankAccounts( - var accounts: MutableList<BankAccount> = mutableListOf() -) - -data class BankMessageList( - val bankMessages: MutableList<BankMessageInfo> = mutableListOf() -) - -data class BankMessageInfo( - // x-libeufin-bank messages do not have any ID or code. - val messageId: String?, - val code: String?, - val length: Long -) - -data class FacadeShowInfo( - val name: String, - val type: String, - // Taler wire gateway API base URL. - // Different from the base URL of the facade. - val baseUrl: String, - val config: JsonNode -) - -data class FacadeInfo( - val name: String, - val type: String, - val bankAccountsRead: MutableList<String>? = mutableListOf(), - val bankAccountsWrite: MutableList<String>? = mutableListOf(), - val bankConnectionsRead: MutableList<String>? = mutableListOf(), - val bankConnectionsWrite: MutableList<String>? = mutableListOf(), - val config: TalerWireGatewayFacadeConfig /* To be abstracted to Any! */ -) - -data class TalerWireGatewayFacadeConfig( - val bankAccount: String, - val bankConnection: String, - val reserveTransferLevel: String, - val currency: String -) - -data class Pain001Data( - val creditorIban: String, - val creditorBic: String?, - val creditorName: String, - val sum: String, - val currency: String, - val subject: String, - val endToEndId: String? = null -) - -data class AccountTask( - val resourceType: String, - val resourceId: String, - val taskName: String, - val taskType: String, - val taskCronspec: String, - val taskParams: String, - val nextScheduledExecutionSec: Long?, // human-readable time (= Epoch when this value doesn't exist in DB) - val prevScheduledExecutionSec: Long? // human-readable time (= Epoch when this value doesn't exist in DB) -) - -data class CreateAccountTaskRequest( - val name: String, - val cronspec: String, - val type: String, - val params: JsonNode -) - -data class ImportBankAccount( - val offeredAccountId: String, - val nexusBankAccountId: String -) - -data class InitiatedPayments( - val initiatedPayments: MutableList<PaymentStatus> = mutableListOf() -) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt deleted file mode 100644 index ef5ff781..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt +++ /dev/null @@ -1,1182 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus.server - -import UtilError -import io.ktor.serialization.jackson.* -import io.ktor.server.plugins.contentnegotiation.* -import com.fasterxml.jackson.core.util.DefaultIndenter -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonMappingException -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.module.kotlin.* -import io.ktor.client.* -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.plugins.* -import io.ktor.server.plugins.callloging.* -import io.ktor.server.plugins.statuspages.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import org.slf4j.event.Level -import tech.libeufin.nexus.* -import tech.libeufin.nexus.bankaccount.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.iso20022.ingestCamtMessageIntoAccount -import tech.libeufin.util.* -import java.net.URLEncoder -import tech.libeufin.nexus.logger - -// Return facade state depending on the type. -fun getFacadeState(type: String, facade: FacadeEntity): JsonNode { - return transaction { - when (type) { - "taler-wire-gateway", - "anastasis" -> { - val state = FacadeStateEntity.find { - FacadeStateTable.facade eq facade.id - }.firstOrNull() - if (state == null) throw NexusError( - HttpStatusCode.NotFound, - "State of facade ${facade.id} not found" - ) - val node = jacksonObjectMapper().createObjectNode() - node.put("bankConnection", state.bankConnection) - node.put("bankAccount", state.bankAccount) - node - } - else -> throw NexusError( - HttpStatusCode.NotFound, - "Facade type $type not supported" - ) - } - } -} - - -fun ensureNonNull(param: String?): String { - return param ?: throw NexusError( - HttpStatusCode.BadRequest, "Bad ID given: $param" - ) -} - -fun ensureLong(param: String?): Long { - val asString = ensureNonNull(param) - return asString.toLongOrNull() ?: throw NexusError( - HttpStatusCode.BadRequest, "Parameter is not Long: $param" - ) -} - -fun <T> expectNonNull(param: T?): T { - return param ?: throw NexusError( - HttpStatusCode.BadRequest, - "Non-null value expected." - ) -} - - -fun ApplicationRequest.hasBody(): Boolean { - if (this.isChunked()) { - return true - } - val contentLengthHeaderStr = this.headers["content-length"] - if (contentLengthHeaderStr != null) { - return try { - val cl = contentLengthHeaderStr.toInt() - cl != 0 - } catch (e: NumberFormatException) { - false - } - } - return false -} - -fun ApplicationCall.expectUrlParameter(name: String): String { - return this.request.queryParameters[name] - ?: throw NexusError(HttpStatusCode.BadRequest, "Parameter '$name' not provided in URI") -} - -fun requireBankConnectionInternal(connId: String): NexusBankConnectionEntity { - return transaction { - NexusBankConnectionEntity.find { NexusBankConnectionsTable.connectionId eq connId }.firstOrNull() - } - ?: throw NexusError(HttpStatusCode.NotFound, "bank connection '$connId' not found") -} - -fun requireBankConnection(call: ApplicationCall, parameterKey: String): NexusBankConnectionEntity { - val name = call.parameters[parameterKey] - if (name == null) { - throw NexusError( - HttpStatusCode.NotFound, - "Parameter '${parameterKey}' wasn't found in URI" - ) - } - return requireBankConnectionInternal(name) -} - -val client = HttpClient { followRedirects = true } -val nexusApp: Application.() -> Unit = { - install(CallLogging) { - this.level = Level.DEBUG - this.logger = tech.libeufin.nexus.logger - } - install(LibeufinDecompressionPlugin) - install(ContentNegotiation) { - jackson { - enable(SerializationFeature.INDENT_OUTPUT) - setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { - indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) - indentObjectsWith(DefaultIndenter(" ", "\n")) - }) - registerModule( - KotlinModule.Builder() - .withReflectionCacheSize(512) - .configure(KotlinFeature.NullToEmptyCollection, false) - .configure(KotlinFeature.NullToEmptyMap, false) - .configure(KotlinFeature.NullIsSameAsDefault, enabled = true) - .configure(KotlinFeature.SingletonSupport, enabled = false) - .configure(KotlinFeature.StrictNullChecks, false) - .build() - ) - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - } - } - install(StatusPages) { - exception<NexusError> { call, cause -> - logger.error("Caught exception while handling '${call.request.uri} (${cause.message})") - call.respond( - status = cause.statusCode, - message = ErrorResponse( - code = TalerErrorCode.TALER_EC_LIBEUFIN_NEXUS_GENERIC_ERROR.code, - hint = "nexus error, see detail", - detail = cause.reason, - ) - ) - } - exception<JsonMappingException> { call, cause -> - logger.error("Exception while handling '${call.request.uri}'", cause.message) - call.respond( - HttpStatusCode.BadRequest, - message = ErrorResponse( - code = TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID.code, - hint = "POSTed data was not valid", - detail = cause.message ?: "not given", - ) - ) - } - exception<UtilError> { call, cause -> - logger.error("Exception while handling '${call.request.uri}': ${cause.message}") - call.respond( - cause.statusCode, - message = ErrorResponse( - code = cause.ec?.code ?: TalerErrorCode.TALER_EC_NONE.code, - hint = "see detail", - detail = cause.reason, - ) - ) - } - exception<EbicsProtocolError> { call, cause -> - logger.error("Caught exception while handling '${call.request.uri}' (${cause.message})") - call.respond( - cause.httpStatusCode, - message = ErrorResponse( - code = TalerErrorCode.TALER_EC_LIBEUFIN_NEXUS_GENERIC_ERROR.code, - hint = "The EBICS communication with the bank failed: ${cause.ebicsTechnicalCode}", - detail = cause.reason, - ) - ) - } - exception<BadRequestException> { call, wrapper -> - var rootCause = wrapper.cause - while (rootCause?.cause != null) rootCause = rootCause.cause - val errorMessage: String? = rootCause?.message ?: wrapper.message - if (errorMessage == null) { - logger.error("The bank didn't detect the cause of a bad request, fail.") - logger.error(wrapper.stackTraceToString()) - throw NexusError( - HttpStatusCode.InternalServerError, - "Did not find bad request details." - ) - } - logger.error(errorMessage) - call.respond( - HttpStatusCode.BadRequest, - ErrorResponse( - code = TalerErrorCode.TALER_EC_LIBEUFIN_NEXUS_GENERIC_ERROR.code, - detail = errorMessage, - hint = "Malformed request or unacceptable values" - ) - ) - } - exception<Exception> { call, cause -> - logger.error( - "Uncaught exception while handling '${call.request.uri}'", - cause.stackTraceToString() - ) - cause.printStackTrace() - call.respond( - HttpStatusCode.InternalServerError, - ErrorResponse( - code = TalerErrorCode.TALER_EC_LIBEUFIN_NEXUS_UNCAUGHT_EXCEPTION.code, - hint = "unexpected exception", - detail = "exception message: ${cause.message}", - ) - ) - } - } - intercept(ApplicationCallPipeline.Fallback) { - if (this.call.response.status() == null) { - call.respondText("Not found (no route matched).\n", ContentType.Text.Plain, HttpStatusCode.NotFound) - return@intercept finish() - } - } - routing { - get("/config") { - call.respond( - makeJsonObject { - prop("version", "0:0:0") - prop("name", "nexus-native") - } - ) - return@get - } - // Shows information about the requesting user. - get("/user") { - val ret = transaction { - val currentUser = authenticateRequest(call.request) - UserResponse( - username = currentUser.username, - superuser = currentUser.superuser - ) - } - call.respond(ret) - return@get - } - - get("/permissions") { - val resp = object { - val permissions = mutableListOf<Permission>() - } - transaction { - requireSuperuser(call.request) - NexusPermissionEntity.all().map { - resp.permissions.add( - Permission( - subjectType = it.subjectType, - subjectId = it.subjectId, - resourceType = it.resourceType, - resourceId = it.resourceId, - permissionName = it.permissionName, - ) - ) - } - } - call.respond(resp) - } - - post("/permissions") { - val req = call.receive<ChangePermissionsRequest>() - val knownPermissions = listOf( - "facade.talerwiregateway.history", "facade.talerwiregateway.transfer", - "facade.anastasis.history" - ) - val permName = req.permission.permissionName.lowercase() - if (!knownPermissions.contains(permName)) { - throw NexusError( - HttpStatusCode.BadRequest, - "Permission $permName not known" - ) - } - transaction { - requireSuperuser(call.request) - val existingPerm = findPermission(req.permission) - when (req.action) { - PermissionChangeAction.GRANT -> { - if (existingPerm == null) { - NexusPermissionEntity.new { - subjectType = req.permission.subjectType - subjectId = req.permission.subjectId - resourceType = req.permission.resourceType - resourceId = req.permission.resourceId - permissionName = permName - - } - } - } - PermissionChangeAction.REVOKE -> { - existingPerm?.delete() - } - } - null - } - call.respond(object {}) - } - - get("/users") { - transaction { - requireSuperuser(call.request) - } - val users = transaction { - transaction { - NexusUserEntity.all().map { - UserInfo(it.username, it.superuser) - } - } - } - val usersResp = UsersResponse(users) - call.respond(usersResp) - return@get - } - - // change a user's password - post("/users/{username}/password") { - val body = call.receive<ChangeUserPassword>() - val targetUsername = ensureNonNull(call.parameters["username"]) - transaction { - requireSuperuser(call.request) - val targetUser = NexusUserEntity.find { - NexusUsersTable.username eq targetUsername - }.firstOrNull() - if (targetUser == null) throw NexusError( - HttpStatusCode.NotFound, - "Username $targetUsername not found" - ) - targetUser.passwordHash = CryptoUtil.hashpw(body.newPassword) - } - call.respond(NexusMessage(message = "Password successfully changed")) - return@post - } - - // Add a new ordinary user in the system (requires superuser privileges) - post("/users") { - requireSuperuser(call.request) - val body = call.receive<CreateUserRequest>() - val requestedUsername = requireValidResourceName(body.username) - transaction { - // check if username is available - val checkUsername = NexusUserEntity.find { - NexusUsersTable.username eq requestedUsername - }.firstOrNull() - if (checkUsername != null) throw NexusError( - HttpStatusCode.Conflict, - "Username $requestedUsername unavailable" - ) - NexusUserEntity.new { - username = requestedUsername - passwordHash = CryptoUtil.hashpw(body.password) - superuser = false - } - } - call.respond( - NexusMessage( - message = "New user '${body.username}' registered" - ) - ) - return@post - } - - get("/bank-connection-protocols") { - requireSuperuser(call.request) - call.respond( - HttpStatusCode.OK, - BankProtocolsResponse(listOf("ebics")) - ) - return@get - } - - route("/bank-connection-protocols/ebics") { - ebicsBankProtocolRoutes(client) - } - - // Shows the bank accounts belonging to the requesting user. - get("/bank-accounts") { - requireSuperuser(call.request) - val bankAccounts = BankAccounts() - transaction { - NexusBankAccountEntity.all().forEach { - bankAccounts.accounts.add( - BankAccount( - ownerName = it.accountHolder, - iban = it.iban, - bic = it.bankCode, - nexusBankAccountId = it.bankAccountName - ) - ) - } - } - call.respond(bankAccounts) - return@get - } - post("/bank-accounts/{accountId}/test-camt-ingestion/{type}") { - requireSuperuser(call.request) - val accountId = ensureNonNull(call.parameters["accountId"]) - val bankAccount = getBankAccount(accountId) - val connId = transaction { bankAccount.defaultBankConnection?.connectionId } - val dialect = if (connId != null) { - val defaultConn = getBankConnection(connId) - defaultConn.dialect - } else null - val msgType = ensureNonNull(call.parameters["type"]) - ingestCamtMessageIntoAccount( - ensureNonNull(accountId), - XMLUtil.parseStringIntoDom(call.receiveText()), - when(msgType) { - "C52", "Z52" -> { FetchLevel.REPORT } - "C53", "Z53" -> { FetchLevel.STATEMENT } - "C54", "Z54" -> { FetchLevel.NOTIFICATION } - else -> throw badRequest("Message type: '$msgType', not supported") - }, - dialect = dialect - ) - call.respond(object {}) - return@post - } - get("/bank-accounts/{accountId}/schedule") { - requireSuperuser(call.request) - val resp = jacksonObjectMapper().createObjectNode() - val ops = jacksonObjectMapper().createObjectNode() - val accountId = ensureNonNull(call.parameters["accountId"]) - resp.set<JsonNode>("schedule", ops) - transaction { - NexusBankAccountEntity.findByName(accountId) - ?: throw unknownBankAccount(accountId) - NexusScheduledTaskEntity.find { - (NexusScheduledTasksTable.resourceType eq "bank-account") and - (NexusScheduledTasksTable.resourceId eq accountId) - - }.forEach { - val t = jacksonObjectMapper().createObjectNode() - ops.set<JsonNode>(it.taskName, t) - t.put("cronspec", it.taskCronspec) - t.put("type", it.taskType) - t.set<JsonNode>("params", jacksonObjectMapper().readTree(it.taskParams)) - } - } - call.respond(resp) - return@get - } - - post("/bank-accounts/{accountId}/schedule") { - requireSuperuser(call.request) - val schedSpec = call.receive<CreateAccountTaskRequest>() - val accountId = ensureNonNull(call.parameters["accountId"]) - transaction { - NexusBankAccountEntity.findByName(accountId) - ?: throw unknownBankAccount(accountId) - try { - NexusCron.parser.parse(schedSpec.cronspec) - } catch (e: IllegalArgumentException) { - throw NexusError(HttpStatusCode.BadRequest, "bad cron spec: ${e.message}") - } - // sanity checks. - when (schedSpec.type) { - "fetch" -> { - jacksonObjectMapper().treeToValue(schedSpec.params, FetchSpecJson::class.java) - ?: throw NexusError(HttpStatusCode.BadRequest, "bad fetch spec") - } - "submit" -> { - } - else -> throw NexusError(HttpStatusCode.BadRequest, "unsupported task type") - } - val oldSchedTask = NexusScheduledTaskEntity.find { - (NexusScheduledTasksTable.taskName eq schedSpec.name) and - (NexusScheduledTasksTable.resourceType eq "bank-account") and - (NexusScheduledTasksTable.resourceId eq accountId) - - }.firstOrNull() - if (oldSchedTask != null) { - throw NexusError(HttpStatusCode.BadRequest, "schedule task already exists") - } - NexusScheduledTaskEntity.new { - resourceType = "bank-account" - resourceId = accountId - this.taskCronspec = schedSpec.cronspec - this.taskName = requireValidResourceName(schedSpec.name) - this.taskType = schedSpec.type - this.taskParams = - jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(schedSpec.params) - } - } - call.respond(object {}) - return@post - } - - get("/bank-accounts/{accountId}/schedule/{taskId}") { - requireSuperuser(call.request) - val taskId = ensureNonNull(call.parameters["taskId"]) - val task = transaction { - NexusScheduledTaskEntity.find { - NexusScheduledTasksTable.taskName eq taskId - }.firstOrNull() - } - if (task == null) throw NexusError(HttpStatusCode.NotFound, "Task ${taskId} wasn't found") - call.respond( - AccountTask( - resourceId = task.resourceId, - resourceType = task.resourceType, - taskName = task.taskName, - taskCronspec = task.taskCronspec, - taskType = task.taskType, - taskParams = task.taskParams, - nextScheduledExecutionSec = task.nextScheduledExecutionSec, - prevScheduledExecutionSec = task.prevScheduledExecutionSec - ) - ) - return@get - } - - delete("/bank-accounts/{accountId}/schedule/{taskId}") { - requireSuperuser(call.request) - logger.info("schedule delete requested") - val accountId = ensureNonNull(call.parameters["accountId"]) - val taskId = ensureNonNull(call.parameters["taskId"]) - transaction { - val bankAccount = NexusBankAccountEntity.findByName(accountId) - if (bankAccount == null) { - throw unknownBankAccount(accountId) - } - val oldSchedTask = NexusScheduledTaskEntity.find { - (NexusScheduledTasksTable.taskName eq taskId) and - (NexusScheduledTasksTable.resourceType eq "bank-account") and - (NexusScheduledTasksTable.resourceId eq accountId) - - }.firstOrNull() - if (oldSchedTask == null) - throw notFound("Task $taskId is not found.") - oldSchedTask.delete() - } - call.respond(object {}) - } - - get("/bank-accounts/{accountid}") { - requireSuperuser(call.request) - val accountId = ensureNonNull(call.parameters["accountid"]) - val res = transaction { - val bankAccount = NexusBankAccountEntity.findByName(accountId) - if (bankAccount == null) { - throw unknownBankAccount(accountId) - } - val holderEnc = URLEncoder.encode(bankAccount.accountHolder, Charsets.UTF_8) - val lastSeenBalance = NexusBankBalanceEntity.find { - NexusBankBalancesTable.bankAccount eq bankAccount.id - }.lastOrNull() - return@transaction makeJsonObject { - prop("defaultBankConnection", bankAccount.defaultBankConnection?.id?.value) - prop("accountPaytoUri", "payto://iban/${bankAccount.iban}?receiver-name=$holderEnc") - prop( - "lastSeenBalance", - if (lastSeenBalance != null) { - val sign = if (lastSeenBalance.creditDebitIndicator == "DBIT") "-" else "" - "${sign}${lastSeenBalance.balance}" - } else { - "not downloaded from the bank yet" - } - ) - } - } - call.respond(res) - } - - // Submit one particular payment to the bank. - post("/bank-accounts/{accountid}/payment-initiations/{uuid}/submit") { - requireSuperuser(call.request) - val uuid = ensureLong(call.parameters["uuid"]) - submitPaymentInitiation(client, uuid) - call.respondText("Payment $uuid submitted") - return@post - } - - post("/bank-accounts/{accountid}/submit-all-payment-initiations") { - requireSuperuser(call.request) - val accountId = ensureNonNull(call.parameters["accountid"]) - submitAllPaymentInitiations(client, accountId) - call.respond(object {}) - return@post - } - - get("/bank-accounts/{accountid}/payment-initiations") { - requireSuperuser(call.request) - val ret = InitiatedPayments() - transaction { - val bankAccount = requireBankAccount(call, "accountid") - PaymentInitiationEntity.find { - PaymentInitiationsTable.bankAccount eq bankAccount.id.value - }.forEach { - val sd = it.submissionDate - ret.initiatedPayments.add( - PaymentStatus( - status = it.confirmationTransaction?.status, - paymentInitiationId = it.id.value.toString(), - submitted = it.submitted, - creditorIban = it.creditorIban, - creditorName = it.creditorName, - creditorBic = it.creditorBic, - amount = "${it.currency}:${it.sum}", - subject = it.subject, - submissionDate = if (sd != null) { - importDateFromMillis(sd).toDashedDate() - } else null, - preparationDate = importDateFromMillis(it.preparationDate).toDashedDate() - ) - ) - } - } - call.respond(ret) - return@get - } - - // Shows information about one particular payment initiation. - get("/bank-accounts/{accountid}/payment-initiations/{uuid}") { - requireSuperuser(call.request) - val res = transaction { - val paymentInitiation = getPaymentInitiation(ensureLong(call.parameters["uuid"])) - return@transaction object { - val paymentInitiation = paymentInitiation - val paymentStatus = paymentInitiation.confirmationTransaction?.status - } - } - val sd = res.paymentInitiation.submissionDate - call.respond( - PaymentStatus( - paymentInitiationId = res.paymentInitiation.id.value.toString(), - submitted = res.paymentInitiation.submitted, - creditorName = res.paymentInitiation.creditorName, - creditorBic = res.paymentInitiation.creditorBic, - creditorIban = res.paymentInitiation.creditorIban, - amount = "${res.paymentInitiation.currency}:${res.paymentInitiation.sum}", - subject = res.paymentInitiation.subject, - submissionDate = if (sd != null) { - importDateFromMillis(sd).toDashedDate() - } else null, - status = res.paymentStatus, - preparationDate = importDateFromMillis(res.paymentInitiation.preparationDate).toDashedDate() - ) - ) - return@get - } - - delete("/bank-accounts/{accountId}/payment-initiations/{uuid}") { - requireSuperuser(call.request) - val uuid = ensureLong(call.parameters["uuid"]) - transaction { - val paymentInitiation = getPaymentInitiation(uuid) - paymentInitiation.delete() - } - call.respond(NexusMessage(message = "Payment initiation $uuid deleted")) - } - - // Adds a new payment initiation. - post("/bank-accounts/{accountid}/payment-initiations") { - requireSuperuser(call.request) - val body = call.receive<CreatePaymentInitiationRequest>() - val accountId = ensureNonNull(call.parameters["accountid"]) - if (!validateBic(body.bic)) { - throw NexusError(HttpStatusCode.BadRequest, "invalid BIC (${body.bic})") - } - // Handle first idempotence. - if (body.uid != null) { - val maybeExists: PaymentInitiationEntity? = transaction { - PaymentInitiationEntity.find { - PaymentInitiationsTable.endToEndId eq body.uid - }.firstOrNull() - } - // If submitted payment looks exactly the same as the one - // found in the database, then respond 200 OK. Otherwise, - // it's 409 Conflict. - if (maybeExists != null && - maybeExists.creditorIban == body.iban && - maybeExists.creditorName == body.name && - maybeExists.subject == body.subject && - maybeExists.creditorBic == body.bic && - "${maybeExists.currency}:${maybeExists.sum}" == body.amount - ) { - call.respond( - HttpStatusCode.OK, - PaymentInitiationResponse(uuid = maybeExists.id.value.toString()) - ) - return@post - } - // The payment was found, but it didn't fulfill the previous check, - // conflict. - if (maybeExists != null) - throw conflict( - "Payment initiation with UID '${body.uid}' " + - "was found already, with different details." - ) - // If the flow reaches here, then the payment wasn't found - // => proceed to create one. - } - val res = transaction { - val bankAccount = getBankAccount(accountId) - val amount = parseAmount(body.amount) - val paymentEntity = addPaymentInitiation( - Pain001Data( - creditorIban = body.iban, - creditorBic = body.bic, - creditorName = body.name, - sum = amount.amount, - currency = amount.currency, - subject = body.subject, - endToEndId = body.uid - ), - bankAccount - ) - return@transaction object { - val uuid = paymentEntity.id.value - } - } - call.respond( - HttpStatusCode.OK, - PaymentInitiationResponse(uuid = res.uuid.toString()) - ) - return@post - } - - // Downloads new transactions from the bank. - post("/bank-accounts/{accountid}/fetch-transactions") { - requireSuperuser(call.request) - val accountid = call.parameters["accountid"] - if (accountid == null) { - throw NexusError( - HttpStatusCode.BadRequest, - "Account id missing" - ) - } - val fetchSpec = if (call.request.hasBody()) { - call.receive<FetchSpecJson>() - } else { - logger.warn("fetch-transactions wants statements (they aren't implemented at the bank)") - FetchSpecLatestJson( - FetchLevel.STATEMENT, - null - ) - } - val ingestionResult = fetchBankAccountTransactions(client, fetchSpec, accountid) - var statusCode = HttpStatusCode.OK - /** - * Client errors are unlikely here, because authentication - * and JSON validity fail earlier. Hence, either Nexus or the - * bank had a problem. NOTE: because this handler triggers multiple - * fetches, it is ALSO possible that although one error is reported, - * SOME transactions made it to the database! - */ - if (ingestionResult.errors != null) { - /** - * Nexus could not handle the error (regardless of it being generated - * here or gotten from the bank). The response body should inform the - * client about what failed. - */ - statusCode = HttpStatusCode.InternalServerError - } - - call.respond( - status = statusCode, - object { - val newTransactions = ingestionResult.newTransactions - val downloadedTransactions = ingestionResult.downloadedTransactions - val errors = mutableListOf<String>().apply { - ingestionResult.errors?.forEach { - this.add(it.message ?: "Error message not found.") - } - } - } - ) - return@post - } - - // Asks list of transactions ALREADY downloaded from the bank. - get("/bank-accounts/{accountid}/transactions") { - requireSuperuser(call.request) - val accountLabel = expectNonNull(call.parameters["accountid"]) - // Getting the URI parameters. - val maybeStart = call.maybeLong("start") // Earliest TX in the result. - val maybeSize = call.maybeLong("size") // How many TXs at most. - val maybeLongPoll = call.maybeLong("long_poll_ms") - - // Ask for a DB event (before the actual query), - // in case the DB is Postgres and the client wants. - val listenHandle = if (isPostgres() && maybeLongPoll != null) { - val channelName = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_NEXUS_TX, - accountLabel - ) - val listenHandle = PostgresListenHandle(channelName) - listenHandle.postgresListen() - listenHandle - } else null - - // Try getting results, and UNLISTEN in case they exist. - val queryParam = GetTransactionsParams( - bankAccountId = accountLabel, - resultSize = maybeSize ?: 5, - startIndex = maybeStart ?: 1 - ) - var ret = getIngestedTransactions(queryParam) - if (ret.isNotEmpty() && listenHandle != null) - listenHandle.postgresUnlisten() // closes the PG connection too. - - // No results and a DB event is pending: wait. - if (ret.isEmpty() && listenHandle != null && maybeLongPoll != null) { - val isNotificationArrived = listenHandle.waitOnIODispatchers(maybeLongPoll) - // The event happened, query again. - if (isNotificationArrived) - ret = getIngestedTransactions(queryParam) - } - call.respond(object {val transactions = ret}) - return@get - } - - // Adds a new bank transport. - post("/bank-connections") { - requireSuperuser(call.request) - // user exists and is authenticated. - val body = call.receive<CreateBankConnectionRequestJson>() - requireValidResourceName(body.name) - transaction { - val user = authenticateRequest(call.request) - val existingConn = - NexusBankConnectionEntity.find { NexusBankConnectionsTable.connectionId eq body.name } - .firstOrNull() - if (existingConn != null) { - // FIXME: make idempotent. - throw NexusError(HttpStatusCode.Conflict, "connection '${body.name}' exists already") - } - when (body) { - is CreateBankConnectionFromBackupRequestJson -> { - val type = body.data.get("type") - if (type == null || !type.isTextual) { - throw NexusError( - HttpStatusCode.BadRequest, - "backup needs type" - ) - } - val plugin = getConnectionPlugin(type.textValue()) - plugin.createConnectionFromBackup( - body.name, - user, - body.passphrase, - body.data - ) - } - is CreateBankConnectionFromNewRequestJson -> { - val plugin = getConnectionPlugin(body.type) - plugin.createConnection( - body.name, - user, - body.data - ) - } - } - } - call.respond(object {}) - } - - post("/bank-connections/delete-connection") { - requireSuperuser(call.request) - val body = call.receive<BankConnectionDeletion>() - transaction { - val conn = - NexusBankConnectionEntity.find { NexusBankConnectionsTable.connectionId eq body.bankConnectionId } - .firstOrNull() ?: throw NexusError( - HttpStatusCode.NotFound, - "Bank connection ${body.bankConnectionId}" - ) - conn.delete() // temporary, and instead just _mark_ it as deleted? - } - call.respond(object {}) - } - - get("/bank-connections") { - requireSuperuser(call.request) - val connList = BankConnectionsList() - transaction { - NexusBankConnectionEntity.all().forEach { - connList.bankConnections.add( - BankConnectionInfo( - name = it.connectionId, - type = it.type - ) - ) - } - } - call.respond(connList) - } - - get("/bank-connections/{connectionName}") { - requireSuperuser(call.request) - val resp = transaction { - val conn = requireBankConnection(call, "connectionName") - getConnectionPlugin(conn.type).getConnectionDetails(conn) - } - call.respond(resp) - } - - post("/bank-connections/{connectionName}/export-backup") { - requireSuperuser(call.request) - val body = call.receive<BackupRequestJson>() - val response = run { - val conn = requireBankConnection(call, "connectionName") - getConnectionPlugin(conn.type).exportBackup(conn.connectionId, body.passphrase) - } - call.response.headers.append("Content-Disposition", "attachment") - call.respond( - HttpStatusCode.OK, - response - ) - } - - post("/bank-connections/{connectionName}/connect") { - requireSuperuser(call.request) - val conn = transaction { - requireBankConnection(call, "connectionName") - } - val plugin = getConnectionPlugin(conn.type) - plugin.connect(client, conn.connectionId) - call.respond(NexusMessage(message = "Connection successful")) - } - - get("/bank-connections/{connectionName}/keyletter") { - requireSuperuser(call.request) - val conn = transaction { - requireBankConnection(call, "connectionName") - } - val pdfBytes = getConnectionPlugin(conn.type).exportAnalogDetails(conn) - call.respondBytes(pdfBytes, ContentType("application", "pdf")) - } - - get("/bank-connections/{connectionName}/messages") { - requireSuperuser(call.request) - val ret = transaction { - val list = BankMessageList() - val conn = requireBankConnection(call, "connectionName") - NexusBankMessageEntity.find { NexusBankMessagesTable.bankConnection eq conn.id }.map { - list.bankMessages.add( - BankMessageInfo( - messageId = it.messageId, - code = it.fetchLevel.jsonName, - length = it.message.bytes.size.toLong() - ) - ) - } - list - } - call.respond(ret) - } - - get("/bank-connections/{connid}/messages/{msgid}") { - requireSuperuser(call.request) - val ret = transaction { - val msgid = call.parameters["msgid"] - if (msgid == null || msgid == "") { - throw NexusError(HttpStatusCode.BadRequest, "missing or invalid message ID") - } - val msg = NexusBankMessageEntity.find { NexusBankMessagesTable.messageId eq msgid }.firstOrNull() - ?: throw NexusError(HttpStatusCode.NotFound, "bank message not found") - return@transaction object { - val msgContent = msg.message.bytes - } - } - call.respondBytes(ret.msgContent, ContentType("application", "xml")) - } - - get("/facades/{fcid}") { - requireSuperuser(call.request) - val fcid = ensureNonNull(call.parameters["fcid"]) - val ret = transaction { - val f = FacadeEntity.findByName(fcid) ?: throw NexusError( - HttpStatusCode.NotFound, "Facade $fcid does not exist" - ) - // FIXME: this only works for TWG urls. - FacadeShowInfo( - name = f.facadeName, - type = f.type, - baseUrl = URLBuilder(call.request.getBaseUrl()).apply { - this.appendPathSegments(listOf("facades", f.facadeName, f.type)) - encodedPath += "/" - }.buildString(), - config = getFacadeState(f.type, f) - ) - } - call.respond(ret) - return@get - } - - get("/facades") { - requireSuperuser(call.request) - val ret = object { - val facades = mutableListOf<FacadeShowInfo>() - } - transaction { - val user = authenticateRequest(call.request) - FacadeEntity.find { - FacadesTable.creator eq user.id - }.forEach { - ret.facades.add( - FacadeShowInfo( - name = it.facadeName, - type = it.type, - baseUrl = URLBuilder(call.request.getBaseUrl()).apply { - this.appendPathSegments(listOf("facades", it.facadeName, it.type)) - encodedPath += "/" - }.buildString(), - config = getFacadeState(it.type, it) - ) - ) - } - } - call.respond(ret) - return@get - } - - delete("/facades/{fcid}") { - requireSuperuser(call.request) - val fcid = ensureNonNull(call.parameters["fcid"]) - transaction { - val f = FacadeEntity.findByName(fcid) ?: throw NexusError( - HttpStatusCode.NotFound, - "Facade $fcid does not exist" - ) - f.delete() - } - call.respond({}) - return@delete - } - - post("/facades") { - val user = requireSuperuser(call.request) - val body = call.receive<FacadeInfo>() - requireValidResourceName(body.name) - if (!listOf("taler-wire-gateway", "anastasis").contains(body.type)) - throw NexusError( - HttpStatusCode.NotImplemented, - "Facade type '${body.type}' is not implemented" - ) - // Check if the facade exists already. - val createNewFacade = transaction { - val maybeFacade = FacadeEntity.findByName(body.name) - // Facade exists, check all the values for idempotence. - if (maybeFacade != null) { - // First get the associated config. - val facadeConfig = getFacadeState(maybeFacade.facadeName) - if (maybeFacade.type != body.type - || maybeFacade.creator.username != user.username - || facadeConfig.bankAccount != body.config.bankAccount - || facadeConfig.bankConnection != body.config.bankConnection - || facadeConfig.reserveTransferLevel != body.config.reserveTransferLevel - || facadeConfig.currency != body.config.currency) { - throw conflict("Facade ${body.name} exists but its state differs from the request.") - } - // Facade exists and has exact same values, inhibit creation. - else return@transaction false - } - // Facade does not exist, trigger creation. - true - } - if (createNewFacade) { - transaction { - val newFacade = FacadeEntity.new { - facadeName = body.name - type = body.type - creator = user - } - FacadeStateEntity.new { - bankAccount = body.config.bankAccount - bankConnection = body.config.bankConnection - reserveTransferLevel = body.config.reserveTransferLevel - facade = newFacade - currency = body.config.currency - } - } - } - call.respond(HttpStatusCode.OK) - return@post - } - - route("/bank-connections/{connid}") { - - // only ebics specific tasks under this part. - route("/ebics") { - ebicsBankConnectionRoutes(client) - } - post("/fetch-accounts") { - requireSuperuser(call.request) - val conn = transaction { - requireBankConnection(call, "connid") - } - getConnectionPlugin(conn.type).fetchAccounts(client, conn.connectionId) - call.respond(object {}) - } - - // show all the offered accounts (both imported and non) - get("/accounts") { - requireSuperuser(call.request) - val ret = OfferedBankAccounts() - transaction { - val conn = requireBankConnection(call, "connid") - OfferedBankAccountEntity.find { - OfferedBankAccountsTable.bankConnection eq conn.id.value - }.forEach { offeredAccount -> - val importedId = offeredAccount.imported?.id - val imported = if (importedId != null) { - NexusBankAccountEntity.findById(importedId) - } else { - null - } - ret.accounts.add( - OfferedBankAccount( - ownerName = offeredAccount.accountHolder, - iban = offeredAccount.iban, - bic = offeredAccount.bankCode, - offeredAccountId = offeredAccount.offeredAccountId, - nexusBankAccountId = imported?.bankAccountName - ) - ) - } - } - call.respond(ret) - } - - // import one account into libeufin. - post("/import-account") { - requireSuperuser(call.request) - val body = call.receive<ImportBankAccount>() - importBankAccount(call, body.offeredAccountId, body.nexusBankAccountId) - call.respond(object {}) - } - } - route("/facades/{fcid}/taler-wire-gateway") { - talerFacadeRoutes(this) - } - route("/facades/{fcid}/anastasis") { - anastasisFacadeRoutes(this) - } - - // Hello endpoint. - get("/") { - call.respondText("Hello, this is Nexus.\n") - return@get - } - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/RequestBodyDecompression.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/server/RequestBodyDecompression.kt deleted file mode 100644 index 0eb5f57d..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/RequestBodyDecompression.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2021 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 - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus.server - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.util.* -import io.ktor.util.pipeline.* -import io.ktor.utils.io.* -import io.ktor.utils.io.jvm.javaio.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.zip.InflaterInputStream - -val LibeufinDecompressionPlugin = createApplicationPlugin("RequestingBodyDecompression") { - onCallReceive { call -> - transformBody { data -> - if (call.request.headers[HttpHeaders.ContentEncoding] == "deflate") { - val brc = withContext(Dispatchers.IO) { - val inflated = InflaterInputStream(data.toInputStream()) - @Suppress("BlockingMethodInNonBlockingContext") - val bytes = inflated.readAllBytes() - ByteReadChannel(bytes) - } - brc - } else data - } - } -}
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt deleted file mode 100644 index 9a433e63..00000000 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt +++ /dev/null @@ -1,536 +0,0 @@ -package tech.libeufin.nexus.xlibeufinbank - -import AgentIdentification -import Batch -import BatchTransaction -import CamtBankAccountEntry -import CashAccount -import CreditDebitIndicator -import CurrencyAmount -import PartyIdentification -import TransactionDetails -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.util.* -import io.ktor.util.* -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.* -import tech.libeufin.nexus.bankaccount.* -import tech.libeufin.nexus.server.* -import tech.libeufin.util.* -import java.net.MalformedURLException -import java.net.URL -import java.time.LocalDate - -// Gets Sandbox URL and credentials, taking the connection name as input. -private fun getXLibeufinBankCredentials(conn: NexusBankConnectionEntity): XLibeufinBankTransport { - val maybeCredentials = transaction { - XLibeufinBankUserEntity.find { - XLibeufinBankUsersTable.nexusBankConnection eq conn.id - }.firstOrNull() - } - if (maybeCredentials == null) throw internalServerError( - "Existing connection ${conn.connectionId} has no transport details" - ) - return XLibeufinBankTransport( - username = maybeCredentials.username, - password = maybeCredentials.password, - baseUrl = maybeCredentials.baseUrl - ) -} -private fun getXLibeufinBankCredentials(connId: String): XLibeufinBankTransport { - val conn = getBankConnection(connId) - return getXLibeufinBankCredentials(conn) -} -class XlibeufinBankConnectionProtocol : BankConnectionProtocol { - override fun getBankUrl(connId: String): String { - return getXLibeufinBankCredentials(connId).baseUrl - } - /** - * Together with checking the credentials, this method downloads - * additional details from the bank, and stores them in the table - * that holds offered bank accounts. That saves one call to the - * "import" method by recycling the information obtained here. - */ - override suspend fun connect(client: HttpClient, connId: String) { - val conn = getBankConnection(connId) - val connDetails = getXLibeufinBankCredentials(conn) - // Defining the URL to request the bank account balance. - val url = connDetails.baseUrl + "/accounts/${connDetails.username}" - // Error handling expected by the caller. - val details = client.get(url) { - expectSuccess = true - basicAuth(connDetails.username, connDetails.password) - } - val txtDetails = details.bodyAsText() - val jDetails = jacksonObjectMapper().readTree(txtDetails) - val paytoUri: String = try { jDetails.get("paytoUri").asText() } - catch (e: Exception) { - logger.error("Did not find 'paytoUri' along the connection" + - " operation from x-libeufin-bank connection $connId." + - " Bank says: $txtDetails" - ) - throw badGateway("Bank missed basic account information ('paytoUri' field)") - } - val paytoObj = parsePayto(payto = paytoUri) - val maybeOfferedAccount = transaction { - OfferedBankAccountEntity.find { - // Sandbox reliably names the bank account with the owner's username - OfferedBankAccountsTable.offeredAccountId eq connDetails.username - }.firstOrNull() - } - // Bank account already imported. - if (maybeOfferedAccount != null) - return - // Import it. - transaction { - OfferedBankAccountEntity.new { - offeredAccountId = connDetails.username - bankConnection = conn - accountHolder = paytoObj.receiverName ?: "Not given by the bank" - bankCode = paytoObj.bic ?: "SANDBOXX" - iban = paytoObj.iban - } - } - } - - /** - * This operation is carried along connect(), because as a side - * effect of checking the credentials it ALSO gets all the offered - * bank account information that this function WOULD have obtained. - * - * Therefore, this method throws error when called, in order to raise - * the clients' awareness not to rely on it. - */ - override suspend fun fetchAccounts(client: HttpClient, connId: String) { - throw NotImplementedError("Please skip this method when using x-libeufin-bank.") - } - - override fun createConnectionFromBackup( - connId: String, - user: NexusUserEntity, - passphrase: String?, - backup: JsonNode - ) { - TODO("Not yet implemented") - } - - override fun createConnection( - connId: String, - user: NexusUserEntity, - data: JsonNode - ) { - val bankConn = transaction { - NexusBankConnectionEntity.new { - this.connectionId = connId - owner = user - type = "x-libeufin-bank" - } - } - val newTransportData = jacksonObjectMapper().treeToValue( - data, XLibeufinBankTransport::class.java - ) ?: throw badRequest("x-libeufin-bank details not found in the request") - // Validate the base URL - try { URL(newTransportData.baseUrl).toURI() } - catch (e: MalformedURLException) { - throw badRequest("Base URL (${newTransportData.baseUrl}) is invalid.") - } - transaction { - XLibeufinBankUserEntity.new { - username = newTransportData.username - password = newTransportData.password - // Only addressing mild cases where ONE slash ends the base URL. - baseUrl = newTransportData.baseUrl.dropLastWhile { it == '/' } - nexusBankConnection = bankConn - } - } - } - - override fun getConnectionDetails(conn: NexusBankConnectionEntity): JsonNode { - val credentials = getXLibeufinBankCredentials(conn) - val mapper = ObjectMapper() - val details = mapper.createObjectNode() - details.put("baseUrl", credentials.baseUrl) - details.put("username", credentials.username) - val node = mapper.createObjectNode() - node.put("type", conn.type) - node.put("owner", conn.owner.username) - node.set<JsonNode>("details", details) - return node - } - - override fun exportBackup(bankConnectionId: String, passphrase: String): JsonNode { - TODO("Not yet implemented") - } - - override fun exportAnalogDetails(conn: NexusBankConnectionEntity): ByteArray { - throw NotImplementedError("x-libeufin-bank does not need analog details") - } - - override suspend fun submitPaymentInitiation( - httpClient: HttpClient, - paymentInitiationId: Long - ) { - /** - * Main steps. - * - * 1) Get prep from the DB. - * 2) Collect credentials. - * 3) Create the format to POST. - * 4) POST the transaction. - * 5) Mark the prep as submitted. - * */ - // 1 - val preparedPayment = getPaymentInitiation(paymentInitiationId) - // 2 - val conn = transaction { preparedPayment.bankAccount.defaultBankConnection } ?: throw - internalServerError("Default connection not found for bank account: ${preparedPayment.bankAccount.bankAccountName}") - val credentials: XLibeufinBankTransport = getXLibeufinBankCredentials(conn) - // 3 - val paytoUri = buildIbanPaytoUri( - iban = preparedPayment.creditorIban, - bic = preparedPayment.creditorBic ?: "SANDBOXX", - receiverName = preparedPayment.creditorName, - message = preparedPayment.subject - ) - val req = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString( - XLibeufinBankPaytoReq( - paytoUri = paytoUri, - amount = "${preparedPayment.currency}:${preparedPayment.sum}", - pmtInfId = preparedPayment.paymentInformationId - ) - ) - // 4 - val url = credentials.baseUrl + "/accounts/${credentials.username}/transactions" - logger.debug("POSTing transactions to x-libeufin-bank at: $url") - val r = httpClient.post(url) { - expectSuccess = false - contentType(ContentType.Application.Json) - basicAuth(credentials.username, credentials.password) - setBody(req) - } - if (r.status.value.toString().startsWith("5")) { - throw NexusError( - HttpStatusCode.BadGateway, - "The bank failed: ${r.bodyAsText()}" - ) - } - if (!r.status.value.toString().startsWith("2")) { - throw NexusError( - /** - * Echoing whichever status code the bank gave. That - * however masks client errors where - for example - a - * request detail causes 404 where Nexus has no power. - */ - HttpStatusCode(r.status.value, r.status.description), - r.bodyAsText() - ) - } - // 5 - transaction { preparedPayment.submitted = true } - } - - override suspend fun fetchTransactions( - fetchSpec: FetchSpecJson, // FIXME: handle time range. - client: HttpClient, - bankConnectionId: String, - accountId: String - ): List<Exception>? { - val conn = getBankConnection(bankConnectionId) - if (fetchSpec.level == FetchLevel.REPORT || fetchSpec.level == FetchLevel.ALL) - throw badRequest("level '${fetchSpec.level}' on x-libeufin-bank" + - "connection (${conn.connectionId}) is not supported:" + - " bank has only 'booked' state." - ) - // Get credentials - val credentials = getXLibeufinBankCredentials(conn) - /** - * Now builds the URL to ask the transactions, according to the - * FetchSpec gotten in the args. Level 'statement' and time range - * 'previous-days' are NOT implemented. - */ - val baseUrl = URL(credentials.baseUrl) - val fetchUrl = url { - protocol = URLProtocol(name = baseUrl.protocol, defaultPort = -1) - port = baseUrl.port - appendPathSegments( - baseUrl.path.dropLastWhile { it == '/' }, - "accounts/${credentials.username}/transactions") - when (fetchSpec) { - is FetchSpecTimeRangeJson -> { - // the parse() method defaults to the YYYY-MM-DD format. - val start: LocalDate = LocalDate.parse(fetchSpec.start) - val end: LocalDate = LocalDate.parse(fetchSpec.end) - this.parameters["from_ms"] = start.millis().toString() - this.parameters["until_ms"] = end.millis().toString() - } - // Gets the last 5 transactions - is FetchSpecLatestJson -> { - // Do nothing, the bare endpoint gets the last 5 txs by default. - } - /* Defines the from_ms URI param. according to the last transaction - * timestamp that was seen in this connection */ - is FetchSpecSinceLastJson -> { - val localBankAccount = getBankAccount(accountId) - // Sandbox doesn't have report vs. statement, defaulting to statement time - // and so does the ingestion routine when storing the last message time. - // The sought time must be incremented by one because the filter is _inclusive_. - this.parameters["from_ms"] = "${localBankAccount.lastStatementCreationTimestamp?.plus(1) ?: 0}" - } - // This wants ALL the transactions, hence it sets the from_ms to zero. - is FetchSpecAllJson -> { - this.parameters["from_ms"] = "0" - } - else -> throw NexusError( - HttpStatusCode.NotImplemented, - "FetchSpec ${fetchSpec::class} not supported" - ) - } - } - logger.debug("Requesting x-libeufin-bank transactions to: $fetchUrl") - val resp: HttpResponse = try { - client.get(fetchUrl) { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth(credentials.username, credentials.password) - } - } catch (e: Exception) { - e.printStackTrace() - logger.error(e.message) - return listOf(e) - } - val respBlob = resp.bodyAsChannel().toByteArray() - transaction { - NexusBankMessageEntity.new { - bankConnection = conn - message = ExposedBlob(respBlob) - fetchLevel = fetchSpec.level - } - } - return null - } -} - -fun ingestXLibeufinBankMessage( - bankAccountId: String, - data: String // JSON -): IngestedTransactionsCount { - val jMessage = try { jacksonObjectMapper().readTree(data) } - catch (e: Exception) { - logger.error("Bank message $data could not" + - " be parsed into JSON by the x-libeufin-bank ingestion.") - throw internalServerError("Could not ingest x-libeufin-bank message.") - } - return ingestXLibeufinBankMessage(bankAccountId, jMessage) -} -/** - * Parses one x-libeufin-bank message and INSERTs Nexus local - * transaction records into the database. After this function - * returns, the transactions are ready to both being communicated - * to the CLI via the native JSON interface OR being further processed - * by ANY facade. - * - * This function: - * - updates the local timestamps related to the latest report. - * - inserts a new NexusBankTransactionEntity. To achieve that, it extracts the: - * -- amount - * -- credit/debit indicator - * -- currency - * - * Note: in contrast to what the CaMt handler does, here there's NO - * status, since Sandbox has only one (unnamed) transaction state and - * all transactions are asked as reports. - */ -fun ingestXLibeufinBankMessage( - bankAccountId: String, - data: JsonNode -): IngestedTransactionsCount { - data class XLibeufinBankTransactions( - val transactions: List<XLibeufinBankTransaction> - ) - val txs = try { - jacksonObjectMapper().treeToValue( - data, - XLibeufinBankTransactions::class.java - ) - } catch (e: Exception) { - throw NexusError( - HttpStatusCode.BadGateway, - "The bank sent invalid x-libeufin-bank transactions." - ) - } - val bankAccount = getBankAccount(bankAccountId) - var newTxs = 0 // Counts how many transactions are new. - txs.transactions.forEach { - val maybeTimestamp = try { - it.date.toLong() - } catch (e: Exception) { - throw NexusError( - HttpStatusCode.BadGateway, - "The bank gave an invalid timestamp " + - "for x-libeufin-bank message: ${it.uid}" - ) - } - // Searching for duplicates. - if (findDuplicate(bankAccountId, "${PaymentUidQualifiers.BANK_GIVEN}:${it.uid}") != null) { - logger.debug("x-libeufin-bank ingestion: transaction ${it.uid} is a duplicate, skipping.") - return@forEach - } - val direction = if (it.debtorIban == bankAccount.iban) - XLibeufinBankDirection.DEBIT else XLibeufinBankDirection.CREDIT - // New tx, storing it. - transaction { - val localTx = NexusBankTransactionEntity.new { - this.bankAccount = bankAccount - this.amount = it.amount - this.currency = it.currency - /** - * Sandbox has only booked state for its transactions: as soon as - * one payment makes it to the database, that is the final (booked) - * state. - */ - this.status = EntryStatus.BOOK - this.accountTransactionId = "${PaymentUidQualifiers.BANK_GIVEN}:${it.uid}" - this.transactionJson = jacksonObjectMapper( - ).writeValueAsString(it.exportAsCamtModel()) - this.creditDebitIndicator = direction.exportAsCamtDirection() - newTxs++ - logger.debug("x-libeufin-bank transaction with subject '${it.subject}' ingested.") - } - /** - * The following block tries to reconcile a previous prepared - * (outgoing) payment with the one being iterated over. - */ - if (direction == XLibeufinBankDirection.DEBIT) { - val maybePrepared = it.pmtInfId?.let { it1 -> getPaymentInitiation(pmtInfId = it1) } - if (maybePrepared != null) maybePrepared.confirmationTransaction = localTx - } - // x-libeufin-bank transactions are ALWAYS modeled as reports - // in Nexus, because such bank protocol supplier doesn't have - // the report vs. statement distinction. Therefore, we only - // consider the last report timestamp. - if ((bankAccount.lastStatementCreationTimestamp ?: 0L) < maybeTimestamp) - bankAccount.lastStatementCreationTimestamp = maybeTimestamp - } - } - return IngestedTransactionsCount( - newTransactions = newTxs, - downloadedTransactions = txs.transactions.size - ) -} - -fun XLibeufinBankTransaction.exportCamtDirectionIndicator(): CreditDebitIndicator = - if (this.direction == XLibeufinBankDirection.CREDIT) - CreditDebitIndicator.CRDT else CreditDebitIndicator.DBIT - -/** - * This function transforms an x-libeufin-bank transaction - * into the JSON representation of CaMt used by Nexus along - * its processing. Notably, this helps to stick to one unified - * type when facades process transactions. - */ -fun XLibeufinBankTransaction.exportAsCamtModel(): CamtBankAccountEntry = - CamtBankAccountEntry( - /** - * Amount obtained by summing all the transactions accounted - * in this report/statement. Here this field equals the amount of the - * _unique_ transaction accounted. - */ - amount = CurrencyAmount(currency = this.currency, value = this.amount), - accountServicerRef = this.uid, - bankTransactionCode = "Not given", - bookingDate = this.date, - counterValueAmount = null, - creditDebitIndicator = this.exportCamtDirectionIndicator(), - currencyExchange = null, - entryRef = null, - instructedAmount = null, - valueDate = null, - status = EntryStatus.BOOK, // x-libeufin-bank always/only BOOK. - /** - * This field accounts for the _unique_ transaction that this - * object represents. - */ - batches = listOf( - Batch( - messageId = null, - paymentInformationId = this.uid, - batchTransactions = listOf( - BatchTransaction( - amount = CurrencyAmount( - currency = this.currency, - value = this.amount - ), - creditDebitIndicator = this.exportCamtDirectionIndicator(), - details = TransactionDetails( - debtor = PartyIdentification( - name = this.debtorName, - countryOfResidence = null, - organizationId = null, - otherId = null, - postalAddress = null, - privateId = null - ), - debtorAccount = CashAccount( - name = null, - currency = this.currency, - iban = this.debtorIban, - otherId = null - ), - debtorAgent = AgentIdentification( - name = null, - bic = this.debtorBic, - clearingSystemCode = null, - clearingSystemMemberId = null, - lei = null, - otherId = null, - postalAddress = null, - proprietaryClearingSystemCode = null - ), - counterValueAmount = null, - currencyExchange = null, - interBankSettlementAmount = null, - proprietaryPurpose = null, - purpose = null, - returnInfo = null, - ultimateCreditor = null, - ultimateDebtor = null, - unstructuredRemittanceInformation = this.subject, - instructedAmount = null, - creditor = PartyIdentification( - name = this.creditorName, - countryOfResidence = null, - organizationId = null, - otherId = null, - postalAddress = null, - privateId = null - ), - creditorAccount = CashAccount( - name = null, - currency = this.currency, - iban = this.creditorIban, - otherId = null - ), - creditorAgent = AgentIdentification( - name = null, - bic = this.creditorBic, - clearingSystemCode = null, - clearingSystemMemberId = null, - lei = null, - otherId = null, - postalAddress = null, - proprietaryClearingSystemCode = null - ) - ) - ) - ) - ) - ) - )
\ No newline at end of file diff --git a/nexus/src/main/resources/logback.xml b/nexus/src/main/resources/logback.xml deleted file mode 100644 index b18b437e..00000000 --- a/nexus/src/main/resources/logback.xml +++ /dev/null @@ -1,23 +0,0 @@ -<!-- configuration scan="true" --> -<configuration> - <appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender"> - <target>System.err</target> - <encoder> - <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> - </encoder> - </appender> - - <logger name="tech.libeufin.nexus" level="ALL" additivity="false"> - <appender-ref ref="STDERR" /> - </logger> - - <logger name="io.netty" level="WARN"/> - <logger name="ktor" level="WARN"/> - <logger name="Exposed" level="WARN"/> - <logger name="tech.libeufin.util" level="DEBUG"/> - - <root level="WARN"> - <appender-ref ref="STDERR"/> - </root> - -</configuration> |