From ea3ceef3740ce30400bc20c7aae09b25d3e0f0c3 Mon Sep 17 00:00:00 2001 From: Christian Grothoff Date: Thu, 7 Sep 2023 15:24:27 +0200 Subject: sandbox -> bank --- bank/README | 25 + bank/build.gradle | 95 ++ .../main/kotlin/tech/libeufin/bank/CircuitApi.kt | 841 ++++++++++ .../kotlin/tech/libeufin/bank/ConversionService.kt | 433 +++++ bank/src/main/kotlin/tech/libeufin/bank/DB.kt | 747 +++++++++ .../src/main/kotlin/tech/libeufin/bank/Database.kt | 665 ++++++++ .../tech/libeufin/bank/EbicsProtocolBackend.kt | 1436 ++++++++++++++++ bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt | 472 ++++++ bank/src/main/kotlin/tech/libeufin/bank/JSON.kt | 154 ++ bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 1711 ++++++++++++++++++++ .../kotlin/tech/libeufin/bank/XMLEbicsConverter.kt | 70 + .../main/kotlin/tech/libeufin/bank/bankAccount.kt | 276 ++++ bank/src/main/resources/logback.xml | 24 + bank/src/main/resources/static/README.txt | 1 + bank/src/test/kotlin/BalanceTest.kt | 115 ++ bank/src/test/kotlin/DBTest.kt | 152 ++ bank/src/test/kotlin/DatabaseTest.kt | 261 +++ bank/src/test/kotlin/EbicsErrorTest.kt | 24 + bank/src/test/kotlin/StringsTest.kt | 37 + sandbox/README | 21 - sandbox/build.gradle | 95 -- .../kotlin/tech/libeufin/sandbox/CircuitApi.kt | 841 ---------- .../tech/libeufin/sandbox/ConversionService.kt | 433 ----- .../src/main/kotlin/tech/libeufin/sandbox/DB.kt | 747 --------- .../main/kotlin/tech/libeufin/sandbox/Database.kt | 665 -------- .../tech/libeufin/sandbox/EbicsProtocolBackend.kt | 1436 ---------------- .../main/kotlin/tech/libeufin/sandbox/Helpers.kt | 472 ------ .../src/main/kotlin/tech/libeufin/sandbox/JSON.kt | 154 -- .../src/main/kotlin/tech/libeufin/sandbox/Main.kt | 1711 -------------------- .../tech/libeufin/sandbox/XMLEbicsConverter.kt | 70 - .../kotlin/tech/libeufin/sandbox/bankAccount.kt | 276 ---- sandbox/src/main/resources/logback.xml | 24 - sandbox/src/main/resources/static/README.txt | 1 - sandbox/src/test/kotlin/BalanceTest.kt | 115 -- sandbox/src/test/kotlin/DBTest.kt | 152 -- sandbox/src/test/kotlin/DatabaseTest.kt | 261 --- sandbox/src/test/kotlin/EbicsErrorTest.kt | 24 - sandbox/src/test/kotlin/StringsTest.kt | 37 - 38 files changed, 7539 insertions(+), 7535 deletions(-) create mode 100644 bank/README create mode 100644 bank/build.gradle create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/DB.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/Database.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/JSON.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/Main.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt create mode 100644 bank/src/main/resources/logback.xml create mode 100644 bank/src/main/resources/static/README.txt create mode 100644 bank/src/test/kotlin/BalanceTest.kt create mode 100644 bank/src/test/kotlin/DBTest.kt create mode 100644 bank/src/test/kotlin/DatabaseTest.kt create mode 100644 bank/src/test/kotlin/EbicsErrorTest.kt create mode 100644 bank/src/test/kotlin/StringsTest.kt delete mode 100644 sandbox/README delete mode 100644 sandbox/build.gradle delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt delete mode 100644 sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt delete mode 100644 sandbox/src/main/resources/logback.xml delete mode 100644 sandbox/src/main/resources/static/README.txt delete mode 100644 sandbox/src/test/kotlin/BalanceTest.kt delete mode 100644 sandbox/src/test/kotlin/DBTest.kt delete mode 100644 sandbox/src/test/kotlin/DatabaseTest.kt delete mode 100644 sandbox/src/test/kotlin/EbicsErrorTest.kt delete mode 100644 sandbox/src/test/kotlin/StringsTest.kt diff --git a/bank/README b/bank/README new file mode 100644 index 00000000..cc8e909a --- /dev/null +++ b/bank/README @@ -0,0 +1,25 @@ +Description +=========== + +The Libeufin bank implements a simple core banking system with +account and REST APIs, including REST APIs for a Web interface +and REST APIs to interact with GNU Taler components. + +It also will provide a server side implementation of multiple +banking protocols currently used in the European Union. Notably, the +EBICS, FinTS, and the major protocols that banks will employ to respect +the PSD2 regulation: https://ec.europa.eu/info/law/payment-services-psd-2-directive-eu-2015-2366_en + + +Running the Bank +=================== + +Run the Bank with the following command + +$ cd +$ ./gradlew bank:run --console=plain --args=serve [--db-name=] + +Documentation +============= + +See https://docs.taler.net/ for the documentation. diff --git a/bank/build.gradle b/bank/build.gradle new file mode 100644 index 00000000..71e44a5a --- /dev/null +++ b/bank/build.gradle @@ -0,0 +1,95 @@ +plugins { + id 'kotlin' + id 'java' + id 'application' + id 'org.jetbrains.kotlin.jvm' + id "com.github.johnrengelman.shadow" version "5.2.0" +} + +sourceCompatibility = "11" +targetCompatibility = "11" +version = rootProject.version + +compileKotlin { + kotlinOptions { + jvmTarget = "11" + } +} + +compileTestKotlin { + kotlinOptions { + jvmTarget = "11" + } +} + +task installToPrefix(type: Copy) { + dependsOn(installShadowDist) + from("build/install/bank-shadow") { + include("**/libeufin-bank") + 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' + +sourceSets { + main.java.srcDirs = ['src/main/kotlin'] +} + +dependencies { + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt' + implementation "com.hubspot.jinjava:jinjava:2.5.9" + implementation 'ch.qos.logback:logback-classic:1.4.5' + implementation project(":util") + + // XML: + 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' + + implementation group: 'org.bouncycastle', name: 'bcprov-jdk16', version: '1.46' + implementation group: 'org.xerial', name: 'sqlite-jdbc', version: '3.36.0.1' + implementation 'org.postgresql:postgresql:42.2.23.jre7' + implementation group: 'org.apache.commons', name: 'commons-compress', version: '1.21' + implementation('com.github.ajalt:clikt:2.8.0') + implementation "org.jetbrains.exposed:exposed-core:$exposed_version" + implementation "org.jetbrains.exposed:exposed-dao:$exposed_version" + implementation "org.jetbrains.exposed:exposed-jdbc:$exposed_version" + + implementation "io.ktor:ktor-server-core:$ktor_version" + implementation "io.ktor:ktor-server-call-logging:$ktor_version" + implementation "io.ktor:ktor-server-cors:$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" + 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" + + testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21' + testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21' + testImplementation group: "junit", name: "junit", version: '4.13.2' + + // UNIX domain sockets support (used to connect to PostgreSQL) + implementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2' +} + +application { + mainClassName = "tech.libeufin.bank.MainKt" + applicationName = "libeufin-bank" + applicationDefaultJvmArgs = ['-Djava.net.preferIPv6Addresses=true'] +} + +jar { + manifest { + attributes "Main-Class": "tech.libeufin.bank.MainKt" + } +} \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt new file mode 100644 index 00000000..4d8d36d9 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt @@ -0,0 +1,841 @@ +package tech.libeufin.sandbox + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.ktor.server.application.* +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.sandbox.CashoutOperationsTable.uuid +import tech.libeufin.util.* +import java.io.File +import java.io.InputStreamReader +import java.math.BigDecimal +import java.util.concurrent.TimeUnit +import kotlin.text.toByteArray + +// CIRCUIT API TYPES +/** + * This type is used by clients to ask the bank a cash-out + * estimate to show to the customer before they confirm the + * cash-out creation. + */ +data class CircuitCashoutEstimateRequest( + /** + * This is the amount that the customer will get deducted + * from their regio bank account to fuel the cash-out operation. + */ + val amount_debit: String +) +data class CircuitCashoutRequest( + val subject: String?, + val amount_debit: String, // As specified by the user via the SPA. + val amount_credit: String, // What actually to transfer after the rates. + /** + * The String type here allows more flexibility with regard to + * the supported TAN methods. This way, supported TAN methods + * can be specified via the configuration or when starting the + * bank. OTOH, catching unsupported TAN methods only via the + * 'enum' type would require to change the source code upon every + * change in the TAN policy. + */ + val tan_channel: String? +) +const val FIAT_CURRENCY = "CHF" // FIXME: make configurable. +// Configuration response: +data class ConfigResp( + val name: String = "circuit", + val version: String = PROTOCOL_VERSION_UNIFIED, + val ratios_and_fees: RatioAndFees, + val fiat_currency: String = FIAT_CURRENCY +) + +// After fixing #7527, the values held by this +// type must be read from the configuration. +data class RatioAndFees( + val buy_at_ratio: Float = 1F, + val sell_at_ratio: Float = 0.95F, + val buy_in_fee: Float = 0F, + val sell_out_fee: Float = 0F +) +val ratiosAndFees = RatioAndFees() + +// User registration request +data class CircuitAccountRequest( + val username: String, + val password: String, + val contact_data: CircuitContactData, + val name: String, + val cashout_address: String, // payto + val internal_iban: String? // Shall be "= null" ? +) +// User contact data to send the TAN. +data class CircuitContactData( + val email: String?, + val phone: String? +) + +data class CircuitAccountReconfiguration( + val contact_data: CircuitContactData, + val cashout_address: String?, + val name: String? = null +) + +data class AccountPasswordChange( + val new_password: String +) + +/** + * That doesn't belong to the Access API because it + * contains the cash-out address and the contact data. + */ +data class CircuitAccountInfo( + val username: String, + val iban: String, + val contact_data: CircuitContactData, + val name: String, + val cashout_address: String? +) + +data class CashoutOperationInfo( + val status: CashoutOperationStatus, + val amount_credit: String, + val amount_debit: String, + val subject: String, + val creation_time: Long, // milliseconds + val confirmation_time: Long?, // milliseconds + val tan_channel: SupportedTanChannels, + val account: String, + val cashout_address: String, + val ratios_and_fees: RatioAndFees +) + +data class CashoutConfirmation(val tan: String) + +// Validate phone number +fun checkPhoneNumber(phoneNumber: String): Boolean { + // From Taler TypeScript + // /^\+[0-9 ]*$/; + val regex = "^\\+[1-9][0-9]+$" + val R = Regex(regex) + return R.matches(phoneNumber) +} + +// Validate e-mail address +fun checkEmailAddress(emailAddress: String): Boolean { + // From Taler TypeScript: + // /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + val regex = "^[a-zA-Z0-9\\.]+@[a-zA-Z0-9\\.]+$" + val R = Regex(regex) + return R.matches(emailAddress) +} + +fun throwIfInstitutionalName(resourceName: String) { + if (resourceName == "bank" || resourceName == "admin") + throw forbidden("Can't operate on institutional resource '$resourceName'") +} + +fun generateCashoutSubject( + amountCredit: AmountWithCurrency, + amountDebit: AmountWithCurrency +): String { + return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" + + " to ${amountCredit.currency}:${amountCredit.amount}" +} + +/** + * By default, it takes the amount in the regional currency + * and applies ratio and fees to convert it to fiat. If the + * 'fromCredit' parameter is true, then it does the inverse + * operation: returns the regional amount that would lead to + * such fiat amount given in the 'amount' parameter. + */ +fun applyCashoutRatioAndFee( + amount: BigDecimal, + ratiosAndFees: RatioAndFees, + fromCredit: Boolean = false +): BigDecimal { + // Normal case, when the calculation starts from the regional amount. + if (!fromCredit) { + val maybeCashoutAmount = ((amount * ratiosAndFees.sell_at_ratio.toBigDecimal()) - + ratiosAndFees.sell_out_fee.toBigDecimal()).roundToTwoDigits() + // throws 500, since bank should not allow to get negative fiat amounts. + if (maybeCashoutAmount < BigDecimal.ZERO) { + logger.error("Cash-out operation caused a negative fiat output." + + " Regional amount was '$amount', cash-out ratio is '${ratiosAndFees.sell_at_ratio}," + + " cash-out fee is '${ratiosAndFees.sell_out_fee}''" + ) + throw internalServerError("Applying cash-out fees yielded negative fiat amount.") + } + return maybeCashoutAmount + } + // UI convenient case, when the calculation starts from the + // desired fiat amount that the user wants eventually be paid. + return ((amount + ratiosAndFees.sell_out_fee.toBigDecimal()) / + ratiosAndFees.sell_at_ratio.toBigDecimal()).roundToTwoDigits() +} + +/** + * NOTE: future versions take the supported TAN method from + * the configuration, or options passed when starting the bank. + */ +const val LIBEUFIN_TAN_TMP_FILE = "/tmp/libeufin-cashout-tan.txt" +enum class SupportedTanChannels { + SMS, + EMAIL, + FILE // Test channel writing the TAN to the LIBEUFIN_TAN_TMP_FILE location. +} +fun isTanChannelSupported(tanChannel: String): Boolean { + enumValues().forEach { + if (tanChannel.uppercase() == it.name) return true + } + return false +} + +var EMAIL_TAN_CMD: String? = null +var SMS_TAN_CMD: String? = null + +// Convenience class to collect TAN data. +private data class TanData( + val cmd: String, + val address: String, + val msg: String +) + +/** + * Runs the command and returns True/False if that succeeded/failed. + * A failed command causes "500 Internal Server Error" to be responded + * along a cash-out creation. 'address' is a phone number or a e-mail address, + * according to which TAN channel is used. 'message' carries the TAN. + * + * The caller is expected to manage the exceptions thrown by this function. + */ +fun runTanCommand(command: String, address: String, message: String): Boolean { + val prep = ProcessBuilder(command, address) + prep.redirectErrorStream(true) // merge STDOUT and STDERR + val proc = prep.start() + proc.outputStream.write(message.toByteArray()) + proc.outputStream.flush(); proc.outputStream.close() + var isSuccessful = false + // Wait the command to finish. + proc.waitFor(10L, TimeUnit.SECONDS) + // Check if timed out. Kill if so. + if (proc.isAlive) { + logger.error("TAN command '$command' timed out, killing it.") + proc.destroy() + // Check if exited gracefully. Kill forcibly if not. + proc.waitFor(5L, TimeUnit.SECONDS) + if (proc.isAlive) { + logger.error("TAN command '$command' didn't terminate after killing it. Try forcefully.") + proc.destroyForcibly() + } + } + // Check if successful. Switch the state if so. + if (proc.exitValue() == 0) isSuccessful = true + // Log STDOUT and STDERR if failed. + if (!isSuccessful) + logger.error(InputStreamReader(proc.inputStream).readText()) + return isSuccessful +} + +fun circuitApi(circuitRoute: Route) { + // Abort a cash-out operation. + circuitRoute.post("/cashouts/{uuid}/abort") { + call.request.basicAuth() // both admin and author allowed + val arg = call.expectUriComponent("uuid") + // Parse and check the UUID. + val maybeUuid = parseUuid(arg) + val maybeOperation = transaction { + CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() + } + if (maybeOperation == null) + throw notFound("Cash-out operation $uuid not found.") + if (maybeOperation.status == CashoutOperationStatus.CONFIRMED) + throw SandboxError( + HttpStatusCode.PreconditionFailed, + "Cash-out operation '$uuid' was confirmed already." + ) + if (maybeOperation.status != CashoutOperationStatus.PENDING) + throw internalServerError("Found an unsupported cash-out operation state: ${maybeOperation.status}") + // Operation found and pending: delete from the database. + transaction { maybeOperation.delete() } + call.respond(HttpStatusCode.NoContent) + return@post + } + // Confirm a cash-out operation + circuitRoute.post("/cashouts/{uuid}/confirm") { + val user = call.request.basicAuth() + // Exclude admin from this operation. + if (user == "admin" || user == "bank") + throw conflict("Institutional user '$user' shouldn't confirm any cash-out.") + // Get the operation identifier. + val operationUuid = parseUuid(call.expectUriComponent("uuid")) + val op = transaction { + CashoutOperationEntity.find { + uuid eq operationUuid + }.firstOrNull() + } + // 404 if the operation is not found. + if (op == null) + throw notFound("Cash-out operation $operationUuid not found") + /** + * Check the TAN. Give precedence to the TAN found + * in the environment, for testing purposes. If that's + * not found, then check with the actual TAN found in + * the database. + */ + val req = call.receive() + val maybeTanFromEnv = System.getenv("LIBEUFIN_CASHOUT_TEST_TAN") + if (maybeTanFromEnv != null) + logger.warn("TAN being read from the environment. Assuming tests are being run") + val checkTan = maybeTanFromEnv ?: op.tan + if (req.tan != checkTan) + throw forbidden("The confirmation of '${op.uuid}' has a wrong TAN '${req.tan}'") + /** + * Correct TAN. Wire the funds to the admin's bank account. After + * this step, the conversion monitor should detect this payment and + * soon initiate the final transfer towards the user fiat bank account. + * NOTE: the funds availability got already checked when this operation + * was created. On top of that, the 'wireTransfer()' helper does also + * check for funds availability. */ + val customer = maybeGetCustomer(user ?: throw SandboxError( + HttpStatusCode.ServiceUnavailable, + "This endpoint isn't served when the authentication is disabled." + )) + transaction { + if (op.cashoutAddress != customer?.cashout_address) throw conflict( + "Inconsistent cash-out address: ${op.cashoutAddress} vs ${customer?.cashout_address}" + ) + // 412 if the operation got already confirmed. + if (op.status == CashoutOperationStatus.CONFIRMED) + throw SandboxError( + HttpStatusCode.PreconditionFailed, + "Cash-out operation $operationUuid was already confirmed." + ) + wireTransfer( + debitAccount = op.account, + creditAccount = "admin", + subject = op.subject, + amount = op.amountDebit + ) + op.status = CashoutOperationStatus.CONFIRMED + op.confirmationTime = getSystemTimeNow().toInstant().toEpochMilli() + // TODO(signal this payment over LIBEUFIN_REGIO_INCOMING) + } + call.respond(HttpStatusCode.NoContent) + return@post + } + // Retrieve the status of a cash-out operation. + circuitRoute.get("/cashouts/{uuid}") { + call.request.basicAuth() // both admin and author + val operationUuid = call.expectUriComponent("uuid") + // Parse and check the UUID. + val maybeUuid = parseUuid(operationUuid) + // Get the operation from the database. + val maybeOperation = transaction { + CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() + } + if (maybeOperation == null) + throw notFound("Cash-out operation $operationUuid not found.") + val ret = CashoutOperationInfo( + amount_credit = maybeOperation.amountCredit, + amount_debit = maybeOperation.amountDebit, + subject = maybeOperation.subject, + status = maybeOperation.status, + creation_time = maybeOperation.creationTime, + confirmation_time = maybeOperation.confirmationTime, + tan_channel = maybeOperation.tanChannel, + account = maybeOperation.account, + cashout_address = maybeOperation.cashoutAddress, + ratios_and_fees = RatioAndFees( + buy_in_fee = maybeOperation.buyInFee.toFloat(), + buy_at_ratio = maybeOperation.buyAtRatio.toFloat(), + sell_out_fee = maybeOperation.sellOutFee.toFloat(), + sell_at_ratio = maybeOperation.sellAtRatio.toFloat() + ) + ) + call.respond(ret) + return@get + } + // Gets the list of all the cash-out operations, + // or those belonging to the account given as a parameter. + circuitRoute.get("/cashouts") { + val user = call.request.basicAuth() + val whichAccount = call.request.queryParameters["account"] + /** + * Only admin's allowed to omit the target account (= get + * all the accounts) or to check other customers cash-out + * operations. + */ + if (user != "admin" && whichAccount != user) throw forbidden( + "Ordinary users can only request their own account" + ) + /** + * At this point, the client has the rights over the account(s) + * whose operations are to be returned. Double-checking that + * Admin doesn't ask its own cash-outs, since that's not supported. + */ + if (whichAccount == "admin") throw badRequest("Cash-out for admin is not supported") + + // Preparing the response. + val node = jacksonObjectMapper().createObjectNode() + val maybeArray = node.putArray("cashouts") + + if (whichAccount == null) { // no target account, return all the cash-outs + transaction { + CashoutOperationEntity.all().forEach { + maybeArray.add(it.uuid.toString()) + } + } + } else { // do filter on the target account. + transaction { + CashoutOperationEntity.find { + CashoutOperationsTable.account eq whichAccount + }.forEach { + maybeArray.add(it.uuid.toString()) + } + } + } + if (maybeArray.size() == 0) { + call.respond(HttpStatusCode.NoContent) + return@get + } + call.respond(node) + return@get + } + circuitRoute.get("/cashouts/estimates") { + call.request.basicAuth() + val demobank = ensureDemobank(call) + // Optionally parsing param 'amount_debit' into number and checking its currency + val maybeAmountDebit: String? = call.request.queryParameters["amount_debit"] + val amountDebit: BigDecimal? = if (maybeAmountDebit != null) { + val amount = parseAmount(maybeAmountDebit) + if (amount.currency != demobank.config.currency) throw badRequest( + "parameter 'amount_debit' has the wrong currency: ${amount.currency}" + ) + try { amount.amount.toBigDecimal() } catch (e: Exception) { + throw badRequest("Cannot extract a number from 'amount_debit'") + } + } else null + // Optionally parsing param 'amount_credit' into number and checking its currency + val maybeAmountCredit: String? = call.request.queryParameters["amount_credit"] + val amountCredit: BigDecimal? = if (maybeAmountCredit != null) { + val amount = parseAmount(maybeAmountCredit) + if (amount.currency != FIAT_CURRENCY) throw badRequest( + "parameter 'amount_credit' has the wrong currency: ${amount.currency}" + ) + try { amount.amount.toBigDecimal() } catch (e: Exception) { + throw badRequest("Cannot extract a number from 'amount_credit'") + } + } else null + val respAmountCredit = if (amountDebit != null) { + val estimate = applyCashoutRatioAndFee(amountDebit, ratiosAndFees) + if (amountCredit != null && estimate != amountCredit) throw badRequest( + "Wrong calculation found in 'amount_credit', bank estimates: $estimate" + ) + estimate + } else null + if (amountDebit == null && amountCredit == null) throw badRequest( + "Both 'amount_credit' and 'amount_debit' are missing" + ) + val respAmountDebit = if (amountCredit != null) { + val estimate = applyCashoutRatioAndFee( + amountCredit, + ratiosAndFees, + fromCredit = true + ) + if (amountDebit != null && estimate != amountDebit) throw badRequest( + "Wrong calculation found in 'amount_credit', bank estimates: $estimate" + ) + estimate + } else null + call.respond(object { + val amount_credit = "$FIAT_CURRENCY:$respAmountCredit" + val amount_debit = "${demobank.config.currency}:$respAmountDebit" + }) + return@get + } + + // Create a cash-out operation. + circuitRoute.post("/cashouts") { + val user = call.request.basicAuth() + if (user == "admin" || user == "bank") throw forbidden("$user can't cash-out.") + // No suitable default user, when the authentication is disabled. + if (user == null) throw SandboxError( + HttpStatusCode.ServiceUnavailable, + "This endpoint isn't served when the authentication is disabled." + ) + val req = call.receive() + + // validate amounts: well-formed and supported currency. + val amountDebit = parseAmount(req.amount_debit) // amount before rates. + val amountCredit = parseAmount(req.amount_credit) // amount after rates, as expected by the client + val demobank = ensureDemobank(call) + // Currency check of the cash-out's circuit part. + if (amountDebit.currency != demobank.config.currency) + throw badRequest("'${req::amount_debit.name}' (${req.amount_debit})" + + " doesn't match the regional currency (${demobank.config.currency})" + ) + // Currency check of the cash-out's fiat part. + if (amountCredit.currency != FIAT_CURRENCY) + throw badRequest("'${req::amount_credit.name}' (${req.amount_credit})" + + " doesn't match the fiat currency ($FIAT_CURRENCY)." + ) + // check if TAN is supported. Default to SMS, if that's missing. + val tanChannel = req.tan_channel?.uppercase() ?: SupportedTanChannels.SMS.name + if (!isTanChannelSupported(tanChannel)) + throw SandboxError( + HttpStatusCode.ServiceUnavailable, + "TAN channel '$tanChannel' not supported." + ) + // check if the user contact data would allow the TAN channel. + val customer: DemobankCustomerEntity? = maybeGetCustomer(username = user) + if (customer == null) throw internalServerError( + "Customer profile '$user' not found after authenticating it." + ) + if (customer.cashout_address == null) throw SandboxError( + HttpStatusCode.PreconditionFailed, + "Cash-out address not found. Did the user register via Circuit API?" + ) + if ((tanChannel == SupportedTanChannels.EMAIL.name) && (customer.email == null)) + throw conflict("E-mail address not found for '$user'. Can't send the TAN") + if ((tanChannel == SupportedTanChannels.SMS.name) && (customer.phone == null)) + throw conflict("Phone number not found for '$user'. Can't send the TAN") + // check rates correctness + val amountDebitAsNumber = BigDecimal(amountDebit.amount) + val expectedAmountCredit = applyCashoutRatioAndFee(amountDebitAsNumber, ratiosAndFees) + val amountCreditAsNumber = BigDecimal(amountCredit.amount).roundToTwoDigits() + if (expectedAmountCredit != amountCreditAsNumber) { + throw badRequest("Rates application are incorrect." + + " The expected amount to credit is: ${expectedAmountCredit}," + + " but ${amountCredit.amount} was specified.") + } + // check that the balance is sufficient + val balance = getBalance( + user, + demobank.name + ) + val balanceCheck = balance - amountDebitAsNumber + if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal(demobank.config.usersDebtLimit)) + throw SandboxError( + HttpStatusCode.PreconditionFailed, + "Cash-out not possible due to insufficient funds. Balance ${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}" + ) + // generate a subject if that's missing + val cashoutSubject = req.subject ?: generateCashoutSubject( + amountCredit = amountCredit, + amountDebit = amountDebit + ) + val op = transaction { + CashoutOperationEntity.new { + this.amountDebit = req.amount_debit + this.amountCredit = req.amount_credit + this.buyAtRatio = ratiosAndFees.buy_at_ratio.toString() + this.buyInFee = ratiosAndFees.buy_in_fee.toString() + this.sellAtRatio = ratiosAndFees.sell_at_ratio.toString() + this.sellOutFee = ratiosAndFees.sell_out_fee.toString() + this.subject = cashoutSubject + this.creationTime = getSystemTimeNow().toInstant().toEpochMilli() + this.tanChannel = SupportedTanChannels.valueOf(tanChannel) + this.account = user + this.tan = getRandomString(5) + this.cashoutAddress = customer.cashout_address ?: throw internalServerError( + "Cash-out address for '$user' not found, after previous check succeeded" + ) + } + } + when (tanChannel) { + SupportedTanChannels.EMAIL.name -> { + val isSuccessful = try { + runTanCommand( + command = EMAIL_TAN_CMD ?: throw internalServerError( + "E-mail TAN supported but the command" + + " was not found. See the --email-tan option from 'serve'" + ), + address = customer.email ?: throw internalServerError( + "Customer has no e-mail address, but previous check should" + + " have detected it!" + ), + message = op.tan + ) + } catch (e: Exception) { + logger.error("Sending the e-mail TAN to ${customer.email} was impossible." + + " Reason: ${e.message}") + throw internalServerError("Could not send the e-mail TAN.") + } + if (!isSuccessful) + throw internalServerError("E-mail TAN command failed.") + } + SupportedTanChannels.SMS.name -> { + val isSuccessful = try { + runTanCommand( + command = SMS_TAN_CMD ?: throw internalServerError( + "SMS TAN supported but the command" + + " was not found. See the --sms-tan option from 'serve'" + ), + address = customer.phone ?: throw internalServerError( + "Customer has no phone number, but previous check should" + + " have detected it!" + + ), + message = op.tan + ) + + } catch (e: Exception) { + logger.error("Sending the SMS TAN to ${customer.phone} was impossible." + + " Reason: ${e.message}") + throw internalServerError("Could not send the SMS TAN.") + } + if (!isSuccessful) + throw internalServerError("SMS TAN command failed.") + } + SupportedTanChannels.FILE.name -> { + try { + File(LIBEUFIN_TAN_TMP_FILE).writeText(op.tan) + } catch (e: Exception) { + logger.error("Could not write to $LIBEUFIN_TAN_TMP_FILE. Reason: ${e.message}") + throw internalServerError("File TAN failed.") + } + } + else -> + throw internalServerError("The bank tried an unsupported TAN channel: $tanChannel.") + } + call.respond(HttpStatusCode.Accepted, object {val uuid = op.uuid}) + return@post + } + // Get Circuit-relevant account data. + circuitRoute.get("/accounts/{resourceName}") { + val username = call.request.basicAuth() + val resourceName = call.expectUriComponent("resourceName") + throwIfInstitutionalName(resourceName) + if (!allowOwnerOrAdmin(username, resourceName)) throw forbidden( + "User $username has no rights over $resourceName" + ) + val customer = getCustomer(resourceName) + /** + * CUSTOMER AND BANK ACCOUNT INVARIANT. + * + * After having found a 'customer' associated with the resourceName + * - see previous line -, the bank must ensure that a 'bank account' + * exist under the same resourceName. If that fails, the bank broke the + * invariant and should respond 500. + */ + val bankAccount = getBankAccountFromLabel(resourceName, withBankFault = true) + /** + * Throwing when name or cash-out address aren't found ensures + * that the customer was indeed added via the Circuit API, as opposed + * to the Access API. + */ + call.respond(CircuitAccountInfo( + username = customer.username, + name = customer.name ?: throw internalServerError( + "Account '$resourceName' was found without owner's name." + ), + cashout_address = customer.cashout_address, + contact_data = CircuitContactData( + email = customer.email, + phone = customer.phone + ), + iban = bankAccount.iban + )) + return@get + } + + // Get summary of all the accounts. + circuitRoute.get("/accounts") { + call.request.basicAuth(onlyAdmin = true) + val maybeFilter: String? = call.request.queryParameters["filter"] + /** + * Equip the given filter with left and right catch-all wildcards, + * otherwise use one catch-all wildcard. + */ + val filter = if (maybeFilter != null) { + "%${maybeFilter}%" + } else "%" + val customers = mutableListOf() + val demobank = ensureDemobank(call) + transaction { + /** + * This block builds the DB query so that IF the %-wildcard was + * given, then BOTH name and name-less accounts are returned. + */ + val query: Op = SqlExpressionBuilder.run { + val like = DemobankCustomersTable.name.like(filter) + /** + * This IF statement is needed because Postgres would NOT + * match a null column even with the %-wildcard. + */ + if (filter == "%") { + return@run like.or(DemobankCustomersTable.name.isNull()) + } + return@run like + } + DemobankCustomerEntity.find { query }.forEach { + customers.add(object { + val username = it.username + val name = it.name + val balance = getBalanceForJson( + getBalance(it.username, demobank.name), + demobank.config.currency + ) + val debitThreshold = getMaxDebitForUser( + it.username, + demobank.name + ) + }) + } + StdOutSqlLogger + } + if (customers.size == 0) { + call.respond(HttpStatusCode.NoContent) + return@get + } + call.respond(object {val customers = customers}) + return@get + } + + // Change password. + circuitRoute.patch("/accounts/{customerUsername}/auth") { + val username = call.request.basicAuth() + val customerUsername = call.expectUriComponent("customerUsername") + throwIfInstitutionalName(customerUsername) + if (!allowOwnerOrAdmin(username, customerUsername)) throw forbidden( + "User $username has no rights over $customerUsername" + ) + // Flow here means admin or username have the rights for this operation. + val req = call.receive() + /** + * The resource/customer might still not exist, in case admin has requested. + * On the other hand, when ordinary customers request, their existence is checked + * along the basic authentication check. + */ + transaction { + val customer = getCustomer(customerUsername) // throws 404, if not found. + customer.passwordHash = CryptoUtil.hashpw(req.new_password) + } + call.respond(HttpStatusCode.NoContent) + return@patch + } + // Change account (mostly contact) data. + circuitRoute.patch("/accounts/{resourceName}") { + val username = call.request.basicAuth() + if (username == null) + throw internalServerError("Authentication disabled, don't have a default for this request.") + val resourceName = call.expectUriComponent("resourceName") + throwIfInstitutionalName(resourceName) + if(!allowOwnerOrAdmin(username, resourceName)) throw forbidden( + "User $username has no rights over $resourceName" + ) + // account found and authentication succeeded + val req = call.receive() + // Only admin's allowed to change the legal name + if (req.name != null && username != "admin") throw forbidden( + "Only admin can change the user legal name" + ) + if ((req.contact_data.email != null) && (!checkEmailAddress(req.contact_data.email))) + throw badRequest("Invalid e-mail address: ${req.contact_data.email}") + if ((req.contact_data.phone != null) && (!checkPhoneNumber(req.contact_data.phone))) + throw badRequest("Invalid phone number: ${req.contact_data.phone}") + try { if (req.cashout_address != null) parsePayto(req.cashout_address) } + catch (e: InvalidPaytoError) { + throw badRequest("Invalid cash-out address: ${req.cashout_address}") + } + transaction { + val user = getCustomer(resourceName) + user.email = req.contact_data.email + user.phone = req.contact_data.phone + user.cashout_address = req.cashout_address + } + call.respond(HttpStatusCode.NoContent) + return@patch + } + // Create new account. + circuitRoute.post("/accounts") { + call.request.basicAuth(onlyAdmin = true) + val req = call.receive() + // Validity and availability check on the input data. + if (req.contact_data.email != null) { + if (!checkEmailAddress(req.contact_data.email)) + throw badRequest("Invalid e-mail address: ${req.contact_data.email}. Won't register") + val maybeEmailConflict = transaction { + DemobankCustomerEntity.find { + DemobankCustomersTable.email eq req.contact_data.email + }.firstOrNull() + } + // Warning since two individuals claimed one same e-mail address. + if (maybeEmailConflict != null) + throw conflict("Won't register user ${req.username}: e-mail conflict on ${req.contact_data.email}") + } + if (req.contact_data.phone != null) { + if (!checkPhoneNumber(req.contact_data.phone)) + throw badRequest("Invalid phone number: ${req.contact_data.phone}. Won't register") + + val maybePhoneConflict = transaction { + DemobankCustomerEntity.find { + DemobankCustomersTable.phone eq req.contact_data.phone + }.firstOrNull() + } + // Warning since two individuals claimed one same phone number. + if (maybePhoneConflict != null) + throw conflict("Won't register user ${req.username}: phone conflict on ${req.contact_data.phone}") + } + /** + * Check that cash-out address parses. IBAN is not + * check-summed in this version; the cash-out operation + * just fails for invalid IBANs and the user has then + * the chance to update their IBAN. + */ + try { + parsePayto(req.cashout_address) + } + catch (e: InvalidPaytoError) { + throw badRequest("Won't register account ${req.username}: invalid cash-out address: ${req.cashout_address}") + } + transaction { + val newAccount = insertNewAccount( + username = req.username, + password = req.password, + name = req.name, + iban = req.internal_iban, + demobank = ensureDemobank(call).name + ) + newAccount.customer.phone = req.contact_data.phone + newAccount.customer.email = req.contact_data.email + newAccount.customer.cashout_address = req.cashout_address + } + call.respond(HttpStatusCode.NoContent) + return@post + } + // Get (conversion rates via) config values. + circuitRoute.get("/config") { + call.respond(ConfigResp(ratios_and_fees = ratiosAndFees)) + return@get + } + // Only Admin and only when balance is zero. + circuitRoute.delete("/accounts/{resourceName}") { + call.request.basicAuth(onlyAdmin = true) + val resourceName = call.expectUriComponent("resourceName") + throwIfInstitutionalName(resourceName) + val customer = getCustomer(resourceName) + val bankAccount = getBankAccountFromLabel( + resourceName, + withBankFault = true // See comment "CUSTOMER AND BANK ACCOUNT INVARIANT". + ) + val balance: BigDecimal = getBalance(bankAccount) + if (!isAmountZero(balance)) { + logger.error("Account $resourceName has $balance balance. Won't delete it") + throw SandboxError( + HttpStatusCode.PreconditionFailed, + "Account $resourceName doesn't have zero balance. Won't delete it" + ) + } + transaction { + bankAccount.delete() + customer.delete() + } + call.respond(HttpStatusCode.NoContent) + return@delete + } +} \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt b/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt new file mode 100644 index 00000000..c760a2b1 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt @@ -0,0 +1,433 @@ +package tech.libeufin.sandbox + +import CamtBankAccountEntry +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 kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.util.* +import java.math.BigDecimal +import kotlin.system.exitProcess + +/** + * This file contains the logic for downloading/submitting incoming/outgoing + * fiat transactions to Nexus. It needs the following values for operating. + * + * 1. Nexus URL. + * 2. Credentials to authenticate at Nexus JSON API. + * 3. Long-polling interval. + * 4. Frequency of the download loop. + * + * Notes: + * + * 1. The account to credit on incoming transactions is ALWAYS "admin". + * 2. The time to submit a new payment is as soon as "admin" receives one + * incoming regional payment. + * 3. At this time, Nexus does NOT offer long polling when it serves the + * transactions via its JSON API. => Fixed. + * 4. At this time, Nexus does NOT offer any filter when it serves the + * transactions via its JSON API. => Can be fixed by using the TWG. + */ + +// DEFINITIONS AND HELPERS + +/** + * Timeout the HTTP client waits for the server to respond, + * after the request is made. + */ +val waitTimeout = 30000L + +/** + * Time to wait before HTTP requesting again to the server. + * This helps to avoid tight cycles in case the server responds + * quickly or the client doesn't long-poll. + */ +val newIterationTimeout = 2000L + +/** + * Response format of Nexus GET /transactions. + */ +data class TransactionItem( + val index: String, + val camtData: CamtBankAccountEntry +) +data class NexusTransactions( + val transactions: List +) + +/** + * This exception signals that the buy-in service could NOT + * GET the list of fiat transactions from Nexus due to a client + * error. Because this is fatal (e.g. wrong credentials, URL not found..), + * the service should be stopped. + */ +class BuyinClientError : Exception() + +/** + * This exception signals that POSTing a cash-out operation + * to Nexus failed due to the client. This is a fatal condition + * therefore the monitor should be stopped. + */ +class CashoutClientError : Exception() +/** + * Executes the 'block' function every 'loopNewReqMs' milliseconds. + * Does not exit/fail the process upon exceptions - just logs them. + */ +fun downloadLoop(block: () -> Unit) { + // Needs "runBlocking {}" to call "delay()" and in case 'block' + // contains suspend functions. + runBlocking { + while(true) { + try { block() } + catch (e: BuyinClientError) { + logger.error("The buy-in monitor had a client error while GETting new" + + " transactions from Neuxs. Stopping it") + // Rethrowing and let the caller manage it + throw e + } + // Tolerating any other error type that's not due to the client. + catch (e: Exception) { + logger.error("Sandbox fiat-incoming monitor excepted: ${e.message}") + } + delay(newIterationTimeout) + } + } +} + +// BUY-IN SIDE. + +/** + * Applies the buy-in ratio and fees to the fiat amount + * that came from Nexus. The result is the regional amount + * that will be wired to the exchange Sandbox account. + */ +fun applyBuyinRatioAndFees( + amount: BigDecimal, + ratiosAndFees: RatioAndFees +): BigDecimal { + val maybeBuyinAmount = ((amount * ratiosAndFees.buy_at_ratio.toBigDecimal()) + - ratiosAndFees.buy_in_fee.toBigDecimal()).roundToTwoDigits() + // Bank's fault, as buying in should never lead to negative. + if (maybeBuyinAmount < BigDecimal.ZERO) { + logger.error("Negative buy-in scenario: input fiat amount was '${amount}'" + + ", buy-in ratio was '${ratiosAndFees.buy_at_ratio}'," + + " buy-in fee was '${ratiosAndFees.buy_in_fee}'") + throw internalServerError("Applying buy-in fees yielded negative regional amount") + } + return maybeBuyinAmount +} + +private fun ensureDisabledRedirects(client: HttpClient) { + client.config { + if (followRedirects) throw Exception( + "HTTP client follows redirects, please disable." + ) + } +} +/** + * This function downloads the incoming fiat transactions from Nexus, + * stores them into the database and triggers the related wire transfer + * to the Taler exchange (to be specified in 'accountToCredit'). Once + * started, this function is not supposed to return, except on _client + * side_ errors. On server side errors it pauses and retries. When + * it returns, the caller is expected to handle the error. + */ +fun buyinMonitor( + demobankName: String, // used to get config values. + client: HttpClient, + accountToCredit: String, + accountToDebit: String = "admin" +) { + ensureDisabledRedirects(client) + val demobank = ensureDemobank(demobankName) + /** + * Getting the config values to send authenticated requests + * to Nexus. Sandbox needs one account at Nexus before being + * able to use these values. + */ + val nexusBaseUrl = getConfigValueOrThrow(demobank.config::nexusBaseUrl) + val usernameAtNexus = getConfigValueOrThrow(demobank.config::usernameAtNexus) + val passwordAtNexus = getConfigValueOrThrow(demobank.config::passwordAtNexus) + /** + * This is the endpoint where Nexus serves all the transactions that + * have ingested from the fiat bank. + */ + val endpoint = "bank-accounts/$usernameAtNexus/transactions" + val uriWithoutStart = joinUrl(nexusBaseUrl, endpoint) + "?long_poll_ms=$waitTimeout" + + // downloadLoop does already try-catch (without failing the process). + downloadLoop { + /** + * This bank account will act as the debtor, once a new fiat + * payment is detected. It's the debtor that pays the related + * regional amount to the exchange, in order to start a withdrawal + * operation (in regional coins). + */ + val debitBankAccount = getBankAccountFromLabel(accountToDebit) + /** + * Setting the 'start' URI param in the following command + * lets Sandbox receive only unseen payments from Nexus. + */ + val uriWithStart = "$uriWithoutStart&start=${debitBankAccount.lastFiatFetch}" + runBlocking { + // Maybe get new fiat transactions. + logger.debug("GETting fiat transactions from: $uriWithStart") + val resp = client.get(uriWithStart) { + expectSuccess = false // Avoids excepting on !2xx + basicAuth(usernameAtNexus, passwordAtNexus) + } + // The server failed, pause and try again + if (resp.status.value.toString().startsWith('5')) { + logger.error("Buy-in monitor requested to a failing Nexus. Retry.") + logger.error("Nexus responded: ${resp.bodyAsText()}") + return@runBlocking + } + // The client failed, fail the process. + if (resp.status.value.toString().startsWith('4')) { + logger.error("Buy-in monitor failed at GETting to Nexus. Stopping the buy-in monitor.") + logger.error("Nexus responded: ${resp.bodyAsText()}") + throw BuyinClientError() + } + // Expect 200 OK. What if 3xx? + if (resp.status.value != HttpStatusCode.OK.value) { + logger.error("Unhandled response status ${resp.status.value}, failing Sandbox") + throw BuyinClientError() + } + // Nexus responded 200 OK, analyzing the result. + /** + * Wire to "admin" if the subject is a public key, or do + * nothing otherwise. + */ + val respObj = jacksonObjectMapper().readValue( + resp.bodyAsText(), + NexusTransactions::class.java + ) // errors are logged by the caller (without failing). + respObj.transactions.forEach { + // Ignoring payments with an invalid reserved public key. + if (extractReservePubFromSubject(it.camtData.getSingletonSubject()) == null) + return@forEach + // Extracts the amount and checks it's at most two fractional digits. + val maybeValidAmount = it.camtData.amount.value + if (!validatePlainAmount(maybeValidAmount)) { + logger.error("Nexus gave one amount with invalid fractional digits: $maybeValidAmount." + + " The transaction has index ${it.index}") + // Advancing the last fetched pointer, to avoid GETting + // this invalid payment again. + transaction { + debitBankAccount.refresh() + debitBankAccount.lastFiatFetch = it.index + } + } + val convertedAmount = applyBuyinRatioAndFees( + maybeValidAmount.toBigDecimal(), + ratiosAndFees + ) + transaction { + wireTransfer( + debitAccount = accountToDebit, + creditAccount = accountToCredit, + demobank = demobankName, + subject = it.camtData.getSingletonSubject(), + amount = "${demobank.config.currency}:$convertedAmount" + ) + // Nexus enqueues the transactions such that the index increases. + // If Sandbox crashes here, it'll ask again using the last successful + // index as the start parameter. Being this an exclusive bound, only + // transactions later than it are expected. + debitBankAccount.refresh() + debitBankAccount.lastFiatFetch = it.index + } + } + } + } +} + +/* DB query helper that fetches the latest cash-out operations that were + confirmed in the regional currency. A cash-out operation is 'confirmed' + when the bank account pointed by the parameter 'bankAccountLabel' gets + one incoming payment. + + The List return type (instead of SizedIterable) lets the caller NOT open + a transaction block to access the values -- although some operations _on + the values_ may be forbidden. +*/ +fun getUnsubmittedTransactions(bankAccountLabel: String): List { + return transaction { + val bankAccount = getBankAccountFromLabel(bankAccountLabel) + val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0 + BankAccountTransactionEntity.find { + BankAccountTransactionsTable.id greater lowerExclusiveLimit and ( + BankAccountTransactionsTable.direction eq "CRDT" + ) and (BankAccountTransactionsTable.account eq bankAccount.id) + }.sortedBy { it.id }.map { it } + /* The latest payment must occupy the highest index, + to reliably update the 'lastFiatSubmission' column of + the bank account. */ + } +} + +// CASH-OUT SIDE. + +/** + * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX) + * on the 'watchedBankAccount' and submits the related cash-out payment + * to Nexus. The fiat payment will then take place ENTIRELY on Nexus' + * responsibility. + */ +suspend fun cashoutMonitor( + httpClient: HttpClient, + watchedBankAccount: String = "admin", + demobankName: String = "default", // used to get config values. + dbEventTimeout: Long = 0 // 0 waits forever. +) { + ensureDisabledRedirects(httpClient) + // Register for a REGIO_TX event. + val eventChannel = buildChannelName( + NotificationsChannelDomains.LIBEUFIN_REGIO_TX, + watchedBankAccount + ) + val objectMapper = jacksonObjectMapper() + val demobank = getDemobank(demobankName) + val bankAccount = getBankAccountFromLabel(watchedBankAccount) + val config = demobank?.config ?: throw internalServerError( + "Demobank '$demobankName' has no configuration." + ) + /** + * The monitor needs the cash-out currency to correctly POST + * payment initiations at Nexus. Recall: Nexus bank accounts + * do not mandate any particular currency, as they serve as mere + * bridges to the backing bank. And: a backing bank may have + * multiple currencies, or the backing bank may not explicitly + * specify any currencies to be _the_ currency of the backed + * bank account. + */ + if (config.cashoutCurrency == null) { + logger.error("Config lacks cash-out currency.") + exitProcess(1) + } + val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl) + val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus) + val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus) + val paymentInitEndpoint = nexusBaseUrl.run { + var nexusBaseUrlFromConfig = this + if (!nexusBaseUrlFromConfig.endsWith('/')) + nexusBaseUrlFromConfig += '/' + /** + * WARNING: Nexus gives the possibility to have bank account names + * DIFFERENT from their owner's username. Sandbox however MUST have + * its Nexus bank account named THE SAME as its username. + */ + nexusBaseUrlFromConfig + "bank-accounts/$usernameAtNexus/payment-initiations" + } + while (true) { + val listenHandle = PostgresListenHandle(eventChannel) + // pessimistically LISTEN + listenHandle.postgresListen() + // but optimistically check for data, case some + // arrived _before_ the LISTEN. + var newTxs = getUnsubmittedTransactions(watchedBankAccount) + // Data found, UNLISTEN. + if (newTxs.isNotEmpty()) { + logger.debug("Found cash-out's without waiting any DB event.") + listenHandle.postgresUnlisten() + } + // Data not found, wait. + else { + logger.debug("Need to wait a DB event for new cash-out's") + val isNotificationArrived = listenHandle.waitOnIODispatchers(dbEventTimeout) + if (isNotificationArrived && listenHandle.receivedPayload == "CRDT") + newTxs = getUnsubmittedTransactions(watchedBankAccount) + } + if (newTxs.isEmpty()) { + logger.debug("DB event timeout expired") + continue + } + logger.debug("POSTing new cash-out's") + newTxs.forEach { + logger.debug("POSTing cash-out '${it.subject}' to $paymentInitEndpoint") + val body = object { + /** + * This field is UID of the request _as assigned by the + * client_. That helps to reconcile transactions or lets + * Nexus implement idempotency. It will NOT identify the created + * resource at the server side. The ID of the created resource is + * assigned _by Nexus_ and communicated in the (successful) response. + */ + val uid = it.accountServicerReference + val iban = it.creditorIban + val bic = it.creditorBic + val amount = "${config.cashoutCurrency}:${it.amount}" + val subject = it.subject + val name = it.creditorName + } + val resp = try { + httpClient.post(paymentInitEndpoint) { + expectSuccess = false // Avoids excepting on !2xx + basicAuth(usernameAtNexus, passwordAtNexus) + contentType(ContentType.Application.Json) + setBody(objectMapper.writeValueAsString(body)) + } + } + // Hard-error, response did not even arrive. + catch (e: Exception) { + logger.error("Cash-out monitor could not reach Nexus. Pause and retry") + logger.error(e.message) + /** + * Explicit delaying because the monitor normally + * waits on DB events, and this retry likely won't + * wait on a DB event. + */ + delay(2000) + return@forEach + } + // Server fault. Pause and retry. + if (resp.status.value.toString().startsWith('5')) { + logger.error("Cash-out monitor POSTed to a failing Nexus. Pause and retry") + logger.error("Server responded: ${resp.bodyAsText()}") + /** + * Explicit delaying because the monitor normally + * waits on DB events, and this retry likely won't + * wait on a DB event. + */ + delay(2000L) + return@forEach + } + // Client fault, fail Sandbox. + if (resp.status.value.toString().startsWith('4')) { + logger.error("Cash-out monitor failed at POSTing to Nexus.") + logger.error("Nexus responded: ${resp.bodyAsText()}") + throw CashoutClientError() + } + // Expecting 200 OK. What if 3xx? + if (resp.status.value != HttpStatusCode.OK.value) { + logger.error("Cash-out monitor, unhandled response status: ${resp.status.value}.") + throw CashoutClientError() + } + // Successful case, mark the wire transfer as submitted, + // and advance the pointer to the last submitted payment. + val responseBody = resp.bodyAsText() + transaction { + CashoutSubmissionEntity.new { + localTransaction = it.id + submissionTime = resp.responseTime.timestamp + /** + * The following block associates the submitted payment + * to the UID that Nexus assigned to it. It is currently not + * used in Sandbox, but might help for reconciliation. + */ + if (responseBody.isNotEmpty()) + maybeNexusResposnse = responseBody + } + // Advancing the 'last submitted bookmark', to avoid + // handling the same transaction multiple times. + bankAccount.lastFiatSubmission = it + } + } + } +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/DB.kt b/bank/src/main/kotlin/tech/libeufin/bank/DB.kt new file mode 100644 index 00000000..523b1bc3 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/DB.kt @@ -0,0 +1,747 @@ +/* + * 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 + * + */ + +package tech.libeufin.sandbox + +import io.ktor.http.* +import org.jetbrains.exposed.dao.Entity +import org.jetbrains.exposed.dao.EntityClass +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.LongEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IdTable +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.util.* +import kotlin.reflect.* +import kotlin.reflect.full.* + +/** + * All the states to give a subscriber. + */ +enum class SubscriberState { + /** + * No keys at all given to the bank. + */ + NEW, + + /** + * Only INI electronic message was successfully sent. + */ + PARTIALLY_INITIALIZED_INI, + + /**r + * Only HIA electronic message was successfully sent. + */ + PARTIALLY_INITIALIZED_HIA, + + /** + * Both INI and HIA were electronically sent with success. + */ + INITIALIZED, + + /** + * All the keys accounted in INI and HIA have been confirmed + * via physical mail. + */ + READY +} + +/** + * All the states that one key can be assigned. + */ +enum class KeyState { + + /** + * The key was never communicated. + */ + MISSING, + + /** + * The key has been electronically sent. + */ + NEW, + + /** + * The key has been confirmed (either via physical mail + * or electronically -- e.g. with certificates) + */ + RELEASED +} + +/** + * Stores one config object to the database. Each field + * name and value populate respectively the configKey and + * configValue columns. Rows are defined in the following way: + * demobankName | configKey | configValue + */ +fun insertConfigPairs(config: DemobankConfig, override: Boolean = false) { + // Fill the config key-value pairs in the DB. + config::class.declaredMemberProperties.forEach { configField -> + val maybeValue = configField.getter.call(config) + if (override) { + val maybeConfigPair = DemobankConfigPairEntity.find { + DemobankConfigPairsTable.configKey eq configField.name + }.firstOrNull() + if (maybeConfigPair == null) + throw internalServerError("Cannot override config value '${configField.name}' not found.") + maybeConfigPair.configValue = maybeValue?.toString() + return@forEach + } + DemobankConfigPairEntity.new { + this.demobankName = config.demobankName + this.configKey = configField.name + this.configValue = maybeValue?.toString() + } + } +} + +object DemobankConfigPairsTable : LongIdTable() { + val demobankName = text("demobankName") + val configKey = text("configKey") + val configValue = text("configValue").nullable() +} + +class DemobankConfigPairEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(DemobankConfigPairsTable) + var demobankName by DemobankConfigPairsTable.demobankName + var configKey by DemobankConfigPairsTable.configKey + var configValue by DemobankConfigPairsTable.configValue +} + +object DemobankConfigsTable : LongIdTable() { + val name = text("hostname") +} + +// Helpers for handling config values in memory. +typealias DemobankConfigKey = String +typealias DemobankConfigValue = String? +fun Pair.expectValue(): String { + if (this.second == null) throw internalServerError("Config value for '${this.first}' is null in the database.") + return this.second as String +} + +class DemobankConfigEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(DemobankConfigsTable) + var name by DemobankConfigsTable.name + /** + * This object gets defined by parsing all the configuration + * values found in the DB for one demobank. Those values are + * retrieved from _another_ table. + */ + val config: DemobankConfig by lazy { + // Getting all the values for this demobank. + val configPairs: List> = transaction { + val maybeConfigPairs = DemobankConfigPairEntity.find { + DemobankConfigPairsTable.demobankName.eq(name) + } + if (maybeConfigPairs.empty()) throw SandboxError( + HttpStatusCode.InternalServerError, + "No config values of $name were found in the database" + ) + // Copying results to a DB-agnostic list, to later operate out of "transaction {}" + maybeConfigPairs.map { Pair(it.configKey, it.configValue) } + } + // Building the args to instantiate a DemobankConfig (non-Exposed) object. + val args = mutableMapOf() + // For each constructor parameter name, find the same-named database entry. + val configClass = DemobankConfig::class + if (configClass.primaryConstructor == null) { + throw SandboxError( + HttpStatusCode.InternalServerError, + "${configClass.simpleName} primaryConstructor is null." + ) + } + if (configClass.primaryConstructor?.parameters == null) { + throw SandboxError( + HttpStatusCode.InternalServerError, + "${configClass.simpleName} primaryConstructor" + + " arguments is null. Cannot set any config value." + ) + } + // For each field in the config object, find the respective DB row. + configClass.primaryConstructor?.parameters?.forEach { par: KParameter -> + val configPairFromDb: Pair? + = configPairs.firstOrNull { + configPair: Pair -> + configPair.first == par.name + } + if (configPairFromDb == null) { + throw SandboxError( + HttpStatusCode.InternalServerError, + "Config key '${par.name}' not found in the database." + ) + } + when(par.type) { + // non-nullable + typeOf() -> { args[par] = configPairFromDb.expectValue().toBoolean() } + typeOf() -> { args[par] = configPairFromDb.expectValue().toInt() } + // nullable + typeOf() -> { args[par] = configPairFromDb.second?.toBoolean() } + typeOf() -> { args[par] = configPairFromDb.second?.toInt() } + else -> args[par] = configPairFromDb.second + } + } + // Proceeding now to instantiate the config class, and make it a field of this type. + configClass.primaryConstructor!!.callBy(args) + } +} + +/** + * Users who are allowed to log into the demo bank. + * Created via the /demobanks/{demobankname}/register endpoint. + */ +object DemobankCustomersTable : LongIdTable() { + val username = text("username") + val passwordHash = text("passwordHash") + val name = text("name").nullable() + val email = text("email").nullable() + val phone = text("phone").nullable() + val cashout_address = text("cashout_address").nullable() +} + +class DemobankCustomerEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(DemobankCustomersTable) + var username by DemobankCustomersTable.username + var passwordHash by DemobankCustomersTable.passwordHash + var name by DemobankCustomersTable.name + var email by DemobankCustomersTable.email + var phone by DemobankCustomersTable.phone + var cashout_address by DemobankCustomersTable.cashout_address +} + +/** + * This table stores RSA public keys of subscribers. + */ +object EbicsSubscriberPublicKeysTable : IntIdTable() { + val rsaPublicKey = blob("rsaPublicKey") + val state = enumeration("state", KeyState::class) +} + +class EbicsSubscriberPublicKeyEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(EbicsSubscriberPublicKeysTable) + var rsaPublicKey by EbicsSubscriberPublicKeysTable.rsaPublicKey + var state by EbicsSubscriberPublicKeysTable.state +} + +/** + * Ebics 'host'(s) that are served by one Sandbox instance. + */ +object EbicsHostsTable : IntIdTable() { + val hostID = text("hostID") + val ebicsVersion = text("ebicsVersion") + val signaturePrivateKey = blob("signaturePrivateKey") + val encryptionPrivateKey = blob("encryptionPrivateKey") + val authenticationPrivateKey = blob("authenticationPrivateKey") +} + +class EbicsHostEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(EbicsHostsTable) + var hostId by EbicsHostsTable.hostID + var ebicsVersion by EbicsHostsTable.ebicsVersion + var signaturePrivateKey by EbicsHostsTable.signaturePrivateKey + var encryptionPrivateKey by EbicsHostsTable.encryptionPrivateKey + var authenticationPrivateKey by EbicsHostsTable.authenticationPrivateKey +} + +/** + * Ebics Subscribers table. + */ +object EbicsSubscribersTable : IntIdTable() { + val userId = text("userID") + val partnerId = text("partnerID") + val systemId = text("systemID").nullable() + val hostId = text("hostID") + val signatureKey = reference("signatureKey", EbicsSubscriberPublicKeysTable).nullable() + val encryptionKey = reference("encryptionKey", EbicsSubscriberPublicKeysTable).nullable() + val authenticationKey = reference("authorizationKey", EbicsSubscriberPublicKeysTable).nullable() + val nextOrderID = integer("nextOrderID") + val state = enumeration("state", SubscriberState::class) + val bankAccount = reference( + "bankAccount", + BankAccountsTable, + onDelete = ReferenceOption.CASCADE + ).nullable() +} + +class EbicsSubscriberEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(EbicsSubscribersTable) + var userId by EbicsSubscribersTable.userId + var partnerId by EbicsSubscribersTable.partnerId + var systemId by EbicsSubscribersTable.systemId + var hostId by EbicsSubscribersTable.hostId + var signatureKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.signatureKey + var encryptionKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.encryptionKey + var authenticationKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.authenticationKey + var nextOrderID by EbicsSubscribersTable.nextOrderID + var state by EbicsSubscribersTable.state + var bankAccount by BankAccountEntity optionalReferencedOn EbicsSubscribersTable.bankAccount +} + +/** + * Details of a download order. + */ +object EbicsDownloadTransactionsTable : IdTable() { + override val id = text("transactionID").entityId() + val orderType = text("orderType") + val host = reference("host", EbicsHostsTable) + val subscriber = reference("subscriber", EbicsSubscribersTable) + val encodedResponse = text("encodedResponse") + val transactionKeyEnc = blob("transactionKeyEnc") + val numSegments = integer("numSegments") + val segmentSize = integer("segmentSize") + val receiptReceived = bool("receiptReceived") +} + +class EbicsDownloadTransactionEntity(id: EntityID) : Entity(id) { + companion object : EntityClass(EbicsDownloadTransactionsTable) + + var orderType by EbicsDownloadTransactionsTable.orderType + var host by EbicsHostEntity referencedOn EbicsDownloadTransactionsTable.host + var subscriber by EbicsSubscriberEntity referencedOn EbicsDownloadTransactionsTable.subscriber + var encodedResponse by EbicsDownloadTransactionsTable.encodedResponse + var numSegments by EbicsDownloadTransactionsTable.numSegments + var transactionKeyEnc by EbicsDownloadTransactionsTable.transactionKeyEnc + var segmentSize by EbicsDownloadTransactionsTable.segmentSize + var receiptReceived by EbicsDownloadTransactionsTable.receiptReceived +} + +/** + * Details of a upload order. + */ +object EbicsUploadTransactionsTable : IdTable() { + override val id = text("transactionID").entityId() + val orderType = text("orderType") + val orderID = text("orderID") + val host = reference("host", EbicsHostsTable) + val subscriber = reference("subscriber", EbicsSubscribersTable) + val numSegments = integer("numSegments") + val lastSeenSegment = integer("lastSeenSegment") + val transactionKeyEnc = blob("transactionKeyEnc") +} + +class EbicsUploadTransactionEntity(id: EntityID) : Entity(id) { + companion object : EntityClass(EbicsUploadTransactionsTable) + var orderType by EbicsUploadTransactionsTable.orderType + var orderID by EbicsUploadTransactionsTable.orderID + var host by EbicsHostEntity referencedOn EbicsUploadTransactionsTable.host + var subscriber by EbicsSubscriberEntity referencedOn EbicsUploadTransactionsTable.subscriber + var numSegments by EbicsUploadTransactionsTable.numSegments + var lastSeenSegment by EbicsUploadTransactionsTable.lastSeenSegment + var transactionKeyEnc by EbicsUploadTransactionsTable.transactionKeyEnc +} + +/** + * FIXME: document this. + */ +object EbicsOrderSignaturesTable : IntIdTable() { + val orderID = text("orderID") + val orderType = text("orderType") + val partnerID = text("partnerID") + val userID = text("userID") + val signatureAlgorithm = text("signatureAlgorithm") + val signatureValue = blob("signatureValue") +} + +class EbicsOrderSignatureEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(EbicsOrderSignaturesTable) + var orderID by EbicsOrderSignaturesTable.orderID + var orderType by EbicsOrderSignaturesTable.orderType + var partnerID by EbicsOrderSignaturesTable.partnerID + var userID by EbicsOrderSignaturesTable.userID + var signatureAlgorithm by EbicsOrderSignaturesTable.signatureAlgorithm + var signatureValue by EbicsOrderSignaturesTable.signatureValue +} + +/** + * FIXME: document this. + */ +object EbicsUploadTransactionChunksTable : IdTable() { + override val id = text("transactionID").entityId() + val chunkIndex = integer("chunkIndex") + val chunkContent = blob("chunkContent") +} + +// FIXME: Is upload chunking not implemented somewhere?! +class EbicsUploadTransactionChunkEntity(id: EntityID) : Entity(id) { + companion object : EntityClass(EbicsUploadTransactionChunksTable) + var chunkIndex by EbicsUploadTransactionChunksTable.chunkIndex + var chunkContent by EbicsUploadTransactionChunksTable.chunkContent +} + + +/** + * Holds those transactions that aren't yet reported in a Camt.053 document. + * After reporting those, the table gets emptied. Rows are merely references + * to the main ledger. + */ +object BankAccountFreshTransactionsTable : LongIdTable() { + val transactionRef = reference( + "transaction", + BankAccountTransactionsTable, + onDelete = ReferenceOption.CASCADE + ) +} +class BankAccountFreshTransactionEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(BankAccountFreshTransactionsTable) + var transactionRef by BankAccountTransactionEntity referencedOn BankAccountFreshTransactionsTable.transactionRef +} + +/** + * Table that keeps all the payments initiated by PAIN.001. + */ +object BankAccountTransactionsTable : LongIdTable() { + val creditorIban = text("creditorIban") + val creditorBic = text("creditorBic").nullable() + val creditorName = text("creditorName") + val debtorIban = text("debtorIban") + val debtorBic = text("debtorBic").nullable() + val debtorName = text("debtorName") + val subject = text("subject") + // Amount is a BigDecimal in String form. + val amount = text("amount") + val currency = text("currency") + // Milliseconds since the Epoch. + val date = long("date") + + /** + * UID assigned to the payment by Sandbox. Despite the camt-looking + * name, this UID is always given, even when no EBICS or camt are being + * served. + */ + val accountServicerReference = text("accountServicerReference") + /** + * The following two values are pain.001 specific. Sandbox stores + * them when it serves EBICS connections. + */ + val pmtInfId = text("pmtInfId").nullable() + val endToEndId = text("EndToEndId").nullable() + val direction = text("direction") + /** + * Bank account of the party whose 'direction' refers. This version allows + * only both parties to be registered at the running Sandbox. + */ + val account = reference( + "account", BankAccountsTable, + onDelete = ReferenceOption.CASCADE + ) + // Redundantly storing the demobank for query convenience. + val demobank = reference("demobank", DemobankConfigsTable) +} + +class BankAccountTransactionEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(BankAccountTransactionsTable) { + override fun new(init: BankAccountTransactionEntity.() -> Unit): BankAccountTransactionEntity { + /** + * Fresh transactions are those that wait to be included in a + * "history" report, likely a Camt.5x message. The "fresh transactions" + * table keeps a list of such transactions. + */ + val freshTx = super.new(init) + BankAccountFreshTransactionsTable.insert { + it[transactionRef] = freshTx.id + } + /** + * The bank account involved in this transaction points to + * it as the "last known" transaction, to make it easier to + * build histories that depend on such record. + */ + freshTx.account.lastTransaction = freshTx + return freshTx + } + } + var creditorIban by BankAccountTransactionsTable.creditorIban + var creditorBic by BankAccountTransactionsTable.creditorBic + var creditorName by BankAccountTransactionsTable.creditorName + var debtorIban by BankAccountTransactionsTable.debtorIban + var debtorBic by BankAccountTransactionsTable.debtorBic + var debtorName by BankAccountTransactionsTable.debtorName + var subject by BankAccountTransactionsTable.subject + var amount by BankAccountTransactionsTable.amount + var currency by BankAccountTransactionsTable.currency + var date by BankAccountTransactionsTable.date + var accountServicerReference by BankAccountTransactionsTable.accountServicerReference + var pmtInfId by BankAccountTransactionsTable.pmtInfId + var endToEndId by BankAccountTransactionsTable.endToEndId + var direction by BankAccountTransactionsTable.direction + var account by BankAccountEntity referencedOn BankAccountTransactionsTable.account + var demobank by DemobankConfigEntity referencedOn BankAccountTransactionsTable.demobank +} + +/** + * Table that keeps information about which bank accounts (iban+bic+name) + * are active in the system. In the current version, 'label' and 'owner' + * are always equal; future versions may change this, when one customer can + * own multiple bank accounts. + */ +object BankAccountsTable : IntIdTable() { + val balance = text("balance").default("0") + val iban = text("iban") + val bic = text("bic").default("SANDBOXX") + val label = text("label").uniqueIndex("accountLabelIndex") + /** + * This field is the username of the customer that owns the + * bank account. Admin is the only exception: that can specify + * this field as "admin" although no customer backs it. + */ + val owner = text("owner") + val isPublic = bool("isPublic").default(false) + val demoBank = reference("demoBank", DemobankConfigsTable) + + /** + * Point to the last transaction related to this account, regardless + * of it being credit or debit. This reference helps to construct + * history results that start from / depend on the last transaction. + */ + val lastTransaction = reference("lastTransaction", BankAccountTransactionsTable).nullable() + + /** + * Points to the transaction that was last submitted by the conversion + * service to Nexus, in order to initiate a fiat payment related to a + * cash-out operation. + */ + val lastFiatSubmission = reference("lastFiatSubmission", BankAccountTransactionsTable).nullable() + + /** + * Tracks the last fiat payment that was read from Nexus. This tracker + * gets updated ONLY IF the exchange gets successfully paid with the related + * amount in the regional currency. + */ + val lastFiatFetch = text("lastFiatFetch").default("0") +} + +class BankAccountEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(BankAccountsTable) + + var balance by BankAccountsTable.balance + var iban by BankAccountsTable.iban + var bic by BankAccountsTable.bic + var label by BankAccountsTable.label + var owner by BankAccountsTable.owner + var isPublic by BankAccountsTable.isPublic + var demoBank by DemobankConfigEntity referencedOn BankAccountsTable.demoBank + var lastTransaction by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastTransaction + var lastFiatSubmission by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastFiatSubmission + var lastFiatFetch by BankAccountsTable.lastFiatFetch +} + +object BankAccountStatementsTable : IntIdTable() { + val statementId = text("statementId") + val creationTime = long("creationTime") + val xmlMessage = text("xmlMessage") + val bankAccount = reference("bankAccount", BankAccountsTable) + // Signed BigDecimal representing a Camt.053 CLBD field. + val balanceClbd = text("balanceClbd").nullable() +} + +class BankAccountStatementEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(BankAccountStatementsTable) + var statementId by BankAccountStatementsTable.statementId + var creationTime by BankAccountStatementsTable.creationTime + var xmlMessage by BankAccountStatementsTable.xmlMessage + var bankAccount by BankAccountEntity referencedOn BankAccountStatementsTable.bankAccount + var balanceClbd by BankAccountStatementsTable.balanceClbd +} + +enum class CashoutOperationStatus { CONFIRMED, PENDING } +object CashoutOperationsTable : LongIdTable() { + val uuid = uuid("uuid").autoGenerate() + /** + * This amount is the one the user entered in the cash-out + * dialog. That will show up as the outgoing transfer in their + * local currency bank account. + */ + val amountDebit = text("amountDebit") + val amountCredit = text("amountCredit") + val buyAtRatio = text("buyAtRatio") + val buyInFee = text("buyInFee") + val sellAtRatio = text("sellAtRatio") + val sellOutFee = text("sellOutFee") + val subject = text("subject") + val creationTime = long("creationTime") // in milliseconds. + val confirmationTime = long("confirmationTime").nullable() // in milliseconds. + val tanChannel = enumeration("tanChannel", SupportedTanChannels::class) + val account = text("account") + val cashoutAddress = text("cashoutAddress") + val tan = text("tan") + val status = enumeration("status", CashoutOperationStatus::class).default(CashoutOperationStatus.PENDING) +} + +class CashoutOperationEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(CashoutOperationsTable) + var uuid by CashoutOperationsTable.uuid + var amountDebit by CashoutOperationsTable.amountDebit + var amountCredit by CashoutOperationsTable.amountCredit + var buyAtRatio by CashoutOperationsTable.buyAtRatio + var buyInFee by CashoutOperationsTable.buyInFee + var sellAtRatio by CashoutOperationsTable.sellAtRatio + var sellOutFee by CashoutOperationsTable.sellOutFee + var subject by CashoutOperationsTable.subject + var creationTime by CashoutOperationsTable.creationTime + var confirmationTime by CashoutOperationsTable.confirmationTime + var tanChannel by CashoutOperationsTable.tanChannel + var account by CashoutOperationsTable.account + var cashoutAddress by CashoutOperationsTable.cashoutAddress + var tan by CashoutOperationsTable.tan + var status by CashoutOperationsTable.status +} +object TalerWithdrawalsTable : LongIdTable() { + val wopid = uuid("wopid").autoGenerate() + val amount = text("amount") // $currency:x.y + /** + * Turns to true after the wallet gave the reserve public key + * and the exchange details to the bank. + */ + val selectionDone = bool("selectionDone").default(false) + val aborted = bool("aborted").default(false) + /** + * Turns to true after the wire transfer to the exchange bank account + * gets completed _on the bank's side_. This does never guarantees that + * the payment arrived at the exchange's bank yet. + */ + val confirmationDone = bool("confirmationDone").default(false) + val reservePub = text("reservePub").nullable() + val selectedExchangePayto = text("selectedExchangePayto").nullable() + val walletBankAccount = reference("walletBankAccount", BankAccountsTable) +} +class TalerWithdrawalEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(TalerWithdrawalsTable) + var wopid by TalerWithdrawalsTable.wopid + var selectionDone by TalerWithdrawalsTable.selectionDone + var confirmationDone by TalerWithdrawalsTable.confirmationDone + var reservePub by TalerWithdrawalsTable.reservePub + var selectedExchangePayto by TalerWithdrawalsTable.selectedExchangePayto + var amount by TalerWithdrawalsTable.amount + var walletBankAccount by BankAccountEntity referencedOn TalerWithdrawalsTable.walletBankAccount + var aborted by TalerWithdrawalsTable.aborted +} + +object BankAccountReportsTable : IntIdTable() { + val reportId = text("reportId") + val creationTime = long("creationTime") + val xmlMessage = text("xmlMessage") + val bankAccount = reference("bankAccount", BankAccountsTable) +} + +/** + * This table tracks the cash-out requests that Sandbox sends to Nexus. + * Only successful requests make it to this table. Failed request would + * either _stop_ the conversion service (for client-side errors) or get retried + * at a later time (for server-side errors.) + */ +object CashoutSubmissionsTable: LongIdTable() { + val localTransaction = reference("localTransaction", BankAccountTransactionsTable).uniqueIndex() + val maybeNexusResponse = text("maybeNexusResponse").nullable() + val submissionTime = long("submissionTime").nullable() // failed don't have it. +} + +class CashoutSubmissionEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(CashoutSubmissionsTable) + var localTransaction by CashoutSubmissionsTable.localTransaction + var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse + var submissionTime by CashoutSubmissionsTable.submissionTime +} + +fun dbDropTables(connStringFromEnv: String) { + connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) + if (isPostgres()) { + val ret = execCommand( + listOf( + "libeufin-load-sql", + "-d", + connStringFromEnv, + "-s", + "sandbox", + "-r" // the drop option + ), + /** + * Tolerating a failure here helps to manage the case + * where an empty database is attempted to be dropped. + */ + throwIfFails = false + ) + if (ret != 0) + logger.warn("Dropping the sandbox tables failed. Was the DB filled before?") + return + } + transaction { + SchemaUtils.drop( + CashoutSubmissionsTable, + EbicsSubscribersTable, + EbicsSubscriberPublicKeysTable, + EbicsHostsTable, + EbicsDownloadTransactionsTable, + EbicsUploadTransactionsTable, + EbicsUploadTransactionChunksTable, + EbicsOrderSignaturesTable, + BankAccountTransactionsTable, + BankAccountFreshTransactionsTable, + BankAccountsTable, + BankAccountReportsTable, + BankAccountStatementsTable, + DemobankConfigsTable, + DemobankConfigPairsTable, + TalerWithdrawalsTable, + DemobankCustomersTable, + CashoutOperationsTable + ) + } + +} + +fun dbCreateTables(connStringFromEnv: String) { + connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) + if (isPostgres()) { + execCommand(listOf( + "libeufin-load-sql", + "-d", + connStringFromEnv, + "-s", + "sandbox" + )) + return + } + // Still using the legacy way for other DBMSs, like SQLite. + transaction { + SchemaUtils.create( + CashoutSubmissionsTable, + DemobankConfigsTable, + DemobankConfigPairsTable, + EbicsSubscribersTable, + EbicsSubscriberPublicKeysTable, + EbicsHostsTable, + EbicsDownloadTransactionsTable, + EbicsUploadTransactionsTable, + EbicsUploadTransactionChunksTable, + EbicsOrderSignaturesTable, + BankAccountTransactionsTable, + BankAccountFreshTransactionsTable, + BankAccountsTable, + BankAccountReportsTable, + BankAccountStatementsTable, + TalerWithdrawalsTable, + DemobankCustomersTable, + CashoutOperationsTable + ) + } +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt new file mode 100644 index 00000000..79f7a404 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -0,0 +1,665 @@ +package tech.libeufin.sandbox + +import org.postgresql.jdbc.PgConnection +import tech.libeufin.util.internalServerError + +import java.sql.DriverManager +import java.sql.PreparedStatement +import java.sql.SQLException +import java.util.* + +private const val DB_CTR_LIMIT = 1000000 + +data class Customer( + val login: String, + val passwordHash: String, + val name: String, + val email: String, + val phone: String, + val cashoutPayto: String, + val cashoutCurrency: String +) + +data class TalerAmount( + val value: Long, + val frac: Int +) + +data class BankAccount( + val iban: String, + val bic: String, + val bankAccountLabel: String, + val owningCustomerId: Long, + val isPublic: Boolean = false, + val lastNexusFetchRowId: Long, + val balance: TalerAmount? = null, + val hasDebt: Boolean +) + +enum class TransactionDirection { + credit, debit +} + +enum class TanChannel { + sms, email, file +} + +data class BankInternalTransaction( + val creditorAccountId: Long, + val debtorAccountId: Long, + val subject: String, + val amount: TalerAmount, + val transactionDate: Long, + val accountServicerReference: String, + val endToEndId: String, + val paymentInformationId: String +) + +data class BankAccountTransaction( + val creditorIban: String, + val creditorBic: String, + val creditorName: String, + val debtorIban: String, + val debtorBic: String, + val debtorName: String, + val subject: String, + val amount: TalerAmount, + val transactionDate: Long, // microseconds + val accountServicerReference: String, + val paymentInformationId: String, + val endToEndId: String, + val direction: TransactionDirection, + val bankAccountId: Long, +) + +data class TalerWithdrawalOperation( + val withdrawalUuid: UUID, + val amount: TalerAmount, + val selectionDone: Boolean = false, + val aborted: Boolean = false, + val confirmationDone: Boolean = false, + val reservePub: ByteArray?, + val selectedExchangePayto: String?, + val walletBankAccount: Long +) + +data class Cashout( + val cashoutUuid: UUID, + val localTransaction: Long? = null, + val amountDebit: TalerAmount, + val amountCredit: TalerAmount, + val buyAtRatio: Int, + val buyInFee: TalerAmount, + val sellAtRatio: Int, + val sellOutFee: TalerAmount, + val subject: String, + val creationTime: Long, + val tanConfirmationTime: Long? = null, + val tanChannel: TanChannel, + val tanCode: String, + val bankAccount: Long, + val cashoutAddress: String, + val cashoutCurrency: String +) + +class Database(private val dbConfig: String) { + private var dbConn: PgConnection? = null + private var dbCtr: Int = 0 + private val preparedStatements: MutableMap = mutableMapOf() + + init { + Class.forName("org.postgresql.Driver") + } + private fun reconnect() { + dbCtr++ + val myDbConn = dbConn + if ((dbCtr < DB_CTR_LIMIT && myDbConn != null) && !(myDbConn.isClosed)) + return + dbConn?.close() + preparedStatements.clear() + dbConn = DriverManager.getConnection(dbConfig).unwrap(PgConnection::class.java) + dbCtr = 0 + dbConn?.execSQLUpdate("SET search_path TO libeufin_bank;") + } + + private fun prepare(sql: String): PreparedStatement { + var ps = preparedStatements[sql] + if (ps != null) return ps + val myDbConn = dbConn + if (myDbConn == null) throw internalServerError("DB connection down") + ps = myDbConn.prepareStatement(sql) + preparedStatements[sql] = ps + return ps + } + + /** + * Helper that returns false if the row to be inserted + * hits a unique key constraint violation, true when it + * succeeds. Any other error (re)throws exception. + */ + private fun myExecute(stmt: PreparedStatement): Boolean { + try { + stmt.execute() + } catch (e: SQLException) { + logger.error(e.message) + // NOTE: it seems that _every_ error gets the 0 code. + if (e.errorCode == 0) return false + // rethrowing, not to hide other types of errors. + throw e + } + return true + } + + // CONFIG + fun configGet(configKey: String): String? { + reconnect() + val stmt = prepare("SELECT config_value FROM configuration WHERE config_key=?;") + stmt.setString(1, configKey) + val rs = stmt.executeQuery() + rs.use { + if(!it.next()) return null + return it.getString("config_value") + } + } + fun configSet(configKey: String, configValue: String) { + reconnect() + val stmt = prepare("CALL bank_set_config(TEXT(?), TEXT(?))") + stmt.setString(1, configKey) + stmt.setString(2, configValue) + stmt.execute() + } + + // CUSTOMERS + fun customerCreate(customer: Customer): Boolean { + reconnect() + val stmt = prepare(""" + INSERT INTO customers ( + login + ,password_hash + ,name + ,email + ,phone + ,cashout_payto + ,cashout_currency + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """ + ) + stmt.setString(1, customer.login) + stmt.setString(2, customer.passwordHash) + stmt.setString(3, customer.name) + stmt.setString(4, customer.email) + stmt.setString(5, customer.phone) + stmt.setString(6, customer.cashoutPayto) + stmt.setString(7, customer.cashoutCurrency) + + return myExecute(stmt) + } + fun customerGetFromLogin(login: String): Customer? { + reconnect() + val stmt = prepare(""" + SELECT + password_hash, + name, + email, + phone, + cashout_payto, + cashout_currency + FROM customers + WHERE login=? + """) + stmt.setString(1, login) + val rs = stmt.executeQuery() + rs.use { + if (!rs.next()) return null + return Customer( + login = login, + passwordHash = it.getString("password_hash"), + name = it.getString("name"), + phone = it.getString("phone"), + email = it.getString("email"), + cashoutCurrency = it.getString("cashout_currency"), + cashoutPayto = it.getString("cashout_payto") + ) + } + } + // Possibly more "customerGetFrom*()" to come. + + // BANK ACCOUNTS + // Returns false on conflicts. + fun bankAccountCreate(bankAccount: BankAccount): Boolean { + reconnect() + val stmt = prepare(""" + INSERT INTO bank_accounts + (iban + ,bic + ,bank_account_label + ,owning_customer_id + ,is_public + ,last_nexus_fetch_row_id + ) + VALUES (?, ?, ?, ?, ?, ?) + """) + stmt.setString(1, bankAccount.iban) + stmt.setString(2, bankAccount.bic) + stmt.setString(3, bankAccount.bankAccountLabel) + stmt.setLong(4, bankAccount.owningCustomerId) + stmt.setBoolean(5, bankAccount.isPublic) + stmt.setLong(6, bankAccount.lastNexusFetchRowId) + // using the default zero value for the balance. + return myExecute(stmt) + } + + fun bankAccountSetMaxDebt( + bankAccountLabel: String, + maxDebt: TalerAmount + ): Boolean { + reconnect() + val stmt = prepare(""" + UPDATE bank_accounts + SET max_debt=(?,?)::taler_amount + WHERE bank_account_label=? + """) + stmt.setLong(1, maxDebt.value) + stmt.setInt(2, maxDebt.frac) + stmt.setString(3, bankAccountLabel) + return myExecute(stmt) + } + + fun bankAccountGetFromLabel(bankAccountLabel: String): BankAccount? { + reconnect() + val stmt = prepare(""" + SELECT + iban + ,bic + ,owning_customer_id + ,is_public + ,last_nexus_fetch_row_id + ,(balance).val AS balance_value + ,(balance).frac AS balance_frac + ,has_debt + FROM bank_accounts + WHERE bank_account_label=? + """) + stmt.setString(1, bankAccountLabel) + + val rs = stmt.executeQuery() + rs.use { + if (!it.next()) return null + return BankAccount( + iban = it.getString("iban"), + bic = it.getString("bic"), + balance = TalerAmount( + it.getLong("balance_value"), + it.getInt("balance_frac") + ), + bankAccountLabel = bankAccountLabel, + lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"), + owningCustomerId = it.getLong("owning_customer_id"), + hasDebt = it.getBoolean("has_debt") + ) + } + } + // More bankAccountGetFrom*() to come, on a needed basis. + + // BANK ACCOUNT TRANSACTIONS + enum class BankTransactionResult { + NO_CREDITOR, + NO_DEBTOR, + SUCCESS, + CONFLICT + } + fun bankTransactionCreate( + tx: BankInternalTransaction + ): BankTransactionResult { + reconnect() + val stmt = prepare(""" + SELECT out_nx_creditor, out_nx_debtor, out_balance_insufficient + FROM bank_wire_transfer(?,?,TEXT(?),(?,?)::taler_amount,?,TEXT(?),TEXT(?),TEXT(?)) + """ + ) + stmt.setLong(1, tx.creditorAccountId) + stmt.setLong(2, tx.debtorAccountId) + stmt.setString(3, tx.subject) + stmt.setLong(4, tx.amount.value) + stmt.setInt(5, tx.amount.frac) + stmt.setLong(6, tx.transactionDate) + stmt.setString(7, tx.accountServicerReference) + stmt.setString(8, tx.paymentInformationId) + stmt.setString(9, tx.endToEndId) + val rs = stmt.executeQuery() + rs.use { + if (!rs.next()) throw internalServerError("Bank transaction didn't properly return") + if (rs.getBoolean("out_nx_debtor")) { + logger.error("No debtor account found") + return BankTransactionResult.NO_DEBTOR + } + if (rs.getBoolean("out_nx_creditor")) { + logger.error("No creditor account found") + return BankTransactionResult.NO_CREDITOR + } + if (rs.getBoolean("out_balance_insufficient")) { + logger.error("Balance insufficient") + return BankTransactionResult.CONFLICT + } + return BankTransactionResult.SUCCESS + } + } + + fun bankTransactionGetForHistoryPage( + upperBound: Long, + bankAccountId: Long, + fromMs: Long, + toMs: Long + ): List { + reconnect() + val stmt = prepare(""" + SELECT + creditor_iban + ,creditor_bic + ,creditor_name + ,debtor_iban + ,debtor_bic + ,debtor_name + ,subject + ,(amount).val AS amount_val + ,(amount).frac AS amount_frac + ,transaction_date + ,account_servicer_reference + ,payment_information_id + ,end_to_end_id + ,direction + ,bank_account_id + FROM bank_account_transactions + WHERE bank_transaction_id < ? + AND bank_account_id=? + AND transaction_date BETWEEN ? AND ? + """) + stmt.setLong(1, upperBound) + stmt.setLong(2, bankAccountId) + stmt.setLong(3, fromMs) + stmt.setLong(4, toMs) + val rs = stmt.executeQuery() + rs.use { + val ret = mutableListOf() + if (!it.next()) return ret + do { + ret.add( + BankAccountTransaction( + creditorIban = it.getString("creditor_iban"), + creditorBic = it.getString("creditor_bic"), + creditorName = it.getString("creditor_name"), + debtorIban = it.getString("debtor_iban"), + debtorBic = it.getString("debtor_bic"), + debtorName = it.getString("debtor_name"), + amount = TalerAmount( + it.getLong("amount_val"), + it.getInt("amount_frac") + ), + accountServicerReference = it.getString("account_servicer_reference"), + endToEndId = it.getString("end_to_end_id"), + direction = it.getString("direction").run { + when(this) { + "credit" -> TransactionDirection.credit + "debit" -> TransactionDirection.debit + else -> throw internalServerError("Wrong direction in transaction: $this") + } + }, + bankAccountId = it.getLong("bank_account_id"), + paymentInformationId = it.getString("payment_information_id"), + subject = it.getString("subject"), + transactionDate = it.getLong("transaction_date") + )) + } while (it.next()) + return ret + } + } + + // WITHDRAWALS + fun talerWithdrawalCreate( + opUUID: UUID, + walletBankAccount: Long, + amount: TalerAmount + ): Boolean { + reconnect() + val stmt = prepare(""" + INSERT INTO + taler_withdrawal_operations + (withdrawal_uuid, wallet_bank_account, amount) + VALUES (?,?,(?,?)::taler_amount) + """) // Take all defaults from the SQL. + stmt.setObject(1, opUUID) + stmt.setLong(2, walletBankAccount) + stmt.setLong(3, amount.value) + stmt.setInt(4, amount.frac) + + return myExecute(stmt) + } + fun talerWithdrawalGet(opUUID: UUID): TalerWithdrawalOperation? { + reconnect() + val stmt = prepare(""" + SELECT + (amount).val as amount_val + ,(amount).frac as amount_frac + ,withdrawal_uuid + ,selection_done + ,aborted + ,confirmation_done + ,reserve_pub + ,selected_exchange_payto + ,wallet_bank_account + FROM taler_withdrawal_operations + WHERE withdrawal_uuid=? + """) + stmt.setObject(1, opUUID) + stmt.executeQuery().use { + if (!it.next()) return null + return TalerWithdrawalOperation( + amount = TalerAmount( + it.getLong("amount_val"), + it.getInt("amount_frac") + ), + selectionDone = it.getBoolean("selection_done"), + selectedExchangePayto = it.getString("selected_exchange_payto"), + walletBankAccount = it.getLong("wallet_bank_account"), + confirmationDone = it.getBoolean("confirmation_done"), + aborted = it.getBoolean("aborted"), + reservePub = it.getBytes("reserve_pub"), + withdrawalUuid = it.getObject("withdrawal_uuid") as UUID + ) + } + } + + // Values coming from the wallet. + fun talerWithdrawalSetDetails( + opUUID: UUID, + exchangePayto: String, + reservePub: ByteArray + ): Boolean { + reconnect() + val stmt = prepare(""" + UPDATE taler_withdrawal_operations + SET selected_exchange_payto = ?, reserve_pub = ?, selection_done = true + WHERE withdrawal_uuid=? + """ + ) + stmt.setString(1, exchangePayto) + stmt.setBytes(2, reservePub) + stmt.setObject(3, opUUID) + return myExecute(stmt) + } + fun talerWithdrawalConfirm(opUUID: UUID): Boolean { + reconnect() + val stmt = prepare(""" + UPDATE taler_withdrawal_operations + SET confirmation_done = true + WHERE withdrawal_uuid=? + """ + ) + stmt.setObject(1, opUUID) + return myExecute(stmt) + } + + fun cashoutCreate(op: Cashout): Boolean { + reconnect() + val stmt = prepare(""" + INSERT INTO cashout_operations ( + cashout_uuid + ,amount_debit + ,amount_credit + ,buy_at_ratio + ,buy_in_fee + ,sell_at_ratio + ,sell_out_fee + ,subject + ,creation_time + ,tan_channel + ,tan_code + ,bank_account + ,cashout_address + ,cashout_currency + ) + VALUES ( + ? + ,(?,?)::taler_amount + ,(?,?)::taler_amount + ,? + ,(?,?)::taler_amount + ,? + ,(?,?)::taler_amount + ,? + ,? + ,?::tan_enum + ,? + ,? + ,? + ,? + ); + """) + stmt.setObject(1, op.cashoutUuid) + stmt.setLong(2, op.amountDebit.value) + stmt.setInt(3, op.amountDebit.frac) + stmt.setLong(4, op.amountCredit.value) + stmt.setInt(5, op.amountCredit.frac) + stmt.setInt(6, op.buyAtRatio) + stmt.setLong(7, op.buyInFee.value) + stmt.setInt(8, op.buyInFee.frac) + stmt.setInt(9, op.sellAtRatio) + stmt.setLong(10, op.sellOutFee.value) + stmt.setInt(11, op.sellOutFee.frac) + stmt.setString(12, op.subject) + stmt.setLong(13, op.creationTime) + stmt.setString(14, op.tanChannel.name) + stmt.setString(15, op.tanCode) + stmt.setLong(16, op.bankAccount) + stmt.setString(17, op.cashoutAddress) + stmt.setString(18, op.cashoutCurrency) + return myExecute(stmt) + } + + fun cashoutConfirm( + opUuid: UUID, + tanConfirmationTimestamp: Long, + bankTransaction: Long // regional payment backing the operation + ): Boolean { + reconnect() + val stmt = prepare(""" + UPDATE cashout_operations + SET tan_confirmation_time = ?, local_transaction = ? + WHERE cashout_uuid=?; + """) + stmt.setLong(1, tanConfirmationTimestamp) + stmt.setLong(2, bankTransaction) + stmt.setObject(3, opUuid) + return myExecute(stmt) + } + // used by /abort + enum class CashoutDeleteResult { + SUCCESS, + CONFLICT_ALREADY_CONFIRMED + } + fun cashoutDelete(opUuid: UUID): CashoutDeleteResult { + val stmt = prepare(""" + SELECT out_already_confirmed + FROM cashout_delete(?) + """) + stmt.setObject(1, opUuid) + stmt.executeQuery().use { + if (!it.next()) { + throw internalServerError("Cashout deletion gave no result") + } + if (it.getBoolean("out_already_confirmed")) return CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED + return CashoutDeleteResult.SUCCESS + } + } + fun cashoutGetFromUuid(opUuid: UUID): Cashout? { + val stmt = prepare(""" + SELECT + (amount_debit).val as amount_debit_val + ,(amount_debit).frac as amount_debit_frac + ,(amount_credit).val as amount_credit_val + ,(amount_credit).frac as amount_credit_frac + ,buy_at_ratio + ,(buy_in_fee).val as buy_in_fee_val + ,(buy_in_fee).frac as buy_in_fee_frac + ,sell_at_ratio + ,(sell_out_fee).val as sell_out_fee_val + ,(sell_out_fee).frac as sell_out_fee_frac + ,subject + ,creation_time + ,tan_channel + ,tan_code + ,bank_account + ,cashout_address + ,cashout_currency + ,tan_confirmation_time + ,local_transaction + FROM cashout_operations + WHERE cashout_uuid=?; + """) + stmt.setObject(1, opUuid) + stmt.executeQuery().use { + if (!it.next()) return null + return Cashout( + amountDebit = TalerAmount( + value = it.getLong("amount_debit_val"), + frac = it.getInt("amount_debit_frac") + ), + amountCredit = TalerAmount( + value = it.getLong("amount_credit_val"), + frac = it.getInt("amount_credit_frac") + ), + bankAccount = it.getLong("bank_account"), + buyAtRatio = it.getInt("buy_at_ratio"), + buyInFee = TalerAmount( + value = it.getLong("buy_in_fee_val"), + frac = it.getInt("buy_in_fee_frac") + ), + cashoutAddress = it.getString("cashout_address"), + cashoutCurrency = it.getString("cashout_currency"), + cashoutUuid = opUuid, + creationTime = it.getLong("creation_time"), + sellAtRatio = it.getInt("sell_at_ratio"), + sellOutFee = TalerAmount( + value = it.getLong("sell_out_fee_val"), + frac = it.getInt("sell_out_fee_frac") + ), + subject = it.getString("subject"), + tanChannel = it.getString("tan_channel").run { + when(this) { + "sms" -> TanChannel.sms + "email" -> TanChannel.email + "file" -> TanChannel.file + else -> throw internalServerError("TAN channel $this unsupported") + } + }, + tanCode = it.getString("tan_code"), + localTransaction = it.getLong("local_transaction"), + tanConfirmationTime = it.getLong("tan_confirmation_time").run { + if (this == 0L) return@run null + return@run this + } + ) + } + } +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt b/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt new file mode 100644 index 00000000..57a61f50 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt @@ -0,0 +1,1436 @@ +/* + * 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 + * + */ + + +package tech.libeufin.sandbox + +import io.ktor.server.application.* +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.util.AttributeKey +import io.ktor.util.date.* +import org.apache.xml.security.binding.xmldsig.RSAKeyValueType +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.statements.api.ExposedBlob +import org.jetbrains.exposed.sql.transactions.transaction +import org.w3c.dom.Document +import tech.libeufin.util.* +import tech.libeufin.util.XMLUtil.Companion.signEbicsResponse +import tech.libeufin.util.ebics_h004.* +import tech.libeufin.util.ebics_hev.HEVResponse +import tech.libeufin.util.ebics_hev.SystemReturnCodeType +import tech.libeufin.util.ebics_s001.SignatureTypes +import tech.libeufin.util.ebics_s001.UserSignatureData +import java.math.BigDecimal +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPublicKey +import java.sql.Connection +import java.util.* +import java.util.zip.DeflaterInputStream +import java.util.zip.InflaterInputStream + +val EbicsHostIdAttribute = AttributeKey("RequestedEbicsHostID") + +data class PainParseResult( + val creditorIban: String, + val creditorName: String, + val creditorBic: String?, + val debtorIban: String, + val debtorName: String, + val debtorBic: String?, + val subject: String, + val amount: String, + val currency: String, + val pmtInfId: String, + val endToEndId: String, + val msgId: String +) + +open class EbicsRequestError( + val errorText: String, + val errorCode: String +) : Exception("$errorText (EBICS error code: $errorCode)") + +class EbicsNoDownloadDataAvailable(reason: String? = null) : EbicsRequestError( + "[EBICS_NO_DOWNLOAD_DATA_AVAILABLE]" + if (reason != null) " $reason" else "", + "090005" +) + +class EbicsInvalidRequestError : EbicsRequestError( + "[EBICS_INVALID_REQUEST] Invalid request", + "060102" +) +class EbicsAccountAuthorisationFailed(reason: String) : EbicsRequestError( + "[EBICS_ACCOUNT_AUTHORISATION_FAILED] $reason", + "091302" +) + +/** + * This error is thrown whenever the Subscriber's state is not suitable + * for the requested action. For example, the subscriber sends a EbicsRequest + * message without having first uploaded their keys (#5973). + */ +class EbicsSubscriberStateError : EbicsRequestError( + "[EBICS_INVALID_USER_OR_USER_STATE] Subscriber unknown or subscriber state inadmissible", + "091002" +) +// hint should mention at least the userID +class EbicsUserUnknown(hint: String) : EbicsRequestError( + "[EBICS_USER_UNKNOWN] $hint", + "091003" +) + +class EbicsOrderParamsIgnored(hint: String) : EbicsRequestError( + "[EBICS_ORDER_PARAMS_IGNORED] $hint", + "031001" +) + + +open class EbicsKeyManagementError(private val errorText: String, private val errorCode: String) : + Exception("EBICS key management error: $errorText ($errorCode)") + +private class EbicsInvalidXmlError : EbicsKeyManagementError( + "[EBICS_INVALID_XML]", + "091010" +) + +private class EbicsUnsupportedOrderType : EbicsRequestError( + "[EBICS_UNSUPPORTED_ORDER_TYPE] Order type not supported", + "091005" +) + +/** + * Used here also for "Internal server error". For example, when the + * sandbox itself generates a invalid XML response. + */ +class EbicsProcessingError(detail: String?) : EbicsRequestError( + // a missing detail is already the bank's fault. + "[EBICS_PROCESSING_ERROR] " + (detail ?: "bank internal error"), + "091116" +) + +class EbicsAmountCheckError(detail: String): EbicsRequestError( + "[EBICS_AMOUNT_CHECK_FAILED] $detail", + "091303" +) + +suspend fun respondEbicsTransfer( + call: ApplicationCall, + errorText: String, + errorCode: String +) { + /** + * Because this handler runs for any error, it could + * handle the case where the Ebics host ID is unknown due + * to an invalid request. Recall: Sandbox is multi-host, and + * which Ebics host was requested belongs to the request document. + * + * Therefore, because any Ebics response + * should speak for one Ebics host, we can't respond any Ebics + * type when the Ebics host ID remains unknown due to invalid + * request. Instead, we'll respond plain text: + */ + if (!call.attributes.contains(EbicsHostIdAttribute)) { + call.respondText("Invalid document.", status = HttpStatusCode.BadRequest) + return + } + val resp = EbicsResponse.createForUploadWithError( + errorText, + errorCode, + // For now, phase gets hard-coded as TRANSFER, + // because errors during initialization should have + // already been caught by the chunking logic. + EbicsTypes.TransactionPhaseType.TRANSFER + ) + val hostAuthPriv = transaction { + val host = EbicsHostEntity.find { + EbicsHostsTable.hostID.upperCase() eq call.attributes[EbicsHostIdAttribute] + .uppercase() + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.InternalServerError, + "Requested Ebics host ID (${call.attributes[EbicsHostIdAttribute]}) not found." + ) + CryptoUtil.loadRsaPrivateKey(host.authenticationPrivateKey.bytes) + } + call.respondText( + signEbicsResponse(resp, hostAuthPriv), + ContentType.Application.Xml, + HttpStatusCode.OK + ) +} + +private suspend fun ApplicationCall.respondEbicsKeyManagement( + errorText: String, + errorCode: String, + bankReturnCode: String, + dataTransfer: CryptoUtil.EncryptionResult? = null, + orderId: String? = null +) { + val responseXml = EbicsKeyManagementResponse().apply { + version = "H004" + header = EbicsKeyManagementResponse.Header().apply { + authenticate = true + mutable = EbicsKeyManagementResponse.MutableHeaderType().apply { + reportText = errorText + returnCode = errorCode + if (orderId != null) { + this.orderID = orderId + } + } + _static = EbicsKeyManagementResponse.EmptyStaticHeader() + } + body = EbicsKeyManagementResponse.Body().apply { + this.returnCode = EbicsKeyManagementResponse.ReturnCode().apply { + this.authenticate = true + this.value = bankReturnCode + } + if (dataTransfer != null) { + this.dataTransfer = EbicsKeyManagementResponse.DataTransfer().apply { + this.dataEncryptionInfo = EbicsTypes.DataEncryptionInfo().apply { + this.authenticate = true + this.transactionKey = dataTransfer.encryptedTransactionKey + this.encryptionPubKeyDigest = EbicsTypes.PubKeyDigest().apply { + this.algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" + this.version = "E002" + this.value = dataTransfer.pubKeyDigest + } + } + this.orderData = EbicsKeyManagementResponse.OrderData().apply { + this.value = Base64.getEncoder().encodeToString(dataTransfer.encryptedData) + } + } + } + } + } + val text = XMLUtil.convertJaxbToString(responseXml) + // logger.info("responding with:\n${text}") + if (!XMLUtil.validateFromString(text)) throw SandboxError( + HttpStatusCode.InternalServerError, + "Outgoint EBICS key management response is invalid" + ) + respondText(text, ContentType.Application.Xml, HttpStatusCode.OK) +} + +fun expectNonNull(x: T?): T { + if (x == null) { + throw EbicsProtocolError(HttpStatusCode.BadRequest, "expected non-null value") + } + return x; +} + +private fun getRelatedParty(branch: XmlElementBuilder, payment: XLibeufinBankTransaction) { + val otherParty = object { + var ibanPath = "CdtrAcct/Id/IBAN" + var namePath = "Cdtr/Nm" + var iban = payment.creditorIban + var name = payment.creditorName + var bicPath = "CdtrAgt/FinInstnId/BIC" + var bic = payment.creditorBic + } + if (payment.direction == XLibeufinBankDirection.CREDIT) { + otherParty.iban = payment.debtorIban + otherParty.ibanPath = "DbtrAcct/Id/IBAN" + otherParty.namePath = "Dbtr/Nm" + otherParty.name = payment.debtorName + otherParty.bic = payment.debtorBic + otherParty.bicPath = "DbtrAgt/FinInstnId/BIC" + } + branch.element("RltdPties") { + element(otherParty.namePath) { + text(otherParty.name) + } + element(otherParty.ibanPath) { + text(otherParty.iban) + } + } + val otherPartyBic = otherParty.bic + if (otherPartyBic != null) { + branch.element("RltdAgts") { + element(otherParty.bicPath) { + text(otherPartyBic) + } + } + } +} + +// This should fix #6269. +private fun getCreditDebitInd(balance: BigDecimal): String { + if (balance < BigDecimal.ZERO) return "DBIT" + return "CRDT" +} + +fun buildCamtString( + type: Int, + subscriberIban: String, + history: MutableList, + currency: String +): SandboxCamt { + /** + * ID types required: + * + * - Message Id + * - Statement / Report Id + * - Electronic sequence number + * - Legal sequence number + * - Entry Id by the Servicer + * - Payment information Id + * - Proprietary code of the bank transaction + * - Id of the servicer (Issuer and Code) + */ + val camtCreationTime = getSystemTimeNow() // FIXME: should this be the payment time? + val dashedDate = camtCreationTime.toDashedDate() + val zonedDateTime = camtCreationTime.toZonedString() + val creationTimeMillis = camtCreationTime.toInstant().toEpochMilli() + val messageId = "sandbox-${creationTimeMillis / 1000}-${getRandomString(10)}" + + val camtMessage = constructXml(indent = true) { + root("Document") { + attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02") + attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") + attribute( + "xsi:schemaLocation", + "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02 camt.0${type}.001.02.xsd" + ) + element(if (type == 53) "BkToCstmrStmt" else "BkToCstmrAcctRpt") { + element("GrpHdr") { + element("MsgId") { + text(messageId) + } + element("CreDtTm") { + text(zonedDateTime) + } + } + element(if (type == 52) "Rpt" else "Stmt") { + element("Id") { + text("0") + } + element("ElctrncSeqNb") { + text("0") + } + element("LglSeqNb") { + text("0") + } + element("CreDtTm") { + text(zonedDateTime) + } + element("Acct") { + // mandatory account identifier + element("Id/IBAN") { + text(subscriberIban) + } + element("Ccy") { + text(currency) + } + element("Ownr/Nm") { + text("Debitor/Owner Name") + } + element("Svcr/FinInstnId") { + element("Nm") { + text("Libeufin Bank") + } + element("Othr") { + element("Id") { + text("0") + } + element("Issr") { + text("XY") + } + } + } + } + history.forEach { + this.element("Ntry") { + element("Amt") { + attribute("Ccy", it.currency) + text(it.amount) + } + element("CdtDbtInd") { + text( + if (subscriberIban.equals(it.creditorIban)) + "CRDT" else "DBIT" + ) + } + element("Sts") { + /* Status of the entry (see 2.4.2.15.5 from the ISO20022 reference document.) + * From the original text: + * "Status of an entry on the books of the account servicer" */ + text("BOOK") + } + element("BookgDt/Dt") { + text(dashedDate) + } // date of the booking + element("ValDt/Dt") { + text(dashedDate) + } // date of assets' actual (un)availability + element("AcctSvcrRef") { + text(it.uid) + } + element("BkTxCd") { + /* "Set of elements used to fully identify the type of underlying + * transaction resulting in an entry". */ + element("Domn") { + element("Cd") { + text("PMNT") + } + element("Fmly") { + element("Cd") { + text("ICDT") + } + element("SubFmlyCd") { + text("ESCT") + } + } + } + element("Prtry") { + element("Cd") { + text("0") + } + element("Issr") { + text("XY") + } + } + } + element("NtryDtls/TxDtls") { + element("Refs") { + element("MsgId") { + text(it.msgId ?: "NOTPROVIDED") + } + element("PmtInfId") { + text(it.pmtInfId ?: "NOTPROVIDED") + } + element("EndToEndId") { + text(it.endToEndId ?: "NOTPROVIDED") + } + } + element("AmtDtls/TxAmt/Amt") { + attribute("Ccy", currency) + text(it.amount) + } + element("BkTxCd") { + element("Domn") { + element("Cd") { + text("PMNT") + } + element("Fmly") { + element("Cd") { + text("ICDT") + } + element("SubFmlyCd") { + text("ESCT") + } + } + } + element("Prtry") { + element("Cd") { + text("0") + } + element("Issr") { + text("XY") + } + } + } + getRelatedParty(this, it) + element("RmtInf/Ustrd") { + text(it.subject) + } + } + } + } + } + } + } + } + return SandboxCamt( + camtMessage = camtMessage, + messageId = messageId, + creationTime = creationTimeMillis + ) +} + +/** + * Builds CAMT response. + * + * @param type 52 or 53. + */ +private fun constructCamtResponse( + type: Int, + subscriber: EbicsSubscriberEntity, + dateRange: Pair? +): List { + if (type != 53 && type != 52) throw EbicsUnsupportedOrderType() + val bankAccount = getBankAccountFromSubscriber(subscriber) + val history = mutableListOf() + if (type == 52) { + if (dateRange != null) { + logger.debug("Finding date-ranged transactions for account: ${bankAccount.label}, range: ${dateRange.first}, ${dateRange.second}") + transaction { + BankAccountTransactionEntity.find { + BankAccountTransactionsTable.account eq bankAccount.id and + BankAccountTransactionsTable.date.between( + dateRange.first, dateRange.second + ) + }.forEach { history.add(getHistoryElementFromTransactionRow(it)) } + } + } else + transaction { + BankAccountFreshTransactionEntity.all().forEach { + if (it.transactionRef.account.label == bankAccount.label) { + history.add(getHistoryElementFromTransactionRow(it)) + } + } + } + if (history.size == 0) throw EbicsNoDownloadDataAvailable() + val camtData = buildCamtString( + type, + bankAccount.iban, + history, + bankAccount.demoBank.config.currency + ) + val paymentsList: String = if (logger.isDebugEnabled) { + var ret = " It includes the payments:" + for (p in history) ret += "\n- ${p.subject}" + ret + } else "" + logger.debug("camt.052 document '${camtData.messageId}' generated.$paymentsList") + return listOf(camtData.camtMessage) + } // end of C52 case. + val ret = mutableListOf() + /** + * Retrieve all the records whose creation date lies into the + * time range given in the function parameters. + */ + if (dateRange != null) { + logger.debug("Serving C53 with date range: $dateRange") + BankAccountStatementEntity.find { + BankAccountStatementsTable.creationTime.between( + dateRange.first, + dateRange.second) and( + BankAccountStatementsTable.bankAccount eq bankAccount.id) + }.forEach { + logger.debug("Including Camt.053: ${it.statementId}") + ret.add(it.xmlMessage) + } + } else { + logger.debug("Serving C53 without date range.") + // No time range was given, hence pick the latest statement. + BankAccountStatementEntity.find { + BankAccountStatementsTable.bankAccount eq bankAccount.id + }.lastOrNull().apply { + if (this != null) { + logger.debug("Including Camt.053: ${this.statementId}") + ret.add(this.xmlMessage) + } + } + } + if (ret.size == 0) throw EbicsNoDownloadDataAvailable() + return ret +} + +/** + * TSD (test download) message. + * + * This is a non-standard EBICS order type use by LibEuFin to + * test download transactions. + * + * In the future, additional parameters (size, chunking, inject fault for retry) might + * be added to the order parameters. + */ +private fun handleEbicsTSD(): ByteArray { + return "Hello World\n".repeat(1024).toByteArray() +} + +private fun handleEbicsPTK(): ByteArray { + return "Hello I am a dummy PTK response.".toByteArray() +} + +private fun parsePain001(paymentRequest: String): PainParseResult { + val painDoc = XMLUtil.parseStringIntoDom(paymentRequest) + return destructXml(painDoc) { + requireRootElement("Document") { + requireUniqueChildNamed("CstmrCdtTrfInitn") { + val msgId = requireUniqueChildNamed("GrpHdr") { + requireUniqueChildNamed("MsgId") { focusElement.textContent } + } + requireUniqueChildNamed("PmtInf") { + val debtorName = requireUniqueChildNamed("Dbtr"){ + requireUniqueChildNamed("Nm") { + focusElement.textContent + } + } + val debtorIban = requireUniqueChildNamed("DbtrAcct"){ + requireUniqueChildNamed("Id") { + requireUniqueChildNamed("IBAN") { + focusElement.textContent + } + } + } + val debtorBic = requireUniqueChildNamed("DbtrAgt"){ + requireUniqueChildNamed("FinInstnId") { + requireUniqueChildNamed("BIC") { + focusElement.textContent + } + } + } + val pmtInfId = requireUniqueChildNamed("PmtInfId") { focusElement.textContent } + val txDetails = requireUniqueChildNamed("CdtTrfTxInf") { + object { + val creditorIban = requireUniqueChildNamed("CdtrAcct") { + requireUniqueChildNamed("Id") { + requireUniqueChildNamed("IBAN") { focusElement.textContent } + } + } + val creditorName = requireUniqueChildNamed("Cdtr") { + requireUniqueChildNamed("Nm") { + focusElement.textContent + } + } + val creditorBic = maybeUniqueChildNamed("CdtrAgt") { + requireUniqueChildNamed("FinInstnId") { + requireUniqueChildNamed("BIC") { + focusElement.textContent + } + } + } + val amt = requireUniqueChildNamed("Amt") { + requireOnlyChild { focusElement } + } + val subject = requireUniqueChildNamed("RmtInf") { + requireUniqueChildNamed("Ustrd") { focusElement.textContent } + } + val endToEndId = requireUniqueChildNamed("PmtId") { + requireUniqueChildNamed("EndToEndId") { focusElement.textContent } + } + } + } + /** + * NOTE: this check breaks the compatibility with pain.001, + * because that allows up to 5 fractional digits. For Taler + * compatibility however, we enforce the max 2 fractional digits policy. + */ + if (!validatePlainAmount(txDetails.amt.textContent)) { + throw EbicsProcessingError( + "Amount number malformed: ${txDetails.amt.textContent}" + ) + } + PainParseResult( + currency = txDetails.amt.getAttribute("Ccy"), + amount = txDetails.amt.textContent, + subject = txDetails.subject, + debtorIban = debtorIban, + debtorName = debtorName, + debtorBic = debtorBic, + creditorName = txDetails.creditorName, + creditorIban = txDetails.creditorIban, + creditorBic = txDetails.creditorBic, + pmtInfId = pmtInfId, + endToEndId = txDetails.endToEndId, + msgId = msgId + ) + } + } + } + } +} + +/** + * Process a payment request in the pain.001 format. Note: + * the receiver IBAN is NOT checked to have one account at + * the Sandbox. That's because (1) it leaves open to send + * payments outside of the running Sandbox and (2) may ease + * tests where the preparation logic can skip creating also + * the receiver account. */ +private fun handleCct( + paymentRequest: String, + requestingSubscriber: EbicsSubscriberEntity +) { + val parseResult = parsePain001(paymentRequest) + logger.debug("Handling Pain.001: ${parseResult.pmtInfId}, " + + "for payment: ${parseResult.subject}") + transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { + // Check that subscriber has a bank account + // and that they have rights over the debtor IBAN + if (requestingSubscriber.bankAccount == null) throw EbicsProcessingError( + "Subscriber '${requestingSubscriber.userId}' does not have a bank account." + ) + if (requestingSubscriber.bankAccount!!.iban != parseResult.debtorIban) throw + EbicsAccountAuthorisationFailed( + "Subscriber '${requestingSubscriber.userId}' does not have rights" + + " over the debtor IBAN '${parseResult.debtorIban}'" + ) + val maybeExist = BankAccountTransactionEntity.find { + BankAccountTransactionsTable.pmtInfId eq parseResult.pmtInfId + }.firstOrNull() + if (maybeExist != null) { + logger.info( + "Nexus submitted twice the Pain: ${maybeExist.pmtInfId}. Not taking any action." + + " Sandbox gave it this reference: ${maybeExist.accountServicerReference}" + ) + return@transaction + } + val bankAccount = getBankAccountFromIban(parseResult.debtorIban) + if (parseResult.currency != bankAccount.demoBank.config.currency) throw EbicsRequestError( + "[EBICS_PROCESSING_ERROR] Currency (${parseResult.currency}) not supported.", + "091116" + ) + // Check for the debit case. + val maybeAmount = try { + BigDecimal(parseResult.amount) + } catch (e: Exception) { + logger.warn("Although PAIN validated, BigDecimal didn't parse its amount (${parseResult.amount})!") + throw EbicsProcessingError("The CCT request contains an invalid amount: ${parseResult.amount}") + } + if (maybeDebit(bankAccount.label, maybeAmount, bankAccount.demoBank.name)) + throw EbicsAmountCheckError("The requested amount (${parseResult.amount}) would exceed the debit threshold") + logger.debug("Wire-transfer'ing endToEndId: ${parseResult.endToEndId}") + wireTransfer( + bankAccount.label, + getBankAccountFromIban(parseResult.creditorIban).label, + bankAccount.demoBank.name, + parseResult.subject, + "${parseResult.currency}:${parseResult.amount}", + endToEndId = parseResult.endToEndId + ) + } +} + +/** + * This handler reports all the fresh transactions, belonging + * to the querying subscriber. + */ +private fun handleEbicsC52(requestContext: RequestContext): ByteArray { + val maybeDateRange = requestContext.requestObject.header.static.orderDetails?.orderParams + val dateRange: Pair? = if (maybeDateRange is EbicsRequest.StandardOrderParams) { + val start: Long? = maybeDateRange.dateRange?.start?.toGregorianCalendar()?.timeInMillis + val end: Long? = maybeDateRange.dateRange?.end?.toGregorianCalendar()?.timeInMillis + Pair(start ?: 0L, end ?: Long.MAX_VALUE) + } else null + logger.debug("Date range: $dateRange") + val report = constructCamtResponse( + 52, + requestContext.subscriber, + dateRange = dateRange + ) + sandboxAssert( + report.size == 1, + "C52 response contains more than one Camt.052 document" + ) + if (!XMLUtil.validateFromString(report[0])) { + logger.error("This document was generated invalid:\n${report[0]}") + throw EbicsProcessingError("One outgoing report was found invalid.") + } + return report.map { it.toByteArray() }.zip() +} + +private fun handleEbicsC53(requestContext: RequestContext): ByteArray { + // Fetch date range. + val orderParams = requestContext.requestObject.header.static.orderDetails?.orderParams // as EbicsRequest.StandardOrderParams + val dateRange = if (orderParams != null) { + val standardOrderParams = orderParams as EbicsRequest.StandardOrderParams + val start = standardOrderParams.dateRange?.start?.toGregorianCalendar()?.timeInMillis + val end = standardOrderParams.dateRange?.end?.toGregorianCalendar()?.timeInMillis + if (start == null || end == null) { + // only accepting when both start/end are given. + null + } else { + Pair(start, end) + } + } else + null + /** + * By multiple statements, this function is responsible to return + * a list of Strings: one for each statement. + */ + val camtStatements = constructCamtResponse( + 53, + requestContext.subscriber, + dateRange + ) + camtStatements.forEach { + if (!XMLUtil.validateFromString(it)) { + logger.error("This document was generated invalid:\n$it") + throw EbicsProcessingError("One outgoing statement was found invalid.") + } + } + return camtStatements.map { it.toByteArray() }.zip() +} + +private suspend fun ApplicationCall.handleEbicsHia(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { + InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } + val keyObject = EbicsOrderUtil.decodeOrderDataXml(orderData) + val encPubXml = keyObject.encryptionPubKeyInfo.pubKeyValue.rsaKeyValue + val authPubXml = keyObject.authenticationPubKeyInfo.pubKeyValue.rsaKeyValue + val encPub = CryptoUtil.loadRsaPublicKeyFromComponents(encPubXml.modulus, encPubXml.exponent) + val authPub = CryptoUtil.loadRsaPublicKeyFromComponents(authPubXml.modulus, authPubXml.exponent) + + val ok = transaction { + val ebicsSubscriber = findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) + if (ebicsSubscriber == null) { + logger.warn("ebics subscriber not found") + throw EbicsInvalidRequestError() + } + when (ebicsSubscriber.state) { + SubscriberState.NEW -> {} + SubscriberState.PARTIALLY_INITIALIZED_INI -> {} + SubscriberState.PARTIALLY_INITIALIZED_HIA, SubscriberState.INITIALIZED, SubscriberState.READY -> { + return@transaction false + } + } + + ebicsSubscriber.authenticationKey = EbicsSubscriberPublicKeyEntity.new { + this.rsaPublicKey = ExposedBlob(authPub.encoded) + state = KeyState.NEW + } + ebicsSubscriber.encryptionKey = EbicsSubscriberPublicKeyEntity.new { + this.rsaPublicKey = ExposedBlob(encPub.encoded) + state = KeyState.NEW + } + ebicsSubscriber.state = when (ebicsSubscriber.state) { + SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_HIA + SubscriberState.PARTIALLY_INITIALIZED_INI -> SubscriberState.INITIALIZED + else -> throw Exception("internal invariant failed") + } + return@transaction true + } + if (ok) { + respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") + } else { + respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") + } +} + +private suspend fun ApplicationCall.handleEbicsIni(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { + InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } + val keyObject = EbicsOrderUtil.decodeOrderDataXml(orderData) + val sigPubXml = keyObject.signaturePubKeyInfo.pubKeyValue.rsaKeyValue + val sigPub = CryptoUtil.loadRsaPublicKeyFromComponents(sigPubXml.modulus, sigPubXml.exponent) + + val ok = transaction { + val ebicsSubscriber = + findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) + if (ebicsSubscriber == null) { + logger.warn("ebics subscriber, ${dumpEbicsSubscriber(header.static)}, not found") + throw EbicsUserUnknown(dumpEbicsSubscriber(header.static)) + } + when (ebicsSubscriber.state) { + SubscriberState.NEW -> {} + SubscriberState.PARTIALLY_INITIALIZED_HIA -> {} + SubscriberState.PARTIALLY_INITIALIZED_INI, SubscriberState.INITIALIZED, SubscriberState.READY -> { + return@transaction false + } + } + ebicsSubscriber.signatureKey = EbicsSubscriberPublicKeyEntity.new { + this.rsaPublicKey = ExposedBlob(sigPub.encoded) + state = KeyState.NEW + } + ebicsSubscriber.state = when (ebicsSubscriber.state) { + SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_INI + SubscriberState.PARTIALLY_INITIALIZED_HIA -> SubscriberState.INITIALIZED + else -> throw Error("internal invariant failed") + } + return@transaction true + } + logger.info("Signature key inserted in database _and_ subscriber state changed accordingly") + if (ok) { + respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") + } else { + respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") + } +} + +private suspend fun ApplicationCall.handleEbicsHpb( + ebicsHostInfo: EbicsHostPublicInfo, + requestDocument: Document, + header: EbicsNpkdRequest.Header +) { + val subscriberKeys = transaction { + val ebicsSubscriber = + findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) + if (ebicsSubscriber == null) { + throw EbicsInvalidRequestError() + } + if (ebicsSubscriber.state != SubscriberState.INITIALIZED) { + throw EbicsSubscriberStateError() + } + val authPubBlob = ebicsSubscriber.authenticationKey!!.rsaPublicKey + val encPubBlob = ebicsSubscriber.encryptionKey!!.rsaPublicKey + val sigPubBlob = ebicsSubscriber.signatureKey!!.rsaPublicKey + SubscriberKeys( + CryptoUtil.loadRsaPublicKey(authPubBlob.bytes), + CryptoUtil.loadRsaPublicKey(encPubBlob.bytes), + CryptoUtil.loadRsaPublicKey(sigPubBlob.bytes) + ) + } + val validationResult = + XMLUtil.verifyEbicsDocument(requestDocument, subscriberKeys.authenticationPublicKey) + if (!validationResult) { + throw EbicsKeyManagementError("invalid signature", "90000") + } + val hpbRespondeData = HPBResponseOrderData().apply { + this.authenticationPubKeyInfo = EbicsTypes.AuthenticationPubKeyInfoType().apply { + this.authenticationVersion = "X002" + this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { + this.rsaKeyValue = RSAKeyValueType().apply { + this.exponent = ebicsHostInfo.authenticationPublicKey.publicExponent.toByteArray() + this.modulus = ebicsHostInfo.authenticationPublicKey.modulus.toByteArray() + } + } + } + this.encryptionPubKeyInfo = EbicsTypes.EncryptionPubKeyInfoType().apply { + this.encryptionVersion = "E002" + this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { + this.rsaKeyValue = RSAKeyValueType().apply { + this.exponent = ebicsHostInfo.encryptionPublicKey.publicExponent.toByteArray() + this.modulus = ebicsHostInfo.encryptionPublicKey.modulus.toByteArray() + } + } + } + this.hostID = ebicsHostInfo.hostID + } + val compressedOrderData = EbicsOrderUtil.encodeOrderDataXml(hpbRespondeData) + val encryptionResult = CryptoUtil.encryptEbicsE002(compressedOrderData, subscriberKeys.encryptionPublicKey) + respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000", encryptionResult, "OR01") +} + +/** + * Find the ebics host corresponding to the one specified in the header. + */ +private fun ensureEbicsHost(requestHostID: String): EbicsHostPublicInfo { + return transaction { + val ebicsHost = + EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestHostID.uppercase(Locale.getDefault()) }.firstOrNull() + if (ebicsHost == null) { + logger.warn("client requested unknown HostID ${requestHostID}") + throw EbicsKeyManagementError("[EBICS_INVALID_HOST_ID]", "091011") + } + val encryptionPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.encryptionPrivateKey.bytes) + val authenticationPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.authenticationPrivateKey.bytes) + EbicsHostPublicInfo( + requestHostID, + CryptoUtil.getRsaPublicFromPrivate(encryptionPrivateKey), + CryptoUtil.getRsaPublicFromPrivate(authenticationPrivateKey) + ) + } +} +fun receiveEbicsXmlInternal(xmlData: String): Document { + // logger.debug("Data received: $xmlData") + val requestDocument: Document = XMLUtil.parseStringIntoDom(xmlData) + if (!XMLUtil.validateFromDom(requestDocument)) { + println("Problematic document was: $requestDocument") + throw EbicsInvalidXmlError() + } + return requestDocument +} + +private fun makePartnerInfo(subscriber: EbicsSubscriberEntity): EbicsTypes.PartnerInfo { + val bankAccount = getBankAccountFromSubscriber(subscriber) + val customerProfile = getCustomer(bankAccount.label) + return EbicsTypes.PartnerInfo().apply { + this.accountInfoList = listOf( + EbicsTypes.AccountInfo().apply { + this.id = bankAccount.label + this.accountHolder = customerProfile.name ?: "Never Given" + this.accountNumberList = listOf( + EbicsTypes.GeneralAccountNumber().apply { + this.international = true + this.value = bankAccount.iban + } + ) + this.currency = bankAccount.demoBank.config.currency + this.description = "Ordinary Bank Account" + this.bankCodeList = listOf( + EbicsTypes.GeneralBankCode().apply { + this.international = true + this.value = bankAccount.bic + } + ) + } + ) + this.addressInfo = EbicsTypes.AddressInfo().apply { + this.name = "Address Info Object" + } + this.bankInfo = EbicsTypes.BankInfo().apply { + this.hostID = subscriber.hostId + } + this.orderInfoList = listOf( + EbicsTypes.AuthOrderInfoType().apply { + this.description = "Transactions statement" + this.orderType = "C53" + this.transferType = "Download" + }, + EbicsTypes.AuthOrderInfoType().apply { + this.description = "Transactions report" + this.orderType = "C52" + this.transferType = "Download" + }, + EbicsTypes.AuthOrderInfoType().apply { + this.description = "Payment initiation (ZIPped payload)" + this.orderType = "CCC" + this.transferType = "Upload" + }, + EbicsTypes.AuthOrderInfoType().apply { + this.description = "Payment initiation (plain text payload)" + this.orderType = "CCT" + this.transferType = "Upload" + }, + EbicsTypes.AuthOrderInfoType().apply { + this.description = "vmk" + this.orderType = "VMK" + this.transferType = "Download" + }, + EbicsTypes.AuthOrderInfoType().apply { + this.description = "sta" + this.orderType = "STA" + this.transferType = "Download" + } + ) + } +} + +private fun handleEbicsHtd(requestContext: RequestContext): ByteArray { + val htd = HTDResponseOrderData().apply { + this.partnerInfo = makePartnerInfo(requestContext.subscriber) + this.userInfo = EbicsTypes.UserInfo().apply { + this.name = "Some User" + this.userID = EbicsTypes.UserIDType().apply { + this.status = 5 + this.value = requestContext.subscriber.userId + } + this.permissionList = listOf( + EbicsTypes.UserPermission().apply { + this.orderTypes = "C53 C52 CCC VMK STA" + } + ) + } + } + val str = XMLUtil.convertJaxbToString(htd) + return str.toByteArray() +} + +private fun handleEbicsHkd(requestContext: RequestContext): ByteArray { + val hkd = HKDResponseOrderData().apply { + this.partnerInfo = makePartnerInfo(requestContext.subscriber) + this.userInfoList = listOf( + EbicsTypes.UserInfo().apply { + this.name = "Some User" + this.userID = EbicsTypes.UserIDType().apply { + this.status = 1 + this.value = requestContext.subscriber.userId + } + this.permissionList = listOf( + EbicsTypes.UserPermission().apply { + this.orderTypes = "C54 C53 C52 CCC" + } + ) + }) + } + val str = XMLUtil.convertJaxbToString(hkd) + return str.toByteArray() +} + +private data class RequestContext( + val ebicsHost: EbicsHostEntity, + val subscriber: EbicsSubscriberEntity, + val clientEncPub: RSAPublicKey, + val clientAuthPub: RSAPublicKey, + val clientSigPub: RSAPublicKey, + val hostEncPriv: RSAPrivateCrtKey, + val hostAuthPriv: RSAPrivateCrtKey, + val requestObject: EbicsRequest, + val uploadTransaction: EbicsUploadTransactionEntity?, + val downloadTransaction: EbicsDownloadTransactionEntity? +) + +/** + * Get segmentation values and the EBICS transaction ID, before + * handing the response to 'createForDownloadTransferPhase()'. + */ +private fun handleEbicsDownloadTransactionTransfer(requestContext: RequestContext): EbicsResponse { + val segmentNumber = + requestContext.requestObject.header.mutable.segmentNumber?.value ?: throw EbicsInvalidRequestError() + val transactionID = requestContext.requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() + val downloadTransaction = requestContext.downloadTransaction ?: throw AssertionError() + return EbicsResponse.createForDownloadTransferPhase( + transactionID, + downloadTransaction.numSegments, + downloadTransaction.segmentSize, + downloadTransaction.encodedResponse, + segmentNumber.toInt() + ) +} + +private fun handleEbicsDownloadTransactionInitialization(requestContext: RequestContext): EbicsResponse { + val orderType = + requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() + val nonce = requestContext.requestObject.header.static.nonce + val transactionID = EbicsOrderUtil.generateTransactionId() + logger.debug( + "Handling download initialization for order type $orderType, " + + "nonce: ${nonce?.toHexString() ?: "not given"}, " + + "transaction ID: $transactionID" + ) + val response = when (orderType) { + "HTD" -> handleEbicsHtd(requestContext) + "HKD" -> handleEbicsHkd(requestContext) + "C53" -> handleEbicsC53(requestContext) + "C52" -> handleEbicsC52(requestContext) + "TSD" -> handleEbicsTSD() + "PTK" -> handleEbicsPTK() + else -> throw EbicsInvalidXmlError() + } + val compressedResponse = DeflaterInputStream(response.inputStream()).use { + it.readAllBytes() + } + val enc = CryptoUtil.encryptEbicsE002(compressedResponse, requestContext.clientEncPub) + val encodedResponse = Base64.getEncoder().encodeToString(enc.encryptedData) + + val segmentSize = 4096 + val totalSize = encodedResponse.length + val numSegments = ((totalSize + segmentSize - 1) / segmentSize) + + EbicsDownloadTransactionEntity.new(transactionID) { + this.subscriber = requestContext.subscriber + this.host = requestContext.ebicsHost + this.orderType = orderType + this.segmentSize = segmentSize + this.transactionKeyEnc = ExposedBlob(enc.encryptedTransactionKey) + this.encodedResponse = encodedResponse + this.numSegments = numSegments + this.receiptReceived = false + } + /** + * In case of C52, the payload (that includes all the pending + * transactions) got at this point persisted into the database. + * The next block causes such transactions NOT to be returned + * along the next C52 request. + */ + if (orderType == "C52") { + val account = getBankAccountFromSubscriber(requestContext.subscriber) + BankAccountFreshTransactionEntity.all().forEach { + if (it.transactionRef.account.label == account.label) + it.delete() + } + } + return EbicsResponse.createForDownloadInitializationPhase( + transactionID, + numSegments, + segmentSize, + enc, // has customer key + encodedResponse + ) +} + +private fun handleEbicsUploadTransactionInitialization(requestContext: RequestContext): EbicsResponse { + val orderType = + requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() + val transactionID = EbicsOrderUtil.generateTransactionId() + logger.debug("Handling upload initialization for order $orderType, " + + "transactionID $transactionID, nonce: " + + (requestContext.requestObject.header.static.nonce?.toHexString() ?: "not given") + ) + val oidn = requestContext.subscriber.nextOrderID++ + if (EbicsOrderUtil.checkOrderIDOverflow(oidn)) throw NotImplementedError() + val orderID = EbicsOrderUtil.computeOrderIDFromNumber(oidn) + val numSegments = + requestContext.requestObject.header.static.numSegments ?: throw EbicsInvalidRequestError() + val transactionKeyEnc = + requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.transactionKey + ?: throw EbicsInvalidRequestError() + val encPubKeyDigest = + requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.encryptionPubKeyDigest?.value + ?: throw EbicsInvalidRequestError() + val encSigData = requestContext.requestObject.body.dataTransfer?.signatureData?.value + ?: throw EbicsInvalidRequestError() + val decryptedSignatureData = CryptoUtil.decryptEbicsE002( + CryptoUtil.EncryptionResult( + transactionKeyEnc, + encPubKeyDigest, + encSigData + ), requestContext.hostEncPriv + ) + val plainSigData = InflaterInputStream(decryptedSignatureData.inputStream()).use { + it.readAllBytes() + } + EbicsUploadTransactionEntity.new(transactionID) { + this.host = requestContext.ebicsHost + this.subscriber = requestContext.subscriber + this.lastSeenSegment = 0 + this.orderType = orderType + this.orderID = orderID + this.numSegments = numSegments.toInt() + this.transactionKeyEnc = ExposedBlob(transactionKeyEnc) + } + val sigObj = XMLUtil.convertStringToJaxb(plainSigData.toString(Charsets.UTF_8)) + for (sig in sigObj.value.orderSignatureList ?: listOf()) { + logger.debug("inserting order signature for orderID $orderID, order type $orderType, transaction '$transactionID'") + EbicsOrderSignatureEntity.new { + this.orderID = orderID + this.orderType = orderType + this.partnerID = sig.partnerID + this.userID = sig.userID + this.signatureAlgorithm = sig.signatureVersion + this.signatureValue = ExposedBlob(sig.signatureValue) + } + } + return EbicsResponse.createForUploadInitializationPhase(transactionID, orderID) +} + +private fun handleEbicsUploadTransactionTransmission(requestContext: RequestContext): EbicsResponse { + val uploadTransaction = requestContext.uploadTransaction ?: throw EbicsInvalidRequestError() + val requestObject = requestContext.requestObject + val requestSegmentNumber = + requestContext.requestObject.header.mutable.segmentNumber?.value?.toInt() ?: throw EbicsInvalidRequestError() + val requestTransactionID = requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() + if (requestSegmentNumber == 1 && uploadTransaction.numSegments == 1) { + val encOrderData = + requestObject.body.dataTransfer?.orderData ?: throw EbicsInvalidRequestError() + val zippedData = CryptoUtil.decryptEbicsE002( + uploadTransaction.transactionKeyEnc.bytes, + Base64.getDecoder().decode(encOrderData), + requestContext.hostEncPriv + ) + val unzippedData = + InflaterInputStream(zippedData.inputStream()).use { it.readAllBytes() } + + val sigs = EbicsOrderSignatureEntity.find { + (EbicsOrderSignaturesTable.orderID eq uploadTransaction.orderID) and + (EbicsOrderSignaturesTable.orderType eq uploadTransaction.orderType) + } + if (sigs.count() == 0L) { + throw EbicsInvalidRequestError() + } + for (sig in sigs) { + if (sig.signatureAlgorithm == "A006") { + + val signedData = CryptoUtil.digestEbicsOrderA006(unzippedData) + val res1 = CryptoUtil.verifyEbicsA006( + sig.signatureValue.bytes, + signedData, + requestContext.clientSigPub + ) + if (!res1) { + throw EbicsInvalidRequestError() + } + + } else { + throw NotImplementedError() + } + } + if (getOrderTypeFromTransactionId(requestTransactionID) == "CCT") { + handleCct(unzippedData.toString(Charsets.UTF_8), + requestContext.subscriber + ) + } + return EbicsResponse.createForUploadTransferPhase( + requestTransactionID, + requestSegmentNumber, + true, + uploadTransaction.orderID + ) + } else { + throw NotImplementedError() + } +} +// req.header.static.hostID. +private fun makeRequestContext(requestObject: EbicsRequest): RequestContext { + val staticHeader = requestObject.header.static + val requestedHostId = staticHeader.hostID + val ebicsHost = + EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestedHostId.uppercase(Locale.getDefault()) } + .firstOrNull() + val requestTransactionID = requestObject.header.static.transactionID + var downloadTransaction: EbicsDownloadTransactionEntity? = null + var uploadTransaction: EbicsUploadTransactionEntity? = null + val subscriber = if (requestTransactionID != null) { + downloadTransaction = EbicsDownloadTransactionEntity.findById(requestTransactionID.uppercase(Locale.getDefault())) + if (downloadTransaction != null) { + downloadTransaction.subscriber + } else { + uploadTransaction = EbicsUploadTransactionEntity.findById(requestTransactionID) + uploadTransaction?.subscriber + } + } else { + val partnerID = staticHeader.partnerID ?: throw EbicsInvalidRequestError() + val userID = staticHeader.userID ?: throw EbicsInvalidRequestError() + findEbicsSubscriber(partnerID, userID, staticHeader.systemID) + } + + if (ebicsHost == null) throw EbicsInvalidRequestError() + + /** + * NOTE: production logic must check against READY state (the + * one activated after the subscriber confirms their keys via post) + */ + if (subscriber == null || subscriber.state != SubscriberState.INITIALIZED) + throw EbicsSubscriberStateError() + + val hostAuthPriv = CryptoUtil.loadRsaPrivateKey( + ebicsHost.authenticationPrivateKey.bytes + ) + val hostEncPriv = CryptoUtil.loadRsaPrivateKey( + ebicsHost.encryptionPrivateKey.bytes + ) + val clientAuthPub = + CryptoUtil.loadRsaPublicKey(subscriber.authenticationKey!!.rsaPublicKey.bytes) + val clientEncPub = + CryptoUtil.loadRsaPublicKey(subscriber.encryptionKey!!.rsaPublicKey.bytes) + val clientSigPub = + CryptoUtil.loadRsaPublicKey(subscriber.signatureKey!!.rsaPublicKey.bytes) + + return RequestContext( + hostAuthPriv = hostAuthPriv, + hostEncPriv = hostEncPriv, + clientAuthPub = clientAuthPub, + clientEncPub = clientEncPub, + clientSigPub = clientSigPub, + ebicsHost = ebicsHost, + requestObject = requestObject, + subscriber = subscriber, + downloadTransaction = downloadTransaction, + uploadTransaction = uploadTransaction + ) +} + +suspend fun ApplicationCall.ebicsweb() { + val requestDocument = this.request.call.receive() + val requestedHostID = requestDocument.getElementsByTagName("HostID") + this.attributes.put( + EbicsHostIdAttribute, + requestedHostID.item(0).textContent + ) + when (requestDocument.documentElement.localName) { + "ebicsUnsecuredRequest" -> { + val requestObject = requestDocument.toObject() + logger.info("Serving a ${requestObject.header.static.orderDetails.orderType} request") + + val orderData = requestObject.body.dataTransfer.orderData.value + val header = requestObject.header + + when (header.static.orderDetails.orderType) { + "INI" -> handleEbicsIni(header, orderData) + "HIA" -> handleEbicsHia(header, orderData) + else -> throw EbicsInvalidXmlError() + } + } + "ebicsHEVRequest" -> { + val hevResponse = HEVResponse().apply { + this.systemReturnCode = SystemReturnCodeType().apply { + this.reportText = "[EBICS_OK]" + this.returnCode = "000000" + } + this.versionNumber = listOf(HEVResponse.VersionNumber.create("H004", "02.50")) + } + + val strResp = XMLUtil.convertJaxbToString(hevResponse) + if (!XMLUtil.validateFromString(strResp)) throw SandboxError( + HttpStatusCode.InternalServerError, + "Outgoing HEV response is invalid" + ) + respondText(strResp, ContentType.Application.Xml, HttpStatusCode.OK) + } + // FIXME: should check subscriber state? + "ebicsNoPubKeyDigestsRequest" -> { + val requestObject = requestDocument.toObject() + val hostInfo = ensureEbicsHost(requestObject.header.static.hostID) + when (requestObject.header.static.orderDetails.orderType) { + "HPB" -> handleEbicsHpb(hostInfo, requestDocument, requestObject.header) + else -> throw EbicsInvalidXmlError() + } + } + // FIXME: must check subscriber state. + "ebicsRequest" -> { + val requestObject = requestDocument.toObject() + val responseXmlStr = transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { + // Step 1 of 3: Get information about the host and subscriber + val requestContext = makeRequestContext(requestObject) + // Step 2 of 3: Validate the signature + val verifyResult = XMLUtil.verifyEbicsDocument(requestDocument, requestContext.clientAuthPub) + if (!verifyResult) { + throw EbicsAccountAuthorisationFailed("Subscriber's signature did not verify") + } + // Step 3 of 3: Generate response + val ebicsResponse: EbicsResponse = when (requestObject.header.mutable.transactionPhase) { + EbicsTypes.TransactionPhaseType.INITIALISATION -> { + if (requestObject.header.static.numSegments == null) { + handleEbicsDownloadTransactionInitialization(requestContext) + } else { + handleEbicsUploadTransactionInitialization(requestContext) + } + } + EbicsTypes.TransactionPhaseType.TRANSFER -> { + if (requestContext.uploadTransaction != null) { + handleEbicsUploadTransactionTransmission(requestContext) + } else if (requestContext.downloadTransaction != null) { + handleEbicsDownloadTransactionTransfer(requestContext) + } else { + throw AssertionError() + } + } + EbicsTypes.TransactionPhaseType.RECEIPT -> { + val requestTransactionID = + requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() + if (requestContext.downloadTransaction == null) + throw EbicsInvalidRequestError() + logger.debug("Handling download receipt for EBICS transaction: " + + requestTransactionID) + /** + * The receipt phase means that the client has already + * received all the data related to the current download + * transaction. Hence this data can now be removed from + * the database. + */ + val ebicsData = transaction { + EbicsDownloadTransactionEntity.findById(requestTransactionID) + } + if (ebicsData == null) + throw SandboxError( + HttpStatusCode.InternalServerError, + "EBICS transaction $requestTransactionID was not" + + "found in the database for deletion.", + LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE + ) + ebicsData.delete() + val receiptCode = + requestObject.body.transferReceipt?.receiptCode ?: throw EbicsInvalidRequestError() + EbicsResponse.createForDownloadReceiptPhase(requestTransactionID, receiptCode == 0) + } + } + signEbicsResponse(ebicsResponse, requestContext.hostAuthPriv) + } + if (!XMLUtil.validateFromString(responseXmlStr)) throw SandboxError( + HttpStatusCode.InternalServerError, + "Outgoing EBICS XML is invalid" + ) + respondText(responseXmlStr, ContentType.Application.Xml, HttpStatusCode.OK) + } + else -> { + /* Log to console and return "unknown type" */ + logger.info("Unknown message, just logging it!") + respond( + HttpStatusCode.NotImplemented, + SandboxError( + HttpStatusCode.NotImplemented, + "Not Implemented" + ) + ) + } + } +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt new file mode 100644 index 00000000..6529b9d1 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt @@ -0,0 +1,472 @@ +/* + * 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 + * + */ + +package tech.libeufin.sandbox + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import io.ktor.server.application.* +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.util.* +import java.security.interfaces.RSAPublicKey +import java.util.* +import java.util.zip.DeflaterInputStream +import kotlin.reflect.KProperty + +data class DemobankConfig( + val allowRegistrations: Boolean, + val currency: String, + val cashoutCurrency: String? = null, + val bankDebtLimit: Int, + val usersDebtLimit: Int, + val withSignupBonus: Boolean, + val demobankName: String, // demobank name. + val captchaUrl: String? = null, + val smsTan: String? = null, // fixme: move the config subcommand + val emailTan: String? = null, // fixme: same as above. + val suggestedExchangeBaseUrl: String? = null, + val suggestedExchangePayto: String? = null, + val nexusBaseUrl: String? = null, + val usernameAtNexus: String? = null, + val passwordAtNexus: String? = null, + val enableConversionService: Boolean = false +) + +fun getConfigValueOrThrow(configKey: KProperty): T { + return configKey.getter.call() ?: throw nullConfigValueError(configKey.name) +} + +/** + * Helps to communicate Camt values without having + * to parse the XML each time one is needed. + */ +data class SandboxCamt( + val camtMessage: String, + val messageId: String, + /** + * That is the number of SECONDS since Epoch. This + * value is exactly what goes into the Camt document. + */ + val creationTime: Long +) + +/** + * DB helper inserting a new "account" into the database. + * The account is made of a 'customer' and 'bank account' + * object. The helper checks first that the username is + * acceptable (chars, no institutional names, available + * names); then checks that IBAN is available and then adds + * the two database objects under the given demobank. This + * function contains the common logic shared by the Access + * and Circuit API. Additional data that is peculiar to one + * API should be added separately. + * + * It returns a AccountPair type. That contains the customer + * object and the bank account; the caller may this way add custom + * values to them. */ +data class AccountPair( + val customer: DemobankCustomerEntity, + val bankAccount: BankAccountEntity +) +fun insertNewAccount(username: String, + password: String, + name: String? = null, // tests and access API may not give one. + iban: String? = null, + demobank: String = "default", + isPublic: Boolean = false): AccountPair { + requireValidResourceName(username) + // Forbid institutional usernames. + if (username == "bank" || username == "admin") { + logger.info("Username: $username not allowed.") + throw forbidden("Username: $username is not allowed.") + } + return transaction { + val demobankFromDb = getDemobank(demobank) + // Bank's fault, because when this function gets + // called, the demobank must exist. + if (demobankFromDb == null) { + logger.error("Demobank '$demobank' not found. Won't add account $username") + throw internalServerError("Demobank $demobank not found. Won't add account $username") + } + // Generate a IBAN if the caller didn't provide one. + val newIban = iban ?: getIban() + // Check IBAN collisions. + val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq newIban).firstOrNull() + if (checkIbanExist != null) { + logger.info("IBAN $newIban not available. Won't register username $username") + throw conflict("IBAN $iban not available.") + } + // Check username availability. + val checkCustomerExist = DemobankCustomerEntity.find { + DemobankCustomersTable.username eq username + }.firstOrNull() + if (checkCustomerExist != null) { + throw SandboxError( + HttpStatusCode.Conflict, + "Username $username not available." + ) + } + val newCustomer = DemobankCustomerEntity.new { + this.username = username + passwordHash = CryptoUtil.hashpw(password) + this.name = name // nullable + } + // Actual account creation. + val newBankAccount = BankAccountEntity.new { + this.iban = newIban + /** + * For now, keep same semantics of Pybank: a username + * is AS WELL a bank account label. In other words, it + * identifies a customer AND a bank account. The reason + * to have the two values (label and owner) is to allow + * multiple bank accounts being owned by one customer. + */ + label = username + owner = username + this.demoBank = demobankFromDb + this.isPublic = isPublic + } + if (demobankFromDb.config.withSignupBonus) + newBankAccount.bonus("${demobankFromDb.config.currency}:100") + AccountPair(customer = newCustomer, bankAccount = newBankAccount) + } +} + +/** + * Return true if access to the bank account can be granted, + * false otherwise. + * + * Given the policy of having bank account names matching + * their owner's username, this function enforces such policy + * with the exception that 'admin' can access every bank + * account. A null username indicates disabled authentication + * checks, hence it grants the access. + */ +fun allowOwnerOrAdmin(username: String?, bankAccountLabel: String): Boolean { + if (username == null) return true + if (username == "admin") return true + return username == bankAccountLabel +} + +/** + * Throws exception if the credentials are wrong. + * + * Return: + * - null if the authentication is disabled (during tests, for example). + * This facilitates tests because allows requests to lack entirely an + * Authorization header. + * - the username of the authenticated user + * - throw exception when the authentication fails + * + * Note: at this point it is ONLY checked whether the user provided + * a valid password for the username mentioned in the Authorization header. + * The actual access to the resources must be later checked by each handler. + */ +fun ApplicationRequest.basicAuth(onlyAdmin: Boolean = false): String? { + val withAuth = this.call.ensureAttribute(WITH_AUTH_ATTRIBUTE_KEY) + if (!withAuth) { + logger.info("Authentication is disabled - assuming tests currently running.") + return null + } + val credentials = getHTTPBasicAuthCredentials(this) + if (credentials.first == "admin") { + // env must contain the admin password, because --with-auth is true. + val adminPassword: String = this.call.ensureAttribute(ADMIN_PASSWORD_ATTRIBUTE_KEY) + if (credentials.second != adminPassword) throw unauthorized( + "Admin authentication failed" + ) + return credentials.first + } + if (onlyAdmin) throw forbidden("Only admin allowed.") + val passwordHash = transaction { + val customer = getCustomer(credentials.first) + customer.passwordHash + } + if (!CryptoUtil.checkPwOrThrow(credentials.second, passwordHash)) + throw unauthorized("Customer '${credentials.first}' gave wrong credentials") + return credentials.first +} + +fun sandboxAssert(condition: Boolean, reason: String) { + if (!condition) throw SandboxError(HttpStatusCode.InternalServerError, reason) +} + +fun getOrderTypeFromTransactionId(transactionID: String): String { + val uploadTransaction = transaction { + EbicsUploadTransactionEntity.findById(transactionID) + } ?: throw SandboxError( + /** + * NOTE: at this point, it might even be the server's fault. + * For example, if it failed to store a ID earlier. + */ + HttpStatusCode.NotFound, + "Could not retrieve order type for transaction: $transactionID" + ) + return uploadTransaction.orderType +} + +fun getHistoryElementFromTransactionRow(dbRow: BankAccountTransactionEntity): XLibeufinBankTransaction { + return XLibeufinBankTransaction( + subject = dbRow.subject, + creditorIban = dbRow.creditorIban, + creditorBic = dbRow.creditorBic, + creditorName = dbRow.creditorName, + debtorIban = dbRow.debtorIban, + debtorBic = dbRow.debtorBic, + debtorName = dbRow.debtorName, + date = dbRow.date.toString(), + amount = dbRow.amount, + currency = dbRow.currency, + // UID assigned by the bank itself. + uid = dbRow.accountServicerReference, + direction = XLibeufinBankDirection.convertCamtDirectionToXLibeufin(dbRow.direction), + // UIDs as gotten from a pain.001 (from EBICS connections.) + pmtInfId = dbRow.pmtInfId, + endToEndId = dbRow.endToEndId + ) +} + +fun printConfig(demobank: DemobankConfigEntity) { + val ret = ObjectMapper() + ret.configure(SerializationFeature.INDENT_OUTPUT, true) + println( + ret.writeValueAsString(object { + val currency = demobank.config.currency + val bankDebtLimit = demobank.config.bankDebtLimit + val usersDebtLimit = demobank.config.usersDebtLimit + val allowRegistrations = demobank.config.allowRegistrations + val name = demobank.name // always 'default' + val withSignupBonus = demobank.config.withSignupBonus + val captchaUrl = demobank.config.captchaUrl + val suggestedExchangeBaseUrl = demobank.config.suggestedExchangeBaseUrl + val suggestedExchangePayto = demobank.config.suggestedExchangePayto + }) + ) +} + +fun getHistoryElementFromTransactionRow( + dbRow: BankAccountFreshTransactionEntity +): XLibeufinBankTransaction { + return getHistoryElementFromTransactionRow(dbRow.transactionRef) +} + +/** + * Need to be called within a transaction {} block. It + * is acceptable to pass a bank account's label as the + * parameter, because usernames can only own one bank + * account whose label equals the owner's username. + * + * Future versions may relax this policy to allow one + * customer to own multiple bank accounts. + */ +fun getCustomer(username: String): DemobankCustomerEntity { + return maybeGetCustomer(username) ?: throw notFound("Customer '${username}' not found") +} +fun maybeGetCustomer(username: String): DemobankCustomerEntity? { + return transaction { + DemobankCustomerEntity.find { + DemobankCustomersTable.username eq username + }.firstOrNull() + } +} + +/** + * Get person name from a customer's username, or throw + * exception if not found. + */ +fun getPersonNameFromCustomer(customerUsername: String): String { + return when (customerUsername) { + "admin" -> "Admin" + else -> transaction { + val ownerCustomer = DemobankCustomerEntity.find( + DemobankCustomersTable.username eq customerUsername + ).firstOrNull() ?: run { + logger.error("Customer '${customerUsername}' not found, couldn't get their name.") + throw SandboxError( + HttpStatusCode.InternalServerError, + "'$customerUsername' not a customer." + ) + + } + ownerCustomer.name ?: "Never given." + } + } +} + +fun getDefaultDemobank(): DemobankConfigEntity { + return transaction { + DemobankConfigEntity.find { + DemobankConfigsTable.name eq "default" + }.firstOrNull() + } ?: throw SandboxError( + HttpStatusCode.InternalServerError, + "Default demobank is missing." + ) +} + +fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity { + val uuid = parseUuid(opId) + return transaction { + TalerWithdrawalEntity.find { + TalerWithdrawalsTable.wopid eq uuid + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, "Withdrawal operation $opId not found." + ) + } +} + +fun getBankAccountFromPayto(paytoUri: String): BankAccountEntity { + val paytoParse = parsePayto(paytoUri) + return getBankAccountFromIban(paytoParse.iban) +} + +fun getBankAccountFromIban(iban: String): BankAccountEntity { + return transaction { + BankAccountEntity.find(BankAccountsTable.iban eq iban).firstOrNull() + } ?: throw SandboxError( + HttpStatusCode.NotFound, + "Did not find a bank account for $iban" + ) +} + +/** + * The argument 'withBankFault' represents the case where + * _the bank_ must ensure that a resource (in this case a bank + * account) exists. For example, every 'customer' should have + * a 'bank account', and if a customer is found without a bank + * account, then the bank broke such condition. + */ +fun getBankAccountFromLabel( + label: String, + demobank: String = "default", + withBankFault: Boolean = false +): BankAccountEntity { + val maybeDemobank = getDemobank(demobank) + if (maybeDemobank == null) { + logger.error("Demobank '$demobank' not found") + throw SandboxError( + HttpStatusCode.NotFound, + "Demobank '$demobank' not found" + ) + } + return getBankAccountFromLabel( + label, + maybeDemobank, + withBankFault + ) +} + +// Get bank account DAO, given its name and demobank. +fun getBankAccountFromLabel( + label: String, + demobank: DemobankConfigEntity, + withBankFault: Boolean = false // documented along the other same-named function. +): BankAccountEntity { + val maybeBankAccount = transaction { + BankAccountEntity.find( + BankAccountsTable.label eq label and ( + BankAccountsTable.demoBank eq demobank.id + ) + ).firstOrNull() + } + if (maybeBankAccount == null && withBankFault) + throw internalServerError( + "Bank account $label was not found, but it should." + ) + if (maybeBankAccount == null) + throw notFound( + "Bank account $label was not found." + ) + return maybeBankAccount +} + +fun getBankAccountFromSubscriber(subscriber: EbicsSubscriberEntity): BankAccountEntity { + return transaction { + subscriber.bankAccount ?: throw SandboxError( + HttpStatusCode.NotFound, + "Subscriber doesn't have any bank account" + ) + } +} + +fun BankAccountEntity.bonus(amount: String) { + wireTransfer( + "admin", + this.label, + this.demoBank.name, + "Sign-up bonus", + amount + ) +} + +fun ensureDemobank(call: ApplicationCall): DemobankConfigEntity { + return ensureDemobank(call.expectUriComponent("demobankid")) +} + +fun ensureDemobank(name: String): DemobankConfigEntity { + return transaction { + DemobankConfigEntity.find { + DemobankConfigsTable.name eq name + }.firstOrNull() ?: throw notFound("Demobank '$name' not found. Was it ever created?") + } +} + +fun getDemobank(name: String?): DemobankConfigEntity? { + return transaction { + if (name == null) { + DemobankConfigEntity.all().firstOrNull() + } else { + DemobankConfigEntity.find { + DemobankConfigsTable.name eq name + }.firstOrNull() + } + } +} + +fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID: String): EbicsSubscriberEntity { + return transaction { + EbicsSubscriberEntity.find { + (EbicsSubscribersTable.userId eq userID) and (EbicsSubscribersTable.partnerId eq partnerID) and + (EbicsSubscribersTable.hostId eq hostID) + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, + "Ebics subscriber (${userID}, ${partnerID}, ${hostID}) not found" + ) + } +} + +/** + * Compress, encrypt, encode a EBICS payload. The payload + * is assumed to be a Zip archive with only one entry. + * Return the customer key (second element) along the data. + */ +fun prepareEbicsPayload( + payload: String, pub: RSAPublicKey +): Pair { + val zipSingleton = mutableListOf(payload.toByteArray()).zip() + val compressedResponse = DeflaterInputStream(zipSingleton.inputStream()).use { + it.readAllBytes() + } + val enc = CryptoUtil.encryptEbicsE002(compressedResponse, pub) + return Pair(Base64.getEncoder().encodeToString(enc.encryptedData), enc) +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt b/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt new file mode 100644 index 00000000..dac660da --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt @@ -0,0 +1,154 @@ +/* + * 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 + * + */ + +package tech.libeufin.sandbox + +import tech.libeufin.util.PaymentInfo + +data class WithdrawalRequest( + /** + * Note: the currency is redundant, because at each point during + * the execution the Demobank should have a handle of the currency. + */ + val amount: String // $CURRENCY:X.Y +) +data class BalanceJson( + val amount: String, + val credit_debit_indicator: String +) +data class Demobank( + val currency: String, + val name: String, + val userDebtLimit: Int, + val bankDebtLimit: Int, + val allowRegistrations: Boolean +) +/** + * Used to show the list of Ebics hosts that exist + * in the system. + */ +data class EbicsHostsResponse( + val ebicsHosts: List +) + +data class EbicsHostCreateRequest( + val hostID: String, + val ebicsVersion: String +) + +/** + * List type that show all the payments existing in the system. + */ +data class AccountTransactions( + val payments: MutableList = mutableListOf() +) + +/** + * Used to create AND show one Ebics subscriber. + */ +data class EbicsSubscriberInfo( + val hostID: String, + val partnerID: String, + val userID: String, + val systemID: String? = null, + val demobankAccountLabel: String +) + +data class AdminGetSubscribers( + var subscribers: MutableList = mutableListOf() +) + +/** + * The following definition is obsolete because it + * doesn't allow to specify a demobank that will host + * the Ebics subscriber. */ +data class EbicsSubscriberObsoleteApi( + val hostID: String, + val partnerID: String, + val userID: String, + val systemID: String? = null +) + +/** + * Allows the admin to associate a new bank account + * to a EBICS subscriber. + */ +data class EbicsBankAccountRequest( + val subscriber: EbicsSubscriberObsoleteApi, + val iban: String, + val bic: String, + val name: String, + /** + * This value labels the bank account to be created + * AND its owner. The 'owner' is a bank's customer + * whose username equals this label AND has the rights + * over such bank accounts. + */ + val label: String +) + +data class CustomerRegistration( + val username: String, + val password: String, + val isPublic: Boolean = false, + // When missing, it's autogenerated. + val iban: String?, + // When missing, stays null in the DB. + val name: String? +) + +// Could be used as a general bank account info container. +data class PublicAccountInfo( + val balance: String, + val iban: String, + // Name / Label of the bank account _and_ of the + // Sandbox username that owns it. + val accountLabel: String + // more ..? +) + +data class CamtParams( + // name/label of the bank account to query. + val bankaccount: String, + val type: Int, + // need range parameter +) + +data class TalerWithdrawalStatus( + val selection_done: Boolean, + val transfer_done: Boolean, + val amount: String, + val wire_types: List = listOf("iban"), + val suggested_exchange: String? = null, + val sender_wire: String? = null, + val aborted: Boolean, + // Not needed with CLI wallets. + val confirm_transfer_url: String? +) + +data class TalerWithdrawalSelection( + val reserve_pub: String, + val selected_exchange: String? +) + +data class SandboxConfig( + val currency: String, + val version: String, + val name: String +) \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt new file mode 100644 index 00000000..bcd11a49 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -0,0 +1,1711 @@ +/* + * 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 + * + */ + +package tech.libeufin.sandbox + +import UtilError +import com.fasterxml.jackson.core.util.DefaultIndenter +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.KotlinFeature +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.context +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.output.CliktHelpFormatter +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.int +import execThrowableOrTerminate +import io.ktor.server.application.* +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import io.ktor.server.plugins.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.util.* +import io.ktor.server.plugins.callloging.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.util.date.* +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.statements.api.ExposedBlob +import org.jetbrains.exposed.sql.transactions.transaction +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.slf4j.event.Level +import org.w3c.dom.Document +import startServer +import tech.libeufin.util.* +import java.math.BigDecimal +import java.net.URL +import java.security.interfaces.RSAPublicKey +import javax.xml.bind.JAXBContext +import kotlin.system.exitProcess + +val logger: Logger = LoggerFactory.getLogger("tech.libeufin.sandbox") +const val PROTOCOL_VERSION_UNIFIED = "0:0:0" // Every protocol is still using the same version. +const val SANDBOX_DB_ENV_VAR_NAME = "LIBEUFIN_SANDBOX_DB_CONNECTION" +private val adminPassword: String? = System.getenv("LIBEUFIN_SANDBOX_ADMIN_PASSWORD") +var WITH_AUTH = true // Needed by helpers too, hence not making it private. + +// Internal error type. +data class SandboxError( + val statusCode: HttpStatusCode, + val reason: String, + val errorCode: LibeufinErrorCode? = null +) : Exception(reason) + +// HTTP response error type. +data class SandboxErrorJson(val error: SandboxErrorDetailJson) +data class SandboxErrorDetailJson(val type: String, val description: String) + +class DefaultExchange : CliktCommand("Set default Taler exchange for a demobank.") { + init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } + private val exchangeBaseUrl by argument("EXCHANGE-BASEURL", "base URL of the default exchange") + private val exchangePayto by argument("EXCHANGE-PAYTO", "default exchange's payto-address") + private val demobank by option("--demobank", help = "Which demobank defaults to EXCHANGE").default("default") + + override fun run() { + val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) + execThrowableOrTerminate { + dbCreateTables(dbConnString) + transaction { + val maybeDemobank: DemobankConfigEntity? = DemobankConfigEntity.find { + DemobankConfigsTable.name eq demobank + }.firstOrNull() + if (maybeDemobank == null) { + System.err.println("Error, demobank $demobank not found.") + exitProcess(1) + } + val config = maybeDemobank.config + /** + * Iterating over the config object's field that hold the exchange + * base URL and Payto. The iteration is only used to retrieve the + * correct names of the DB column 'configKey', because this is named + * after such fields. + */ + listOf( + Pair(config::suggestedExchangeBaseUrl, exchangeBaseUrl), + Pair(config::suggestedExchangePayto, exchangePayto) + ).forEach { + val maybeConfigPair = DemobankConfigPairEntity.find { + DemobankConfigPairsTable.demobankName eq demobank and( + DemobankConfigPairsTable.configKey eq it.first.name) + }.firstOrNull() + /** + * The DB doesn't contain any column to hold the exchange URL + * or Payto, fail. That should never happen, because the DB row + * are created _after_ the DemobankConfig object that _does_ contain + * such fields. + */ + if (maybeConfigPair == null) { + System.err.println("Config key '${it.first.name}' for demobank '$demobank' not found in DB.") + exitProcess(1) + } + maybeConfigPair.configValue = it.second + } + } + } + } +} + +class Config : CliktCommand("Insert one configuration (a.k.a. demobank) into the database.") { + init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } + private val nameArgument by argument( + "NAME", help = "Name of this configuration. Currently, only 'default' is admitted." + ) + private val showOption by option( + "--show", + help = "Only show values, other options will be ignored." + ).flag("--no-show", default = false) + // FIXME: This really should not be a global option! + private val captchaUrlOption by option( + "--captcha-url", help = "Needed for browser wallets." + ).default("https://bank.demo.taler.net/") + private val currencyOption by option("--currency").default("EUR") + private val bankDebtLimitOption by option("--bank-debt-limit").int().default(1000000) + private val usersDebtLimitOption by option("--users-debt-limit").int().default(1000) + private val allowRegistrationsOption by option( + "--with-registrations", + help = "(defaults to allow registrations)" /* mentioning here as help message did not. */ + ).flag("--without-registrations", default = true) + private val withSignupBonusOption by option( + "--with-signup-bonus", + help = "Award new customers with 100 units of currency! (defaults to NO bonus)" + ).flag("--without-signup-bonus", default = false) + + override fun run() { + val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) + if (nameArgument != "default") { + System.err.println("This version admits only the 'default' name") + exitProcess(1) + } + execThrowableOrTerminate { + dbCreateTables(dbConnString) + val maybeDemobank = transaction { getDemobank(nameArgument) } + if (showOption) { + if (maybeDemobank != null) { + printConfig(maybeDemobank) + } else { + println("Demobank: $nameArgument not found.") + System.exit(1) + } + return@execThrowableOrTerminate + } + if (bankDebtLimitOption < 0 || usersDebtLimitOption < 0) { + System.err.println("Debt numbers can't be negative.") + exitProcess(1) + } + /* + Warning if the CAPTCHA URL does not include the {wopid} placeholder. + Not a reason to fail because the bank may be run WITHOUT providing Taler. + */ + if (!hasWopidPlaceholder(captchaUrlOption)) + logger.warn("CAPTCHA URL doesn't have the WOPID placeholder." + + " Taler withdrawals decrease usability") + + // The user asks to _set_ values, regardless of overriding or creating. + val config = DemobankConfig( + currency = currencyOption, + bankDebtLimit = bankDebtLimitOption, + usersDebtLimit = usersDebtLimitOption, + allowRegistrations = allowRegistrationsOption, + demobankName = nameArgument, + withSignupBonus = withSignupBonusOption, + captchaUrl = captchaUrlOption + ) + /** + * The demobank didn't exist. Now: + * 1, Store the config values in the database. + * 2, Store the demobank name in the database. + * 3, Create the admin bank account under this demobank. + */ + if (maybeDemobank == null) { + transaction { + insertConfigPairs(config) + val demoBank = DemobankConfigEntity.new { this.name = nameArgument } + BankAccountEntity.new { + iban = getIban() + label = "admin" + owner = "admin" // Not backed by an actual customer object. + // For now, the model assumes always one demobank + this.demoBank = demoBank + } + } + } + // Demobank exists: update its config values in the database. + else transaction { insertConfigPairs(config, override = true) } + } + } +} + +/** + * This command generates Camt53 statements - for all the bank accounts - + * every time it gets run. The statements are only stored into the database. + * The user should then query either via Ebics or via the JSON interface, + * in order to retrieve their statements. + */ +class Camt053Tick : CliktCommand( + "Make a new Camt.053 time tick; all the fresh transactions" + + " will be inserted in a new Camt.053 report" +) { + override fun run() { + val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) + execThrowableOrTerminate { dbCreateTables(dbConnString) } + val newStatements = mutableMapOf>() + /** + * For each bank account, extract the latest statement and + * include all the later transactions in a new statement. + * Build empty statement, if the account does not have any + * transaction yet. + */ + transaction { + BankAccountEntity.all().forEach { accountIter -> + // Give this account a entry in the final output. + newStatements.putIfAbsent(accountIter.label, mutableListOf()) + val lastStatement = BankAccountStatementEntity.find { + BankAccountStatementsTable.bankAccount eq accountIter.id.value + }.lastOrNull() + val lastStatementTime = lastStatement?.creationTime ?: 0L + BankAccountTransactionEntity.find { + BankAccountTransactionsTable.date.greater(lastStatementTime) and( + BankAccountTransactionsTable.account eq accountIter.id.value + ) + }.forEach { + newStatements[accountIter.label]?.add( + getHistoryElementFromTransactionRow(it) + ) ?: run { + logger.error("Array operation failed while building statements for account: ${accountIter.label}") + System.err.println("Fatal array error while building the statement, please report.") + exitProcess(1) + } + } + /** + * Resorting the closing (CLBD) balance of the last statement; will + * become the PRCD balance of the _new_ one. + */ + val camtData = buildCamtString( + 53, + accountIter.iban, + newStatements[accountIter.label]!!, + currency = accountIter.demoBank.config.currency + ) + BankAccountStatementEntity.new { + statementId = camtData.messageId + creationTime = getSystemTimeNow().toInstant().epochSecond + xmlMessage = camtData.camtMessage + bankAccount = accountIter + } + } + BankAccountFreshTransactionsTable.deleteAll() + } + } +} + +class MakeTransaction : CliktCommand("Wire-transfer money between Sandbox bank accounts") { + init { + context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } + } + private val creditAccount by option(help = "Label of the bank account receiving the payment").required() + private val debitAccount by option(help = "Label of the bank account issuing the payment").required() + private val demobankArg by option("--demobank", help = "Which Demobank books this transaction").default("default") + private val amount by argument("AMOUNT", "Amount, in the CUR:X.Y format") + private val subjectArg by argument("SUBJECT", "Payment's subject") + + override fun run() { + /** + * Merely connecting here (and NOT creating any table) because this + * command should only be run after actual bank accounts exist in the + * system, meaning therefore that the database got already set up. + */ + execThrowableOrTerminate { + val pgConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) + connectWithSchema(getJdbcConnectionFromPg(pgConnString)) + } + // Refuse to operate without a default demobank. + val demobank = getDemobank("default") + if (demobank == null) { + System.err.println("Sandbox cannot operate without a 'default' demobank.") + System.err.println("Please make one with the 'libeufin-sandbox config' command.") + exitProcess(1) + } + try { + wireTransfer(debitAccount, creditAccount, demobankArg, subjectArg, amount) + } catch (e: SandboxError) { + System.err.println(e.message) + exitProcess(1) + } catch (e: Exception) { + System.err.println(e.message) + exitProcess(1) + } + } +} + +class ResetTables : CliktCommand("Drop all the tables from the database") { + init { + context { + helpFormatter = CliktHelpFormatter(showDefaultValues = true) + } + } + override fun run() { + val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) + execThrowableOrTerminate { + dbDropTables(dbConnString) + dbCreateTables(dbConnString) + } + } +} + +class Serve : CliktCommand("Run sandbox HTTP server") { + init { + context { + helpFormatter = CliktHelpFormatter(showDefaultValues = true) + } + } + private val auth by option( + "--auth", + help = "Disable authentication." + ).flag("--no-auth", default = 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) + private val logLevel by option( + help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug', 'trace', 'all'" + ) + private val port by option().int().default(5000) + 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 smsTan by option(help = "Command to send the TAN via SMS." + + " The command gets the TAN via STDIN and the phone number" + + " as its first parameter" + ) + private val emailTan by option(help = "Command to send the TAN via e-mail." + + " The command gets the TAN via STDIN and the e-mail address as its" + + " first parameter.") + override fun run() { + WITH_AUTH = auth + setLogLevel(logLevel) + if (WITH_AUTH && adminPassword == null) { + System.err.println( + "Error: auth is enabled, but env " + + "LIBEUFIN_SANDBOX_ADMIN_PASSWORD is not." + + " (Option --no-auth exists for tests)" + ) + exitProcess(1) + } + execThrowableOrTerminate { + dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)) + } + // Refuse to operate without a 'default' demobank. + val demobank = getDemobank("default") + if (demobank == null) { + System.err.println("Sandbox cannot operate without a 'default' demobank.") + System.err.println("Please make one with the 'libeufin-sandbox config' command.") + exitProcess(1) + } + if (withUnixSocket != null) { + startServer( + withUnixSocket!!, + app = sandboxApp + ) + exitProcess(0) + } + SMS_TAN_CMD = smsTan + EMAIL_TAN_CMD = emailTan + + logger.info("Starting Sandbox on port ${this.port}") + startServerWithIPv4Fallback( + options = StartServerOptions( + ipv4OnlyOpt = this.ipv4Only, + localhostOnlyOpt = this.localhostOnly, + portOpt = this.port + ), + app = sandboxApp + ) + } +} + +private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank { + return Demobank( + currency = fromDb.config.currency, + userDebtLimit = fromDb.config.usersDebtLimit, + bankDebtLimit = fromDb.config.bankDebtLimit, + allowRegistrations = fromDb.config.allowRegistrations, + name = fromDb.name + ) +} +fun findEbicsSubscriber(partnerID: String, userID: String, systemID: String?): EbicsSubscriberEntity? { + return if (systemID == null) { + EbicsSubscriberEntity.find { + (EbicsSubscribersTable.partnerId eq partnerID) and (EbicsSubscribersTable.userId eq userID) + } + } else { + EbicsSubscriberEntity.find { + (EbicsSubscribersTable.partnerId eq partnerID) and + (EbicsSubscribersTable.userId eq userID) and + (EbicsSubscribersTable.systemId eq systemID) + } + }.firstOrNull() +} + +data class SubscriberKeys( + val authenticationPublicKey: RSAPublicKey, + val encryptionPublicKey: RSAPublicKey, + val signaturePublicKey: RSAPublicKey +) + +data class EbicsHostPublicInfo( + val hostID: String, + val encryptionPublicKey: RSAPublicKey, + val authenticationPublicKey: RSAPublicKey +) + +data class BankAccountInfo( + val label: String, + val name: String, + val iban: String, + val bic: String, +) + +inline fun Document.toObject(): T { + val jc = JAXBContext.newInstance(T::class.java) + val m = jc.createUnmarshaller() + return m.unmarshal(this, T::class.java).value +} + +fun ensureNonNull(param: String?): String { + return param ?: throw SandboxError( + HttpStatusCode.BadRequest, "Bad ID given: $param" + ) +} + +class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true, printHelpOnEmptyArgs = true) { + init { versionOption(getVersion()) } + override fun run() = Unit +} + +fun main(args: Array) { + SandboxCommand().subcommands( + Serve(), + ResetTables(), + Config(), + MakeTransaction(), + Camt053Tick(), + DefaultExchange() + ).main(args) +} + +fun setJsonHandler(ctx: ObjectMapper) { + ctx.enable(SerializationFeature.INDENT_OUTPUT) + ctx.setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { + indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) + indentObjectsWith(DefaultIndenter(" ", "\n")) + }) + ctx.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() + ) +} + +private suspend fun getWithdrawal(call: ApplicationCall) { + val op = getWithdrawalOperation(call.expectUriComponent("withdrawal_id")) + if (!op.selectionDone && op.reservePub != null) throw internalServerError( + "Unselected withdrawal has a reserve public key", + LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE + ) + call.respond(object { + val amount = op.amount + val aborted = op.aborted + val confirmation_done = op.confirmationDone + val selection_done = op.selectionDone + val selected_reserve_pub = op.reservePub + val selected_exchange_account = op.selectedExchangePayto + }) +} + +private suspend fun confirmWithdrawal(call: ApplicationCall) { + val withdrawalId = call.expectUriComponent("withdrawal_id") + logger.debug("Maybe confirming withdrawal: $withdrawalId") + transaction { + val wo = getWithdrawalOperation(withdrawalId) + if (wo.aborted) throw SandboxError( + HttpStatusCode.Conflict, + "Cannot confirm an aborted withdrawal." + ) + if (!wo.selectionDone) throw SandboxError( + HttpStatusCode.UnprocessableEntity, + "Cannot confirm a unselected withdrawal: " + + "specify exchange and reserve public key via Integration API first." + ) + /** + * The wallet chose not to select any exchange, use the default. + */ + val demobank = ensureDemobank(call) + if (wo.selectedExchangePayto == null) { + wo.selectedExchangePayto = demobank.config.suggestedExchangePayto + } + val exchangeBankAccount = getBankAccountFromPayto( + wo.selectedExchangePayto ?: throw internalServerError( + "Cannot withdraw without an exchange." + ) + ) + logger.debug("Withdrawal ${wo.wopid} confirmed? ${wo.confirmationDone}") + if (!wo.confirmationDone) { + wireTransfer( + debitAccount = wo.walletBankAccount.label, + creditAccount = exchangeBankAccount.label, + amount = wo.amount, + subject = wo.reservePub ?: throw internalServerError( + "Cannot transfer funds without reserve public key." + ), + // provide the currency. + demobank = ensureDemobank(call).name + ) + wo.confirmationDone = true + } + wo.confirmationDone + } + call.respond(object {}) +} + +private suspend fun abortWithdrawal(call: ApplicationCall) { + val withdrawalId = call.expectUriComponent("withdrawal_id") + val operation = getWithdrawalOperation(withdrawalId) + if (operation.confirmationDone) throw conflict("Cannot abort paid withdrawal.") + transaction { operation.aborted = true } + call.respond(object {}) +} + +val sandboxApp: Application.() -> Unit = { + install(CallLogging) { + this.level = Level.DEBUG + this.logger = tech.libeufin.sandbox.logger + this.format { call -> + "${call.response.status()}, ${call.request.httpMethod.value} ${call.request.path()}" + } + } + install(CORS) { + anyHost() + allowHeader(HttpHeaders.Authorization) + allowHeader(HttpHeaders.ContentType) + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Patch) + allowMethod(HttpMethod.Delete) + allowCredentials = true + } + install(IgnoreTrailingSlash) + install(ContentNegotiation) { + register(ContentType.Text.Xml, XMLEbicsConverter()) + /** + * Content type "text" must go to the XML parser + * because Nexus can't set explicitly the Content-Type + * (see https://github.com/ktorio/ktor/issues/1127) to + * "xml" and the request made gets somehow assigned the + * "text/plain" type: */ + register(ContentType.Text.Plain, XMLEbicsConverter()) + jackson(contentType = ContentType.Application.Json) { setJsonHandler(this) } + /** + * Make jackson the default parser. It runs also when + * the Content-Type request header is missing. */ + jackson(contentType = ContentType.Any) { setJsonHandler(this) } + } + install(StatusPages) { + // Bank's fault: it should check the operands. Respond 500 + exception { call, cause -> + logger.error("Exception while handling '${call.request.uri}', ${cause.stackTraceToString()}") + call.respond( + HttpStatusCode.InternalServerError, + SandboxErrorJson( + error = SandboxErrorDetailJson( + type = "sandbox-error", + description = cause.message ?: "Bank's error: arithmetic exception." + ) + ) + ) + } + // Not necessarily the bank's fault. + exception { call, cause -> + logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") + call.respond( + cause.statusCode, + SandboxErrorJson( + error = SandboxErrorDetailJson( + type = "sandbox-error", + description = cause.reason + ) + ) + ) + } + // Not necessarily the bank's fault. + exception { call, cause -> + logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") + call.respond( + cause.statusCode, + SandboxErrorJson( + error = SandboxErrorDetailJson( + type = "util-error", + description = cause.reason + ) + ) + ) + } + /** + * Happens when a request fails to parse. This branch triggers + * only when a JSON request fails. XML problems are caught within + * the /ebicsweb handler and always ultimately rethrown as "EbicsRequestError", + * hence they do not reach this branch. + */ + exception { 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 SandboxError( + HttpStatusCode.InternalServerError, + "Did not find bad request details." + ) + } + logger.error(errorMessage) + call.respond( + HttpStatusCode.BadRequest, + SandboxErrorJson( + error = SandboxErrorDetailJson( + type = "sandbox-error", + description = errorMessage + ) + ) + ) + } + // Catch-all error, respond 500 because the bank didn't handle it. + exception { call, cause -> + logger.error("Unhandled exception while handling '${call.request.uri}'\n${cause.stackTraceToString()}") + call.respond( + HttpStatusCode.InternalServerError, + SandboxErrorJson( + error = SandboxErrorDetailJson( + type = "sandbox-error", + description = cause.message ?: "Bank's error: unhandled exception." + ) + ) + ) + } + exception { call, cause -> + logger.error("Handling EbicsRequestError: ${cause.message}") + respondEbicsTransfer(call, cause.errorText, cause.errorCode) + } + } + intercept(ApplicationCallPipeline.Setup) { + val ac: ApplicationCall = call + ac.attributes.put(WITH_AUTH_ATTRIBUTE_KEY, WITH_AUTH) + if (WITH_AUTH) { + if(adminPassword == null) { + throw internalServerError( + "Sandbox has no admin password defined." + + " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in the environment, " + + "or launch with --no-auth." + + ) + } + ac.attributes.put(ADMIN_PASSWORD_ATTRIBUTE_KEY, adminPassword) + } + return@intercept + } + intercept(ApplicationCallPipeline.Fallback) { + if (this.call.response.status() == null) { + call.respondText( + "Not found (no route matched).\n", + io.ktor.http.ContentType.Text.Plain, + io.ktor.http.HttpStatusCode.NotFound + ) + return@intercept finish() + } + } + routing { + get("/") { + call.respondText( + "Hello, this is the Sandbox\n", + ContentType.Text.Plain + ) + } + // Respond with the last statement of the requesting account. + // Query details in the body. + post("/admin/payments/camt") { + val username = call.request.basicAuth() + val body = call.receive() + if (body.type != 53) throw SandboxError( + HttpStatusCode.NotFound, + "Only Camt.053 documents can be generated." + ) + if (!allowOwnerOrAdmin(username, body.bankaccount)) + throw unauthorized("User '${username}' has no rights over" + + " bank account '${body.bankaccount}'") + val camtMessage = transaction { + val bankaccount = getBankAccountFromLabel( + body.bankaccount, + getDefaultDemobank() + ) + BankAccountStatementEntity.find { + BankAccountStatementsTable.bankAccount eq bankaccount.id + }.lastOrNull()?.xmlMessage ?: throw SandboxError( + HttpStatusCode.NotFound, + "Could not find any statements; please wait next tick" + ) + } + call.respondText( + camtMessage, ContentType.Text.Xml, HttpStatusCode.OK + ) + return@post + } + + /** + * Create a new bank account, no EBICS relation. Okay + * to let a user, since having a particular username allocates + * already a bank account with such label. + */ + post("/admin/bank-accounts/{label}") { + val username = call.request.basicAuth() + val body = call.receive() + if (!allowOwnerOrAdmin(username, body.label)) + throw unauthorized("User '$username' has no rights over" + + " bank account '${body.label}'" + ) + if (body.label == "admin" || body.label == "bank") throw forbidden( + "Requested bank account label '${body.label}' not allowed." + ) + transaction { + val maybeBankAccount = BankAccountEntity.find { + BankAccountsTable.label eq body.label + }.firstOrNull() + if (maybeBankAccount != null) + throw conflict("Bank account '${body.label}' exist already") + // owner username == bank account label + val maybeCustomer = DemobankCustomerEntity.find { + DemobankCustomersTable.username eq body.label + }.firstOrNull() + if (maybeCustomer == null) + throw notFound("Customer '${body.label}' not found," + + " cannot own any bank account.") + BankAccountEntity.new { + iban = body.iban + bic = body.bic + label = body.label + owner = body.label + demoBank = getDefaultDemobank() + } + } + call.respond(object {}) + return@post + } + + // Information about one bank account. + get("/admin/bank-accounts/{label}") { + val username = call.request.basicAuth() + val label = call.expectUriComponent("label") + val ret = transaction { + val demobank = getDefaultDemobank() + val bankAccount = getBankAccountFromLabel(label, demobank) + if (!allowOwnerOrAdmin(username, label)) + throw unauthorized("'${username}' has no rights over '$label'") + val balance = getBalance(bankAccount) + object { + val balance = "${bankAccount.demoBank.config.currency}:${balance}" + val iban = bankAccount.iban + val bic = bankAccount.bic + val label = bankAccount.label + } + } + call.respond(ret) + return@get + } + + // Book one incoming payment for the requesting account. + // The debtor is not required to have a customer account at this Sandbox. + post("/admin/bank-accounts/{label}/simulate-incoming-transaction") { + call.request.basicAuth(onlyAdmin = true) + val body = call.receive() + val accountLabel = ensureNonNull(call.parameters["label"]) + val reqDebtorBic = body.debtorBic + if (reqDebtorBic != null && !validateBic(reqDebtorBic)) { + throw SandboxError( + HttpStatusCode.BadRequest, + "invalid BIC" + ) + } + val amount = parseAmount(body.amount) + transaction { + val demobank = getDefaultDemobank() + val account = getBankAccountFromLabel( + accountLabel, demobank + ) + val randId = getRandomString(16) + val customer = getCustomer(accountLabel) + BankAccountTransactionEntity.new { + creditorIban = account.iban + creditorBic = account.bic + creditorName = customer.name ?: "Name not given." + debtorIban = body.debtorIban + debtorBic = reqDebtorBic + debtorName = body.debtorName + subject = body.subject + this.amount = amount.amount + date = getSystemTimeNow().toInstant().toEpochMilli() + accountServicerReference = "sandbox-$randId" + this.account = account + direction = "CRDT" + this.demobank = demobank + this.currency = demobank.config.currency + } + } + call.respond(object {}) + } + // Associates a new bank account with an existing Ebics subscriber. + post("/admin/ebics/bank-accounts") { + call.request.basicAuth(onlyAdmin = true) + val body = call.receive() + val subscriber = getEbicsSubscriberFromDetails( + body.subscriber.userID, + body.subscriber.partnerID, + body.subscriber.hostID + ) + val res = insertNewAccount( + username = body.label, + /** + * This value makes only happy the account creator helper. + * Logic using this OBSOLETE HTTP handler would NOT expect + * to use this password anyway. The reason is that such obsolete + * tests access their banking data always through the EBICS + * subscriber, needing therefore no HTTP basic password to operate. + */ + password = "not-used", + iban = body.iban + ) + transaction { subscriber.bankAccount = res.bankAccount } + call.respond({}) + return@post + } + + // Information about all the default demobank's bank accounts + get("/admin/bank-accounts") { + call.request.basicAuth(onlyAdmin = true) + val accounts = mutableListOf() + transaction { + val demobank = getDefaultDemobank() + // Finds all the accounts of this demobank. + BankAccountEntity.find { BankAccountsTable.demoBank eq demobank.id }.forEach { + accounts.add( + BankAccountInfo( + label = it.label, + bic = it.bic, + iban = it.iban, + name = "Bank account owner's name" + ) + ) + } + } + call.respond(accounts) + } + + // Details of all the transactions of one bank account. + get("/admin/bank-accounts/{label}/transactions") { + val username = call.request.basicAuth() + val ret = AccountTransactions() + val accountLabel = ensureNonNull(call.parameters["label"]) + if (!allowOwnerOrAdmin(username, accountLabel)) + throw unauthorized("Requesting user '${username}'" + + " has no rights over bank account '${accountLabel}'" + ) + transaction { + val demobank = getDefaultDemobank() + val account = getBankAccountFromLabel(accountLabel, demobank) + BankAccountTransactionEntity.find { + BankAccountTransactionsTable.account eq account.id + }.forEach { + ret.payments.add( + PaymentInfo( + accountLabel = account.label, + creditorIban = it.creditorIban, + accountServicerReference = it.accountServicerReference, + paymentInformationId = it.pmtInfId, + debtorIban = it.debtorIban, + subject = it.subject, + date = GMTDate(it.date).toHttpDate(), + amount = it.amount, + creditorBic = it.creditorBic, + creditorName = it.creditorName, + debtorBic = it.debtorBic, + debtorName = it.debtorName, + currency = it.currency, + creditDebitIndicator = when (it.direction) { + "CRDT" -> "credit" + "DBIT" -> "debit" + else -> throw Error("invalid direction") + } + ) + ) + } + } + call.respond(ret) + } + /** + * Generate one incoming and one outgoing transactions for + * one bank account. Counterparts do not need to have an account + * at this Sandbox. + */ + post("/admin/bank-accounts/{label}/generate-transactions") { + call.request.basicAuth(onlyAdmin = true) + transaction { + val accountLabel = ensureNonNull(call.parameters["label"]) + val demobank = getDefaultDemobank() + val account = getBankAccountFromLabel(accountLabel, demobank) + val transactionReferenceCrdt = getRandomString(8) + val transactionReferenceDbit = getRandomString(8) + + run { + val amount = kotlin.random.Random.nextLong(5, 25) + BankAccountTransactionEntity.new { + creditorIban = account.iban + creditorBic = account.bic + creditorName = "Creditor Name" + debtorIban = "DE64500105178797276788" + debtorBic = "DEUTDEBB101" + debtorName = "Max Mustermann" + subject = "sample transaction $transactionReferenceCrdt" + this.amount = amount.toString() + date = getSystemTimeNow().toInstant().toEpochMilli() + accountServicerReference = transactionReferenceCrdt + this.account = account + direction = "CRDT" + this.demobank = demobank + currency = demobank.config.currency + } + } + + run { + val amount = kotlin.random.Random.nextLong(5, 25) + + BankAccountTransactionEntity.new { + debtorIban = account.iban + debtorBic = account.bic + debtorName = "Debitor Name" + creditorIban = "DE64500105178797276788" + creditorBic = "DEUTDEBB101" + creditorName = "Max Mustermann" + subject = "sample transaction $transactionReferenceDbit" + this.amount = amount.toString() + date = getSystemTimeNow().toInstant().toEpochMilli() + accountServicerReference = transactionReferenceDbit + this.account = account + direction = "DBIT" + this.demobank = demobank + currency = demobank.config.currency + } + } + } + call.respond(object {}) + } + + /** + * Create a new EBICS subscriber without associating + * a bank account to it. Currently every registered + * user is allowed to call this. + */ + post("/admin/ebics/subscribers") { + call.request.basicAuth(onlyAdmin = true) + val body = call.receive() + transaction { + // Check the host ID exists. + EbicsHostEntity.find { + EbicsHostsTable.hostID eq body.hostID + }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") + // Check it exists first. + val maybeSubscriber = EbicsSubscriberEntity.find { + EbicsSubscribersTable.userId eq body.userID and ( + EbicsSubscribersTable.partnerId eq body.partnerID + ) and (EbicsSubscribersTable.systemId eq body.systemID) and + (EbicsSubscribersTable.hostId eq body.hostID) + }.firstOrNull() + if (maybeSubscriber != null) throw conflict("EBICS subscriber exists already") + EbicsSubscriberEntity.new { + partnerId = body.partnerID + userId = body.userID + systemId = null + hostId = body.hostID + state = SubscriberState.NEW + nextOrderID = 1 + } + } + call.respondText( + "Subscriber created.", + ContentType.Text.Plain, HttpStatusCode.OK + ) + return@post + } + + // Shows details of all the EBICS subscribers of this Sandbox. + get("/admin/ebics/subscribers") { + call.request.basicAuth(onlyAdmin = true) + val ret = AdminGetSubscribers() + transaction { + EbicsSubscriberEntity.all().forEach { + ret.subscribers.add( + EbicsSubscriberInfo( + userID = it.userId, + partnerID = it.partnerId, + hostID = it.hostId, + demobankAccountLabel = it.bankAccount?.label ?: "not associated yet" + ) + ) + } + } + call.respond(ret) + return@get + } + + // Change keys used in the EBICS communications. + post("/admin/ebics/hosts/{hostID}/rotate-keys") { + call.request.basicAuth(onlyAdmin = true) + val hostID: String = call.parameters["hostID"] ?: throw SandboxError( + io.ktor.http.HttpStatusCode.BadRequest, "host ID missing in URL" + ) + transaction { + val host = EbicsHostEntity.find { + EbicsHostsTable.hostID eq hostID + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, "Host $hostID not found" + ) + val pairA = CryptoUtil.generateRsaKeyPair(2048) + val pairB = CryptoUtil.generateRsaKeyPair(2048) + val pairC = CryptoUtil.generateRsaKeyPair(2048) + host.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) + host.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) + host.signaturePrivateKey = ExposedBlob(pairC.private.encoded) + } + call.respondText( + "Keys of '${hostID}' rotated.", + ContentType.Text.Plain, + HttpStatusCode.OK + ) + return@post + } + + // Create a new EBICS host + post("/admin/ebics/hosts") { + call.request.basicAuth(onlyAdmin = true) + val req = call.receive() + val pairA = CryptoUtil.generateRsaKeyPair(2048) + val pairB = CryptoUtil.generateRsaKeyPair(2048) + val pairC = CryptoUtil.generateRsaKeyPair(2048) + transaction { + val maybeHost = EbicsHostEntity.find { + EbicsHostsTable.hostID eq req.hostID + }.firstOrNull() + if (maybeHost != null) { + logger.info("EBICS host '${req.hostID}' exists already, this request conflicts.") + throw conflict("EBICS host '${req.hostID}' exists already") + } + EbicsHostEntity.new { + this.ebicsVersion = req.ebicsVersion + this.hostId = req.hostID + this.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) + this.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) + this.signaturePrivateKey = ExposedBlob(pairC.private.encoded) + } + } + call.respondText( + "Host '${req.hostID}' created.", + ContentType.Text.Plain, + HttpStatusCode.OK + ) + return@post + } + + // Show the names of all the Ebics hosts + get("/admin/ebics/hosts") { + call.request.basicAuth(onlyAdmin = true) + val ebicsHosts = transaction { + EbicsHostEntity.all().map { it.hostId } + } + call.respond(EbicsHostsResponse(ebicsHosts)) + } + // Process one EBICS request + post("/ebicsweb") { + try { call.ebicsweb() } + /** + * The catch blocks try to extract a EBICS error message from the + * exception type being handled. NOT logging under each catch block + * as ultimately the registered exception handler is expected to log. */ + catch (e: UtilError) { + throw EbicsProcessingError("Serving EBICS threw unmanaged UtilError: ${e.reason}") + } + catch (e: SandboxError) { + val errorInfo: String = e.message ?: e.stackTraceToString() + logger.info(errorInfo) + // Should translate to EBICS error code. + when (e.errorCode) { + LibeufinErrorCode.LIBEUFIN_EC_INVALID_STATE -> throw EbicsProcessingError("Invalid bank state.") + LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE -> throw EbicsProcessingError("Inconsistent bank state.") + else -> throw EbicsProcessingError("Unknown Libeufin error code: ${e.errorCode}.") + } + } + catch (e: EbicsNoDownloadDataAvailable) { + respondEbicsTransfer(call, e.errorText, e.errorCode) + } + catch (e: EbicsRequestError) { + /** + * Preventing the last catch-all block from handling + * a known error type. Rethrowing here to let the top-level + * handler take action. + */ + throw e + } + catch (e: Exception) { + logger.error(e.stackTraceToString()) + throw EbicsProcessingError(e.message) + } + return@post + } + + /** + * Create a new demobank instance with a particular currency, + * debt limit and possibly other configuration + * (could also be a CLI command for now) + */ + post("/demobanks") { + throw NotImplementedError("Feature only available at the libeufin-sandbox CLI") + } + + get("/demobanks") { + expectAdmin(call.request.basicAuth()) + val ret = object { val demoBanks = mutableListOf() } + transaction { + DemobankConfigEntity.all().forEach { + ret.demoBanks.add(getJsonFromDemobankConfig(it)) + } + } + call.respond(ret) + return@get + } + + get("/demobanks/{demobankid}") { + val demobank = ensureDemobank(call) + expectAdmin(call.request.basicAuth()) + call.respond(getJsonFromDemobankConfig(demobank)) + return@get + } + + route("/demobanks/{demobankid}") { + // NOTE: TWG assumes that username == bank account label. + route("/taler-wire-gateway") { + post("/{exchangeUsername}/admin/add-incoming") { + val username = call.expectUriComponent("exchangeUsername") + val usernameAuth = call.request.basicAuth() + if (username != usernameAuth) + throw forbidden("Bank account name and username differ: $username vs $usernameAuth") + logger.debug("TWG add-incoming passed authentication") + val body = try { call.receive() } + catch (e: Exception) { + logger.error("/admin/add-incoming failed at parsing the request body") + throw SandboxError( + HttpStatusCode.BadRequest, + "Invalid request" + ) + } + val singletonTx = transaction { + val demobank = ensureDemobank(call) + val bankAccountCredit = getBankAccountFromLabel(username, demobank) + if (bankAccountCredit.owner != username) throw forbidden( + "User '$username' cannot access bank account with label: $username." + ) + val bankAccountDebit = getBankAccountFromPayto(body.debit_account) + logger.debug("TWG add-incoming about to wire transfer") + val ref = wireTransfer( + bankAccountDebit.label, + bankAccountCredit.label, + demobank.name, + body.reserve_pub, + body.amount + ) + /** + * The remaining part aims at returning an x-libeufin-bank-formatted + * message to Nexus, to let it ingest the (incoming side of the) payment + * information. The format choice makes it more practical for Nexus, + * because it handles this format already for the x-libeufin-bank connection + * type. + */ + val incomingTx = BankAccountTransactionEntity.find { + BankAccountTransactionsTable.accountServicerReference eq ref and ( + BankAccountTransactionsTable.direction eq "CRDT" + ) // closes the 'and'. + }.firstOrNull() + if (incomingTx == null) + throw internalServerError("Just created transaction not found in DB. AcctSvcrRef: $ref") + val incomingHistoryElement = getHistoryElementFromTransactionRow(incomingTx) + logger.debug("TWG add-incoming has wire transferred, AcctSvcrRef: $ref") + incomingHistoryElement + } + val resp = object { + val transactions = listOf(singletonTx) + } + call.respond(resp) + return@post + } + } + // Talk to wallets. + route("/integration-api") { + get("/config") { + val demobank = ensureDemobank(call) + call.respond(SandboxConfig( + name = "taler-bank-integration", + version = PROTOCOL_VERSION_UNIFIED, + currency = demobank.config.currency + )) + return@get + } + post("/withdrawal-operation/{wopid}") { + val arg = ensureNonNull(call.parameters["wopid"]) + val withdrawalUuid = parseUuid(arg) + val body = call.receive() + val transferDone = transaction { + val wo = TalerWithdrawalEntity.find { + TalerWithdrawalsTable.wopid eq withdrawalUuid + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, "Withdrawal operation $withdrawalUuid not found." + ) + if (wo.confirmationDone) { + return@transaction true + } + if (wo.selectionDone) { + if (body.reserve_pub != wo.reservePub) throw SandboxError( + HttpStatusCode.Conflict, + "Selecting a different reserve from the one already selected" + ) + if (body.selected_exchange != wo.selectedExchangePayto) throw SandboxError( + HttpStatusCode.Conflict, + "Selecting a different exchange from the one already selected" + ) + return@transaction false + } + // Flow here means never selected, hence must as well never be paid. + if (wo.confirmationDone) throw internalServerError( + "Withdrawal ${wo.wopid} knew NO exchange and reserve pub, " + + "but is marked as paid!" + ) + wo.reservePub = body.reserve_pub + wo.selectedExchangePayto = body.selected_exchange + wo.selectionDone = true + false + } + call.respond(object { + val transfer_done: Boolean = transferDone + }) + return@post + } + get("/withdrawal-operation/{wopid}") { + val arg = ensureNonNull(call.parameters["wopid"]) + val maybeWithdrawalUuid = parseUuid(arg) + val maybeWithdrawalOp = transaction { + TalerWithdrawalEntity.find { + TalerWithdrawalsTable.wopid eq maybeWithdrawalUuid + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, + "Withdrawal operation: $arg not found" + ) + } + val demobank = ensureDemobank(call) + val captchaPage: String? = demobank.config.captchaUrl?.replace("{wopid}",arg) + if (captchaPage == null) + throw internalServerError("demobank ${demobank.name} lacks the CAPTCHA URL from the configuration.") + val ret = TalerWithdrawalStatus( + selection_done = maybeWithdrawalOp.selectionDone, + transfer_done = maybeWithdrawalOp.confirmationDone, + amount = maybeWithdrawalOp.amount, + suggested_exchange = demobank.config.suggestedExchangeBaseUrl, + aborted = maybeWithdrawalOp.aborted, + confirm_transfer_url = captchaPage + ) + call.respond(ret) + return@get + } + } + route("/circuit-api") { + circuitApi(this) + } + // Talk to Web UI. + route("/access-api") { + post("/accounts/{account_name}/transactions") { + val username = call.request.basicAuth() + val demobank = ensureDemobank(call) + val bankAccount = getBankAccountFromLabel( + call.expectUriComponent("account_name"), + demobank + ) + // note: admin has no rights to create transactions on non-admin accounts. + val authGranted: Boolean = !WITH_AUTH + if (!authGranted && username != bankAccount.label) + throw unauthorized("Username '$username' has no rights over bank account ${bankAccount.label}") + val req = call.receive() + val payto = parsePayto(req.paytoUri) + val amount: String? = payto.amount ?: req.amount + if (amount == null) throw badRequest("Amount is missing") + /** + * The transaction block below lets the 'demoBank' field + * of 'bankAccount' be correctly accessed. */ + transaction { + wireTransfer( + debitAccount = bankAccount.label, + creditAccount = getBankAccountFromIban(payto.iban).label, + demobank = bankAccount.demoBank.name, + subject = payto.message ?: throw badRequest( + "'message' query parameter missing in Payto address" + ), + amount = amount, + pmtInfId = req.pmtInfId + ) + } + call.respond(object {}) + return@post + } + // Information about one withdrawal. + get("/accounts/{account_name}/withdrawals/{withdrawal_id}") { + getWithdrawal(call) + return@get + } + // account-less style: + get("/withdrawals/{withdrawal_id}") { + getWithdrawal(call) + return@get + } + // Create a new withdrawal operation. + post("/accounts/{account_name}/withdrawals") { + var username = call.request.basicAuth() + val demobank = ensureDemobank(call) + /** + * Check here if the user has the right over the claimed bank account. After + * this check, the withdrawal operation will be allowed only by providing its + * UID. */ + val maybeOwnedAccount = getBankAccountFromLabel( + call.expectUriComponent("account_name"), + demobank + ) + val authGranted = !WITH_AUTH // note: admin not allowed on non-admin accounts + if (!authGranted && maybeOwnedAccount.owner != username) + throw unauthorized("Customer '$username' has no rights over bank account '${maybeOwnedAccount.label}'") + val req = call.receive() + // Check for currency consistency + val amount = parseAmount(req.amount) + if (amount.currency != demobank.config.currency) + throw badRequest("Currency ${amount.currency} differs from Demobank's: ${demobank.config.currency}") + // Check funds are sufficient. + if ( + maybeDebit( + maybeOwnedAccount.label, + BigDecimal(amount.amount), + transaction { maybeOwnedAccount.demoBank.name } + )) { + logger.error("Account ${maybeOwnedAccount.label} would surpass debit threshold. Not withdrawing") + throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") + } + val wo: TalerWithdrawalEntity = transaction { + TalerWithdrawalEntity.new { + this.amount = req.amount + walletBankAccount = maybeOwnedAccount + } + } + val baseUrl = URL(call.request.getBaseUrl()) + val withdrawUri = url { + protocol = URLProtocol( + name = "taler".plus(if (baseUrl.protocol.lowercase() == "http") "+http" else ""), + defaultPort = -1 + ) + host = "withdraw" + val pathSegments = mutableListOf( + /** + * encodes the hostname(+port) of the actual + * bank that will serve the withdrawal request. + */ + baseUrl.host.plus( + if (baseUrl.port != -1) + ":${baseUrl.port}" + else "" + ) + ) + /** + * Slashes can only be intermediate and single, + * any other combination results in badly formed URIs. + * The following loop ensure this for the current URI path. + * This might even come from X-Forwarded-Prefix. + */ + baseUrl.path.split("/").forEach { + if (it.isNotEmpty()) pathSegments.add(it) + } + pathSegments.add("demobanks/${demobank.name}/integration-api/${wo.wopid}") + this.appendPathSegments(pathSegments) + } + call.respond(object { + val withdrawal_id = wo.wopid.toString() + val taler_withdraw_uri = withdrawUri + }) + return@post + } + // Confirm a withdrawal: no basic auth, because the ID should be unguessable. + post("/accounts/{account_name}/withdrawals/{withdrawal_id}/confirm") { + confirmWithdrawal(call) + return@post + } + // account-less style: + post("/withdrawals/{withdrawal_id}/confirm") { + confirmWithdrawal(call) + return@post + } + // Aborting withdrawals: + post("/accounts/{account_name}/withdrawals/{withdrawal_id}/abort") { + abortWithdrawal(call) + return@post + } + // account-less style: + post("/withdrawals/{withdrawal_id}/abort") { + abortWithdrawal(call) + return@post + } + // Bank account basic information. + get("/accounts/{account_name}") { + val username = call.request.basicAuth() + val accountAccessed = call.expectUriComponent("account_name") + val demobank = ensureDemobank(call) + val bankAccount = getBankAccountFromLabel(accountAccessed, demobank) + val authGranted = !WITH_AUTH || bankAccount.isPublic || username == "admin" + if (!authGranted && bankAccount.owner != username) + throw forbidden("Customer '$username' cannot access bank account '$accountAccessed'") + val balance = getBalance(bankAccount) + logger.debug("Balance of '$username': ${balance.toPlainString()}") + call.respond(object { + val balance = object { + val amount = "${demobank.config.currency}:${balance.abs().toPlainString()}" + val credit_debit_indicator = if (balance < BigDecimal.ZERO) "debit" else "credit" + } + val paytoUri = buildIbanPaytoUri( + iban = bankAccount.iban, + bic = bankAccount.bic, + // username 'null' should only happen when auth is disabled. + receiverName = getPersonNameFromCustomer(bankAccount.owner) + ) + val iban = bankAccount.iban + // The Elvis operator helps the --no-auth case, + // where username would be empty + val debitThreshold = getMaxDebitForUser( + username = username ?: "admin", + demobankName = demobank.name + ).toString() + }) + return@get + } + get("/accounts/{account_name}/transactions/{tId}") { + val username = call.request.basicAuth() + val demobank = ensureDemobank(call) + val bankAccount = getBankAccountFromLabel( + call.expectUriComponent("account_name"), + demobank + ) + val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" + if (!authGranted && username != bankAccount.owner) + throw forbidden("Cannot access bank account ${bankAccount.label}") + val tId = call.parameters["tId"] ?: throw badRequest("URI didn't contain the transaction ID") + val tx: BankAccountTransactionEntity? = transaction { + BankAccountTransactionEntity.find { + BankAccountTransactionsTable.accountServicerReference eq tId + }.firstOrNull() + } + if (tx == null) throw notFound("Transaction $tId wasn't found") + call.respond(getHistoryElementFromTransactionRow(tx)) + return@get + } + get("/accounts/{account_name}/transactions") { + val username = call.request.basicAuth() + val demobank = ensureDemobank(call) + val bankAccount = getBankAccountFromLabel( + call.expectUriComponent("account_name"), + demobank + ) + val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" + if (!authGranted && bankAccount.owner != username) + throw forbidden("Cannot access bank account ${bankAccount.label}") + // Paging values. + val page: Int = expectInt(call.request.queryParameters["page"] ?: "1") + if (page < 1) throw badRequest("'page' param is less than 1") + val size: Int = expectInt(call.request.queryParameters["size"] ?: "5") + if (size < 1) throw badRequest("'size' param is less than 1") + // Time range filter values + val fromMs: Long = expectLong(call.request.queryParameters["from_ms"] ?: "0") + if (fromMs < 0) throw badRequest("'from_ms' param is less than 0") + val untilMs: Long = expectLong(call.request.queryParameters["until_ms"] ?: Long.MAX_VALUE.toString()) + if (untilMs < 0) throw badRequest("'until_ms' param is less than 0") + val longPollMs: Long? = call.maybeLong("long_poll_ms") + // LISTEN, if Postgres. + val listenHandle = if (isPostgres() && longPollMs != null) { + val channelName = buildChannelName( + NotificationsChannelDomains.LIBEUFIN_REGIO_TX, + call.expectUriComponent("account_name") + ) + val listenHandle = PostgresListenHandle(channelName) + // Can't LISTEN on the same DB TX that checks for data, as Exposed + // closes that connection and the notification getter would fail. + // Can't invoke the notification getter in the same DB TX either, + // as it would block the DB. + listenHandle.postgresListen() + listenHandle + } else null + val historyParams = HistoryParams( + pageNumber = page, + pageSize = size, + bankAccount = bankAccount, + fromMs = fromMs, + untilMs = untilMs + ) + var ret: List = transaction { + extractTxHistory(historyParams) + } + logger.debug("Is payment data empty? ${ret.isEmpty()}") + // Data was found already, UNLISTEN and respond. + if (listenHandle != null && ret.isNotEmpty()) { + logger.debug("No need to wait DB events, payment data found.") + listenHandle.postgresUnlisten() + call.respond(object {val transactions = ret}) + return@get + } + // No data was found, sleep until the timeout or getting woken up. + // Third condition only silences the compiler. + if (listenHandle != null && longPollMs != null) { + logger.debug("Waiting DB event for new payment data.") + val notificationArrived = listenHandle.waitOnIODispatchers(longPollMs) + // Only if the awaited event fired, query again the DB. + if (notificationArrived) + { + ret = transaction { + // Refreshing to update the index to the very last transaction. + historyParams.bankAccount.refresh() + extractTxHistory(historyParams) + } + } + } + call.respond(object {val transactions = ret}) + return@get + } + get("/public-accounts") { + val demobank = ensureDemobank(call) + val ret = object { + val publicAccounts = mutableListOf() + } + transaction { + BankAccountEntity.find { + BankAccountsTable.isPublic eq true and( + BankAccountsTable.demoBank eq demobank.id + ) + }.forEach { + val balanceIter = getBalance(it) + ret.publicAccounts.add( + PublicAccountInfo( + balance = "${demobank.config.currency}:$balanceIter", + iban = it.iban, + accountLabel = it.label + ) + ) + } + } + call.respond(ret) + return@get + } + delete("accounts/{account_name}") { + val username = call.request.basicAuth() + val demobank = ensureDemobank(call) + val authGranted = !WITH_AUTH || username == "admin" + val bankAccountLabel = call.expectUriComponent("account_name") + /** + * This helper fails if the demobank that is mentioned in the URI + * is not hosting the account to be deleted. + */ + val bankAccount = getBankAccountFromLabel( + bankAccountLabel, + demobank + ) + if (!authGranted && username != bankAccount.owner) + throw unauthorized("User '$username' has no rights to delete bank account '$bankAccountLabel'") + transaction { + val customerAccount = getCustomer(bankAccount.owner) + bankAccount.delete() + customerAccount.delete() + } + call.respond(object {}) + return@delete + } + // Keeping the prefix "testing" not to break tests. + post("/testing/register") { + // Check demobank was created. + val demobank = ensureDemobank(call) + if (!demobank.config.allowRegistrations) { + throw SandboxError( + HttpStatusCode.UnprocessableEntity, + "The bank doesn't allow new registrations at the moment." + ) + } + val req = call.receive() + val newAccount = insertNewAccount( + req.username, + req.password, + name = req.name, + iban = req.iban, + demobank = demobank.name, + isPublic = req.isPublic + ) + val balance = getBalance(newAccount.bankAccount) + call.respond(object { + val balance = getBalanceForJson(balance, demobank.config.currency) + val paytoUri = buildIbanPaytoUri( + iban = newAccount.bankAccount.iban, + bic = newAccount.bankAccount.bic, + receiverName = getPersonNameFromCustomer(req.username) + ) + val iban = newAccount.bankAccount.iban + val debitThreshold = getMaxDebitForUser( + req.username, + demobank.name + ).toString() + }) + return@post + } + } + route("/ebics") { + /** + * Associate an existing bank account to one EBICS subscriber. + * If the subscriber is not found, it is created. + */ + post("/subscribers") { + // Only the admin can create Ebics subscribers. + val user = call.request.basicAuth() + if (WITH_AUTH && (user != "admin")) throw forbidden("Only the Administrator can create Ebics subscribers.") + val body = call.receive() + // Create or get the Ebics subscriber that is found. + transaction { + // Check that host ID exists + EbicsHostEntity.find { + EbicsHostsTable.hostID eq body.hostID + }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") + val subscriber: EbicsSubscriberEntity = EbicsSubscriberEntity.find { + (EbicsSubscribersTable.partnerId eq body.partnerID).and( + EbicsSubscribersTable.userId eq body.userID + ).and(EbicsSubscribersTable.hostId eq body.hostID) + }.firstOrNull() ?: EbicsSubscriberEntity.new { + partnerId = body.partnerID + userId = body.userID + systemId = null + hostId = body.hostID + state = SubscriberState.NEW + nextOrderID = 1 + } + val bankAccount = getBankAccountFromLabel( + body.demobankAccountLabel, + ensureDemobank(call) + ) + subscriber.bankAccount = bankAccount + } + call.respond(object {}) + return@post + } + } + } + } +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt b/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt new file mode 100644 index 00000000..f76ad942 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt @@ -0,0 +1,70 @@ +package tech.libeufin.sandbox + +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.serialization.* +import io.ktor.util.reflect.* +import io.ktor.utils.io.* +import io.ktor.utils.io.charsets.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import tech.libeufin.util.XMLUtil + +class XMLEbicsConverter : ContentConverter { + override suspend fun deserialize( + charset: Charset, + typeInfo: TypeInfo, + content: ByteReadChannel + ): Any { + return withContext(Dispatchers.IO) { + try { + receiveEbicsXmlInternal(content.toInputStream().reader().readText()) + } catch (e: Exception) { + throw SandboxError( + HttpStatusCode.BadRequest, + "Document is invalid XML." + ) + } + } + } + + // The following annotation was suggested by Intellij. + @Deprecated( + "Please override and use serializeNullable instead", + replaceWith = ReplaceWith("serializeNullable(charset, typeInfo, contentType, value)"), + level = DeprecationLevel.WARNING + ) + override suspend fun serialize( + contentType: ContentType, + charset: Charset, + typeInfo: TypeInfo, + value: Any + ): OutgoingContent? { + return super.serializeNullable(contentType, charset, typeInfo, value) + } + + override suspend fun serializeNullable( + contentType: ContentType, + charset: Charset, + typeInfo: TypeInfo, + value: Any? + ): OutgoingContent? { + val conv = try { + XMLUtil.convertJaxbToString(value) + } catch (e: Exception) { + /** + * Not always an error: the content negotiation might have + * only checked if this handler could convert the response. + */ + return null + } + return OutputStreamContent({ + val out = this; + withContext(Dispatchers.IO) { + out.write(conv.toByteArray()) + }}, + contentType.withCharset(charset) + ) + } +} \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt new file mode 100644 index 00000000..d82a0eb4 --- /dev/null +++ b/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt @@ -0,0 +1,276 @@ +package tech.libeufin.sandbox + +import io.ktor.http.* +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.util.* +import java.math.BigDecimal + +/** + * Check whether the given bank account would surpass the + * debit threshold, in case the potential amount gets transferred. + * Returns true when the debit WOULD be surpassed. */ +fun maybeDebit( + accountLabel: String, + requestedAmount: BigDecimal, + demobankName: String = "default" +): Boolean { + val demobank = getDemobank(demobankName) ?: throw notFound( + "Demobank '${demobankName}' not found when trying to check the debit threshold" + + " for user $accountLabel" + ) + val balance = getBalance(accountLabel, demobankName) + val maxDebt = if (accountLabel == "admin") { + demobank.config.bankDebtLimit + } else demobank.config.usersDebtLimit + val balanceCheck = balance - requestedAmount + if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal.valueOf(maxDebt.toLong())) { + logger.warn("User '$accountLabel' would surpass the debit" + + " threshold of $maxDebt, given the requested amount of ${requestedAmount.toPlainString()}") + return true + } + return false +} + +fun getMaxDebitForUser( + username: String, + demobankName: String = "default" +): Int { + val bank = getDemobank(demobankName) ?: throw internalServerError( + "demobank $demobankName not found" + ) + if (username == "admin") return bank.config.bankDebtLimit + return bank.config.usersDebtLimit +} + +fun getBalanceForJson(value: BigDecimal, currency: String): BalanceJson { + return BalanceJson( + amount = "${currency}:${value.abs()}", + credit_debit_indicator = if (value < BigDecimal.ZERO) "debit" else "credit" + ) +} + +fun getBalance(bankAccount: BankAccountEntity): BigDecimal { + return BigDecimal(bankAccount.balance) +} + +/** + * This function balances _in bank account statements_. A statement + * witnesses the bank account after a given business time slot. Therefore + * _this_ type of balance is not guaranteed to hold the _actual_ and + * more up-to-date bank account. It'll be used when Sandbox will support + * the issuing of bank statement. + */ +fun getBalanceForStatement( + bankAccount: BankAccountEntity, + withPending: Boolean = true +): BigDecimal { + val lastStatement = transaction { + BankAccountStatementEntity.find { + BankAccountStatementsTable.bankAccount eq bankAccount.id + }.lastOrNull() + } + var lastBalance = if (lastStatement == null) { + BigDecimal.ZERO + } else { BigDecimal(lastStatement.balanceClbd) } + if (!withPending) return lastBalance + /** + * Caller asks to include the pending transactions in the + * balance. The block below gets the transactions happened + * later than the last statement and adds them to the balance + * that was calculated so far. + */ + transaction { + val pendingTransactions = BankAccountTransactionEntity.find { + BankAccountTransactionsTable.account eq bankAccount.id and ( + BankAccountTransactionsTable.date.greater(lastStatement?.creationTime ?: 0L)) + } + pendingTransactions.forEach { tx -> + when (tx.direction) { + "DBIT" -> lastBalance -= parseDecimal(tx.amount) + "CRDT" -> lastBalance += parseDecimal(tx.amount) + else -> { + logger.error("Transaction ${tx.id} is neither debit nor credit.") + throw SandboxError( + HttpStatusCode.InternalServerError, + "Error in transactions state." + ) + } + } + } + } + return lastBalance +} + +// Gets the balance of 'accountLabel', which is hosted at 'demobankName'. +fun getBalance(accountLabel: String, + demobankName: String = "default" +): BigDecimal { + val demobank = getDemobank(demobankName) ?: throw SandboxError( + HttpStatusCode.InternalServerError, + "Demobank '$demobankName' not found" + ) + + /** + * Setting withBankFault to true for the following reason: + * when asking for a balance, the bank should have made sure + * that the user has a bank account (together with a customer profile). + * If that's not the case, it's bank's fault, since it didn't check + * earlier. + */ + val account = getBankAccountFromLabel( + accountLabel, + demobank, + withBankFault = true + ) + return getBalance(account) +} + +/** + * 'debitAccount' and 'creditAccount' are customer usernames + * and ALSO labels of the bank accounts owned by them. They are + * used to both resort a bank account and the legal name owning + * the bank accounts. + */ +fun wireTransfer( + debitAccount: String, + creditAccount: String, + demobank: String = "default", + subject: String, + amount: String, // $currency:x.y + pmtInfId: String? = null, + endToEndId: String? = null +): String { + logger.debug("Maybe wire transfer (endToEndId: $endToEndId): $debitAccount -> $creditAccount, $subject, $amount") + return transaction { + val demobankDb = ensureDemobank(demobank) + val debitAccountDb = getBankAccountFromLabel(debitAccount, demobankDb) + val creditAccountDb = getBankAccountFromLabel(creditAccount, demobankDb) + val parsedAmount = parseAmount(amount) + // Potential amount to transfer. + val amountAsNumber = BigDecimal(parsedAmount.amount) + if (amountAsNumber == BigDecimal.ZERO) + throw badRequest("Wire transfers of zero not possible.") + if (parsedAmount.currency != demobankDb.config.currency) + throw badRequest( + "Won't wire transfer with currency: ${parsedAmount.currency}." + + " Only ${demobankDb.config.currency} allowed." + ) + // Check funds are sufficient. + if ( + maybeDebit( + debitAccountDb.label, + amountAsNumber, + demobankDb.name + )) { + logger.error("Account ${debitAccountDb.label} would surpass debit threshold. Rollback wire transfer") + throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") + } + val timeStamp = getNowMillis() + val transactionRef = getRandomString(8) + BankAccountTransactionEntity.new { + creditorIban = creditAccountDb.iban + creditorBic = creditAccountDb.bic + this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) + debtorIban = debitAccountDb.iban + debtorBic = debitAccountDb.bic + debtorName = getPersonNameFromCustomer(debitAccountDb.owner) + this.subject = subject + this.amount = parsedAmount.amount + this.currency = demobankDb.config.currency + date = timeStamp + accountServicerReference = transactionRef + account = creditAccountDb + direction = "CRDT" + this.demobank = demobankDb + this.pmtInfId = pmtInfId + } + BankAccountTransactionEntity.new { + creditorIban = creditAccountDb.iban + creditorBic = creditAccountDb.bic + this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) + debtorIban = debitAccountDb.iban + debtorBic = debitAccountDb.bic + debtorName = getPersonNameFromCustomer(debitAccountDb.owner) + this.subject = subject + this.amount = parsedAmount.amount + this.currency = demobankDb.config.currency + date = timeStamp + accountServicerReference = transactionRef + account = debitAccountDb + direction = "DBIT" + this.demobank = demobankDb + this.pmtInfId = pmtInfId + this.endToEndId = endToEndId + } + + // Adjusting the balances (acceptable debit conditions checked before). + // Debit: + val newDebitBalance = (BigDecimal(debitAccountDb.balance) - amountAsNumber).roundToTwoDigits() + debitAccountDb.balance = newDebitBalance.toPlainString() + // Credit: + val newCreditBalance = (BigDecimal(creditAccountDb.balance) + amountAsNumber).roundToTwoDigits() + creditAccountDb.balance = newCreditBalance.toPlainString() + + // Signaling this wire transfer's event. + if (this.isPostgres()) { + val creditChannel = buildChannelName( + NotificationsChannelDomains.LIBEUFIN_REGIO_TX, + creditAccountDb.label + ) + this.postgresNotify(creditChannel, "CRDT") + val debitChannel = buildChannelName( + NotificationsChannelDomains.LIBEUFIN_REGIO_TX, + debitAccountDb.label + ) + this.postgresNotify(debitChannel, "DBIT") + } + transactionRef + } +} + +/** + * Helper that constructs a transactions history page + * according to the URI parameters passed to Access API's + * GET /transactions. + */ +data class HistoryParams( + val pageNumber: Int, + val pageSize: Int, + val fromMs: Long, + val untilMs: Long, + val bankAccount: BankAccountEntity +) + +fun extractTxHistory(params: HistoryParams): List { + val ret = mutableListOf() + + /** + * Helper that gets transactions earlier than the 'firstElementId' + * transaction AND that match the URI parameters. + */ + fun getPage(firstElementId: Long): Iterable { + return BankAccountTransactionEntity.find { + (BankAccountTransactionsTable.id lessEq firstElementId) and + (BankAccountTransactionsTable.account eq params.bankAccount.id) and + (BankAccountTransactionsTable.date.between(params.fromMs, params.untilMs)) + }.sortedByDescending { it.id.value }.take(params.pageSize) + } + // Gets a pointer to the last transaction of this bank account. + val lastTransaction: BankAccountTransactionEntity? = params.bankAccount.lastTransaction + if (lastTransaction == null) return ret + var nextPageIdUpperLimit: Long = lastTransaction.id.value + + // This loop fetches (and discards) pages until the desired one is found. + for (i in 1..(params.pageNumber)) { + val pageBuf = getPage(nextPageIdUpperLimit) + logger.debug("pageBuf #$i follows. Request wants #${params.pageNumber}:") + pageBuf.forEach { logger.debug("ID: ${it.id}, subject: ${it.subject}, amount: ${it.currency}:${it.amount}") } + if (pageBuf.none()) return ret + nextPageIdUpperLimit = pageBuf.last().id.value - 1 + if (i == params.pageNumber) pageBuf.forEach { + ret.add(getHistoryElementFromTransactionRow(it)) + } + } + return ret +} \ No newline at end of file diff --git a/bank/src/main/resources/logback.xml b/bank/src/main/resources/logback.xml new file mode 100644 index 00000000..cefb7182 --- /dev/null +++ b/bank/src/main/resources/logback.xml @@ -0,0 +1,24 @@ + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + diff --git a/bank/src/main/resources/static/README.txt b/bank/src/main/resources/static/README.txt new file mode 100644 index 00000000..beab625a --- /dev/null +++ b/bank/src/main/resources/static/README.txt @@ -0,0 +1 @@ +The spa.html file is generated from merchant-backoffice.git -> /packages/bank/ \ No newline at end of file diff --git a/bank/src/test/kotlin/BalanceTest.kt b/bank/src/test/kotlin/BalanceTest.kt new file mode 100644 index 00000000..eb09cc64 --- /dev/null +++ b/bank/src/test/kotlin/BalanceTest.kt @@ -0,0 +1,115 @@ +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.Test +import tech.libeufin.sandbox.* +import tech.libeufin.util.millis +import tech.libeufin.util.roundToTwoDigits +import java.math.BigDecimal +import java.time.LocalDateTime + +class BalanceTest { + @Test + fun balanceTest() { + val config = DemobankConfig( + currency = "EUR", + bankDebtLimit = 1000000, + usersDebtLimit = 10000, + allowRegistrations = true, + demobankName = "default", + withSignupBonus = false + ) + withTestDatabase { + transaction { + insertConfigPairs(config) + val demobank = DemobankConfigEntity.new { + name = "default" + } + val one = BankAccountEntity.new { + iban = "IBAN 1" + bic = "BIC" + label = "label 1" + owner = "admin" + this.demoBank = demobank + } + val other = BankAccountEntity.new { + iban = "IBAN 2" + bic = "BIC" + label = "label 2" + owner = "admin" + this.demoBank = demobank + } + BankAccountTransactionEntity.new { + account = one + creditorIban = "earns" + creditorBic = "BIC" + creditorName = "Creditor Name" + debtorIban = "spends" + debtorBic = "BIC" + debtorName = "Debitor Name" + subject = "deal" + amount = "1" + date = LocalDateTime.now().millis() + currency = "EUR" + pmtInfId = "0" + direction = "CRDT" + accountServicerReference = "test-account-servicer-reference" + this.demobank = demobank + } + BankAccountTransactionEntity.new { + account = one + creditorIban = "earns" + creditorBic = "BIC" + creditorName = "Creditor Name" + debtorIban = "spends" + debtorBic = "BIC" + debtorName = "Debitor Name" + subject = "deal" + amount = "1" + date = LocalDateTime.now().millis() + currency = "EUR" + pmtInfId = "0" + direction = "CRDT" + accountServicerReference = "test-account-servicer-reference" + this.demobank = demobank + } + BankAccountTransactionEntity.new { + account = one + creditorIban = "earns" + creditorBic = "BIC" + creditorName = "Creditor Name" + debtorIban = "spends" + debtorBic = "BIC" + debtorName = "Debitor Name" + subject = "deal" + amount = "1" + date = LocalDateTime.now().millis() + currency = "EUR" + pmtInfId = "0" + direction = "DBIT" + accountServicerReference = "test-account-servicer-reference" + this.demobank = demobank + } + wireTransfer( + other.label, one.label, demobank.name, "one gets 1", "EUR:1" + ) + wireTransfer( + other.label, one.label, demobank.name, "one gets another 1", "EUR:1" + ) + wireTransfer( + one.label, other.label, demobank.name, "one gives 1", "EUR:1" + ) + val maybeOneBalance: BigDecimal = getBalance(one) + println(maybeOneBalance) + assert(BigDecimal.ONE.roundToTwoDigits() == maybeOneBalance.roundToTwoDigits()) + } + } + } + @Test + fun balanceAbsTest() { + val minus = BigDecimal.ZERO - BigDecimal.ONE + val plus = BigDecimal.ONE + println(minus.abs().toPlainString()) + println(plus.abs().toPlainString()) + } +} diff --git a/bank/src/test/kotlin/DBTest.kt b/bank/src/test/kotlin/DBTest.kt new file mode 100644 index 00000000..bc5a33c5 --- /dev/null +++ b/bank/src/test/kotlin/DBTest.kt @@ -0,0 +1,152 @@ +/* + * 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 + * + */ + +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.Test +import tech.libeufin.sandbox.* +import tech.libeufin.util.connectWithSchema +import tech.libeufin.util.getCurrentUser +import tech.libeufin.util.getJdbcConnectionFromPg +import tech.libeufin.util.millis +import java.io.File +import java.time.LocalDateTime +import kotlin.reflect.KProperty +import kotlin.reflect.typeOf + +/** + * Run a block after connecting to the test database. + * Cleans up the DB file afterwards. + */ +fun withTestDatabase(f: () -> Unit) { + dbDropTables("postgresql:///libeufincheck") + dbCreateTables("postgresql:///libeufincheck") + f() +} + +class DBTest { + private var config = DemobankConfig( + currency = "EUR", + bankDebtLimit = 1000000, + usersDebtLimit = 10000, + allowRegistrations = true, + demobankName = "default", + withSignupBonus = false, + ) + + /** + * This tests the conversion from a Postgres connection + * string to a JDBC one. + */ + @Test + fun connectionStringTest() { + getJdbcConnectionFromPg("postgres://auditor-basedb") + var conv = getJdbcConnectionFromPg("postgresql:///libeufincheck") + connectWithSchema(getJdbcConnectionFromPg("postgres:///libeufincheck")) + connectWithSchema(conv) + conv = getJdbcConnectionFromPg("postgresql://localhost:5432/libeufincheck?user=${System.getProperty("user.name")}") + connectWithSchema(conv) + conv = getJdbcConnectionFromPg("postgresql:///libeufincheck?host=/tmp/libeufin") + var exception: Exception? = null + try { + connectWithSchema(conv) + } catch (e: Exception) { + exception = e + } + assert(exception is UtilError) + } + + /** + * Storing configuration values into the database, + * then extract them and check that they equal the + * configuration model object. + */ + @Test + fun insertPairsTest() { + withTestDatabase { + // Config model. + val config = DemobankConfig( + currency = "EUR", + bankDebtLimit = 1, + usersDebtLimit = 2, + allowRegistrations = true, + demobankName = "default", + withSignupBonus = true + ) + transaction { + DemobankConfigEntity.new { name = "default" } + insertConfigPairs(config) + val db = getDefaultDemobank() + /** + * db.config extracts config values from the database + * and puts them in a fresh config model object. + */ + assert(config.hashCode() == db.config.hashCode()) + } + } + } + + @Test + fun betweenDates() { + withTestDatabase { + transaction { + insertConfigPairs(config) + val demobank = DemobankConfigEntity.new { + name = "default" + } + val bankAccount = BankAccountEntity.new { + iban = "iban" + bic = "bic" + label = "label" + owner = "test" + demoBank = demobank + } + BankAccountTransactionEntity.new { + account = bankAccount + creditorIban = "earns" + creditorBic = "BIC" + creditorName = "Creditor Name" + debtorIban = "spends" + debtorBic = "BIC" + debtorName = "Debitor Name" + subject = "deal" + amount = "EUR:1" + date = LocalDateTime.now().millis() + currency = "EUR" + pmtInfId = "0" + direction = "DBIT" + accountServicerReference = "test-account-servicer-reference" + this.demobank = demobank + } + } + // The block below tests the date range in the database query + transaction { + addLogger(StdOutSqlLogger) + BankAccountTransactionEntity.find { + BankAccountTransactionsTable.date.between( + 0, // 1970-01-01 + LocalDateTime.now().millis() // + ) + }.apply { + assert(this.count() == 1L) + } + } + } + } +} \ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt new file mode 100644 index 00000000..7716cd26 --- /dev/null +++ b/bank/src/test/kotlin/DatabaseTest.kt @@ -0,0 +1,261 @@ +import org.junit.Test +import tech.libeufin.sandbox.* +import tech.libeufin.util.execCommand +import java.util.UUID + +class DatabaseTest { + private val customerFoo = Customer( + login = "foo", + passwordHash = "hash", + name = "Foo", + phone = "+00", + email = "foo@b.ar", + cashoutPayto = "payto://external-IBAN", + cashoutCurrency = "KUDOS" + ) + private val customerBar = Customer( + login = "bar", + passwordHash = "hash", + name = "Bar", + phone = "+00", + email = "foo@b.ar", + cashoutPayto = "payto://external-IBAN", + cashoutCurrency = "KUDOS" + ) + private val bankAccountFoo = BankAccount( + iban = "FOO-IBAN-XYZ", + bic = "FOO-BIC", + bankAccountLabel = "foo", + lastNexusFetchRowId = 1L, + owningCustomerId = 1L, + hasDebt = false + ) + private val bankAccountBar = BankAccount( + iban = "BAR-IBAN-ABC", + bic = "BAR-BIC", + bankAccountLabel = "bar", + lastNexusFetchRowId = 1L, + owningCustomerId = 2L, + hasDebt = false + ) + + fun initDb(): Database { + execCommand( + listOf( + "libeufin-bank-dbinit", + "-d", + "libeufincheck", + "-r" + ), + throwIfFails = true + ) + val db = Database("jdbc:postgresql:///libeufincheck") + return db + } + + @Test + fun bankTransactionsTest() { + val db = initDb() + assert(db.customerCreate(customerFoo)) + assert(db.customerCreate(customerBar)) + assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountBar)) + var fooAccount = db.bankAccountGetFromLabel("foo") + assert(fooAccount?.hasDebt == false) // Foo has NO debit. + // Preparing the payment data. + db.bankAccountSetMaxDebt( + "foo", + TalerAmount(100, 0) + ) + db.bankAccountSetMaxDebt( + "bar", + TalerAmount(50, 0) + ) + val fooPaysBar = BankInternalTransaction( + creditorAccountId = 2, + debtorAccountId = 1, + subject = "test", + amount = TalerAmount(10, 0), + accountServicerReference = "acct-svcr-ref", + endToEndId = "end-to-end-id", + paymentInformationId = "pmtinfid", + transactionDate = 100000L + ) + val firstSpending = db.bankTransactionCreate(fooPaysBar) // Foo pays Bar and goes debit. + assert(firstSpending == Database.BankTransactionResult.SUCCESS) + fooAccount = db.bankAccountGetFromLabel("foo") + // Foo: credit -> debit + assert(fooAccount?.hasDebt == true) // Asserting Foo's debit. + // Now checking that more spending doesn't get Foo out of debit. + val secondSpending = db.bankTransactionCreate(fooPaysBar) + assert(secondSpending == Database.BankTransactionResult.SUCCESS) + fooAccount = db.bankAccountGetFromLabel("foo") + // Checking that Foo's debit is two times the paid amount + // Foo: debit -> debit + assert(fooAccount?.balance?.value == 20L + && fooAccount.balance?.frac == 0 + && fooAccount.hasDebt + ) + // Asserting Bar has a positive balance and what Foo paid so far. + var barAccount = db.bankAccountGetFromLabel("bar") + val barBalance: TalerAmount? = barAccount?.balance + assert( + barAccount?.hasDebt == false + && barBalance?.value == 20L && barBalance.frac == 0 + ) + // Bar pays so that its balance remains positive. + val barPaysFoo = BankInternalTransaction( + creditorAccountId = 1, + debtorAccountId = 2, + subject = "test", + amount = TalerAmount(10, 0), + accountServicerReference = "acct-svcr-ref", + endToEndId = "end-to-end-id", + paymentInformationId = "pmtinfid", + transactionDate = 100000L + ) + val barPays = db.bankTransactionCreate(barPaysFoo) + assert(barPays == Database.BankTransactionResult.SUCCESS) + barAccount = db.bankAccountGetFromLabel("bar") + val barBalanceTen: TalerAmount? = barAccount?.balance + // Bar: credit -> credit + assert(barAccount?.hasDebt == false && barBalanceTen?.value == 10L && barBalanceTen.frac == 0) + // Bar pays again to let Foo return in credit. + val barPaysAgain = db.bankTransactionCreate(barPaysFoo) + assert(barPaysAgain == Database.BankTransactionResult.SUCCESS) + // Refreshing the two accounts. + barAccount = db.bankAccountGetFromLabel("bar") + fooAccount = db.bankAccountGetFromLabel("foo") + // Foo should have returned to zero and no debt, same for Bar. + // Foo: debit -> credit + assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == false) + assert(fooAccount?.balance?.equals(TalerAmount(0, 0)) == true) + assert(barAccount?.balance?.equals(TalerAmount(0, 0)) == true) + // Bringing Bar to debit. + val barPaysMore = db.bankTransactionCreate(barPaysFoo) + assert(barPaysAgain == Database.BankTransactionResult.SUCCESS) + barAccount = db.bankAccountGetFromLabel("bar") + fooAccount = db.bankAccountGetFromLabel("foo") + // Bar: credit -> debit + assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == true) + assert(fooAccount?.balance?.equals(TalerAmount(10, 0)) == true) + assert(barAccount?.balance?.equals(TalerAmount(10, 0)) == true) + } + @Test + fun customerCreationTest() { + val db = initDb() + assert(db.customerGetFromLogin("foo") == null) + db.customerCreate(customerFoo) + assert(db.customerGetFromLogin("foo")?.name == "Foo") + // Trigger conflict. + assert(!db.customerCreate(customerFoo)) + } + @Test + fun configTest() { + val db = initDb() + assert(db.configGet("bar") == null) + assert(db.configGet("bar") == null) + db.configSet("foo", "bar") + assert(db.configGet("foo") == "bar") + } + @Test + fun bankAccountTest() { + val db = initDb() + assert(db.bankAccountGetFromLabel("foo") == null) + assert(db.customerCreate(customerFoo)) + assert(db.bankAccountCreate(bankAccountFoo)) + assert(!db.bankAccountCreate(bankAccountFoo)) // Triggers conflict. + assert(db.bankAccountGetFromLabel("foo")?.bankAccountLabel == "foo") + assert(db.bankAccountGetFromLabel("foo")?.balance?.equals(TalerAmount(0, 0)) == true) + } + + @Test + fun withdrawalTest() { + val db = initDb() + val uuid = UUID.randomUUID() + assert(db.customerCreate(customerFoo)) + assert(db.bankAccountCreate(bankAccountFoo)) + // insert new. + assert(db.talerWithdrawalCreate( + uuid, + 1L, + TalerAmount(1, 0) + )) + // get it. + val op = db.talerWithdrawalGet(uuid) + assert(op?.walletBankAccount == 1L && op.withdrawalUuid == uuid) + // Setting the details. + assert(db.talerWithdrawalSetDetails( + uuid, + "exchange-payto", + ByteArray(32) + )) + val opSelected = db.talerWithdrawalGet(uuid) + assert(opSelected?.selectionDone == true && !opSelected.confirmationDone) + assert(db.talerWithdrawalConfirm(uuid)) + // Finally confirming the operation (means customer wired funds to the exchange.) + assert(db.talerWithdrawalGet(uuid)?.confirmationDone == true) + } + // Only testing the interaction between Kotlin and the DBMS. No actual logic tested. + @Test + fun historyTest() { + val db = initDb() + val res = db.bankTransactionGetForHistoryPage( + 10L, + 1L, + fromMs = 0, + toMs = Long.MAX_VALUE + ) + assert(res.isEmpty()) + } + @Test + fun cashoutTest() { + val db = initDb() + val op = Cashout( + cashoutUuid = UUID.randomUUID(), + amountDebit = TalerAmount(1, 0), + amountCredit = TalerAmount(2, 0), + bankAccount = 1L, + buyAtRatio = 3, + buyInFee = TalerAmount(0, 22), + sellAtRatio = 2, + sellOutFee = TalerAmount(0, 44), + cashoutAddress = "IBAN", + cashoutCurrency = "KUDOS", + creationTime = 3L, + subject = "31st", + tanChannel = TanChannel.sms, + tanCode = "secret", + ) + assert(db.customerCreate(customerFoo)) + assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.customerCreate(customerBar)) + assert(db.bankAccountCreate(bankAccountBar)) + assert(db.cashoutCreate(op)) + val fromDb = db.cashoutGetFromUuid(op.cashoutUuid) + assert(fromDb?.subject == op.subject && fromDb.tanConfirmationTime == null) + assert(db.cashoutDelete(op.cashoutUuid) == Database.CashoutDeleteResult.SUCCESS) + assert(db.cashoutCreate(op)) + db.bankAccountSetMaxDebt( + "foo", + TalerAmount(100, 0) + ) + assert(db.bankTransactionCreate(BankInternalTransaction( + creditorAccountId = 2, + debtorAccountId = 1, + subject = "backing the cash-out", + amount = TalerAmount(10, 0), + accountServicerReference = "acct-svcr-ref", + endToEndId = "end-to-end-id", + paymentInformationId = "pmtinfid", + transactionDate = 100000L + )) == Database.BankTransactionResult.SUCCESS) + // Confirming the cash-out + assert(db.cashoutConfirm(op.cashoutUuid, 1L, 1L)) + // Checking the confirmation took place. + assert(db.cashoutGetFromUuid(op.cashoutUuid)?.tanConfirmationTime != null) + // Deleting the operation. + assert(db.cashoutDelete(op.cashoutUuid) == Database.CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED) + assert(db.cashoutGetFromUuid(op.cashoutUuid) != null) // previous didn't delete. + } +} \ No newline at end of file diff --git a/bank/src/test/kotlin/EbicsErrorTest.kt b/bank/src/test/kotlin/EbicsErrorTest.kt new file mode 100644 index 00000000..e0be736b --- /dev/null +++ b/bank/src/test/kotlin/EbicsErrorTest.kt @@ -0,0 +1,24 @@ +import org.apache.xml.security.binding.xmldsig.SignatureType +import org.junit.Test +import tech.libeufin.util.CryptoUtil +import tech.libeufin.util.XMLUtil +import tech.libeufin.util.ebics_h004.EbicsResponse +import tech.libeufin.util.ebics_h004.EbicsTypes + +class EbicsErrorTest { + + @Test + fun makeEbicsErrorResponse() { + val pair = CryptoUtil.generateRsaKeyPair(2048) + val resp = EbicsResponse.createForUploadWithError( + "[EBICS_ERROR] abc", + "012345", + EbicsTypes.TransactionPhaseType.INITIALISATION + ) + val signedResp = XMLUtil.signEbicsResponse(resp, pair.private) + XMLUtil.validateFromString(signedResp) + assert(resp.header.mutable.reportText == "[EBICS_ERROR] abc") + assert(resp.header.mutable.returnCode == "012345") + assert(resp.body.returnCode.value == "012345") + } +} \ No newline at end of file diff --git a/bank/src/test/kotlin/StringsTest.kt b/bank/src/test/kotlin/StringsTest.kt new file mode 100644 index 00000000..892a419c --- /dev/null +++ b/bank/src/test/kotlin/StringsTest.kt @@ -0,0 +1,37 @@ +import org.junit.Test +import tech.libeufin.util.hasWopidPlaceholder +import tech.libeufin.util.validateBic + +class StringsTest { + + @Test + fun hasWopidTest() { + assert(hasWopidPlaceholder("http://example.com/#/{wopid}")) + assert(!hasWopidPlaceholder("http://example.com")) + assert(hasWopidPlaceholder("http://example.com/#/{WOPID}")) + assert(!hasWopidPlaceholder("{ W O P I D }")) + } + + @Test + fun replaceWopidPlaceholderTest() { + assert( + "http://example.com/#/operation/{wopid}".replace("{wopid}", "987") + == "http://example.com/#/operation/987" + ) + assert("http://example.com".replace("{wopid}", "not-replaced") + == "http://example.com" + ) + } + + @Test + fun bicTest() { + assert(validateBic("GENODEM1GLS")) + assert(validateBic("AUTOATW1XXX")) + } + + @Test + fun booleanToString() { + assert(true.toString() == "true") + assert(false.toString() == "false") + } +} \ No newline at end of file diff --git a/sandbox/README b/sandbox/README deleted file mode 100644 index 4e82d682..00000000 --- a/sandbox/README +++ /dev/null @@ -1,21 +0,0 @@ -Description -=========== - -The Libeufin Sandbox aims at implementing the server side of multiple -banking protocols currently used in the European Union. Notably, the -EBICS, FinTS, and the major protocols that banks will employ to respect -the PSD2 regulation: https://ec.europa.eu/info/law/payment-services-psd-2-directive-eu-2015-2366_en - - -Running the sandbox -=================== - -Run the sandbox with the following command - -$ cd -$ ./gradlew sandbox:run --console=plain --args=serve [--db-name=] - -Documentation -============= - -See https://docs.libeufin.tech/ for the documentation. diff --git a/sandbox/build.gradle b/sandbox/build.gradle deleted file mode 100644 index bc57b942..00000000 --- a/sandbox/build.gradle +++ /dev/null @@ -1,95 +0,0 @@ -plugins { - id 'kotlin' - id 'java' - id 'application' - id 'org.jetbrains.kotlin.jvm' - id "com.github.johnrengelman.shadow" version "5.2.0" -} - -sourceCompatibility = "11" -targetCompatibility = "11" -version = rootProject.version - -compileKotlin { - kotlinOptions { - jvmTarget = "11" - } -} - -compileTestKotlin { - kotlinOptions { - jvmTarget = "11" - } -} - -task installToPrefix(type: Copy) { - dependsOn(installShadowDist) - from("build/install/sandbox-shadow") { - include("**/libeufin-sandbox") - 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' - -sourceSets { - main.java.srcDirs = ['src/main/kotlin'] -} - -dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt' - implementation "com.hubspot.jinjava:jinjava:2.5.9" - implementation 'ch.qos.logback:logback-classic:1.4.5' - implementation project(":util") - - // XML: - 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' - - implementation group: 'org.bouncycastle', name: 'bcprov-jdk16', version: '1.46' - implementation group: 'org.xerial', name: 'sqlite-jdbc', version: '3.36.0.1' - implementation 'org.postgresql:postgresql:42.2.23.jre7' - implementation group: 'org.apache.commons', name: 'commons-compress', version: '1.21' - implementation('com.github.ajalt:clikt:2.8.0') - implementation "org.jetbrains.exposed:exposed-core:$exposed_version" - implementation "org.jetbrains.exposed:exposed-dao:$exposed_version" - implementation "org.jetbrains.exposed:exposed-jdbc:$exposed_version" - - implementation "io.ktor:ktor-server-core:$ktor_version" - implementation "io.ktor:ktor-server-call-logging:$ktor_version" - implementation "io.ktor:ktor-server-cors:$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" - 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" - - testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21' - testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21' - testImplementation group: "junit", name: "junit", version: '4.13.2' - - // UNIX domain sockets support (used to connect to PostgreSQL) - implementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2' -} - -application { - mainClassName = "tech.libeufin.sandbox.MainKt" - applicationName = "libeufin-sandbox" - applicationDefaultJvmArgs = ['-Djava.net.preferIPv6Addresses=true'] -} - -jar { - manifest { - attributes "Main-Class": "tech.libeufin.sandbox.MainKt" - } -} \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt deleted file mode 100644 index 4d8d36d9..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt +++ /dev/null @@ -1,841 +0,0 @@ -package tech.libeufin.sandbox - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.server.application.* -import io.ktor.http.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.sandbox.CashoutOperationsTable.uuid -import tech.libeufin.util.* -import java.io.File -import java.io.InputStreamReader -import java.math.BigDecimal -import java.util.concurrent.TimeUnit -import kotlin.text.toByteArray - -// CIRCUIT API TYPES -/** - * This type is used by clients to ask the bank a cash-out - * estimate to show to the customer before they confirm the - * cash-out creation. - */ -data class CircuitCashoutEstimateRequest( - /** - * This is the amount that the customer will get deducted - * from their regio bank account to fuel the cash-out operation. - */ - val amount_debit: String -) -data class CircuitCashoutRequest( - val subject: String?, - val amount_debit: String, // As specified by the user via the SPA. - val amount_credit: String, // What actually to transfer after the rates. - /** - * The String type here allows more flexibility with regard to - * the supported TAN methods. This way, supported TAN methods - * can be specified via the configuration or when starting the - * bank. OTOH, catching unsupported TAN methods only via the - * 'enum' type would require to change the source code upon every - * change in the TAN policy. - */ - val tan_channel: String? -) -const val FIAT_CURRENCY = "CHF" // FIXME: make configurable. -// Configuration response: -data class ConfigResp( - val name: String = "circuit", - val version: String = PROTOCOL_VERSION_UNIFIED, - val ratios_and_fees: RatioAndFees, - val fiat_currency: String = FIAT_CURRENCY -) - -// After fixing #7527, the values held by this -// type must be read from the configuration. -data class RatioAndFees( - val buy_at_ratio: Float = 1F, - val sell_at_ratio: Float = 0.95F, - val buy_in_fee: Float = 0F, - val sell_out_fee: Float = 0F -) -val ratiosAndFees = RatioAndFees() - -// User registration request -data class CircuitAccountRequest( - val username: String, - val password: String, - val contact_data: CircuitContactData, - val name: String, - val cashout_address: String, // payto - val internal_iban: String? // Shall be "= null" ? -) -// User contact data to send the TAN. -data class CircuitContactData( - val email: String?, - val phone: String? -) - -data class CircuitAccountReconfiguration( - val contact_data: CircuitContactData, - val cashout_address: String?, - val name: String? = null -) - -data class AccountPasswordChange( - val new_password: String -) - -/** - * That doesn't belong to the Access API because it - * contains the cash-out address and the contact data. - */ -data class CircuitAccountInfo( - val username: String, - val iban: String, - val contact_data: CircuitContactData, - val name: String, - val cashout_address: String? -) - -data class CashoutOperationInfo( - val status: CashoutOperationStatus, - val amount_credit: String, - val amount_debit: String, - val subject: String, - val creation_time: Long, // milliseconds - val confirmation_time: Long?, // milliseconds - val tan_channel: SupportedTanChannels, - val account: String, - val cashout_address: String, - val ratios_and_fees: RatioAndFees -) - -data class CashoutConfirmation(val tan: String) - -// Validate phone number -fun checkPhoneNumber(phoneNumber: String): Boolean { - // From Taler TypeScript - // /^\+[0-9 ]*$/; - val regex = "^\\+[1-9][0-9]+$" - val R = Regex(regex) - return R.matches(phoneNumber) -} - -// Validate e-mail address -fun checkEmailAddress(emailAddress: String): Boolean { - // From Taler TypeScript: - // /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - val regex = "^[a-zA-Z0-9\\.]+@[a-zA-Z0-9\\.]+$" - val R = Regex(regex) - return R.matches(emailAddress) -} - -fun throwIfInstitutionalName(resourceName: String) { - if (resourceName == "bank" || resourceName == "admin") - throw forbidden("Can't operate on institutional resource '$resourceName'") -} - -fun generateCashoutSubject( - amountCredit: AmountWithCurrency, - amountDebit: AmountWithCurrency -): String { - return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" + - " to ${amountCredit.currency}:${amountCredit.amount}" -} - -/** - * By default, it takes the amount in the regional currency - * and applies ratio and fees to convert it to fiat. If the - * 'fromCredit' parameter is true, then it does the inverse - * operation: returns the regional amount that would lead to - * such fiat amount given in the 'amount' parameter. - */ -fun applyCashoutRatioAndFee( - amount: BigDecimal, - ratiosAndFees: RatioAndFees, - fromCredit: Boolean = false -): BigDecimal { - // Normal case, when the calculation starts from the regional amount. - if (!fromCredit) { - val maybeCashoutAmount = ((amount * ratiosAndFees.sell_at_ratio.toBigDecimal()) - - ratiosAndFees.sell_out_fee.toBigDecimal()).roundToTwoDigits() - // throws 500, since bank should not allow to get negative fiat amounts. - if (maybeCashoutAmount < BigDecimal.ZERO) { - logger.error("Cash-out operation caused a negative fiat output." + - " Regional amount was '$amount', cash-out ratio is '${ratiosAndFees.sell_at_ratio}," + - " cash-out fee is '${ratiosAndFees.sell_out_fee}''" - ) - throw internalServerError("Applying cash-out fees yielded negative fiat amount.") - } - return maybeCashoutAmount - } - // UI convenient case, when the calculation starts from the - // desired fiat amount that the user wants eventually be paid. - return ((amount + ratiosAndFees.sell_out_fee.toBigDecimal()) / - ratiosAndFees.sell_at_ratio.toBigDecimal()).roundToTwoDigits() -} - -/** - * NOTE: future versions take the supported TAN method from - * the configuration, or options passed when starting the bank. - */ -const val LIBEUFIN_TAN_TMP_FILE = "/tmp/libeufin-cashout-tan.txt" -enum class SupportedTanChannels { - SMS, - EMAIL, - FILE // Test channel writing the TAN to the LIBEUFIN_TAN_TMP_FILE location. -} -fun isTanChannelSupported(tanChannel: String): Boolean { - enumValues().forEach { - if (tanChannel.uppercase() == it.name) return true - } - return false -} - -var EMAIL_TAN_CMD: String? = null -var SMS_TAN_CMD: String? = null - -// Convenience class to collect TAN data. -private data class TanData( - val cmd: String, - val address: String, - val msg: String -) - -/** - * Runs the command and returns True/False if that succeeded/failed. - * A failed command causes "500 Internal Server Error" to be responded - * along a cash-out creation. 'address' is a phone number or a e-mail address, - * according to which TAN channel is used. 'message' carries the TAN. - * - * The caller is expected to manage the exceptions thrown by this function. - */ -fun runTanCommand(command: String, address: String, message: String): Boolean { - val prep = ProcessBuilder(command, address) - prep.redirectErrorStream(true) // merge STDOUT and STDERR - val proc = prep.start() - proc.outputStream.write(message.toByteArray()) - proc.outputStream.flush(); proc.outputStream.close() - var isSuccessful = false - // Wait the command to finish. - proc.waitFor(10L, TimeUnit.SECONDS) - // Check if timed out. Kill if so. - if (proc.isAlive) { - logger.error("TAN command '$command' timed out, killing it.") - proc.destroy() - // Check if exited gracefully. Kill forcibly if not. - proc.waitFor(5L, TimeUnit.SECONDS) - if (proc.isAlive) { - logger.error("TAN command '$command' didn't terminate after killing it. Try forcefully.") - proc.destroyForcibly() - } - } - // Check if successful. Switch the state if so. - if (proc.exitValue() == 0) isSuccessful = true - // Log STDOUT and STDERR if failed. - if (!isSuccessful) - logger.error(InputStreamReader(proc.inputStream).readText()) - return isSuccessful -} - -fun circuitApi(circuitRoute: Route) { - // Abort a cash-out operation. - circuitRoute.post("/cashouts/{uuid}/abort") { - call.request.basicAuth() // both admin and author allowed - val arg = call.expectUriComponent("uuid") - // Parse and check the UUID. - val maybeUuid = parseUuid(arg) - val maybeOperation = transaction { - CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() - } - if (maybeOperation == null) - throw notFound("Cash-out operation $uuid not found.") - if (maybeOperation.status == CashoutOperationStatus.CONFIRMED) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out operation '$uuid' was confirmed already." - ) - if (maybeOperation.status != CashoutOperationStatus.PENDING) - throw internalServerError("Found an unsupported cash-out operation state: ${maybeOperation.status}") - // Operation found and pending: delete from the database. - transaction { maybeOperation.delete() } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Confirm a cash-out operation - circuitRoute.post("/cashouts/{uuid}/confirm") { - val user = call.request.basicAuth() - // Exclude admin from this operation. - if (user == "admin" || user == "bank") - throw conflict("Institutional user '$user' shouldn't confirm any cash-out.") - // Get the operation identifier. - val operationUuid = parseUuid(call.expectUriComponent("uuid")) - val op = transaction { - CashoutOperationEntity.find { - uuid eq operationUuid - }.firstOrNull() - } - // 404 if the operation is not found. - if (op == null) - throw notFound("Cash-out operation $operationUuid not found") - /** - * Check the TAN. Give precedence to the TAN found - * in the environment, for testing purposes. If that's - * not found, then check with the actual TAN found in - * the database. - */ - val req = call.receive() - val maybeTanFromEnv = System.getenv("LIBEUFIN_CASHOUT_TEST_TAN") - if (maybeTanFromEnv != null) - logger.warn("TAN being read from the environment. Assuming tests are being run") - val checkTan = maybeTanFromEnv ?: op.tan - if (req.tan != checkTan) - throw forbidden("The confirmation of '${op.uuid}' has a wrong TAN '${req.tan}'") - /** - * Correct TAN. Wire the funds to the admin's bank account. After - * this step, the conversion monitor should detect this payment and - * soon initiate the final transfer towards the user fiat bank account. - * NOTE: the funds availability got already checked when this operation - * was created. On top of that, the 'wireTransfer()' helper does also - * check for funds availability. */ - val customer = maybeGetCustomer(user ?: throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "This endpoint isn't served when the authentication is disabled." - )) - transaction { - if (op.cashoutAddress != customer?.cashout_address) throw conflict( - "Inconsistent cash-out address: ${op.cashoutAddress} vs ${customer?.cashout_address}" - ) - // 412 if the operation got already confirmed. - if (op.status == CashoutOperationStatus.CONFIRMED) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out operation $operationUuid was already confirmed." - ) - wireTransfer( - debitAccount = op.account, - creditAccount = "admin", - subject = op.subject, - amount = op.amountDebit - ) - op.status = CashoutOperationStatus.CONFIRMED - op.confirmationTime = getSystemTimeNow().toInstant().toEpochMilli() - // TODO(signal this payment over LIBEUFIN_REGIO_INCOMING) - } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Retrieve the status of a cash-out operation. - circuitRoute.get("/cashouts/{uuid}") { - call.request.basicAuth() // both admin and author - val operationUuid = call.expectUriComponent("uuid") - // Parse and check the UUID. - val maybeUuid = parseUuid(operationUuid) - // Get the operation from the database. - val maybeOperation = transaction { - CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() - } - if (maybeOperation == null) - throw notFound("Cash-out operation $operationUuid not found.") - val ret = CashoutOperationInfo( - amount_credit = maybeOperation.amountCredit, - amount_debit = maybeOperation.amountDebit, - subject = maybeOperation.subject, - status = maybeOperation.status, - creation_time = maybeOperation.creationTime, - confirmation_time = maybeOperation.confirmationTime, - tan_channel = maybeOperation.tanChannel, - account = maybeOperation.account, - cashout_address = maybeOperation.cashoutAddress, - ratios_and_fees = RatioAndFees( - buy_in_fee = maybeOperation.buyInFee.toFloat(), - buy_at_ratio = maybeOperation.buyAtRatio.toFloat(), - sell_out_fee = maybeOperation.sellOutFee.toFloat(), - sell_at_ratio = maybeOperation.sellAtRatio.toFloat() - ) - ) - call.respond(ret) - return@get - } - // Gets the list of all the cash-out operations, - // or those belonging to the account given as a parameter. - circuitRoute.get("/cashouts") { - val user = call.request.basicAuth() - val whichAccount = call.request.queryParameters["account"] - /** - * Only admin's allowed to omit the target account (= get - * all the accounts) or to check other customers cash-out - * operations. - */ - if (user != "admin" && whichAccount != user) throw forbidden( - "Ordinary users can only request their own account" - ) - /** - * At this point, the client has the rights over the account(s) - * whose operations are to be returned. Double-checking that - * Admin doesn't ask its own cash-outs, since that's not supported. - */ - if (whichAccount == "admin") throw badRequest("Cash-out for admin is not supported") - - // Preparing the response. - val node = jacksonObjectMapper().createObjectNode() - val maybeArray = node.putArray("cashouts") - - if (whichAccount == null) { // no target account, return all the cash-outs - transaction { - CashoutOperationEntity.all().forEach { - maybeArray.add(it.uuid.toString()) - } - } - } else { // do filter on the target account. - transaction { - CashoutOperationEntity.find { - CashoutOperationsTable.account eq whichAccount - }.forEach { - maybeArray.add(it.uuid.toString()) - } - } - } - if (maybeArray.size() == 0) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond(node) - return@get - } - circuitRoute.get("/cashouts/estimates") { - call.request.basicAuth() - val demobank = ensureDemobank(call) - // Optionally parsing param 'amount_debit' into number and checking its currency - val maybeAmountDebit: String? = call.request.queryParameters["amount_debit"] - val amountDebit: BigDecimal? = if (maybeAmountDebit != null) { - val amount = parseAmount(maybeAmountDebit) - if (amount.currency != demobank.config.currency) throw badRequest( - "parameter 'amount_debit' has the wrong currency: ${amount.currency}" - ) - try { amount.amount.toBigDecimal() } catch (e: Exception) { - throw badRequest("Cannot extract a number from 'amount_debit'") - } - } else null - // Optionally parsing param 'amount_credit' into number and checking its currency - val maybeAmountCredit: String? = call.request.queryParameters["amount_credit"] - val amountCredit: BigDecimal? = if (maybeAmountCredit != null) { - val amount = parseAmount(maybeAmountCredit) - if (amount.currency != FIAT_CURRENCY) throw badRequest( - "parameter 'amount_credit' has the wrong currency: ${amount.currency}" - ) - try { amount.amount.toBigDecimal() } catch (e: Exception) { - throw badRequest("Cannot extract a number from 'amount_credit'") - } - } else null - val respAmountCredit = if (amountDebit != null) { - val estimate = applyCashoutRatioAndFee(amountDebit, ratiosAndFees) - if (amountCredit != null && estimate != amountCredit) throw badRequest( - "Wrong calculation found in 'amount_credit', bank estimates: $estimate" - ) - estimate - } else null - if (amountDebit == null && amountCredit == null) throw badRequest( - "Both 'amount_credit' and 'amount_debit' are missing" - ) - val respAmountDebit = if (amountCredit != null) { - val estimate = applyCashoutRatioAndFee( - amountCredit, - ratiosAndFees, - fromCredit = true - ) - if (amountDebit != null && estimate != amountDebit) throw badRequest( - "Wrong calculation found in 'amount_credit', bank estimates: $estimate" - ) - estimate - } else null - call.respond(object { - val amount_credit = "$FIAT_CURRENCY:$respAmountCredit" - val amount_debit = "${demobank.config.currency}:$respAmountDebit" - }) - return@get - } - - // Create a cash-out operation. - circuitRoute.post("/cashouts") { - val user = call.request.basicAuth() - if (user == "admin" || user == "bank") throw forbidden("$user can't cash-out.") - // No suitable default user, when the authentication is disabled. - if (user == null) throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "This endpoint isn't served when the authentication is disabled." - ) - val req = call.receive() - - // validate amounts: well-formed and supported currency. - val amountDebit = parseAmount(req.amount_debit) // amount before rates. - val amountCredit = parseAmount(req.amount_credit) // amount after rates, as expected by the client - val demobank = ensureDemobank(call) - // Currency check of the cash-out's circuit part. - if (amountDebit.currency != demobank.config.currency) - throw badRequest("'${req::amount_debit.name}' (${req.amount_debit})" + - " doesn't match the regional currency (${demobank.config.currency})" - ) - // Currency check of the cash-out's fiat part. - if (amountCredit.currency != FIAT_CURRENCY) - throw badRequest("'${req::amount_credit.name}' (${req.amount_credit})" + - " doesn't match the fiat currency ($FIAT_CURRENCY)." - ) - // check if TAN is supported. Default to SMS, if that's missing. - val tanChannel = req.tan_channel?.uppercase() ?: SupportedTanChannels.SMS.name - if (!isTanChannelSupported(tanChannel)) - throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "TAN channel '$tanChannel' not supported." - ) - // check if the user contact data would allow the TAN channel. - val customer: DemobankCustomerEntity? = maybeGetCustomer(username = user) - if (customer == null) throw internalServerError( - "Customer profile '$user' not found after authenticating it." - ) - if (customer.cashout_address == null) throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out address not found. Did the user register via Circuit API?" - ) - if ((tanChannel == SupportedTanChannels.EMAIL.name) && (customer.email == null)) - throw conflict("E-mail address not found for '$user'. Can't send the TAN") - if ((tanChannel == SupportedTanChannels.SMS.name) && (customer.phone == null)) - throw conflict("Phone number not found for '$user'. Can't send the TAN") - // check rates correctness - val amountDebitAsNumber = BigDecimal(amountDebit.amount) - val expectedAmountCredit = applyCashoutRatioAndFee(amountDebitAsNumber, ratiosAndFees) - val amountCreditAsNumber = BigDecimal(amountCredit.amount).roundToTwoDigits() - if (expectedAmountCredit != amountCreditAsNumber) { - throw badRequest("Rates application are incorrect." + - " The expected amount to credit is: ${expectedAmountCredit}," + - " but ${amountCredit.amount} was specified.") - } - // check that the balance is sufficient - val balance = getBalance( - user, - demobank.name - ) - val balanceCheck = balance - amountDebitAsNumber - if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal(demobank.config.usersDebtLimit)) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out not possible due to insufficient funds. Balance ${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}" - ) - // generate a subject if that's missing - val cashoutSubject = req.subject ?: generateCashoutSubject( - amountCredit = amountCredit, - amountDebit = amountDebit - ) - val op = transaction { - CashoutOperationEntity.new { - this.amountDebit = req.amount_debit - this.amountCredit = req.amount_credit - this.buyAtRatio = ratiosAndFees.buy_at_ratio.toString() - this.buyInFee = ratiosAndFees.buy_in_fee.toString() - this.sellAtRatio = ratiosAndFees.sell_at_ratio.toString() - this.sellOutFee = ratiosAndFees.sell_out_fee.toString() - this.subject = cashoutSubject - this.creationTime = getSystemTimeNow().toInstant().toEpochMilli() - this.tanChannel = SupportedTanChannels.valueOf(tanChannel) - this.account = user - this.tan = getRandomString(5) - this.cashoutAddress = customer.cashout_address ?: throw internalServerError( - "Cash-out address for '$user' not found, after previous check succeeded" - ) - } - } - when (tanChannel) { - SupportedTanChannels.EMAIL.name -> { - val isSuccessful = try { - runTanCommand( - command = EMAIL_TAN_CMD ?: throw internalServerError( - "E-mail TAN supported but the command" + - " was not found. See the --email-tan option from 'serve'" - ), - address = customer.email ?: throw internalServerError( - "Customer has no e-mail address, but previous check should" + - " have detected it!" - ), - message = op.tan - ) - } catch (e: Exception) { - logger.error("Sending the e-mail TAN to ${customer.email} was impossible." + - " Reason: ${e.message}") - throw internalServerError("Could not send the e-mail TAN.") - } - if (!isSuccessful) - throw internalServerError("E-mail TAN command failed.") - } - SupportedTanChannels.SMS.name -> { - val isSuccessful = try { - runTanCommand( - command = SMS_TAN_CMD ?: throw internalServerError( - "SMS TAN supported but the command" + - " was not found. See the --sms-tan option from 'serve'" - ), - address = customer.phone ?: throw internalServerError( - "Customer has no phone number, but previous check should" + - " have detected it!" - - ), - message = op.tan - ) - - } catch (e: Exception) { - logger.error("Sending the SMS TAN to ${customer.phone} was impossible." + - " Reason: ${e.message}") - throw internalServerError("Could not send the SMS TAN.") - } - if (!isSuccessful) - throw internalServerError("SMS TAN command failed.") - } - SupportedTanChannels.FILE.name -> { - try { - File(LIBEUFIN_TAN_TMP_FILE).writeText(op.tan) - } catch (e: Exception) { - logger.error("Could not write to $LIBEUFIN_TAN_TMP_FILE. Reason: ${e.message}") - throw internalServerError("File TAN failed.") - } - } - else -> - throw internalServerError("The bank tried an unsupported TAN channel: $tanChannel.") - } - call.respond(HttpStatusCode.Accepted, object {val uuid = op.uuid}) - return@post - } - // Get Circuit-relevant account data. - circuitRoute.get("/accounts/{resourceName}") { - val username = call.request.basicAuth() - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - if (!allowOwnerOrAdmin(username, resourceName)) throw forbidden( - "User $username has no rights over $resourceName" - ) - val customer = getCustomer(resourceName) - /** - * CUSTOMER AND BANK ACCOUNT INVARIANT. - * - * After having found a 'customer' associated with the resourceName - * - see previous line -, the bank must ensure that a 'bank account' - * exist under the same resourceName. If that fails, the bank broke the - * invariant and should respond 500. - */ - val bankAccount = getBankAccountFromLabel(resourceName, withBankFault = true) - /** - * Throwing when name or cash-out address aren't found ensures - * that the customer was indeed added via the Circuit API, as opposed - * to the Access API. - */ - call.respond(CircuitAccountInfo( - username = customer.username, - name = customer.name ?: throw internalServerError( - "Account '$resourceName' was found without owner's name." - ), - cashout_address = customer.cashout_address, - contact_data = CircuitContactData( - email = customer.email, - phone = customer.phone - ), - iban = bankAccount.iban - )) - return@get - } - - // Get summary of all the accounts. - circuitRoute.get("/accounts") { - call.request.basicAuth(onlyAdmin = true) - val maybeFilter: String? = call.request.queryParameters["filter"] - /** - * Equip the given filter with left and right catch-all wildcards, - * otherwise use one catch-all wildcard. - */ - val filter = if (maybeFilter != null) { - "%${maybeFilter}%" - } else "%" - val customers = mutableListOf() - val demobank = ensureDemobank(call) - transaction { - /** - * This block builds the DB query so that IF the %-wildcard was - * given, then BOTH name and name-less accounts are returned. - */ - val query: Op = SqlExpressionBuilder.run { - val like = DemobankCustomersTable.name.like(filter) - /** - * This IF statement is needed because Postgres would NOT - * match a null column even with the %-wildcard. - */ - if (filter == "%") { - return@run like.or(DemobankCustomersTable.name.isNull()) - } - return@run like - } - DemobankCustomerEntity.find { query }.forEach { - customers.add(object { - val username = it.username - val name = it.name - val balance = getBalanceForJson( - getBalance(it.username, demobank.name), - demobank.config.currency - ) - val debitThreshold = getMaxDebitForUser( - it.username, - demobank.name - ) - }) - } - StdOutSqlLogger - } - if (customers.size == 0) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond(object {val customers = customers}) - return@get - } - - // Change password. - circuitRoute.patch("/accounts/{customerUsername}/auth") { - val username = call.request.basicAuth() - val customerUsername = call.expectUriComponent("customerUsername") - throwIfInstitutionalName(customerUsername) - if (!allowOwnerOrAdmin(username, customerUsername)) throw forbidden( - "User $username has no rights over $customerUsername" - ) - // Flow here means admin or username have the rights for this operation. - val req = call.receive() - /** - * The resource/customer might still not exist, in case admin has requested. - * On the other hand, when ordinary customers request, their existence is checked - * along the basic authentication check. - */ - transaction { - val customer = getCustomer(customerUsername) // throws 404, if not found. - customer.passwordHash = CryptoUtil.hashpw(req.new_password) - } - call.respond(HttpStatusCode.NoContent) - return@patch - } - // Change account (mostly contact) data. - circuitRoute.patch("/accounts/{resourceName}") { - val username = call.request.basicAuth() - if (username == null) - throw internalServerError("Authentication disabled, don't have a default for this request.") - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - if(!allowOwnerOrAdmin(username, resourceName)) throw forbidden( - "User $username has no rights over $resourceName" - ) - // account found and authentication succeeded - val req = call.receive() - // Only admin's allowed to change the legal name - if (req.name != null && username != "admin") throw forbidden( - "Only admin can change the user legal name" - ) - if ((req.contact_data.email != null) && (!checkEmailAddress(req.contact_data.email))) - throw badRequest("Invalid e-mail address: ${req.contact_data.email}") - if ((req.contact_data.phone != null) && (!checkPhoneNumber(req.contact_data.phone))) - throw badRequest("Invalid phone number: ${req.contact_data.phone}") - try { if (req.cashout_address != null) parsePayto(req.cashout_address) } - catch (e: InvalidPaytoError) { - throw badRequest("Invalid cash-out address: ${req.cashout_address}") - } - transaction { - val user = getCustomer(resourceName) - user.email = req.contact_data.email - user.phone = req.contact_data.phone - user.cashout_address = req.cashout_address - } - call.respond(HttpStatusCode.NoContent) - return@patch - } - // Create new account. - circuitRoute.post("/accounts") { - call.request.basicAuth(onlyAdmin = true) - val req = call.receive() - // Validity and availability check on the input data. - if (req.contact_data.email != null) { - if (!checkEmailAddress(req.contact_data.email)) - throw badRequest("Invalid e-mail address: ${req.contact_data.email}. Won't register") - val maybeEmailConflict = transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.email eq req.contact_data.email - }.firstOrNull() - } - // Warning since two individuals claimed one same e-mail address. - if (maybeEmailConflict != null) - throw conflict("Won't register user ${req.username}: e-mail conflict on ${req.contact_data.email}") - } - if (req.contact_data.phone != null) { - if (!checkPhoneNumber(req.contact_data.phone)) - throw badRequest("Invalid phone number: ${req.contact_data.phone}. Won't register") - - val maybePhoneConflict = transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.phone eq req.contact_data.phone - }.firstOrNull() - } - // Warning since two individuals claimed one same phone number. - if (maybePhoneConflict != null) - throw conflict("Won't register user ${req.username}: phone conflict on ${req.contact_data.phone}") - } - /** - * Check that cash-out address parses. IBAN is not - * check-summed in this version; the cash-out operation - * just fails for invalid IBANs and the user has then - * the chance to update their IBAN. - */ - try { - parsePayto(req.cashout_address) - } - catch (e: InvalidPaytoError) { - throw badRequest("Won't register account ${req.username}: invalid cash-out address: ${req.cashout_address}") - } - transaction { - val newAccount = insertNewAccount( - username = req.username, - password = req.password, - name = req.name, - iban = req.internal_iban, - demobank = ensureDemobank(call).name - ) - newAccount.customer.phone = req.contact_data.phone - newAccount.customer.email = req.contact_data.email - newAccount.customer.cashout_address = req.cashout_address - } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Get (conversion rates via) config values. - circuitRoute.get("/config") { - call.respond(ConfigResp(ratios_and_fees = ratiosAndFees)) - return@get - } - // Only Admin and only when balance is zero. - circuitRoute.delete("/accounts/{resourceName}") { - call.request.basicAuth(onlyAdmin = true) - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - val customer = getCustomer(resourceName) - val bankAccount = getBankAccountFromLabel( - resourceName, - withBankFault = true // See comment "CUSTOMER AND BANK ACCOUNT INVARIANT". - ) - val balance: BigDecimal = getBalance(bankAccount) - if (!isAmountZero(balance)) { - logger.error("Account $resourceName has $balance balance. Won't delete it") - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Account $resourceName doesn't have zero balance. Won't delete it" - ) - } - transaction { - bankAccount.delete() - customer.delete() - } - call.respond(HttpStatusCode.NoContent) - return@delete - } -} \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt deleted file mode 100644 index c760a2b1..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt +++ /dev/null @@ -1,433 +0,0 @@ -package tech.libeufin.sandbox - -import CamtBankAccountEntry -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 kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.math.BigDecimal -import kotlin.system.exitProcess - -/** - * This file contains the logic for downloading/submitting incoming/outgoing - * fiat transactions to Nexus. It needs the following values for operating. - * - * 1. Nexus URL. - * 2. Credentials to authenticate at Nexus JSON API. - * 3. Long-polling interval. - * 4. Frequency of the download loop. - * - * Notes: - * - * 1. The account to credit on incoming transactions is ALWAYS "admin". - * 2. The time to submit a new payment is as soon as "admin" receives one - * incoming regional payment. - * 3. At this time, Nexus does NOT offer long polling when it serves the - * transactions via its JSON API. => Fixed. - * 4. At this time, Nexus does NOT offer any filter when it serves the - * transactions via its JSON API. => Can be fixed by using the TWG. - */ - -// DEFINITIONS AND HELPERS - -/** - * Timeout the HTTP client waits for the server to respond, - * after the request is made. - */ -val waitTimeout = 30000L - -/** - * Time to wait before HTTP requesting again to the server. - * This helps to avoid tight cycles in case the server responds - * quickly or the client doesn't long-poll. - */ -val newIterationTimeout = 2000L - -/** - * Response format of Nexus GET /transactions. - */ -data class TransactionItem( - val index: String, - val camtData: CamtBankAccountEntry -) -data class NexusTransactions( - val transactions: List -) - -/** - * This exception signals that the buy-in service could NOT - * GET the list of fiat transactions from Nexus due to a client - * error. Because this is fatal (e.g. wrong credentials, URL not found..), - * the service should be stopped. - */ -class BuyinClientError : Exception() - -/** - * This exception signals that POSTing a cash-out operation - * to Nexus failed due to the client. This is a fatal condition - * therefore the monitor should be stopped. - */ -class CashoutClientError : Exception() -/** - * Executes the 'block' function every 'loopNewReqMs' milliseconds. - * Does not exit/fail the process upon exceptions - just logs them. - */ -fun downloadLoop(block: () -> Unit) { - // Needs "runBlocking {}" to call "delay()" and in case 'block' - // contains suspend functions. - runBlocking { - while(true) { - try { block() } - catch (e: BuyinClientError) { - logger.error("The buy-in monitor had a client error while GETting new" + - " transactions from Neuxs. Stopping it") - // Rethrowing and let the caller manage it - throw e - } - // Tolerating any other error type that's not due to the client. - catch (e: Exception) { - logger.error("Sandbox fiat-incoming monitor excepted: ${e.message}") - } - delay(newIterationTimeout) - } - } -} - -// BUY-IN SIDE. - -/** - * Applies the buy-in ratio and fees to the fiat amount - * that came from Nexus. The result is the regional amount - * that will be wired to the exchange Sandbox account. - */ -fun applyBuyinRatioAndFees( - amount: BigDecimal, - ratiosAndFees: RatioAndFees -): BigDecimal { - val maybeBuyinAmount = ((amount * ratiosAndFees.buy_at_ratio.toBigDecimal()) - - ratiosAndFees.buy_in_fee.toBigDecimal()).roundToTwoDigits() - // Bank's fault, as buying in should never lead to negative. - if (maybeBuyinAmount < BigDecimal.ZERO) { - logger.error("Negative buy-in scenario: input fiat amount was '${amount}'" + - ", buy-in ratio was '${ratiosAndFees.buy_at_ratio}'," + - " buy-in fee was '${ratiosAndFees.buy_in_fee}'") - throw internalServerError("Applying buy-in fees yielded negative regional amount") - } - return maybeBuyinAmount -} - -private fun ensureDisabledRedirects(client: HttpClient) { - client.config { - if (followRedirects) throw Exception( - "HTTP client follows redirects, please disable." - ) - } -} -/** - * This function downloads the incoming fiat transactions from Nexus, - * stores them into the database and triggers the related wire transfer - * to the Taler exchange (to be specified in 'accountToCredit'). Once - * started, this function is not supposed to return, except on _client - * side_ errors. On server side errors it pauses and retries. When - * it returns, the caller is expected to handle the error. - */ -fun buyinMonitor( - demobankName: String, // used to get config values. - client: HttpClient, - accountToCredit: String, - accountToDebit: String = "admin" -) { - ensureDisabledRedirects(client) - val demobank = ensureDemobank(demobankName) - /** - * Getting the config values to send authenticated requests - * to Nexus. Sandbox needs one account at Nexus before being - * able to use these values. - */ - val nexusBaseUrl = getConfigValueOrThrow(demobank.config::nexusBaseUrl) - val usernameAtNexus = getConfigValueOrThrow(demobank.config::usernameAtNexus) - val passwordAtNexus = getConfigValueOrThrow(demobank.config::passwordAtNexus) - /** - * This is the endpoint where Nexus serves all the transactions that - * have ingested from the fiat bank. - */ - val endpoint = "bank-accounts/$usernameAtNexus/transactions" - val uriWithoutStart = joinUrl(nexusBaseUrl, endpoint) + "?long_poll_ms=$waitTimeout" - - // downloadLoop does already try-catch (without failing the process). - downloadLoop { - /** - * This bank account will act as the debtor, once a new fiat - * payment is detected. It's the debtor that pays the related - * regional amount to the exchange, in order to start a withdrawal - * operation (in regional coins). - */ - val debitBankAccount = getBankAccountFromLabel(accountToDebit) - /** - * Setting the 'start' URI param in the following command - * lets Sandbox receive only unseen payments from Nexus. - */ - val uriWithStart = "$uriWithoutStart&start=${debitBankAccount.lastFiatFetch}" - runBlocking { - // Maybe get new fiat transactions. - logger.debug("GETting fiat transactions from: $uriWithStart") - val resp = client.get(uriWithStart) { - expectSuccess = false // Avoids excepting on !2xx - basicAuth(usernameAtNexus, passwordAtNexus) - } - // The server failed, pause and try again - if (resp.status.value.toString().startsWith('5')) { - logger.error("Buy-in monitor requested to a failing Nexus. Retry.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - return@runBlocking - } - // The client failed, fail the process. - if (resp.status.value.toString().startsWith('4')) { - logger.error("Buy-in monitor failed at GETting to Nexus. Stopping the buy-in monitor.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - throw BuyinClientError() - } - // Expect 200 OK. What if 3xx? - if (resp.status.value != HttpStatusCode.OK.value) { - logger.error("Unhandled response status ${resp.status.value}, failing Sandbox") - throw BuyinClientError() - } - // Nexus responded 200 OK, analyzing the result. - /** - * Wire to "admin" if the subject is a public key, or do - * nothing otherwise. - */ - val respObj = jacksonObjectMapper().readValue( - resp.bodyAsText(), - NexusTransactions::class.java - ) // errors are logged by the caller (without failing). - respObj.transactions.forEach { - // Ignoring payments with an invalid reserved public key. - if (extractReservePubFromSubject(it.camtData.getSingletonSubject()) == null) - return@forEach - // Extracts the amount and checks it's at most two fractional digits. - val maybeValidAmount = it.camtData.amount.value - if (!validatePlainAmount(maybeValidAmount)) { - logger.error("Nexus gave one amount with invalid fractional digits: $maybeValidAmount." + - " The transaction has index ${it.index}") - // Advancing the last fetched pointer, to avoid GETting - // this invalid payment again. - transaction { - debitBankAccount.refresh() - debitBankAccount.lastFiatFetch = it.index - } - } - val convertedAmount = applyBuyinRatioAndFees( - maybeValidAmount.toBigDecimal(), - ratiosAndFees - ) - transaction { - wireTransfer( - debitAccount = accountToDebit, - creditAccount = accountToCredit, - demobank = demobankName, - subject = it.camtData.getSingletonSubject(), - amount = "${demobank.config.currency}:$convertedAmount" - ) - // Nexus enqueues the transactions such that the index increases. - // If Sandbox crashes here, it'll ask again using the last successful - // index as the start parameter. Being this an exclusive bound, only - // transactions later than it are expected. - debitBankAccount.refresh() - debitBankAccount.lastFiatFetch = it.index - } - } - } - } -} - -/* DB query helper that fetches the latest cash-out operations that were - confirmed in the regional currency. A cash-out operation is 'confirmed' - when the bank account pointed by the parameter 'bankAccountLabel' gets - one incoming payment. - - The List return type (instead of SizedIterable) lets the caller NOT open - a transaction block to access the values -- although some operations _on - the values_ may be forbidden. -*/ -fun getUnsubmittedTransactions(bankAccountLabel: String): List { - return transaction { - val bankAccount = getBankAccountFromLabel(bankAccountLabel) - val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0 - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.id greater lowerExclusiveLimit and ( - BankAccountTransactionsTable.direction eq "CRDT" - ) and (BankAccountTransactionsTable.account eq bankAccount.id) - }.sortedBy { it.id }.map { it } - /* The latest payment must occupy the highest index, - to reliably update the 'lastFiatSubmission' column of - the bank account. */ - } -} - -// CASH-OUT SIDE. - -/** - * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX) - * on the 'watchedBankAccount' and submits the related cash-out payment - * to Nexus. The fiat payment will then take place ENTIRELY on Nexus' - * responsibility. - */ -suspend fun cashoutMonitor( - httpClient: HttpClient, - watchedBankAccount: String = "admin", - demobankName: String = "default", // used to get config values. - dbEventTimeout: Long = 0 // 0 waits forever. -) { - ensureDisabledRedirects(httpClient) - // Register for a REGIO_TX event. - val eventChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - watchedBankAccount - ) - val objectMapper = jacksonObjectMapper() - val demobank = getDemobank(demobankName) - val bankAccount = getBankAccountFromLabel(watchedBankAccount) - val config = demobank?.config ?: throw internalServerError( - "Demobank '$demobankName' has no configuration." - ) - /** - * The monitor needs the cash-out currency to correctly POST - * payment initiations at Nexus. Recall: Nexus bank accounts - * do not mandate any particular currency, as they serve as mere - * bridges to the backing bank. And: a backing bank may have - * multiple currencies, or the backing bank may not explicitly - * specify any currencies to be _the_ currency of the backed - * bank account. - */ - if (config.cashoutCurrency == null) { - logger.error("Config lacks cash-out currency.") - exitProcess(1) - } - val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl) - val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus) - val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus) - val paymentInitEndpoint = nexusBaseUrl.run { - var nexusBaseUrlFromConfig = this - if (!nexusBaseUrlFromConfig.endsWith('/')) - nexusBaseUrlFromConfig += '/' - /** - * WARNING: Nexus gives the possibility to have bank account names - * DIFFERENT from their owner's username. Sandbox however MUST have - * its Nexus bank account named THE SAME as its username. - */ - nexusBaseUrlFromConfig + "bank-accounts/$usernameAtNexus/payment-initiations" - } - while (true) { - val listenHandle = PostgresListenHandle(eventChannel) - // pessimistically LISTEN - listenHandle.postgresListen() - // but optimistically check for data, case some - // arrived _before_ the LISTEN. - var newTxs = getUnsubmittedTransactions(watchedBankAccount) - // Data found, UNLISTEN. - if (newTxs.isNotEmpty()) { - logger.debug("Found cash-out's without waiting any DB event.") - listenHandle.postgresUnlisten() - } - // Data not found, wait. - else { - logger.debug("Need to wait a DB event for new cash-out's") - val isNotificationArrived = listenHandle.waitOnIODispatchers(dbEventTimeout) - if (isNotificationArrived && listenHandle.receivedPayload == "CRDT") - newTxs = getUnsubmittedTransactions(watchedBankAccount) - } - if (newTxs.isEmpty()) { - logger.debug("DB event timeout expired") - continue - } - logger.debug("POSTing new cash-out's") - newTxs.forEach { - logger.debug("POSTing cash-out '${it.subject}' to $paymentInitEndpoint") - val body = object { - /** - * This field is UID of the request _as assigned by the - * client_. That helps to reconcile transactions or lets - * Nexus implement idempotency. It will NOT identify the created - * resource at the server side. The ID of the created resource is - * assigned _by Nexus_ and communicated in the (successful) response. - */ - val uid = it.accountServicerReference - val iban = it.creditorIban - val bic = it.creditorBic - val amount = "${config.cashoutCurrency}:${it.amount}" - val subject = it.subject - val name = it.creditorName - } - val resp = try { - httpClient.post(paymentInitEndpoint) { - expectSuccess = false // Avoids excepting on !2xx - basicAuth(usernameAtNexus, passwordAtNexus) - contentType(ContentType.Application.Json) - setBody(objectMapper.writeValueAsString(body)) - } - } - // Hard-error, response did not even arrive. - catch (e: Exception) { - logger.error("Cash-out monitor could not reach Nexus. Pause and retry") - logger.error(e.message) - /** - * Explicit delaying because the monitor normally - * waits on DB events, and this retry likely won't - * wait on a DB event. - */ - delay(2000) - return@forEach - } - // Server fault. Pause and retry. - if (resp.status.value.toString().startsWith('5')) { - logger.error("Cash-out monitor POSTed to a failing Nexus. Pause and retry") - logger.error("Server responded: ${resp.bodyAsText()}") - /** - * Explicit delaying because the monitor normally - * waits on DB events, and this retry likely won't - * wait on a DB event. - */ - delay(2000L) - return@forEach - } - // Client fault, fail Sandbox. - if (resp.status.value.toString().startsWith('4')) { - logger.error("Cash-out monitor failed at POSTing to Nexus.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - throw CashoutClientError() - } - // Expecting 200 OK. What if 3xx? - if (resp.status.value != HttpStatusCode.OK.value) { - logger.error("Cash-out monitor, unhandled response status: ${resp.status.value}.") - throw CashoutClientError() - } - // Successful case, mark the wire transfer as submitted, - // and advance the pointer to the last submitted payment. - val responseBody = resp.bodyAsText() - transaction { - CashoutSubmissionEntity.new { - localTransaction = it.id - submissionTime = resp.responseTime.timestamp - /** - * The following block associates the submitted payment - * to the UID that Nexus assigned to it. It is currently not - * used in Sandbox, but might help for reconciliation. - */ - if (responseBody.isNotEmpty()) - maybeNexusResposnse = responseBody - } - // Advancing the 'last submitted bookmark', to avoid - // handling the same transaction multiple times. - bankAccount.lastFiatSubmission = it - } - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt deleted file mode 100644 index 523b1bc3..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt +++ /dev/null @@ -1,747 +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 - * - */ - -package tech.libeufin.sandbox - -import io.ktor.http.* -import org.jetbrains.exposed.dao.Entity -import org.jetbrains.exposed.dao.EntityClass -import org.jetbrains.exposed.dao.IntEntity -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.IntEntityClass -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.IdTable -import org.jetbrains.exposed.dao.id.IntIdTable -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import kotlin.reflect.* -import kotlin.reflect.full.* - -/** - * All the states to give a subscriber. - */ -enum class SubscriberState { - /** - * No keys at all given to the bank. - */ - NEW, - - /** - * Only INI electronic message was successfully sent. - */ - PARTIALLY_INITIALIZED_INI, - - /**r - * Only HIA electronic message was successfully sent. - */ - PARTIALLY_INITIALIZED_HIA, - - /** - * Both INI and HIA were electronically sent with success. - */ - INITIALIZED, - - /** - * All the keys accounted in INI and HIA have been confirmed - * via physical mail. - */ - READY -} - -/** - * All the states that one key can be assigned. - */ -enum class KeyState { - - /** - * The key was never communicated. - */ - MISSING, - - /** - * The key has been electronically sent. - */ - NEW, - - /** - * The key has been confirmed (either via physical mail - * or electronically -- e.g. with certificates) - */ - RELEASED -} - -/** - * Stores one config object to the database. Each field - * name and value populate respectively the configKey and - * configValue columns. Rows are defined in the following way: - * demobankName | configKey | configValue - */ -fun insertConfigPairs(config: DemobankConfig, override: Boolean = false) { - // Fill the config key-value pairs in the DB. - config::class.declaredMemberProperties.forEach { configField -> - val maybeValue = configField.getter.call(config) - if (override) { - val maybeConfigPair = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.configKey eq configField.name - }.firstOrNull() - if (maybeConfigPair == null) - throw internalServerError("Cannot override config value '${configField.name}' not found.") - maybeConfigPair.configValue = maybeValue?.toString() - return@forEach - } - DemobankConfigPairEntity.new { - this.demobankName = config.demobankName - this.configKey = configField.name - this.configValue = maybeValue?.toString() - } - } -} - -object DemobankConfigPairsTable : LongIdTable() { - val demobankName = text("demobankName") - val configKey = text("configKey") - val configValue = text("configValue").nullable() -} - -class DemobankConfigPairEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(DemobankConfigPairsTable) - var demobankName by DemobankConfigPairsTable.demobankName - var configKey by DemobankConfigPairsTable.configKey - var configValue by DemobankConfigPairsTable.configValue -} - -object DemobankConfigsTable : LongIdTable() { - val name = text("hostname") -} - -// Helpers for handling config values in memory. -typealias DemobankConfigKey = String -typealias DemobankConfigValue = String? -fun Pair.expectValue(): String { - if (this.second == null) throw internalServerError("Config value for '${this.first}' is null in the database.") - return this.second as String -} - -class DemobankConfigEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(DemobankConfigsTable) - var name by DemobankConfigsTable.name - /** - * This object gets defined by parsing all the configuration - * values found in the DB for one demobank. Those values are - * retrieved from _another_ table. - */ - val config: DemobankConfig by lazy { - // Getting all the values for this demobank. - val configPairs: List> = transaction { - val maybeConfigPairs = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.demobankName.eq(name) - } - if (maybeConfigPairs.empty()) throw SandboxError( - HttpStatusCode.InternalServerError, - "No config values of $name were found in the database" - ) - // Copying results to a DB-agnostic list, to later operate out of "transaction {}" - maybeConfigPairs.map { Pair(it.configKey, it.configValue) } - } - // Building the args to instantiate a DemobankConfig (non-Exposed) object. - val args = mutableMapOf() - // For each constructor parameter name, find the same-named database entry. - val configClass = DemobankConfig::class - if (configClass.primaryConstructor == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "${configClass.simpleName} primaryConstructor is null." - ) - } - if (configClass.primaryConstructor?.parameters == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "${configClass.simpleName} primaryConstructor" + - " arguments is null. Cannot set any config value." - ) - } - // For each field in the config object, find the respective DB row. - configClass.primaryConstructor?.parameters?.forEach { par: KParameter -> - val configPairFromDb: Pair? - = configPairs.firstOrNull { - configPair: Pair -> - configPair.first == par.name - } - if (configPairFromDb == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "Config key '${par.name}' not found in the database." - ) - } - when(par.type) { - // non-nullable - typeOf() -> { args[par] = configPairFromDb.expectValue().toBoolean() } - typeOf() -> { args[par] = configPairFromDb.expectValue().toInt() } - // nullable - typeOf() -> { args[par] = configPairFromDb.second?.toBoolean() } - typeOf() -> { args[par] = configPairFromDb.second?.toInt() } - else -> args[par] = configPairFromDb.second - } - } - // Proceeding now to instantiate the config class, and make it a field of this type. - configClass.primaryConstructor!!.callBy(args) - } -} - -/** - * Users who are allowed to log into the demo bank. - * Created via the /demobanks/{demobankname}/register endpoint. - */ -object DemobankCustomersTable : LongIdTable() { - val username = text("username") - val passwordHash = text("passwordHash") - val name = text("name").nullable() - val email = text("email").nullable() - val phone = text("phone").nullable() - val cashout_address = text("cashout_address").nullable() -} - -class DemobankCustomerEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(DemobankCustomersTable) - var username by DemobankCustomersTable.username - var passwordHash by DemobankCustomersTable.passwordHash - var name by DemobankCustomersTable.name - var email by DemobankCustomersTable.email - var phone by DemobankCustomersTable.phone - var cashout_address by DemobankCustomersTable.cashout_address -} - -/** - * This table stores RSA public keys of subscribers. - */ -object EbicsSubscriberPublicKeysTable : IntIdTable() { - val rsaPublicKey = blob("rsaPublicKey") - val state = enumeration("state", KeyState::class) -} - -class EbicsSubscriberPublicKeyEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsSubscriberPublicKeysTable) - var rsaPublicKey by EbicsSubscriberPublicKeysTable.rsaPublicKey - var state by EbicsSubscriberPublicKeysTable.state -} - -/** - * Ebics 'host'(s) that are served by one Sandbox instance. - */ -object EbicsHostsTable : IntIdTable() { - val hostID = text("hostID") - val ebicsVersion = text("ebicsVersion") - val signaturePrivateKey = blob("signaturePrivateKey") - val encryptionPrivateKey = blob("encryptionPrivateKey") - val authenticationPrivateKey = blob("authenticationPrivateKey") -} - -class EbicsHostEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsHostsTable) - var hostId by EbicsHostsTable.hostID - var ebicsVersion by EbicsHostsTable.ebicsVersion - var signaturePrivateKey by EbicsHostsTable.signaturePrivateKey - var encryptionPrivateKey by EbicsHostsTable.encryptionPrivateKey - var authenticationPrivateKey by EbicsHostsTable.authenticationPrivateKey -} - -/** - * Ebics Subscribers table. - */ -object EbicsSubscribersTable : IntIdTable() { - val userId = text("userID") - val partnerId = text("partnerID") - val systemId = text("systemID").nullable() - val hostId = text("hostID") - val signatureKey = reference("signatureKey", EbicsSubscriberPublicKeysTable).nullable() - val encryptionKey = reference("encryptionKey", EbicsSubscriberPublicKeysTable).nullable() - val authenticationKey = reference("authorizationKey", EbicsSubscriberPublicKeysTable).nullable() - val nextOrderID = integer("nextOrderID") - val state = enumeration("state", SubscriberState::class) - val bankAccount = reference( - "bankAccount", - BankAccountsTable, - onDelete = ReferenceOption.CASCADE - ).nullable() -} - -class EbicsSubscriberEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsSubscribersTable) - var userId by EbicsSubscribersTable.userId - var partnerId by EbicsSubscribersTable.partnerId - var systemId by EbicsSubscribersTable.systemId - var hostId by EbicsSubscribersTable.hostId - var signatureKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.signatureKey - var encryptionKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.encryptionKey - var authenticationKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.authenticationKey - var nextOrderID by EbicsSubscribersTable.nextOrderID - var state by EbicsSubscribersTable.state - var bankAccount by BankAccountEntity optionalReferencedOn EbicsSubscribersTable.bankAccount -} - -/** - * Details of a download order. - */ -object EbicsDownloadTransactionsTable : IdTable() { - override val id = text("transactionID").entityId() - val orderType = text("orderType") - val host = reference("host", EbicsHostsTable) - val subscriber = reference("subscriber", EbicsSubscribersTable) - val encodedResponse = text("encodedResponse") - val transactionKeyEnc = blob("transactionKeyEnc") - val numSegments = integer("numSegments") - val segmentSize = integer("segmentSize") - val receiptReceived = bool("receiptReceived") -} - -class EbicsDownloadTransactionEntity(id: EntityID) : Entity(id) { - companion object : EntityClass(EbicsDownloadTransactionsTable) - - var orderType by EbicsDownloadTransactionsTable.orderType - var host by EbicsHostEntity referencedOn EbicsDownloadTransactionsTable.host - var subscriber by EbicsSubscriberEntity referencedOn EbicsDownloadTransactionsTable.subscriber - var encodedResponse by EbicsDownloadTransactionsTable.encodedResponse - var numSegments by EbicsDownloadTransactionsTable.numSegments - var transactionKeyEnc by EbicsDownloadTransactionsTable.transactionKeyEnc - var segmentSize by EbicsDownloadTransactionsTable.segmentSize - var receiptReceived by EbicsDownloadTransactionsTable.receiptReceived -} - -/** - * Details of a upload order. - */ -object EbicsUploadTransactionsTable : IdTable() { - override val id = text("transactionID").entityId() - val orderType = text("orderType") - val orderID = text("orderID") - val host = reference("host", EbicsHostsTable) - val subscriber = reference("subscriber", EbicsSubscribersTable) - val numSegments = integer("numSegments") - val lastSeenSegment = integer("lastSeenSegment") - val transactionKeyEnc = blob("transactionKeyEnc") -} - -class EbicsUploadTransactionEntity(id: EntityID) : Entity(id) { - companion object : EntityClass(EbicsUploadTransactionsTable) - var orderType by EbicsUploadTransactionsTable.orderType - var orderID by EbicsUploadTransactionsTable.orderID - var host by EbicsHostEntity referencedOn EbicsUploadTransactionsTable.host - var subscriber by EbicsSubscriberEntity referencedOn EbicsUploadTransactionsTable.subscriber - var numSegments by EbicsUploadTransactionsTable.numSegments - var lastSeenSegment by EbicsUploadTransactionsTable.lastSeenSegment - var transactionKeyEnc by EbicsUploadTransactionsTable.transactionKeyEnc -} - -/** - * FIXME: document this. - */ -object EbicsOrderSignaturesTable : IntIdTable() { - val orderID = text("orderID") - val orderType = text("orderType") - val partnerID = text("partnerID") - val userID = text("userID") - val signatureAlgorithm = text("signatureAlgorithm") - val signatureValue = blob("signatureValue") -} - -class EbicsOrderSignatureEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(EbicsOrderSignaturesTable) - var orderID by EbicsOrderSignaturesTable.orderID - var orderType by EbicsOrderSignaturesTable.orderType - var partnerID by EbicsOrderSignaturesTable.partnerID - var userID by EbicsOrderSignaturesTable.userID - var signatureAlgorithm by EbicsOrderSignaturesTable.signatureAlgorithm - var signatureValue by EbicsOrderSignaturesTable.signatureValue -} - -/** - * FIXME: document this. - */ -object EbicsUploadTransactionChunksTable : IdTable() { - override val id = text("transactionID").entityId() - val chunkIndex = integer("chunkIndex") - val chunkContent = blob("chunkContent") -} - -// FIXME: Is upload chunking not implemented somewhere?! -class EbicsUploadTransactionChunkEntity(id: EntityID) : Entity(id) { - companion object : EntityClass(EbicsUploadTransactionChunksTable) - var chunkIndex by EbicsUploadTransactionChunksTable.chunkIndex - var chunkContent by EbicsUploadTransactionChunksTable.chunkContent -} - - -/** - * Holds those transactions that aren't yet reported in a Camt.053 document. - * After reporting those, the table gets emptied. Rows are merely references - * to the main ledger. - */ -object BankAccountFreshTransactionsTable : LongIdTable() { - val transactionRef = reference( - "transaction", - BankAccountTransactionsTable, - onDelete = ReferenceOption.CASCADE - ) -} -class BankAccountFreshTransactionEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(BankAccountFreshTransactionsTable) - var transactionRef by BankAccountTransactionEntity referencedOn BankAccountFreshTransactionsTable.transactionRef -} - -/** - * Table that keeps all the payments initiated by PAIN.001. - */ -object BankAccountTransactionsTable : LongIdTable() { - val creditorIban = text("creditorIban") - val creditorBic = text("creditorBic").nullable() - val creditorName = text("creditorName") - val debtorIban = text("debtorIban") - val debtorBic = text("debtorBic").nullable() - val debtorName = text("debtorName") - val subject = text("subject") - // Amount is a BigDecimal in String form. - val amount = text("amount") - val currency = text("currency") - // Milliseconds since the Epoch. - val date = long("date") - - /** - * UID assigned to the payment by Sandbox. Despite the camt-looking - * name, this UID is always given, even when no EBICS or camt are being - * served. - */ - val accountServicerReference = text("accountServicerReference") - /** - * The following two values are pain.001 specific. Sandbox stores - * them when it serves EBICS connections. - */ - val pmtInfId = text("pmtInfId").nullable() - val endToEndId = text("EndToEndId").nullable() - val direction = text("direction") - /** - * Bank account of the party whose 'direction' refers. This version allows - * only both parties to be registered at the running Sandbox. - */ - val account = reference( - "account", BankAccountsTable, - onDelete = ReferenceOption.CASCADE - ) - // Redundantly storing the demobank for query convenience. - val demobank = reference("demobank", DemobankConfigsTable) -} - -class BankAccountTransactionEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(BankAccountTransactionsTable) { - override fun new(init: BankAccountTransactionEntity.() -> Unit): BankAccountTransactionEntity { - /** - * Fresh transactions are those that wait to be included in a - * "history" report, likely a Camt.5x message. The "fresh transactions" - * table keeps a list of such transactions. - */ - val freshTx = super.new(init) - BankAccountFreshTransactionsTable.insert { - it[transactionRef] = freshTx.id - } - /** - * The bank account involved in this transaction points to - * it as the "last known" transaction, to make it easier to - * build histories that depend on such record. - */ - freshTx.account.lastTransaction = freshTx - return freshTx - } - } - var creditorIban by BankAccountTransactionsTable.creditorIban - var creditorBic by BankAccountTransactionsTable.creditorBic - var creditorName by BankAccountTransactionsTable.creditorName - var debtorIban by BankAccountTransactionsTable.debtorIban - var debtorBic by BankAccountTransactionsTable.debtorBic - var debtorName by BankAccountTransactionsTable.debtorName - var subject by BankAccountTransactionsTable.subject - var amount by BankAccountTransactionsTable.amount - var currency by BankAccountTransactionsTable.currency - var date by BankAccountTransactionsTable.date - var accountServicerReference by BankAccountTransactionsTable.accountServicerReference - var pmtInfId by BankAccountTransactionsTable.pmtInfId - var endToEndId by BankAccountTransactionsTable.endToEndId - var direction by BankAccountTransactionsTable.direction - var account by BankAccountEntity referencedOn BankAccountTransactionsTable.account - var demobank by DemobankConfigEntity referencedOn BankAccountTransactionsTable.demobank -} - -/** - * Table that keeps information about which bank accounts (iban+bic+name) - * are active in the system. In the current version, 'label' and 'owner' - * are always equal; future versions may change this, when one customer can - * own multiple bank accounts. - */ -object BankAccountsTable : IntIdTable() { - val balance = text("balance").default("0") - val iban = text("iban") - val bic = text("bic").default("SANDBOXX") - val label = text("label").uniqueIndex("accountLabelIndex") - /** - * This field is the username of the customer that owns the - * bank account. Admin is the only exception: that can specify - * this field as "admin" although no customer backs it. - */ - val owner = text("owner") - val isPublic = bool("isPublic").default(false) - val demoBank = reference("demoBank", DemobankConfigsTable) - - /** - * Point to the last transaction related to this account, regardless - * of it being credit or debit. This reference helps to construct - * history results that start from / depend on the last transaction. - */ - val lastTransaction = reference("lastTransaction", BankAccountTransactionsTable).nullable() - - /** - * Points to the transaction that was last submitted by the conversion - * service to Nexus, in order to initiate a fiat payment related to a - * cash-out operation. - */ - val lastFiatSubmission = reference("lastFiatSubmission", BankAccountTransactionsTable).nullable() - - /** - * Tracks the last fiat payment that was read from Nexus. This tracker - * gets updated ONLY IF the exchange gets successfully paid with the related - * amount in the regional currency. - */ - val lastFiatFetch = text("lastFiatFetch").default("0") -} - -class BankAccountEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(BankAccountsTable) - - var balance by BankAccountsTable.balance - var iban by BankAccountsTable.iban - var bic by BankAccountsTable.bic - var label by BankAccountsTable.label - var owner by BankAccountsTable.owner - var isPublic by BankAccountsTable.isPublic - var demoBank by DemobankConfigEntity referencedOn BankAccountsTable.demoBank - var lastTransaction by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastTransaction - var lastFiatSubmission by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastFiatSubmission - var lastFiatFetch by BankAccountsTable.lastFiatFetch -} - -object BankAccountStatementsTable : IntIdTable() { - val statementId = text("statementId") - val creationTime = long("creationTime") - val xmlMessage = text("xmlMessage") - val bankAccount = reference("bankAccount", BankAccountsTable) - // Signed BigDecimal representing a Camt.053 CLBD field. - val balanceClbd = text("balanceClbd").nullable() -} - -class BankAccountStatementEntity(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(BankAccountStatementsTable) - var statementId by BankAccountStatementsTable.statementId - var creationTime by BankAccountStatementsTable.creationTime - var xmlMessage by BankAccountStatementsTable.xmlMessage - var bankAccount by BankAccountEntity referencedOn BankAccountStatementsTable.bankAccount - var balanceClbd by BankAccountStatementsTable.balanceClbd -} - -enum class CashoutOperationStatus { CONFIRMED, PENDING } -object CashoutOperationsTable : LongIdTable() { - val uuid = uuid("uuid").autoGenerate() - /** - * This amount is the one the user entered in the cash-out - * dialog. That will show up as the outgoing transfer in their - * local currency bank account. - */ - val amountDebit = text("amountDebit") - val amountCredit = text("amountCredit") - val buyAtRatio = text("buyAtRatio") - val buyInFee = text("buyInFee") - val sellAtRatio = text("sellAtRatio") - val sellOutFee = text("sellOutFee") - val subject = text("subject") - val creationTime = long("creationTime") // in milliseconds. - val confirmationTime = long("confirmationTime").nullable() // in milliseconds. - val tanChannel = enumeration("tanChannel", SupportedTanChannels::class) - val account = text("account") - val cashoutAddress = text("cashoutAddress") - val tan = text("tan") - val status = enumeration("status", CashoutOperationStatus::class).default(CashoutOperationStatus.PENDING) -} - -class CashoutOperationEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(CashoutOperationsTable) - var uuid by CashoutOperationsTable.uuid - var amountDebit by CashoutOperationsTable.amountDebit - var amountCredit by CashoutOperationsTable.amountCredit - var buyAtRatio by CashoutOperationsTable.buyAtRatio - var buyInFee by CashoutOperationsTable.buyInFee - var sellAtRatio by CashoutOperationsTable.sellAtRatio - var sellOutFee by CashoutOperationsTable.sellOutFee - var subject by CashoutOperationsTable.subject - var creationTime by CashoutOperationsTable.creationTime - var confirmationTime by CashoutOperationsTable.confirmationTime - var tanChannel by CashoutOperationsTable.tanChannel - var account by CashoutOperationsTable.account - var cashoutAddress by CashoutOperationsTable.cashoutAddress - var tan by CashoutOperationsTable.tan - var status by CashoutOperationsTable.status -} -object TalerWithdrawalsTable : LongIdTable() { - val wopid = uuid("wopid").autoGenerate() - val amount = text("amount") // $currency:x.y - /** - * Turns to true after the wallet gave the reserve public key - * and the exchange details to the bank. - */ - val selectionDone = bool("selectionDone").default(false) - val aborted = bool("aborted").default(false) - /** - * Turns to true after the wire transfer to the exchange bank account - * gets completed _on the bank's side_. This does never guarantees that - * the payment arrived at the exchange's bank yet. - */ - val confirmationDone = bool("confirmationDone").default(false) - val reservePub = text("reservePub").nullable() - val selectedExchangePayto = text("selectedExchangePayto").nullable() - val walletBankAccount = reference("walletBankAccount", BankAccountsTable) -} -class TalerWithdrawalEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(TalerWithdrawalsTable) - var wopid by TalerWithdrawalsTable.wopid - var selectionDone by TalerWithdrawalsTable.selectionDone - var confirmationDone by TalerWithdrawalsTable.confirmationDone - var reservePub by TalerWithdrawalsTable.reservePub - var selectedExchangePayto by TalerWithdrawalsTable.selectedExchangePayto - var amount by TalerWithdrawalsTable.amount - var walletBankAccount by BankAccountEntity referencedOn TalerWithdrawalsTable.walletBankAccount - var aborted by TalerWithdrawalsTable.aborted -} - -object BankAccountReportsTable : IntIdTable() { - val reportId = text("reportId") - val creationTime = long("creationTime") - val xmlMessage = text("xmlMessage") - val bankAccount = reference("bankAccount", BankAccountsTable) -} - -/** - * This table tracks the cash-out requests that Sandbox sends to Nexus. - * Only successful requests make it to this table. Failed request would - * either _stop_ the conversion service (for client-side errors) or get retried - * at a later time (for server-side errors.) - */ -object CashoutSubmissionsTable: LongIdTable() { - val localTransaction = reference("localTransaction", BankAccountTransactionsTable).uniqueIndex() - val maybeNexusResponse = text("maybeNexusResponse").nullable() - val submissionTime = long("submissionTime").nullable() // failed don't have it. -} - -class CashoutSubmissionEntity(id: EntityID) : LongEntity(id) { - companion object : LongEntityClass(CashoutSubmissionsTable) - var localTransaction by CashoutSubmissionsTable.localTransaction - var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse - var submissionTime by CashoutSubmissionsTable.submissionTime -} - -fun dbDropTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - val ret = execCommand( - listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "sandbox", - "-r" // the drop option - ), - /** - * Tolerating a failure here helps to manage the case - * where an empty database is attempted to be dropped. - */ - throwIfFails = false - ) - if (ret != 0) - logger.warn("Dropping the sandbox tables failed. Was the DB filled before?") - return - } - transaction { - SchemaUtils.drop( - CashoutSubmissionsTable, - EbicsSubscribersTable, - EbicsSubscriberPublicKeysTable, - EbicsHostsTable, - EbicsDownloadTransactionsTable, - EbicsUploadTransactionsTable, - EbicsUploadTransactionChunksTable, - EbicsOrderSignaturesTable, - BankAccountTransactionsTable, - BankAccountFreshTransactionsTable, - BankAccountsTable, - BankAccountReportsTable, - BankAccountStatementsTable, - DemobankConfigsTable, - DemobankConfigPairsTable, - TalerWithdrawalsTable, - DemobankCustomersTable, - CashoutOperationsTable - ) - } - -} - -fun dbCreateTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - execCommand(listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "sandbox" - )) - return - } - // Still using the legacy way for other DBMSs, like SQLite. - transaction { - SchemaUtils.create( - CashoutSubmissionsTable, - DemobankConfigsTable, - DemobankConfigPairsTable, - EbicsSubscribersTable, - EbicsSubscriberPublicKeysTable, - EbicsHostsTable, - EbicsDownloadTransactionsTable, - EbicsUploadTransactionsTable, - EbicsUploadTransactionChunksTable, - EbicsOrderSignaturesTable, - BankAccountTransactionsTable, - BankAccountFreshTransactionsTable, - BankAccountsTable, - BankAccountReportsTable, - BankAccountStatementsTable, - TalerWithdrawalsTable, - DemobankCustomersTable, - CashoutOperationsTable - ) - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt deleted file mode 100644 index 79f7a404..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt +++ /dev/null @@ -1,665 +0,0 @@ -package tech.libeufin.sandbox - -import org.postgresql.jdbc.PgConnection -import tech.libeufin.util.internalServerError - -import java.sql.DriverManager -import java.sql.PreparedStatement -import java.sql.SQLException -import java.util.* - -private const val DB_CTR_LIMIT = 1000000 - -data class Customer( - val login: String, - val passwordHash: String, - val name: String, - val email: String, - val phone: String, - val cashoutPayto: String, - val cashoutCurrency: String -) - -data class TalerAmount( - val value: Long, - val frac: Int -) - -data class BankAccount( - val iban: String, - val bic: String, - val bankAccountLabel: String, - val owningCustomerId: Long, - val isPublic: Boolean = false, - val lastNexusFetchRowId: Long, - val balance: TalerAmount? = null, - val hasDebt: Boolean -) - -enum class TransactionDirection { - credit, debit -} - -enum class TanChannel { - sms, email, file -} - -data class BankInternalTransaction( - val creditorAccountId: Long, - val debtorAccountId: Long, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, - val accountServicerReference: String, - val endToEndId: String, - val paymentInformationId: String -) - -data class BankAccountTransaction( - val creditorIban: String, - val creditorBic: String, - val creditorName: String, - val debtorIban: String, - val debtorBic: String, - val debtorName: String, - val subject: String, - val amount: TalerAmount, - val transactionDate: Long, // microseconds - val accountServicerReference: String, - val paymentInformationId: String, - val endToEndId: String, - val direction: TransactionDirection, - val bankAccountId: Long, -) - -data class TalerWithdrawalOperation( - val withdrawalUuid: UUID, - val amount: TalerAmount, - val selectionDone: Boolean = false, - val aborted: Boolean = false, - val confirmationDone: Boolean = false, - val reservePub: ByteArray?, - val selectedExchangePayto: String?, - val walletBankAccount: Long -) - -data class Cashout( - val cashoutUuid: UUID, - val localTransaction: Long? = null, - val amountDebit: TalerAmount, - val amountCredit: TalerAmount, - val buyAtRatio: Int, - val buyInFee: TalerAmount, - val sellAtRatio: Int, - val sellOutFee: TalerAmount, - val subject: String, - val creationTime: Long, - val tanConfirmationTime: Long? = null, - val tanChannel: TanChannel, - val tanCode: String, - val bankAccount: Long, - val cashoutAddress: String, - val cashoutCurrency: String -) - -class Database(private val dbConfig: String) { - private var dbConn: PgConnection? = null - private var dbCtr: Int = 0 - private val preparedStatements: MutableMap = mutableMapOf() - - init { - Class.forName("org.postgresql.Driver") - } - private fun reconnect() { - dbCtr++ - val myDbConn = dbConn - if ((dbCtr < DB_CTR_LIMIT && myDbConn != null) && !(myDbConn.isClosed)) - return - dbConn?.close() - preparedStatements.clear() - dbConn = DriverManager.getConnection(dbConfig).unwrap(PgConnection::class.java) - dbCtr = 0 - dbConn?.execSQLUpdate("SET search_path TO libeufin_bank;") - } - - private fun prepare(sql: String): PreparedStatement { - var ps = preparedStatements[sql] - if (ps != null) return ps - val myDbConn = dbConn - if (myDbConn == null) throw internalServerError("DB connection down") - ps = myDbConn.prepareStatement(sql) - preparedStatements[sql] = ps - return ps - } - - /** - * Helper that returns false if the row to be inserted - * hits a unique key constraint violation, true when it - * succeeds. Any other error (re)throws exception. - */ - private fun myExecute(stmt: PreparedStatement): Boolean { - try { - stmt.execute() - } catch (e: SQLException) { - logger.error(e.message) - // NOTE: it seems that _every_ error gets the 0 code. - if (e.errorCode == 0) return false - // rethrowing, not to hide other types of errors. - throw e - } - return true - } - - // CONFIG - fun configGet(configKey: String): String? { - reconnect() - val stmt = prepare("SELECT config_value FROM configuration WHERE config_key=?;") - stmt.setString(1, configKey) - val rs = stmt.executeQuery() - rs.use { - if(!it.next()) return null - return it.getString("config_value") - } - } - fun configSet(configKey: String, configValue: String) { - reconnect() - val stmt = prepare("CALL bank_set_config(TEXT(?), TEXT(?))") - stmt.setString(1, configKey) - stmt.setString(2, configValue) - stmt.execute() - } - - // CUSTOMERS - fun customerCreate(customer: Customer): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO customers ( - login - ,password_hash - ,name - ,email - ,phone - ,cashout_payto - ,cashout_currency - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - """ - ) - stmt.setString(1, customer.login) - stmt.setString(2, customer.passwordHash) - stmt.setString(3, customer.name) - stmt.setString(4, customer.email) - stmt.setString(5, customer.phone) - stmt.setString(6, customer.cashoutPayto) - stmt.setString(7, customer.cashoutCurrency) - - return myExecute(stmt) - } - fun customerGetFromLogin(login: String): Customer? { - reconnect() - val stmt = prepare(""" - SELECT - password_hash, - name, - email, - phone, - cashout_payto, - cashout_currency - FROM customers - WHERE login=? - """) - stmt.setString(1, login) - val rs = stmt.executeQuery() - rs.use { - if (!rs.next()) return null - return Customer( - login = login, - passwordHash = it.getString("password_hash"), - name = it.getString("name"), - phone = it.getString("phone"), - email = it.getString("email"), - cashoutCurrency = it.getString("cashout_currency"), - cashoutPayto = it.getString("cashout_payto") - ) - } - } - // Possibly more "customerGetFrom*()" to come. - - // BANK ACCOUNTS - // Returns false on conflicts. - fun bankAccountCreate(bankAccount: BankAccount): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO bank_accounts - (iban - ,bic - ,bank_account_label - ,owning_customer_id - ,is_public - ,last_nexus_fetch_row_id - ) - VALUES (?, ?, ?, ?, ?, ?) - """) - stmt.setString(1, bankAccount.iban) - stmt.setString(2, bankAccount.bic) - stmt.setString(3, bankAccount.bankAccountLabel) - stmt.setLong(4, bankAccount.owningCustomerId) - stmt.setBoolean(5, bankAccount.isPublic) - stmt.setLong(6, bankAccount.lastNexusFetchRowId) - // using the default zero value for the balance. - return myExecute(stmt) - } - - fun bankAccountSetMaxDebt( - bankAccountLabel: String, - maxDebt: TalerAmount - ): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE bank_accounts - SET max_debt=(?,?)::taler_amount - WHERE bank_account_label=? - """) - stmt.setLong(1, maxDebt.value) - stmt.setInt(2, maxDebt.frac) - stmt.setString(3, bankAccountLabel) - return myExecute(stmt) - } - - fun bankAccountGetFromLabel(bankAccountLabel: String): BankAccount? { - reconnect() - val stmt = prepare(""" - SELECT - iban - ,bic - ,owning_customer_id - ,is_public - ,last_nexus_fetch_row_id - ,(balance).val AS balance_value - ,(balance).frac AS balance_frac - ,has_debt - FROM bank_accounts - WHERE bank_account_label=? - """) - stmt.setString(1, bankAccountLabel) - - val rs = stmt.executeQuery() - rs.use { - if (!it.next()) return null - return BankAccount( - iban = it.getString("iban"), - bic = it.getString("bic"), - balance = TalerAmount( - it.getLong("balance_value"), - it.getInt("balance_frac") - ), - bankAccountLabel = bankAccountLabel, - lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"), - owningCustomerId = it.getLong("owning_customer_id"), - hasDebt = it.getBoolean("has_debt") - ) - } - } - // More bankAccountGetFrom*() to come, on a needed basis. - - // BANK ACCOUNT TRANSACTIONS - enum class BankTransactionResult { - NO_CREDITOR, - NO_DEBTOR, - SUCCESS, - CONFLICT - } - fun bankTransactionCreate( - tx: BankInternalTransaction - ): BankTransactionResult { - reconnect() - val stmt = prepare(""" - SELECT out_nx_creditor, out_nx_debtor, out_balance_insufficient - FROM bank_wire_transfer(?,?,TEXT(?),(?,?)::taler_amount,?,TEXT(?),TEXT(?),TEXT(?)) - """ - ) - stmt.setLong(1, tx.creditorAccountId) - stmt.setLong(2, tx.debtorAccountId) - stmt.setString(3, tx.subject) - stmt.setLong(4, tx.amount.value) - stmt.setInt(5, tx.amount.frac) - stmt.setLong(6, tx.transactionDate) - stmt.setString(7, tx.accountServicerReference) - stmt.setString(8, tx.paymentInformationId) - stmt.setString(9, tx.endToEndId) - val rs = stmt.executeQuery() - rs.use { - if (!rs.next()) throw internalServerError("Bank transaction didn't properly return") - if (rs.getBoolean("out_nx_debtor")) { - logger.error("No debtor account found") - return BankTransactionResult.NO_DEBTOR - } - if (rs.getBoolean("out_nx_creditor")) { - logger.error("No creditor account found") - return BankTransactionResult.NO_CREDITOR - } - if (rs.getBoolean("out_balance_insufficient")) { - logger.error("Balance insufficient") - return BankTransactionResult.CONFLICT - } - return BankTransactionResult.SUCCESS - } - } - - fun bankTransactionGetForHistoryPage( - upperBound: Long, - bankAccountId: Long, - fromMs: Long, - toMs: Long - ): List { - reconnect() - val stmt = prepare(""" - SELECT - creditor_iban - ,creditor_bic - ,creditor_name - ,debtor_iban - ,debtor_bic - ,debtor_name - ,subject - ,(amount).val AS amount_val - ,(amount).frac AS amount_frac - ,transaction_date - ,account_servicer_reference - ,payment_information_id - ,end_to_end_id - ,direction - ,bank_account_id - FROM bank_account_transactions - WHERE bank_transaction_id < ? - AND bank_account_id=? - AND transaction_date BETWEEN ? AND ? - """) - stmt.setLong(1, upperBound) - stmt.setLong(2, bankAccountId) - stmt.setLong(3, fromMs) - stmt.setLong(4, toMs) - val rs = stmt.executeQuery() - rs.use { - val ret = mutableListOf() - if (!it.next()) return ret - do { - ret.add( - BankAccountTransaction( - creditorIban = it.getString("creditor_iban"), - creditorBic = it.getString("creditor_bic"), - creditorName = it.getString("creditor_name"), - debtorIban = it.getString("debtor_iban"), - debtorBic = it.getString("debtor_bic"), - debtorName = it.getString("debtor_name"), - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac") - ), - accountServicerReference = it.getString("account_servicer_reference"), - endToEndId = it.getString("end_to_end_id"), - direction = it.getString("direction").run { - when(this) { - "credit" -> TransactionDirection.credit - "debit" -> TransactionDirection.debit - else -> throw internalServerError("Wrong direction in transaction: $this") - } - }, - bankAccountId = it.getLong("bank_account_id"), - paymentInformationId = it.getString("payment_information_id"), - subject = it.getString("subject"), - transactionDate = it.getLong("transaction_date") - )) - } while (it.next()) - return ret - } - } - - // WITHDRAWALS - fun talerWithdrawalCreate( - opUUID: UUID, - walletBankAccount: Long, - amount: TalerAmount - ): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO - taler_withdrawal_operations - (withdrawal_uuid, wallet_bank_account, amount) - VALUES (?,?,(?,?)::taler_amount) - """) // Take all defaults from the SQL. - stmt.setObject(1, opUUID) - stmt.setLong(2, walletBankAccount) - stmt.setLong(3, amount.value) - stmt.setInt(4, amount.frac) - - return myExecute(stmt) - } - fun talerWithdrawalGet(opUUID: UUID): TalerWithdrawalOperation? { - reconnect() - val stmt = prepare(""" - SELECT - (amount).val as amount_val - ,(amount).frac as amount_frac - ,withdrawal_uuid - ,selection_done - ,aborted - ,confirmation_done - ,reserve_pub - ,selected_exchange_payto - ,wallet_bank_account - FROM taler_withdrawal_operations - WHERE withdrawal_uuid=? - """) - stmt.setObject(1, opUUID) - stmt.executeQuery().use { - if (!it.next()) return null - return TalerWithdrawalOperation( - amount = TalerAmount( - it.getLong("amount_val"), - it.getInt("amount_frac") - ), - selectionDone = it.getBoolean("selection_done"), - selectedExchangePayto = it.getString("selected_exchange_payto"), - walletBankAccount = it.getLong("wallet_bank_account"), - confirmationDone = it.getBoolean("confirmation_done"), - aborted = it.getBoolean("aborted"), - reservePub = it.getBytes("reserve_pub"), - withdrawalUuid = it.getObject("withdrawal_uuid") as UUID - ) - } - } - - // Values coming from the wallet. - fun talerWithdrawalSetDetails( - opUUID: UUID, - exchangePayto: String, - reservePub: ByteArray - ): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE taler_withdrawal_operations - SET selected_exchange_payto = ?, reserve_pub = ?, selection_done = true - WHERE withdrawal_uuid=? - """ - ) - stmt.setString(1, exchangePayto) - stmt.setBytes(2, reservePub) - stmt.setObject(3, opUUID) - return myExecute(stmt) - } - fun talerWithdrawalConfirm(opUUID: UUID): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE taler_withdrawal_operations - SET confirmation_done = true - WHERE withdrawal_uuid=? - """ - ) - stmt.setObject(1, opUUID) - return myExecute(stmt) - } - - fun cashoutCreate(op: Cashout): Boolean { - reconnect() - val stmt = prepare(""" - INSERT INTO cashout_operations ( - cashout_uuid - ,amount_debit - ,amount_credit - ,buy_at_ratio - ,buy_in_fee - ,sell_at_ratio - ,sell_out_fee - ,subject - ,creation_time - ,tan_channel - ,tan_code - ,bank_account - ,cashout_address - ,cashout_currency - ) - VALUES ( - ? - ,(?,?)::taler_amount - ,(?,?)::taler_amount - ,? - ,(?,?)::taler_amount - ,? - ,(?,?)::taler_amount - ,? - ,? - ,?::tan_enum - ,? - ,? - ,? - ,? - ); - """) - stmt.setObject(1, op.cashoutUuid) - stmt.setLong(2, op.amountDebit.value) - stmt.setInt(3, op.amountDebit.frac) - stmt.setLong(4, op.amountCredit.value) - stmt.setInt(5, op.amountCredit.frac) - stmt.setInt(6, op.buyAtRatio) - stmt.setLong(7, op.buyInFee.value) - stmt.setInt(8, op.buyInFee.frac) - stmt.setInt(9, op.sellAtRatio) - stmt.setLong(10, op.sellOutFee.value) - stmt.setInt(11, op.sellOutFee.frac) - stmt.setString(12, op.subject) - stmt.setLong(13, op.creationTime) - stmt.setString(14, op.tanChannel.name) - stmt.setString(15, op.tanCode) - stmt.setLong(16, op.bankAccount) - stmt.setString(17, op.cashoutAddress) - stmt.setString(18, op.cashoutCurrency) - return myExecute(stmt) - } - - fun cashoutConfirm( - opUuid: UUID, - tanConfirmationTimestamp: Long, - bankTransaction: Long // regional payment backing the operation - ): Boolean { - reconnect() - val stmt = prepare(""" - UPDATE cashout_operations - SET tan_confirmation_time = ?, local_transaction = ? - WHERE cashout_uuid=?; - """) - stmt.setLong(1, tanConfirmationTimestamp) - stmt.setLong(2, bankTransaction) - stmt.setObject(3, opUuid) - return myExecute(stmt) - } - // used by /abort - enum class CashoutDeleteResult { - SUCCESS, - CONFLICT_ALREADY_CONFIRMED - } - fun cashoutDelete(opUuid: UUID): CashoutDeleteResult { - val stmt = prepare(""" - SELECT out_already_confirmed - FROM cashout_delete(?) - """) - stmt.setObject(1, opUuid) - stmt.executeQuery().use { - if (!it.next()) { - throw internalServerError("Cashout deletion gave no result") - } - if (it.getBoolean("out_already_confirmed")) return CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED - return CashoutDeleteResult.SUCCESS - } - } - fun cashoutGetFromUuid(opUuid: UUID): Cashout? { - val stmt = prepare(""" - SELECT - (amount_debit).val as amount_debit_val - ,(amount_debit).frac as amount_debit_frac - ,(amount_credit).val as amount_credit_val - ,(amount_credit).frac as amount_credit_frac - ,buy_at_ratio - ,(buy_in_fee).val as buy_in_fee_val - ,(buy_in_fee).frac as buy_in_fee_frac - ,sell_at_ratio - ,(sell_out_fee).val as sell_out_fee_val - ,(sell_out_fee).frac as sell_out_fee_frac - ,subject - ,creation_time - ,tan_channel - ,tan_code - ,bank_account - ,cashout_address - ,cashout_currency - ,tan_confirmation_time - ,local_transaction - FROM cashout_operations - WHERE cashout_uuid=?; - """) - stmt.setObject(1, opUuid) - stmt.executeQuery().use { - if (!it.next()) return null - return Cashout( - amountDebit = TalerAmount( - value = it.getLong("amount_debit_val"), - frac = it.getInt("amount_debit_frac") - ), - amountCredit = TalerAmount( - value = it.getLong("amount_credit_val"), - frac = it.getInt("amount_credit_frac") - ), - bankAccount = it.getLong("bank_account"), - buyAtRatio = it.getInt("buy_at_ratio"), - buyInFee = TalerAmount( - value = it.getLong("buy_in_fee_val"), - frac = it.getInt("buy_in_fee_frac") - ), - cashoutAddress = it.getString("cashout_address"), - cashoutCurrency = it.getString("cashout_currency"), - cashoutUuid = opUuid, - creationTime = it.getLong("creation_time"), - sellAtRatio = it.getInt("sell_at_ratio"), - sellOutFee = TalerAmount( - value = it.getLong("sell_out_fee_val"), - frac = it.getInt("sell_out_fee_frac") - ), - subject = it.getString("subject"), - tanChannel = it.getString("tan_channel").run { - when(this) { - "sms" -> TanChannel.sms - "email" -> TanChannel.email - "file" -> TanChannel.file - else -> throw internalServerError("TAN channel $this unsupported") - } - }, - tanCode = it.getString("tan_code"), - localTransaction = it.getLong("local_transaction"), - tanConfirmationTime = it.getLong("tan_confirmation_time").run { - if (this == 0L) return@run null - return@run this - } - ) - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt deleted file mode 100644 index 57a61f50..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt +++ /dev/null @@ -1,1436 +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 - * - */ - - -package tech.libeufin.sandbox - -import io.ktor.server.application.* -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.util.AttributeKey -import io.ktor.util.date.* -import org.apache.xml.security.binding.xmldsig.RSAKeyValueType -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction -import org.w3c.dom.Document -import tech.libeufin.util.* -import tech.libeufin.util.XMLUtil.Companion.signEbicsResponse -import tech.libeufin.util.ebics_h004.* -import tech.libeufin.util.ebics_hev.HEVResponse -import tech.libeufin.util.ebics_hev.SystemReturnCodeType -import tech.libeufin.util.ebics_s001.SignatureTypes -import tech.libeufin.util.ebics_s001.UserSignatureData -import java.math.BigDecimal -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey -import java.sql.Connection -import java.util.* -import java.util.zip.DeflaterInputStream -import java.util.zip.InflaterInputStream - -val EbicsHostIdAttribute = AttributeKey("RequestedEbicsHostID") - -data class PainParseResult( - val creditorIban: String, - val creditorName: String, - val creditorBic: String?, - val debtorIban: String, - val debtorName: String, - val debtorBic: String?, - val subject: String, - val amount: String, - val currency: String, - val pmtInfId: String, - val endToEndId: String, - val msgId: String -) - -open class EbicsRequestError( - val errorText: String, - val errorCode: String -) : Exception("$errorText (EBICS error code: $errorCode)") - -class EbicsNoDownloadDataAvailable(reason: String? = null) : EbicsRequestError( - "[EBICS_NO_DOWNLOAD_DATA_AVAILABLE]" + if (reason != null) " $reason" else "", - "090005" -) - -class EbicsInvalidRequestError : EbicsRequestError( - "[EBICS_INVALID_REQUEST] Invalid request", - "060102" -) -class EbicsAccountAuthorisationFailed(reason: String) : EbicsRequestError( - "[EBICS_ACCOUNT_AUTHORISATION_FAILED] $reason", - "091302" -) - -/** - * This error is thrown whenever the Subscriber's state is not suitable - * for the requested action. For example, the subscriber sends a EbicsRequest - * message without having first uploaded their keys (#5973). - */ -class EbicsSubscriberStateError : EbicsRequestError( - "[EBICS_INVALID_USER_OR_USER_STATE] Subscriber unknown or subscriber state inadmissible", - "091002" -) -// hint should mention at least the userID -class EbicsUserUnknown(hint: String) : EbicsRequestError( - "[EBICS_USER_UNKNOWN] $hint", - "091003" -) - -class EbicsOrderParamsIgnored(hint: String) : EbicsRequestError( - "[EBICS_ORDER_PARAMS_IGNORED] $hint", - "031001" -) - - -open class EbicsKeyManagementError(private val errorText: String, private val errorCode: String) : - Exception("EBICS key management error: $errorText ($errorCode)") - -private class EbicsInvalidXmlError : EbicsKeyManagementError( - "[EBICS_INVALID_XML]", - "091010" -) - -private class EbicsUnsupportedOrderType : EbicsRequestError( - "[EBICS_UNSUPPORTED_ORDER_TYPE] Order type not supported", - "091005" -) - -/** - * Used here also for "Internal server error". For example, when the - * sandbox itself generates a invalid XML response. - */ -class EbicsProcessingError(detail: String?) : EbicsRequestError( - // a missing detail is already the bank's fault. - "[EBICS_PROCESSING_ERROR] " + (detail ?: "bank internal error"), - "091116" -) - -class EbicsAmountCheckError(detail: String): EbicsRequestError( - "[EBICS_AMOUNT_CHECK_FAILED] $detail", - "091303" -) - -suspend fun respondEbicsTransfer( - call: ApplicationCall, - errorText: String, - errorCode: String -) { - /** - * Because this handler runs for any error, it could - * handle the case where the Ebics host ID is unknown due - * to an invalid request. Recall: Sandbox is multi-host, and - * which Ebics host was requested belongs to the request document. - * - * Therefore, because any Ebics response - * should speak for one Ebics host, we can't respond any Ebics - * type when the Ebics host ID remains unknown due to invalid - * request. Instead, we'll respond plain text: - */ - if (!call.attributes.contains(EbicsHostIdAttribute)) { - call.respondText("Invalid document.", status = HttpStatusCode.BadRequest) - return - } - val resp = EbicsResponse.createForUploadWithError( - errorText, - errorCode, - // For now, phase gets hard-coded as TRANSFER, - // because errors during initialization should have - // already been caught by the chunking logic. - EbicsTypes.TransactionPhaseType.TRANSFER - ) - val hostAuthPriv = transaction { - val host = EbicsHostEntity.find { - EbicsHostsTable.hostID.upperCase() eq call.attributes[EbicsHostIdAttribute] - .uppercase() - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Requested Ebics host ID (${call.attributes[EbicsHostIdAttribute]}) not found." - ) - CryptoUtil.loadRsaPrivateKey(host.authenticationPrivateKey.bytes) - } - call.respondText( - signEbicsResponse(resp, hostAuthPriv), - ContentType.Application.Xml, - HttpStatusCode.OK - ) -} - -private suspend fun ApplicationCall.respondEbicsKeyManagement( - errorText: String, - errorCode: String, - bankReturnCode: String, - dataTransfer: CryptoUtil.EncryptionResult? = null, - orderId: String? = null -) { - val responseXml = EbicsKeyManagementResponse().apply { - version = "H004" - header = EbicsKeyManagementResponse.Header().apply { - authenticate = true - mutable = EbicsKeyManagementResponse.MutableHeaderType().apply { - reportText = errorText - returnCode = errorCode - if (orderId != null) { - this.orderID = orderId - } - } - _static = EbicsKeyManagementResponse.EmptyStaticHeader() - } - body = EbicsKeyManagementResponse.Body().apply { - this.returnCode = EbicsKeyManagementResponse.ReturnCode().apply { - this.authenticate = true - this.value = bankReturnCode - } - if (dataTransfer != null) { - this.dataTransfer = EbicsKeyManagementResponse.DataTransfer().apply { - this.dataEncryptionInfo = EbicsTypes.DataEncryptionInfo().apply { - this.authenticate = true - this.transactionKey = dataTransfer.encryptedTransactionKey - this.encryptionPubKeyDigest = EbicsTypes.PubKeyDigest().apply { - this.algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" - this.version = "E002" - this.value = dataTransfer.pubKeyDigest - } - } - this.orderData = EbicsKeyManagementResponse.OrderData().apply { - this.value = Base64.getEncoder().encodeToString(dataTransfer.encryptedData) - } - } - } - } - } - val text = XMLUtil.convertJaxbToString(responseXml) - // logger.info("responding with:\n${text}") - if (!XMLUtil.validateFromString(text)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoint EBICS key management response is invalid" - ) - respondText(text, ContentType.Application.Xml, HttpStatusCode.OK) -} - -fun expectNonNull(x: T?): T { - if (x == null) { - throw EbicsProtocolError(HttpStatusCode.BadRequest, "expected non-null value") - } - return x; -} - -private fun getRelatedParty(branch: XmlElementBuilder, payment: XLibeufinBankTransaction) { - val otherParty = object { - var ibanPath = "CdtrAcct/Id/IBAN" - var namePath = "Cdtr/Nm" - var iban = payment.creditorIban - var name = payment.creditorName - var bicPath = "CdtrAgt/FinInstnId/BIC" - var bic = payment.creditorBic - } - if (payment.direction == XLibeufinBankDirection.CREDIT) { - otherParty.iban = payment.debtorIban - otherParty.ibanPath = "DbtrAcct/Id/IBAN" - otherParty.namePath = "Dbtr/Nm" - otherParty.name = payment.debtorName - otherParty.bic = payment.debtorBic - otherParty.bicPath = "DbtrAgt/FinInstnId/BIC" - } - branch.element("RltdPties") { - element(otherParty.namePath) { - text(otherParty.name) - } - element(otherParty.ibanPath) { - text(otherParty.iban) - } - } - val otherPartyBic = otherParty.bic - if (otherPartyBic != null) { - branch.element("RltdAgts") { - element(otherParty.bicPath) { - text(otherPartyBic) - } - } - } -} - -// This should fix #6269. -private fun getCreditDebitInd(balance: BigDecimal): String { - if (balance < BigDecimal.ZERO) return "DBIT" - return "CRDT" -} - -fun buildCamtString( - type: Int, - subscriberIban: String, - history: MutableList, - currency: String -): SandboxCamt { - /** - * ID types required: - * - * - Message Id - * - Statement / Report Id - * - Electronic sequence number - * - Legal sequence number - * - Entry Id by the Servicer - * - Payment information Id - * - Proprietary code of the bank transaction - * - Id of the servicer (Issuer and Code) - */ - val camtCreationTime = getSystemTimeNow() // FIXME: should this be the payment time? - val dashedDate = camtCreationTime.toDashedDate() - val zonedDateTime = camtCreationTime.toZonedString() - val creationTimeMillis = camtCreationTime.toInstant().toEpochMilli() - val messageId = "sandbox-${creationTimeMillis / 1000}-${getRandomString(10)}" - - val camtMessage = constructXml(indent = true) { - root("Document") { - attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02") - attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") - attribute( - "xsi:schemaLocation", - "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02 camt.0${type}.001.02.xsd" - ) - element(if (type == 53) "BkToCstmrStmt" else "BkToCstmrAcctRpt") { - element("GrpHdr") { - element("MsgId") { - text(messageId) - } - element("CreDtTm") { - text(zonedDateTime) - } - } - element(if (type == 52) "Rpt" else "Stmt") { - element("Id") { - text("0") - } - element("ElctrncSeqNb") { - text("0") - } - element("LglSeqNb") { - text("0") - } - element("CreDtTm") { - text(zonedDateTime) - } - element("Acct") { - // mandatory account identifier - element("Id/IBAN") { - text(subscriberIban) - } - element("Ccy") { - text(currency) - } - element("Ownr/Nm") { - text("Debitor/Owner Name") - } - element("Svcr/FinInstnId") { - element("Nm") { - text("Libeufin Bank") - } - element("Othr") { - element("Id") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - } - history.forEach { - this.element("Ntry") { - element("Amt") { - attribute("Ccy", it.currency) - text(it.amount) - } - element("CdtDbtInd") { - text( - if (subscriberIban.equals(it.creditorIban)) - "CRDT" else "DBIT" - ) - } - element("Sts") { - /* Status of the entry (see 2.4.2.15.5 from the ISO20022 reference document.) - * From the original text: - * "Status of an entry on the books of the account servicer" */ - text("BOOK") - } - element("BookgDt/Dt") { - text(dashedDate) - } // date of the booking - element("ValDt/Dt") { - text(dashedDate) - } // date of assets' actual (un)availability - element("AcctSvcrRef") { - text(it.uid) - } - element("BkTxCd") { - /* "Set of elements used to fully identify the type of underlying - * transaction resulting in an entry". */ - element("Domn") { - element("Cd") { - text("PMNT") - } - element("Fmly") { - element("Cd") { - text("ICDT") - } - element("SubFmlyCd") { - text("ESCT") - } - } - } - element("Prtry") { - element("Cd") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - element("NtryDtls/TxDtls") { - element("Refs") { - element("MsgId") { - text(it.msgId ?: "NOTPROVIDED") - } - element("PmtInfId") { - text(it.pmtInfId ?: "NOTPROVIDED") - } - element("EndToEndId") { - text(it.endToEndId ?: "NOTPROVIDED") - } - } - element("AmtDtls/TxAmt/Amt") { - attribute("Ccy", currency) - text(it.amount) - } - element("BkTxCd") { - element("Domn") { - element("Cd") { - text("PMNT") - } - element("Fmly") { - element("Cd") { - text("ICDT") - } - element("SubFmlyCd") { - text("ESCT") - } - } - } - element("Prtry") { - element("Cd") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - getRelatedParty(this, it) - element("RmtInf/Ustrd") { - text(it.subject) - } - } - } - } - } - } - } - } - return SandboxCamt( - camtMessage = camtMessage, - messageId = messageId, - creationTime = creationTimeMillis - ) -} - -/** - * Builds CAMT response. - * - * @param type 52 or 53. - */ -private fun constructCamtResponse( - type: Int, - subscriber: EbicsSubscriberEntity, - dateRange: Pair? -): List { - if (type != 53 && type != 52) throw EbicsUnsupportedOrderType() - val bankAccount = getBankAccountFromSubscriber(subscriber) - val history = mutableListOf() - if (type == 52) { - if (dateRange != null) { - logger.debug("Finding date-ranged transactions for account: ${bankAccount.label}, range: ${dateRange.first}, ${dateRange.second}") - transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq bankAccount.id and - BankAccountTransactionsTable.date.between( - dateRange.first, dateRange.second - ) - }.forEach { history.add(getHistoryElementFromTransactionRow(it)) } - } - } else - transaction { - BankAccountFreshTransactionEntity.all().forEach { - if (it.transactionRef.account.label == bankAccount.label) { - history.add(getHistoryElementFromTransactionRow(it)) - } - } - } - if (history.size == 0) throw EbicsNoDownloadDataAvailable() - val camtData = buildCamtString( - type, - bankAccount.iban, - history, - bankAccount.demoBank.config.currency - ) - val paymentsList: String = if (logger.isDebugEnabled) { - var ret = " It includes the payments:" - for (p in history) ret += "\n- ${p.subject}" - ret - } else "" - logger.debug("camt.052 document '${camtData.messageId}' generated.$paymentsList") - return listOf(camtData.camtMessage) - } // end of C52 case. - val ret = mutableListOf() - /** - * Retrieve all the records whose creation date lies into the - * time range given in the function parameters. - */ - if (dateRange != null) { - logger.debug("Serving C53 with date range: $dateRange") - BankAccountStatementEntity.find { - BankAccountStatementsTable.creationTime.between( - dateRange.first, - dateRange.second) and( - BankAccountStatementsTable.bankAccount eq bankAccount.id) - }.forEach { - logger.debug("Including Camt.053: ${it.statementId}") - ret.add(it.xmlMessage) - } - } else { - logger.debug("Serving C53 without date range.") - // No time range was given, hence pick the latest statement. - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankAccount.id - }.lastOrNull().apply { - if (this != null) { - logger.debug("Including Camt.053: ${this.statementId}") - ret.add(this.xmlMessage) - } - } - } - if (ret.size == 0) throw EbicsNoDownloadDataAvailable() - return ret -} - -/** - * TSD (test download) message. - * - * This is a non-standard EBICS order type use by LibEuFin to - * test download transactions. - * - * In the future, additional parameters (size, chunking, inject fault for retry) might - * be added to the order parameters. - */ -private fun handleEbicsTSD(): ByteArray { - return "Hello World\n".repeat(1024).toByteArray() -} - -private fun handleEbicsPTK(): ByteArray { - return "Hello I am a dummy PTK response.".toByteArray() -} - -private fun parsePain001(paymentRequest: String): PainParseResult { - val painDoc = XMLUtil.parseStringIntoDom(paymentRequest) - return destructXml(painDoc) { - requireRootElement("Document") { - requireUniqueChildNamed("CstmrCdtTrfInitn") { - val msgId = requireUniqueChildNamed("GrpHdr") { - requireUniqueChildNamed("MsgId") { focusElement.textContent } - } - requireUniqueChildNamed("PmtInf") { - val debtorName = requireUniqueChildNamed("Dbtr"){ - requireUniqueChildNamed("Nm") { - focusElement.textContent - } - } - val debtorIban = requireUniqueChildNamed("DbtrAcct"){ - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { - focusElement.textContent - } - } - } - val debtorBic = requireUniqueChildNamed("DbtrAgt"){ - requireUniqueChildNamed("FinInstnId") { - requireUniqueChildNamed("BIC") { - focusElement.textContent - } - } - } - val pmtInfId = requireUniqueChildNamed("PmtInfId") { focusElement.textContent } - val txDetails = requireUniqueChildNamed("CdtTrfTxInf") { - object { - val creditorIban = requireUniqueChildNamed("CdtrAcct") { - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { focusElement.textContent } - } - } - val creditorName = requireUniqueChildNamed("Cdtr") { - requireUniqueChildNamed("Nm") { - focusElement.textContent - } - } - val creditorBic = maybeUniqueChildNamed("CdtrAgt") { - requireUniqueChildNamed("FinInstnId") { - requireUniqueChildNamed("BIC") { - focusElement.textContent - } - } - } - val amt = requireUniqueChildNamed("Amt") { - requireOnlyChild { focusElement } - } - val subject = requireUniqueChildNamed("RmtInf") { - requireUniqueChildNamed("Ustrd") { focusElement.textContent } - } - val endToEndId = requireUniqueChildNamed("PmtId") { - requireUniqueChildNamed("EndToEndId") { focusElement.textContent } - } - } - } - /** - * NOTE: this check breaks the compatibility with pain.001, - * because that allows up to 5 fractional digits. For Taler - * compatibility however, we enforce the max 2 fractional digits policy. - */ - if (!validatePlainAmount(txDetails.amt.textContent)) { - throw EbicsProcessingError( - "Amount number malformed: ${txDetails.amt.textContent}" - ) - } - PainParseResult( - currency = txDetails.amt.getAttribute("Ccy"), - amount = txDetails.amt.textContent, - subject = txDetails.subject, - debtorIban = debtorIban, - debtorName = debtorName, - debtorBic = debtorBic, - creditorName = txDetails.creditorName, - creditorIban = txDetails.creditorIban, - creditorBic = txDetails.creditorBic, - pmtInfId = pmtInfId, - endToEndId = txDetails.endToEndId, - msgId = msgId - ) - } - } - } - } -} - -/** - * Process a payment request in the pain.001 format. Note: - * the receiver IBAN is NOT checked to have one account at - * the Sandbox. That's because (1) it leaves open to send - * payments outside of the running Sandbox and (2) may ease - * tests where the preparation logic can skip creating also - * the receiver account. */ -private fun handleCct( - paymentRequest: String, - requestingSubscriber: EbicsSubscriberEntity -) { - val parseResult = parsePain001(paymentRequest) - logger.debug("Handling Pain.001: ${parseResult.pmtInfId}, " + - "for payment: ${parseResult.subject}") - transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { - // Check that subscriber has a bank account - // and that they have rights over the debtor IBAN - if (requestingSubscriber.bankAccount == null) throw EbicsProcessingError( - "Subscriber '${requestingSubscriber.userId}' does not have a bank account." - ) - if (requestingSubscriber.bankAccount!!.iban != parseResult.debtorIban) throw - EbicsAccountAuthorisationFailed( - "Subscriber '${requestingSubscriber.userId}' does not have rights" + - " over the debtor IBAN '${parseResult.debtorIban}'" - ) - val maybeExist = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.pmtInfId eq parseResult.pmtInfId - }.firstOrNull() - if (maybeExist != null) { - logger.info( - "Nexus submitted twice the Pain: ${maybeExist.pmtInfId}. Not taking any action." + - " Sandbox gave it this reference: ${maybeExist.accountServicerReference}" - ) - return@transaction - } - val bankAccount = getBankAccountFromIban(parseResult.debtorIban) - if (parseResult.currency != bankAccount.demoBank.config.currency) throw EbicsRequestError( - "[EBICS_PROCESSING_ERROR] Currency (${parseResult.currency}) not supported.", - "091116" - ) - // Check for the debit case. - val maybeAmount = try { - BigDecimal(parseResult.amount) - } catch (e: Exception) { - logger.warn("Although PAIN validated, BigDecimal didn't parse its amount (${parseResult.amount})!") - throw EbicsProcessingError("The CCT request contains an invalid amount: ${parseResult.amount}") - } - if (maybeDebit(bankAccount.label, maybeAmount, bankAccount.demoBank.name)) - throw EbicsAmountCheckError("The requested amount (${parseResult.amount}) would exceed the debit threshold") - logger.debug("Wire-transfer'ing endToEndId: ${parseResult.endToEndId}") - wireTransfer( - bankAccount.label, - getBankAccountFromIban(parseResult.creditorIban).label, - bankAccount.demoBank.name, - parseResult.subject, - "${parseResult.currency}:${parseResult.amount}", - endToEndId = parseResult.endToEndId - ) - } -} - -/** - * This handler reports all the fresh transactions, belonging - * to the querying subscriber. - */ -private fun handleEbicsC52(requestContext: RequestContext): ByteArray { - val maybeDateRange = requestContext.requestObject.header.static.orderDetails?.orderParams - val dateRange: Pair? = if (maybeDateRange is EbicsRequest.StandardOrderParams) { - val start: Long? = maybeDateRange.dateRange?.start?.toGregorianCalendar()?.timeInMillis - val end: Long? = maybeDateRange.dateRange?.end?.toGregorianCalendar()?.timeInMillis - Pair(start ?: 0L, end ?: Long.MAX_VALUE) - } else null - logger.debug("Date range: $dateRange") - val report = constructCamtResponse( - 52, - requestContext.subscriber, - dateRange = dateRange - ) - sandboxAssert( - report.size == 1, - "C52 response contains more than one Camt.052 document" - ) - if (!XMLUtil.validateFromString(report[0])) { - logger.error("This document was generated invalid:\n${report[0]}") - throw EbicsProcessingError("One outgoing report was found invalid.") - } - return report.map { it.toByteArray() }.zip() -} - -private fun handleEbicsC53(requestContext: RequestContext): ByteArray { - // Fetch date range. - val orderParams = requestContext.requestObject.header.static.orderDetails?.orderParams // as EbicsRequest.StandardOrderParams - val dateRange = if (orderParams != null) { - val standardOrderParams = orderParams as EbicsRequest.StandardOrderParams - val start = standardOrderParams.dateRange?.start?.toGregorianCalendar()?.timeInMillis - val end = standardOrderParams.dateRange?.end?.toGregorianCalendar()?.timeInMillis - if (start == null || end == null) { - // only accepting when both start/end are given. - null - } else { - Pair(start, end) - } - } else - null - /** - * By multiple statements, this function is responsible to return - * a list of Strings: one for each statement. - */ - val camtStatements = constructCamtResponse( - 53, - requestContext.subscriber, - dateRange - ) - camtStatements.forEach { - if (!XMLUtil.validateFromString(it)) { - logger.error("This document was generated invalid:\n$it") - throw EbicsProcessingError("One outgoing statement was found invalid.") - } - } - return camtStatements.map { it.toByteArray() }.zip() -} - -private suspend fun ApplicationCall.handleEbicsHia(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { - InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } - val keyObject = EbicsOrderUtil.decodeOrderDataXml(orderData) - val encPubXml = keyObject.encryptionPubKeyInfo.pubKeyValue.rsaKeyValue - val authPubXml = keyObject.authenticationPubKeyInfo.pubKeyValue.rsaKeyValue - val encPub = CryptoUtil.loadRsaPublicKeyFromComponents(encPubXml.modulus, encPubXml.exponent) - val authPub = CryptoUtil.loadRsaPublicKeyFromComponents(authPubXml.modulus, authPubXml.exponent) - - val ok = transaction { - val ebicsSubscriber = findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - logger.warn("ebics subscriber not found") - throw EbicsInvalidRequestError() - } - when (ebicsSubscriber.state) { - SubscriberState.NEW -> {} - SubscriberState.PARTIALLY_INITIALIZED_INI -> {} - SubscriberState.PARTIALLY_INITIALIZED_HIA, SubscriberState.INITIALIZED, SubscriberState.READY -> { - return@transaction false - } - } - - ebicsSubscriber.authenticationKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(authPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.encryptionKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(encPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.state = when (ebicsSubscriber.state) { - SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_HIA - SubscriberState.PARTIALLY_INITIALIZED_INI -> SubscriberState.INITIALIZED - else -> throw Exception("internal invariant failed") - } - return@transaction true - } - if (ok) { - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") - } else { - respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") - } -} - -private suspend fun ApplicationCall.handleEbicsIni(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { - InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } - val keyObject = EbicsOrderUtil.decodeOrderDataXml(orderData) - val sigPubXml = keyObject.signaturePubKeyInfo.pubKeyValue.rsaKeyValue - val sigPub = CryptoUtil.loadRsaPublicKeyFromComponents(sigPubXml.modulus, sigPubXml.exponent) - - val ok = transaction { - val ebicsSubscriber = - findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - logger.warn("ebics subscriber, ${dumpEbicsSubscriber(header.static)}, not found") - throw EbicsUserUnknown(dumpEbicsSubscriber(header.static)) - } - when (ebicsSubscriber.state) { - SubscriberState.NEW -> {} - SubscriberState.PARTIALLY_INITIALIZED_HIA -> {} - SubscriberState.PARTIALLY_INITIALIZED_INI, SubscriberState.INITIALIZED, SubscriberState.READY -> { - return@transaction false - } - } - ebicsSubscriber.signatureKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(sigPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.state = when (ebicsSubscriber.state) { - SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_INI - SubscriberState.PARTIALLY_INITIALIZED_HIA -> SubscriberState.INITIALIZED - else -> throw Error("internal invariant failed") - } - return@transaction true - } - logger.info("Signature key inserted in database _and_ subscriber state changed accordingly") - if (ok) { - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") - } else { - respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") - } -} - -private suspend fun ApplicationCall.handleEbicsHpb( - ebicsHostInfo: EbicsHostPublicInfo, - requestDocument: Document, - header: EbicsNpkdRequest.Header -) { - val subscriberKeys = transaction { - val ebicsSubscriber = - findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - throw EbicsInvalidRequestError() - } - if (ebicsSubscriber.state != SubscriberState.INITIALIZED) { - throw EbicsSubscriberStateError() - } - val authPubBlob = ebicsSubscriber.authenticationKey!!.rsaPublicKey - val encPubBlob = ebicsSubscriber.encryptionKey!!.rsaPublicKey - val sigPubBlob = ebicsSubscriber.signatureKey!!.rsaPublicKey - SubscriberKeys( - CryptoUtil.loadRsaPublicKey(authPubBlob.bytes), - CryptoUtil.loadRsaPublicKey(encPubBlob.bytes), - CryptoUtil.loadRsaPublicKey(sigPubBlob.bytes) - ) - } - val validationResult = - XMLUtil.verifyEbicsDocument(requestDocument, subscriberKeys.authenticationPublicKey) - if (!validationResult) { - throw EbicsKeyManagementError("invalid signature", "90000") - } - val hpbRespondeData = HPBResponseOrderData().apply { - this.authenticationPubKeyInfo = EbicsTypes.AuthenticationPubKeyInfoType().apply { - this.authenticationVersion = "X002" - this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { - this.rsaKeyValue = RSAKeyValueType().apply { - this.exponent = ebicsHostInfo.authenticationPublicKey.publicExponent.toByteArray() - this.modulus = ebicsHostInfo.authenticationPublicKey.modulus.toByteArray() - } - } - } - this.encryptionPubKeyInfo = EbicsTypes.EncryptionPubKeyInfoType().apply { - this.encryptionVersion = "E002" - this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { - this.rsaKeyValue = RSAKeyValueType().apply { - this.exponent = ebicsHostInfo.encryptionPublicKey.publicExponent.toByteArray() - this.modulus = ebicsHostInfo.encryptionPublicKey.modulus.toByteArray() - } - } - } - this.hostID = ebicsHostInfo.hostID - } - val compressedOrderData = EbicsOrderUtil.encodeOrderDataXml(hpbRespondeData) - val encryptionResult = CryptoUtil.encryptEbicsE002(compressedOrderData, subscriberKeys.encryptionPublicKey) - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000", encryptionResult, "OR01") -} - -/** - * Find the ebics host corresponding to the one specified in the header. - */ -private fun ensureEbicsHost(requestHostID: String): EbicsHostPublicInfo { - return transaction { - val ebicsHost = - EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestHostID.uppercase(Locale.getDefault()) }.firstOrNull() - if (ebicsHost == null) { - logger.warn("client requested unknown HostID ${requestHostID}") - throw EbicsKeyManagementError("[EBICS_INVALID_HOST_ID]", "091011") - } - val encryptionPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.encryptionPrivateKey.bytes) - val authenticationPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.authenticationPrivateKey.bytes) - EbicsHostPublicInfo( - requestHostID, - CryptoUtil.getRsaPublicFromPrivate(encryptionPrivateKey), - CryptoUtil.getRsaPublicFromPrivate(authenticationPrivateKey) - ) - } -} -fun receiveEbicsXmlInternal(xmlData: String): Document { - // logger.debug("Data received: $xmlData") - val requestDocument: Document = XMLUtil.parseStringIntoDom(xmlData) - if (!XMLUtil.validateFromDom(requestDocument)) { - println("Problematic document was: $requestDocument") - throw EbicsInvalidXmlError() - } - return requestDocument -} - -private fun makePartnerInfo(subscriber: EbicsSubscriberEntity): EbicsTypes.PartnerInfo { - val bankAccount = getBankAccountFromSubscriber(subscriber) - val customerProfile = getCustomer(bankAccount.label) - return EbicsTypes.PartnerInfo().apply { - this.accountInfoList = listOf( - EbicsTypes.AccountInfo().apply { - this.id = bankAccount.label - this.accountHolder = customerProfile.name ?: "Never Given" - this.accountNumberList = listOf( - EbicsTypes.GeneralAccountNumber().apply { - this.international = true - this.value = bankAccount.iban - } - ) - this.currency = bankAccount.demoBank.config.currency - this.description = "Ordinary Bank Account" - this.bankCodeList = listOf( - EbicsTypes.GeneralBankCode().apply { - this.international = true - this.value = bankAccount.bic - } - ) - } - ) - this.addressInfo = EbicsTypes.AddressInfo().apply { - this.name = "Address Info Object" - } - this.bankInfo = EbicsTypes.BankInfo().apply { - this.hostID = subscriber.hostId - } - this.orderInfoList = listOf( - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Transactions statement" - this.orderType = "C53" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Transactions report" - this.orderType = "C52" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Payment initiation (ZIPped payload)" - this.orderType = "CCC" - this.transferType = "Upload" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Payment initiation (plain text payload)" - this.orderType = "CCT" - this.transferType = "Upload" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "vmk" - this.orderType = "VMK" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "sta" - this.orderType = "STA" - this.transferType = "Download" - } - ) - } -} - -private fun handleEbicsHtd(requestContext: RequestContext): ByteArray { - val htd = HTDResponseOrderData().apply { - this.partnerInfo = makePartnerInfo(requestContext.subscriber) - this.userInfo = EbicsTypes.UserInfo().apply { - this.name = "Some User" - this.userID = EbicsTypes.UserIDType().apply { - this.status = 5 - this.value = requestContext.subscriber.userId - } - this.permissionList = listOf( - EbicsTypes.UserPermission().apply { - this.orderTypes = "C53 C52 CCC VMK STA" - } - ) - } - } - val str = XMLUtil.convertJaxbToString(htd) - return str.toByteArray() -} - -private fun handleEbicsHkd(requestContext: RequestContext): ByteArray { - val hkd = HKDResponseOrderData().apply { - this.partnerInfo = makePartnerInfo(requestContext.subscriber) - this.userInfoList = listOf( - EbicsTypes.UserInfo().apply { - this.name = "Some User" - this.userID = EbicsTypes.UserIDType().apply { - this.status = 1 - this.value = requestContext.subscriber.userId - } - this.permissionList = listOf( - EbicsTypes.UserPermission().apply { - this.orderTypes = "C54 C53 C52 CCC" - } - ) - }) - } - val str = XMLUtil.convertJaxbToString(hkd) - return str.toByteArray() -} - -private data class RequestContext( - val ebicsHost: EbicsHostEntity, - val subscriber: EbicsSubscriberEntity, - val clientEncPub: RSAPublicKey, - val clientAuthPub: RSAPublicKey, - val clientSigPub: RSAPublicKey, - val hostEncPriv: RSAPrivateCrtKey, - val hostAuthPriv: RSAPrivateCrtKey, - val requestObject: EbicsRequest, - val uploadTransaction: EbicsUploadTransactionEntity?, - val downloadTransaction: EbicsDownloadTransactionEntity? -) - -/** - * Get segmentation values and the EBICS transaction ID, before - * handing the response to 'createForDownloadTransferPhase()'. - */ -private fun handleEbicsDownloadTransactionTransfer(requestContext: RequestContext): EbicsResponse { - val segmentNumber = - requestContext.requestObject.header.mutable.segmentNumber?.value ?: throw EbicsInvalidRequestError() - val transactionID = requestContext.requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - val downloadTransaction = requestContext.downloadTransaction ?: throw AssertionError() - return EbicsResponse.createForDownloadTransferPhase( - transactionID, - downloadTransaction.numSegments, - downloadTransaction.segmentSize, - downloadTransaction.encodedResponse, - segmentNumber.toInt() - ) -} - -private fun handleEbicsDownloadTransactionInitialization(requestContext: RequestContext): EbicsResponse { - val orderType = - requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() - val nonce = requestContext.requestObject.header.static.nonce - val transactionID = EbicsOrderUtil.generateTransactionId() - logger.debug( - "Handling download initialization for order type $orderType, " + - "nonce: ${nonce?.toHexString() ?: "not given"}, " + - "transaction ID: $transactionID" - ) - val response = when (orderType) { - "HTD" -> handleEbicsHtd(requestContext) - "HKD" -> handleEbicsHkd(requestContext) - "C53" -> handleEbicsC53(requestContext) - "C52" -> handleEbicsC52(requestContext) - "TSD" -> handleEbicsTSD() - "PTK" -> handleEbicsPTK() - else -> throw EbicsInvalidXmlError() - } - val compressedResponse = DeflaterInputStream(response.inputStream()).use { - it.readAllBytes() - } - val enc = CryptoUtil.encryptEbicsE002(compressedResponse, requestContext.clientEncPub) - val encodedResponse = Base64.getEncoder().encodeToString(enc.encryptedData) - - val segmentSize = 4096 - val totalSize = encodedResponse.length - val numSegments = ((totalSize + segmentSize - 1) / segmentSize) - - EbicsDownloadTransactionEntity.new(transactionID) { - this.subscriber = requestContext.subscriber - this.host = requestContext.ebicsHost - this.orderType = orderType - this.segmentSize = segmentSize - this.transactionKeyEnc = ExposedBlob(enc.encryptedTransactionKey) - this.encodedResponse = encodedResponse - this.numSegments = numSegments - this.receiptReceived = false - } - /** - * In case of C52, the payload (that includes all the pending - * transactions) got at this point persisted into the database. - * The next block causes such transactions NOT to be returned - * along the next C52 request. - */ - if (orderType == "C52") { - val account = getBankAccountFromSubscriber(requestContext.subscriber) - BankAccountFreshTransactionEntity.all().forEach { - if (it.transactionRef.account.label == account.label) - it.delete() - } - } - return EbicsResponse.createForDownloadInitializationPhase( - transactionID, - numSegments, - segmentSize, - enc, // has customer key - encodedResponse - ) -} - -private fun handleEbicsUploadTransactionInitialization(requestContext: RequestContext): EbicsResponse { - val orderType = - requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() - val transactionID = EbicsOrderUtil.generateTransactionId() - logger.debug("Handling upload initialization for order $orderType, " + - "transactionID $transactionID, nonce: " + - (requestContext.requestObject.header.static.nonce?.toHexString() ?: "not given") - ) - val oidn = requestContext.subscriber.nextOrderID++ - if (EbicsOrderUtil.checkOrderIDOverflow(oidn)) throw NotImplementedError() - val orderID = EbicsOrderUtil.computeOrderIDFromNumber(oidn) - val numSegments = - requestContext.requestObject.header.static.numSegments ?: throw EbicsInvalidRequestError() - val transactionKeyEnc = - requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.transactionKey - ?: throw EbicsInvalidRequestError() - val encPubKeyDigest = - requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.encryptionPubKeyDigest?.value - ?: throw EbicsInvalidRequestError() - val encSigData = requestContext.requestObject.body.dataTransfer?.signatureData?.value - ?: throw EbicsInvalidRequestError() - val decryptedSignatureData = CryptoUtil.decryptEbicsE002( - CryptoUtil.EncryptionResult( - transactionKeyEnc, - encPubKeyDigest, - encSigData - ), requestContext.hostEncPriv - ) - val plainSigData = InflaterInputStream(decryptedSignatureData.inputStream()).use { - it.readAllBytes() - } - EbicsUploadTransactionEntity.new(transactionID) { - this.host = requestContext.ebicsHost - this.subscriber = requestContext.subscriber - this.lastSeenSegment = 0 - this.orderType = orderType - this.orderID = orderID - this.numSegments = numSegments.toInt() - this.transactionKeyEnc = ExposedBlob(transactionKeyEnc) - } - val sigObj = XMLUtil.convertStringToJaxb(plainSigData.toString(Charsets.UTF_8)) - for (sig in sigObj.value.orderSignatureList ?: listOf()) { - logger.debug("inserting order signature for orderID $orderID, order type $orderType, transaction '$transactionID'") - EbicsOrderSignatureEntity.new { - this.orderID = orderID - this.orderType = orderType - this.partnerID = sig.partnerID - this.userID = sig.userID - this.signatureAlgorithm = sig.signatureVersion - this.signatureValue = ExposedBlob(sig.signatureValue) - } - } - return EbicsResponse.createForUploadInitializationPhase(transactionID, orderID) -} - -private fun handleEbicsUploadTransactionTransmission(requestContext: RequestContext): EbicsResponse { - val uploadTransaction = requestContext.uploadTransaction ?: throw EbicsInvalidRequestError() - val requestObject = requestContext.requestObject - val requestSegmentNumber = - requestContext.requestObject.header.mutable.segmentNumber?.value?.toInt() ?: throw EbicsInvalidRequestError() - val requestTransactionID = requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - if (requestSegmentNumber == 1 && uploadTransaction.numSegments == 1) { - val encOrderData = - requestObject.body.dataTransfer?.orderData ?: throw EbicsInvalidRequestError() - val zippedData = CryptoUtil.decryptEbicsE002( - uploadTransaction.transactionKeyEnc.bytes, - Base64.getDecoder().decode(encOrderData), - requestContext.hostEncPriv - ) - val unzippedData = - InflaterInputStream(zippedData.inputStream()).use { it.readAllBytes() } - - val sigs = EbicsOrderSignatureEntity.find { - (EbicsOrderSignaturesTable.orderID eq uploadTransaction.orderID) and - (EbicsOrderSignaturesTable.orderType eq uploadTransaction.orderType) - } - if (sigs.count() == 0L) { - throw EbicsInvalidRequestError() - } - for (sig in sigs) { - if (sig.signatureAlgorithm == "A006") { - - val signedData = CryptoUtil.digestEbicsOrderA006(unzippedData) - val res1 = CryptoUtil.verifyEbicsA006( - sig.signatureValue.bytes, - signedData, - requestContext.clientSigPub - ) - if (!res1) { - throw EbicsInvalidRequestError() - } - - } else { - throw NotImplementedError() - } - } - if (getOrderTypeFromTransactionId(requestTransactionID) == "CCT") { - handleCct(unzippedData.toString(Charsets.UTF_8), - requestContext.subscriber - ) - } - return EbicsResponse.createForUploadTransferPhase( - requestTransactionID, - requestSegmentNumber, - true, - uploadTransaction.orderID - ) - } else { - throw NotImplementedError() - } -} -// req.header.static.hostID. -private fun makeRequestContext(requestObject: EbicsRequest): RequestContext { - val staticHeader = requestObject.header.static - val requestedHostId = staticHeader.hostID - val ebicsHost = - EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestedHostId.uppercase(Locale.getDefault()) } - .firstOrNull() - val requestTransactionID = requestObject.header.static.transactionID - var downloadTransaction: EbicsDownloadTransactionEntity? = null - var uploadTransaction: EbicsUploadTransactionEntity? = null - val subscriber = if (requestTransactionID != null) { - downloadTransaction = EbicsDownloadTransactionEntity.findById(requestTransactionID.uppercase(Locale.getDefault())) - if (downloadTransaction != null) { - downloadTransaction.subscriber - } else { - uploadTransaction = EbicsUploadTransactionEntity.findById(requestTransactionID) - uploadTransaction?.subscriber - } - } else { - val partnerID = staticHeader.partnerID ?: throw EbicsInvalidRequestError() - val userID = staticHeader.userID ?: throw EbicsInvalidRequestError() - findEbicsSubscriber(partnerID, userID, staticHeader.systemID) - } - - if (ebicsHost == null) throw EbicsInvalidRequestError() - - /** - * NOTE: production logic must check against READY state (the - * one activated after the subscriber confirms their keys via post) - */ - if (subscriber == null || subscriber.state != SubscriberState.INITIALIZED) - throw EbicsSubscriberStateError() - - val hostAuthPriv = CryptoUtil.loadRsaPrivateKey( - ebicsHost.authenticationPrivateKey.bytes - ) - val hostEncPriv = CryptoUtil.loadRsaPrivateKey( - ebicsHost.encryptionPrivateKey.bytes - ) - val clientAuthPub = - CryptoUtil.loadRsaPublicKey(subscriber.authenticationKey!!.rsaPublicKey.bytes) - val clientEncPub = - CryptoUtil.loadRsaPublicKey(subscriber.encryptionKey!!.rsaPublicKey.bytes) - val clientSigPub = - CryptoUtil.loadRsaPublicKey(subscriber.signatureKey!!.rsaPublicKey.bytes) - - return RequestContext( - hostAuthPriv = hostAuthPriv, - hostEncPriv = hostEncPriv, - clientAuthPub = clientAuthPub, - clientEncPub = clientEncPub, - clientSigPub = clientSigPub, - ebicsHost = ebicsHost, - requestObject = requestObject, - subscriber = subscriber, - downloadTransaction = downloadTransaction, - uploadTransaction = uploadTransaction - ) -} - -suspend fun ApplicationCall.ebicsweb() { - val requestDocument = this.request.call.receive() - val requestedHostID = requestDocument.getElementsByTagName("HostID") - this.attributes.put( - EbicsHostIdAttribute, - requestedHostID.item(0).textContent - ) - when (requestDocument.documentElement.localName) { - "ebicsUnsecuredRequest" -> { - val requestObject = requestDocument.toObject() - logger.info("Serving a ${requestObject.header.static.orderDetails.orderType} request") - - val orderData = requestObject.body.dataTransfer.orderData.value - val header = requestObject.header - - when (header.static.orderDetails.orderType) { - "INI" -> handleEbicsIni(header, orderData) - "HIA" -> handleEbicsHia(header, orderData) - else -> throw EbicsInvalidXmlError() - } - } - "ebicsHEVRequest" -> { - val hevResponse = HEVResponse().apply { - this.systemReturnCode = SystemReturnCodeType().apply { - this.reportText = "[EBICS_OK]" - this.returnCode = "000000" - } - this.versionNumber = listOf(HEVResponse.VersionNumber.create("H004", "02.50")) - } - - val strResp = XMLUtil.convertJaxbToString(hevResponse) - if (!XMLUtil.validateFromString(strResp)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoing HEV response is invalid" - ) - respondText(strResp, ContentType.Application.Xml, HttpStatusCode.OK) - } - // FIXME: should check subscriber state? - "ebicsNoPubKeyDigestsRequest" -> { - val requestObject = requestDocument.toObject() - val hostInfo = ensureEbicsHost(requestObject.header.static.hostID) - when (requestObject.header.static.orderDetails.orderType) { - "HPB" -> handleEbicsHpb(hostInfo, requestDocument, requestObject.header) - else -> throw EbicsInvalidXmlError() - } - } - // FIXME: must check subscriber state. - "ebicsRequest" -> { - val requestObject = requestDocument.toObject() - val responseXmlStr = transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { - // Step 1 of 3: Get information about the host and subscriber - val requestContext = makeRequestContext(requestObject) - // Step 2 of 3: Validate the signature - val verifyResult = XMLUtil.verifyEbicsDocument(requestDocument, requestContext.clientAuthPub) - if (!verifyResult) { - throw EbicsAccountAuthorisationFailed("Subscriber's signature did not verify") - } - // Step 3 of 3: Generate response - val ebicsResponse: EbicsResponse = when (requestObject.header.mutable.transactionPhase) { - EbicsTypes.TransactionPhaseType.INITIALISATION -> { - if (requestObject.header.static.numSegments == null) { - handleEbicsDownloadTransactionInitialization(requestContext) - } else { - handleEbicsUploadTransactionInitialization(requestContext) - } - } - EbicsTypes.TransactionPhaseType.TRANSFER -> { - if (requestContext.uploadTransaction != null) { - handleEbicsUploadTransactionTransmission(requestContext) - } else if (requestContext.downloadTransaction != null) { - handleEbicsDownloadTransactionTransfer(requestContext) - } else { - throw AssertionError() - } - } - EbicsTypes.TransactionPhaseType.RECEIPT -> { - val requestTransactionID = - requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - if (requestContext.downloadTransaction == null) - throw EbicsInvalidRequestError() - logger.debug("Handling download receipt for EBICS transaction: " + - requestTransactionID) - /** - * The receipt phase means that the client has already - * received all the data related to the current download - * transaction. Hence this data can now be removed from - * the database. - */ - val ebicsData = transaction { - EbicsDownloadTransactionEntity.findById(requestTransactionID) - } - if (ebicsData == null) - throw SandboxError( - HttpStatusCode.InternalServerError, - "EBICS transaction $requestTransactionID was not" + - "found in the database for deletion.", - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE - ) - ebicsData.delete() - val receiptCode = - requestObject.body.transferReceipt?.receiptCode ?: throw EbicsInvalidRequestError() - EbicsResponse.createForDownloadReceiptPhase(requestTransactionID, receiptCode == 0) - } - } - signEbicsResponse(ebicsResponse, requestContext.hostAuthPriv) - } - if (!XMLUtil.validateFromString(responseXmlStr)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoing EBICS XML is invalid" - ) - respondText(responseXmlStr, ContentType.Application.Xml, HttpStatusCode.OK) - } - else -> { - /* Log to console and return "unknown type" */ - logger.info("Unknown message, just logging it!") - respond( - HttpStatusCode.NotImplemented, - SandboxError( - HttpStatusCode.NotImplemented, - "Not Implemented" - ) - ) - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt deleted file mode 100644 index 6529b9d1..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt +++ /dev/null @@ -1,472 +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 - * - */ - -package tech.libeufin.sandbox - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import io.ktor.server.application.* -import io.ktor.http.HttpStatusCode -import io.ktor.server.request.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.security.interfaces.RSAPublicKey -import java.util.* -import java.util.zip.DeflaterInputStream -import kotlin.reflect.KProperty - -data class DemobankConfig( - val allowRegistrations: Boolean, - val currency: String, - val cashoutCurrency: String? = null, - val bankDebtLimit: Int, - val usersDebtLimit: Int, - val withSignupBonus: Boolean, - val demobankName: String, // demobank name. - val captchaUrl: String? = null, - val smsTan: String? = null, // fixme: move the config subcommand - val emailTan: String? = null, // fixme: same as above. - val suggestedExchangeBaseUrl: String? = null, - val suggestedExchangePayto: String? = null, - val nexusBaseUrl: String? = null, - val usernameAtNexus: String? = null, - val passwordAtNexus: String? = null, - val enableConversionService: Boolean = false -) - -fun getConfigValueOrThrow(configKey: KProperty): T { - return configKey.getter.call() ?: throw nullConfigValueError(configKey.name) -} - -/** - * Helps to communicate Camt values without having - * to parse the XML each time one is needed. - */ -data class SandboxCamt( - val camtMessage: String, - val messageId: String, - /** - * That is the number of SECONDS since Epoch. This - * value is exactly what goes into the Camt document. - */ - val creationTime: Long -) - -/** - * DB helper inserting a new "account" into the database. - * The account is made of a 'customer' and 'bank account' - * object. The helper checks first that the username is - * acceptable (chars, no institutional names, available - * names); then checks that IBAN is available and then adds - * the two database objects under the given demobank. This - * function contains the common logic shared by the Access - * and Circuit API. Additional data that is peculiar to one - * API should be added separately. - * - * It returns a AccountPair type. That contains the customer - * object and the bank account; the caller may this way add custom - * values to them. */ -data class AccountPair( - val customer: DemobankCustomerEntity, - val bankAccount: BankAccountEntity -) -fun insertNewAccount(username: String, - password: String, - name: String? = null, // tests and access API may not give one. - iban: String? = null, - demobank: String = "default", - isPublic: Boolean = false): AccountPair { - requireValidResourceName(username) - // Forbid institutional usernames. - if (username == "bank" || username == "admin") { - logger.info("Username: $username not allowed.") - throw forbidden("Username: $username is not allowed.") - } - return transaction { - val demobankFromDb = getDemobank(demobank) - // Bank's fault, because when this function gets - // called, the demobank must exist. - if (demobankFromDb == null) { - logger.error("Demobank '$demobank' not found. Won't add account $username") - throw internalServerError("Demobank $demobank not found. Won't add account $username") - } - // Generate a IBAN if the caller didn't provide one. - val newIban = iban ?: getIban() - // Check IBAN collisions. - val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq newIban).firstOrNull() - if (checkIbanExist != null) { - logger.info("IBAN $newIban not available. Won't register username $username") - throw conflict("IBAN $iban not available.") - } - // Check username availability. - val checkCustomerExist = DemobankCustomerEntity.find { - DemobankCustomersTable.username eq username - }.firstOrNull() - if (checkCustomerExist != null) { - throw SandboxError( - HttpStatusCode.Conflict, - "Username $username not available." - ) - } - val newCustomer = DemobankCustomerEntity.new { - this.username = username - passwordHash = CryptoUtil.hashpw(password) - this.name = name // nullable - } - // Actual account creation. - val newBankAccount = BankAccountEntity.new { - this.iban = newIban - /** - * For now, keep same semantics of Pybank: a username - * is AS WELL a bank account label. In other words, it - * identifies a customer AND a bank account. The reason - * to have the two values (label and owner) is to allow - * multiple bank accounts being owned by one customer. - */ - label = username - owner = username - this.demoBank = demobankFromDb - this.isPublic = isPublic - } - if (demobankFromDb.config.withSignupBonus) - newBankAccount.bonus("${demobankFromDb.config.currency}:100") - AccountPair(customer = newCustomer, bankAccount = newBankAccount) - } -} - -/** - * Return true if access to the bank account can be granted, - * false otherwise. - * - * Given the policy of having bank account names matching - * their owner's username, this function enforces such policy - * with the exception that 'admin' can access every bank - * account. A null username indicates disabled authentication - * checks, hence it grants the access. - */ -fun allowOwnerOrAdmin(username: String?, bankAccountLabel: String): Boolean { - if (username == null) return true - if (username == "admin") return true - return username == bankAccountLabel -} - -/** - * Throws exception if the credentials are wrong. - * - * Return: - * - null if the authentication is disabled (during tests, for example). - * This facilitates tests because allows requests to lack entirely an - * Authorization header. - * - the username of the authenticated user - * - throw exception when the authentication fails - * - * Note: at this point it is ONLY checked whether the user provided - * a valid password for the username mentioned in the Authorization header. - * The actual access to the resources must be later checked by each handler. - */ -fun ApplicationRequest.basicAuth(onlyAdmin: Boolean = false): String? { - val withAuth = this.call.ensureAttribute(WITH_AUTH_ATTRIBUTE_KEY) - if (!withAuth) { - logger.info("Authentication is disabled - assuming tests currently running.") - return null - } - val credentials = getHTTPBasicAuthCredentials(this) - if (credentials.first == "admin") { - // env must contain the admin password, because --with-auth is true. - val adminPassword: String = this.call.ensureAttribute(ADMIN_PASSWORD_ATTRIBUTE_KEY) - if (credentials.second != adminPassword) throw unauthorized( - "Admin authentication failed" - ) - return credentials.first - } - if (onlyAdmin) throw forbidden("Only admin allowed.") - val passwordHash = transaction { - val customer = getCustomer(credentials.first) - customer.passwordHash - } - if (!CryptoUtil.checkPwOrThrow(credentials.second, passwordHash)) - throw unauthorized("Customer '${credentials.first}' gave wrong credentials") - return credentials.first -} - -fun sandboxAssert(condition: Boolean, reason: String) { - if (!condition) throw SandboxError(HttpStatusCode.InternalServerError, reason) -} - -fun getOrderTypeFromTransactionId(transactionID: String): String { - val uploadTransaction = transaction { - EbicsUploadTransactionEntity.findById(transactionID) - } ?: throw SandboxError( - /** - * NOTE: at this point, it might even be the server's fault. - * For example, if it failed to store a ID earlier. - */ - HttpStatusCode.NotFound, - "Could not retrieve order type for transaction: $transactionID" - ) - return uploadTransaction.orderType -} - -fun getHistoryElementFromTransactionRow(dbRow: BankAccountTransactionEntity): XLibeufinBankTransaction { - return XLibeufinBankTransaction( - subject = dbRow.subject, - creditorIban = dbRow.creditorIban, - creditorBic = dbRow.creditorBic, - creditorName = dbRow.creditorName, - debtorIban = dbRow.debtorIban, - debtorBic = dbRow.debtorBic, - debtorName = dbRow.debtorName, - date = dbRow.date.toString(), - amount = dbRow.amount, - currency = dbRow.currency, - // UID assigned by the bank itself. - uid = dbRow.accountServicerReference, - direction = XLibeufinBankDirection.convertCamtDirectionToXLibeufin(dbRow.direction), - // UIDs as gotten from a pain.001 (from EBICS connections.) - pmtInfId = dbRow.pmtInfId, - endToEndId = dbRow.endToEndId - ) -} - -fun printConfig(demobank: DemobankConfigEntity) { - val ret = ObjectMapper() - ret.configure(SerializationFeature.INDENT_OUTPUT, true) - println( - ret.writeValueAsString(object { - val currency = demobank.config.currency - val bankDebtLimit = demobank.config.bankDebtLimit - val usersDebtLimit = demobank.config.usersDebtLimit - val allowRegistrations = demobank.config.allowRegistrations - val name = demobank.name // always 'default' - val withSignupBonus = demobank.config.withSignupBonus - val captchaUrl = demobank.config.captchaUrl - val suggestedExchangeBaseUrl = demobank.config.suggestedExchangeBaseUrl - val suggestedExchangePayto = demobank.config.suggestedExchangePayto - }) - ) -} - -fun getHistoryElementFromTransactionRow( - dbRow: BankAccountFreshTransactionEntity -): XLibeufinBankTransaction { - return getHistoryElementFromTransactionRow(dbRow.transactionRef) -} - -/** - * Need to be called within a transaction {} block. It - * is acceptable to pass a bank account's label as the - * parameter, because usernames can only own one bank - * account whose label equals the owner's username. - * - * Future versions may relax this policy to allow one - * customer to own multiple bank accounts. - */ -fun getCustomer(username: String): DemobankCustomerEntity { - return maybeGetCustomer(username) ?: throw notFound("Customer '${username}' not found") -} -fun maybeGetCustomer(username: String): DemobankCustomerEntity? { - return transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.username eq username - }.firstOrNull() - } -} - -/** - * Get person name from a customer's username, or throw - * exception if not found. - */ -fun getPersonNameFromCustomer(customerUsername: String): String { - return when (customerUsername) { - "admin" -> "Admin" - else -> transaction { - val ownerCustomer = DemobankCustomerEntity.find( - DemobankCustomersTable.username eq customerUsername - ).firstOrNull() ?: run { - logger.error("Customer '${customerUsername}' not found, couldn't get their name.") - throw SandboxError( - HttpStatusCode.InternalServerError, - "'$customerUsername' not a customer." - ) - - } - ownerCustomer.name ?: "Never given." - } - } -} - -fun getDefaultDemobank(): DemobankConfigEntity { - return transaction { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq "default" - }.firstOrNull() - } ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Default demobank is missing." - ) -} - -fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity { - val uuid = parseUuid(opId) - return transaction { - TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq uuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Withdrawal operation $opId not found." - ) - } -} - -fun getBankAccountFromPayto(paytoUri: String): BankAccountEntity { - val paytoParse = parsePayto(paytoUri) - return getBankAccountFromIban(paytoParse.iban) -} - -fun getBankAccountFromIban(iban: String): BankAccountEntity { - return transaction { - BankAccountEntity.find(BankAccountsTable.iban eq iban).firstOrNull() - } ?: throw SandboxError( - HttpStatusCode.NotFound, - "Did not find a bank account for $iban" - ) -} - -/** - * The argument 'withBankFault' represents the case where - * _the bank_ must ensure that a resource (in this case a bank - * account) exists. For example, every 'customer' should have - * a 'bank account', and if a customer is found without a bank - * account, then the bank broke such condition. - */ -fun getBankAccountFromLabel( - label: String, - demobank: String = "default", - withBankFault: Boolean = false -): BankAccountEntity { - val maybeDemobank = getDemobank(demobank) - if (maybeDemobank == null) { - logger.error("Demobank '$demobank' not found") - throw SandboxError( - HttpStatusCode.NotFound, - "Demobank '$demobank' not found" - ) - } - return getBankAccountFromLabel( - label, - maybeDemobank, - withBankFault - ) -} - -// Get bank account DAO, given its name and demobank. -fun getBankAccountFromLabel( - label: String, - demobank: DemobankConfigEntity, - withBankFault: Boolean = false // documented along the other same-named function. -): BankAccountEntity { - val maybeBankAccount = transaction { - BankAccountEntity.find( - BankAccountsTable.label eq label and ( - BankAccountsTable.demoBank eq demobank.id - ) - ).firstOrNull() - } - if (maybeBankAccount == null && withBankFault) - throw internalServerError( - "Bank account $label was not found, but it should." - ) - if (maybeBankAccount == null) - throw notFound( - "Bank account $label was not found." - ) - return maybeBankAccount -} - -fun getBankAccountFromSubscriber(subscriber: EbicsSubscriberEntity): BankAccountEntity { - return transaction { - subscriber.bankAccount ?: throw SandboxError( - HttpStatusCode.NotFound, - "Subscriber doesn't have any bank account" - ) - } -} - -fun BankAccountEntity.bonus(amount: String) { - wireTransfer( - "admin", - this.label, - this.demoBank.name, - "Sign-up bonus", - amount - ) -} - -fun ensureDemobank(call: ApplicationCall): DemobankConfigEntity { - return ensureDemobank(call.expectUriComponent("demobankid")) -} - -fun ensureDemobank(name: String): DemobankConfigEntity { - return transaction { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq name - }.firstOrNull() ?: throw notFound("Demobank '$name' not found. Was it ever created?") - } -} - -fun getDemobank(name: String?): DemobankConfigEntity? { - return transaction { - if (name == null) { - DemobankConfigEntity.all().firstOrNull() - } else { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq name - }.firstOrNull() - } - } -} - -fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID: String): EbicsSubscriberEntity { - return transaction { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.userId eq userID) and (EbicsSubscribersTable.partnerId eq partnerID) and - (EbicsSubscribersTable.hostId eq hostID) - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Ebics subscriber (${userID}, ${partnerID}, ${hostID}) not found" - ) - } -} - -/** - * Compress, encrypt, encode a EBICS payload. The payload - * is assumed to be a Zip archive with only one entry. - * Return the customer key (second element) along the data. - */ -fun prepareEbicsPayload( - payload: String, pub: RSAPublicKey -): Pair { - val zipSingleton = mutableListOf(payload.toByteArray()).zip() - val compressedResponse = DeflaterInputStream(zipSingleton.inputStream()).use { - it.readAllBytes() - } - val enc = CryptoUtil.encryptEbicsE002(compressedResponse, pub) - return Pair(Base64.getEncoder().encodeToString(enc.encryptedData), enc) -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt deleted file mode 100644 index dac660da..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt +++ /dev/null @@ -1,154 +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 - * - */ - -package tech.libeufin.sandbox - -import tech.libeufin.util.PaymentInfo - -data class WithdrawalRequest( - /** - * Note: the currency is redundant, because at each point during - * the execution the Demobank should have a handle of the currency. - */ - val amount: String // $CURRENCY:X.Y -) -data class BalanceJson( - val amount: String, - val credit_debit_indicator: String -) -data class Demobank( - val currency: String, - val name: String, - val userDebtLimit: Int, - val bankDebtLimit: Int, - val allowRegistrations: Boolean -) -/** - * Used to show the list of Ebics hosts that exist - * in the system. - */ -data class EbicsHostsResponse( - val ebicsHosts: List -) - -data class EbicsHostCreateRequest( - val hostID: String, - val ebicsVersion: String -) - -/** - * List type that show all the payments existing in the system. - */ -data class AccountTransactions( - val payments: MutableList = mutableListOf() -) - -/** - * Used to create AND show one Ebics subscriber. - */ -data class EbicsSubscriberInfo( - val hostID: String, - val partnerID: String, - val userID: String, - val systemID: String? = null, - val demobankAccountLabel: String -) - -data class AdminGetSubscribers( - var subscribers: MutableList = mutableListOf() -) - -/** - * The following definition is obsolete because it - * doesn't allow to specify a demobank that will host - * the Ebics subscriber. */ -data class EbicsSubscriberObsoleteApi( - val hostID: String, - val partnerID: String, - val userID: String, - val systemID: String? = null -) - -/** - * Allows the admin to associate a new bank account - * to a EBICS subscriber. - */ -data class EbicsBankAccountRequest( - val subscriber: EbicsSubscriberObsoleteApi, - val iban: String, - val bic: String, - val name: String, - /** - * This value labels the bank account to be created - * AND its owner. The 'owner' is a bank's customer - * whose username equals this label AND has the rights - * over such bank accounts. - */ - val label: String -) - -data class CustomerRegistration( - val username: String, - val password: String, - val isPublic: Boolean = false, - // When missing, it's autogenerated. - val iban: String?, - // When missing, stays null in the DB. - val name: String? -) - -// Could be used as a general bank account info container. -data class PublicAccountInfo( - val balance: String, - val iban: String, - // Name / Label of the bank account _and_ of the - // Sandbox username that owns it. - val accountLabel: String - // more ..? -) - -data class CamtParams( - // name/label of the bank account to query. - val bankaccount: String, - val type: Int, - // need range parameter -) - -data class TalerWithdrawalStatus( - val selection_done: Boolean, - val transfer_done: Boolean, - val amount: String, - val wire_types: List = listOf("iban"), - val suggested_exchange: String? = null, - val sender_wire: String? = null, - val aborted: Boolean, - // Not needed with CLI wallets. - val confirm_transfer_url: String? -) - -data class TalerWithdrawalSelection( - val reserve_pub: String, - val selected_exchange: String? -) - -data class SandboxConfig( - val currency: String, - val version: String, - val name: String -) \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt deleted file mode 100644 index bcd11a49..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt +++ /dev/null @@ -1,1711 +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 - * - */ - -package tech.libeufin.sandbox - -import UtilError -import com.fasterxml.jackson.core.util.DefaultIndenter -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.module.kotlin.KotlinFeature -import com.fasterxml.jackson.module.kotlin.KotlinModule -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.context -import com.github.ajalt.clikt.core.subcommands -import com.github.ajalt.clikt.output.CliktHelpFormatter -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.int -import execThrowableOrTerminate -import io.ktor.server.application.* -import io.ktor.http.* -import io.ktor.serialization.jackson.* -import io.ktor.server.plugins.* -import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.plugins.statuspages.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.server.util.* -import io.ktor.server.plugins.callloging.* -import io.ktor.server.plugins.cors.routing.* -import io.ktor.util.date.* -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.slf4j.event.Level -import org.w3c.dom.Document -import startServer -import tech.libeufin.util.* -import java.math.BigDecimal -import java.net.URL -import java.security.interfaces.RSAPublicKey -import javax.xml.bind.JAXBContext -import kotlin.system.exitProcess - -val logger: Logger = LoggerFactory.getLogger("tech.libeufin.sandbox") -const val PROTOCOL_VERSION_UNIFIED = "0:0:0" // Every protocol is still using the same version. -const val SANDBOX_DB_ENV_VAR_NAME = "LIBEUFIN_SANDBOX_DB_CONNECTION" -private val adminPassword: String? = System.getenv("LIBEUFIN_SANDBOX_ADMIN_PASSWORD") -var WITH_AUTH = true // Needed by helpers too, hence not making it private. - -// Internal error type. -data class SandboxError( - val statusCode: HttpStatusCode, - val reason: String, - val errorCode: LibeufinErrorCode? = null -) : Exception(reason) - -// HTTP response error type. -data class SandboxErrorJson(val error: SandboxErrorDetailJson) -data class SandboxErrorDetailJson(val type: String, val description: String) - -class DefaultExchange : CliktCommand("Set default Taler exchange for a demobank.") { - init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } - private val exchangeBaseUrl by argument("EXCHANGE-BASEURL", "base URL of the default exchange") - private val exchangePayto by argument("EXCHANGE-PAYTO", "default exchange's payto-address") - private val demobank by option("--demobank", help = "Which demobank defaults to EXCHANGE").default("default") - - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { - dbCreateTables(dbConnString) - transaction { - val maybeDemobank: DemobankConfigEntity? = DemobankConfigEntity.find { - DemobankConfigsTable.name eq demobank - }.firstOrNull() - if (maybeDemobank == null) { - System.err.println("Error, demobank $demobank not found.") - exitProcess(1) - } - val config = maybeDemobank.config - /** - * Iterating over the config object's field that hold the exchange - * base URL and Payto. The iteration is only used to retrieve the - * correct names of the DB column 'configKey', because this is named - * after such fields. - */ - listOf( - Pair(config::suggestedExchangeBaseUrl, exchangeBaseUrl), - Pair(config::suggestedExchangePayto, exchangePayto) - ).forEach { - val maybeConfigPair = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.demobankName eq demobank and( - DemobankConfigPairsTable.configKey eq it.first.name) - }.firstOrNull() - /** - * The DB doesn't contain any column to hold the exchange URL - * or Payto, fail. That should never happen, because the DB row - * are created _after_ the DemobankConfig object that _does_ contain - * such fields. - */ - if (maybeConfigPair == null) { - System.err.println("Config key '${it.first.name}' for demobank '$demobank' not found in DB.") - exitProcess(1) - } - maybeConfigPair.configValue = it.second - } - } - } - } -} - -class Config : CliktCommand("Insert one configuration (a.k.a. demobank) into the database.") { - init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } - private val nameArgument by argument( - "NAME", help = "Name of this configuration. Currently, only 'default' is admitted." - ) - private val showOption by option( - "--show", - help = "Only show values, other options will be ignored." - ).flag("--no-show", default = false) - // FIXME: This really should not be a global option! - private val captchaUrlOption by option( - "--captcha-url", help = "Needed for browser wallets." - ).default("https://bank.demo.taler.net/") - private val currencyOption by option("--currency").default("EUR") - private val bankDebtLimitOption by option("--bank-debt-limit").int().default(1000000) - private val usersDebtLimitOption by option("--users-debt-limit").int().default(1000) - private val allowRegistrationsOption by option( - "--with-registrations", - help = "(defaults to allow registrations)" /* mentioning here as help message did not. */ - ).flag("--without-registrations", default = true) - private val withSignupBonusOption by option( - "--with-signup-bonus", - help = "Award new customers with 100 units of currency! (defaults to NO bonus)" - ).flag("--without-signup-bonus", default = false) - - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - if (nameArgument != "default") { - System.err.println("This version admits only the 'default' name") - exitProcess(1) - } - execThrowableOrTerminate { - dbCreateTables(dbConnString) - val maybeDemobank = transaction { getDemobank(nameArgument) } - if (showOption) { - if (maybeDemobank != null) { - printConfig(maybeDemobank) - } else { - println("Demobank: $nameArgument not found.") - System.exit(1) - } - return@execThrowableOrTerminate - } - if (bankDebtLimitOption < 0 || usersDebtLimitOption < 0) { - System.err.println("Debt numbers can't be negative.") - exitProcess(1) - } - /* - Warning if the CAPTCHA URL does not include the {wopid} placeholder. - Not a reason to fail because the bank may be run WITHOUT providing Taler. - */ - if (!hasWopidPlaceholder(captchaUrlOption)) - logger.warn("CAPTCHA URL doesn't have the WOPID placeholder." + - " Taler withdrawals decrease usability") - - // The user asks to _set_ values, regardless of overriding or creating. - val config = DemobankConfig( - currency = currencyOption, - bankDebtLimit = bankDebtLimitOption, - usersDebtLimit = usersDebtLimitOption, - allowRegistrations = allowRegistrationsOption, - demobankName = nameArgument, - withSignupBonus = withSignupBonusOption, - captchaUrl = captchaUrlOption - ) - /** - * The demobank didn't exist. Now: - * 1, Store the config values in the database. - * 2, Store the demobank name in the database. - * 3, Create the admin bank account under this demobank. - */ - if (maybeDemobank == null) { - transaction { - insertConfigPairs(config) - val demoBank = DemobankConfigEntity.new { this.name = nameArgument } - BankAccountEntity.new { - iban = getIban() - label = "admin" - owner = "admin" // Not backed by an actual customer object. - // For now, the model assumes always one demobank - this.demoBank = demoBank - } - } - } - // Demobank exists: update its config values in the database. - else transaction { insertConfigPairs(config, override = true) } - } - } -} - -/** - * This command generates Camt53 statements - for all the bank accounts - - * every time it gets run. The statements are only stored into the database. - * The user should then query either via Ebics or via the JSON interface, - * in order to retrieve their statements. - */ -class Camt053Tick : CliktCommand( - "Make a new Camt.053 time tick; all the fresh transactions" + - " will be inserted in a new Camt.053 report" -) { - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { dbCreateTables(dbConnString) } - val newStatements = mutableMapOf>() - /** - * For each bank account, extract the latest statement and - * include all the later transactions in a new statement. - * Build empty statement, if the account does not have any - * transaction yet. - */ - transaction { - BankAccountEntity.all().forEach { accountIter -> - // Give this account a entry in the final output. - newStatements.putIfAbsent(accountIter.label, mutableListOf()) - val lastStatement = BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq accountIter.id.value - }.lastOrNull() - val lastStatementTime = lastStatement?.creationTime ?: 0L - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.date.greater(lastStatementTime) and( - BankAccountTransactionsTable.account eq accountIter.id.value - ) - }.forEach { - newStatements[accountIter.label]?.add( - getHistoryElementFromTransactionRow(it) - ) ?: run { - logger.error("Array operation failed while building statements for account: ${accountIter.label}") - System.err.println("Fatal array error while building the statement, please report.") - exitProcess(1) - } - } - /** - * Resorting the closing (CLBD) balance of the last statement; will - * become the PRCD balance of the _new_ one. - */ - val camtData = buildCamtString( - 53, - accountIter.iban, - newStatements[accountIter.label]!!, - currency = accountIter.demoBank.config.currency - ) - BankAccountStatementEntity.new { - statementId = camtData.messageId - creationTime = getSystemTimeNow().toInstant().epochSecond - xmlMessage = camtData.camtMessage - bankAccount = accountIter - } - } - BankAccountFreshTransactionsTable.deleteAll() - } - } -} - -class MakeTransaction : CliktCommand("Wire-transfer money between Sandbox bank accounts") { - init { - context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } - } - private val creditAccount by option(help = "Label of the bank account receiving the payment").required() - private val debitAccount by option(help = "Label of the bank account issuing the payment").required() - private val demobankArg by option("--demobank", help = "Which Demobank books this transaction").default("default") - private val amount by argument("AMOUNT", "Amount, in the CUR:X.Y format") - private val subjectArg by argument("SUBJECT", "Payment's subject") - - override fun run() { - /** - * Merely connecting here (and NOT creating any table) because this - * command should only be run after actual bank accounts exist in the - * system, meaning therefore that the database got already set up. - */ - execThrowableOrTerminate { - val pgConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - connectWithSchema(getJdbcConnectionFromPg(pgConnString)) - } - // Refuse to operate without a default demobank. - val demobank = getDemobank("default") - if (demobank == null) { - System.err.println("Sandbox cannot operate without a 'default' demobank.") - System.err.println("Please make one with the 'libeufin-sandbox config' command.") - exitProcess(1) - } - try { - wireTransfer(debitAccount, creditAccount, demobankArg, subjectArg, amount) - } catch (e: SandboxError) { - System.err.println(e.message) - exitProcess(1) - } catch (e: Exception) { - System.err.println(e.message) - exitProcess(1) - } - } -} - -class ResetTables : CliktCommand("Drop all the tables from the database") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { - dbDropTables(dbConnString) - dbCreateTables(dbConnString) - } - } -} - -class Serve : CliktCommand("Run sandbox HTTP server") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - private val auth by option( - "--auth", - help = "Disable authentication." - ).flag("--no-auth", default = 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) - private val logLevel by option( - help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug', 'trace', 'all'" - ) - private val port by option().int().default(5000) - 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 smsTan by option(help = "Command to send the TAN via SMS." + - " The command gets the TAN via STDIN and the phone number" + - " as its first parameter" - ) - private val emailTan by option(help = "Command to send the TAN via e-mail." + - " The command gets the TAN via STDIN and the e-mail address as its" + - " first parameter.") - override fun run() { - WITH_AUTH = auth - setLogLevel(logLevel) - if (WITH_AUTH && adminPassword == null) { - System.err.println( - "Error: auth is enabled, but env " + - "LIBEUFIN_SANDBOX_ADMIN_PASSWORD is not." - + " (Option --no-auth exists for tests)" - ) - exitProcess(1) - } - execThrowableOrTerminate { - dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)) - } - // Refuse to operate without a 'default' demobank. - val demobank = getDemobank("default") - if (demobank == null) { - System.err.println("Sandbox cannot operate without a 'default' demobank.") - System.err.println("Please make one with the 'libeufin-sandbox config' command.") - exitProcess(1) - } - if (withUnixSocket != null) { - startServer( - withUnixSocket!!, - app = sandboxApp - ) - exitProcess(0) - } - SMS_TAN_CMD = smsTan - EMAIL_TAN_CMD = emailTan - - logger.info("Starting Sandbox on port ${this.port}") - startServerWithIPv4Fallback( - options = StartServerOptions( - ipv4OnlyOpt = this.ipv4Only, - localhostOnlyOpt = this.localhostOnly, - portOpt = this.port - ), - app = sandboxApp - ) - } -} - -private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank { - return Demobank( - currency = fromDb.config.currency, - userDebtLimit = fromDb.config.usersDebtLimit, - bankDebtLimit = fromDb.config.bankDebtLimit, - allowRegistrations = fromDb.config.allowRegistrations, - name = fromDb.name - ) -} -fun findEbicsSubscriber(partnerID: String, userID: String, systemID: String?): EbicsSubscriberEntity? { - return if (systemID == null) { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq partnerID) and (EbicsSubscribersTable.userId eq userID) - } - } else { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq partnerID) and - (EbicsSubscribersTable.userId eq userID) and - (EbicsSubscribersTable.systemId eq systemID) - } - }.firstOrNull() -} - -data class SubscriberKeys( - val authenticationPublicKey: RSAPublicKey, - val encryptionPublicKey: RSAPublicKey, - val signaturePublicKey: RSAPublicKey -) - -data class EbicsHostPublicInfo( - val hostID: String, - val encryptionPublicKey: RSAPublicKey, - val authenticationPublicKey: RSAPublicKey -) - -data class BankAccountInfo( - val label: String, - val name: String, - val iban: String, - val bic: String, -) - -inline fun Document.toObject(): T { - val jc = JAXBContext.newInstance(T::class.java) - val m = jc.createUnmarshaller() - return m.unmarshal(this, T::class.java).value -} - -fun ensureNonNull(param: String?): String { - return param ?: throw SandboxError( - HttpStatusCode.BadRequest, "Bad ID given: $param" - ) -} - -class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true, printHelpOnEmptyArgs = true) { - init { versionOption(getVersion()) } - override fun run() = Unit -} - -fun main(args: Array) { - SandboxCommand().subcommands( - Serve(), - ResetTables(), - Config(), - MakeTransaction(), - Camt053Tick(), - DefaultExchange() - ).main(args) -} - -fun setJsonHandler(ctx: ObjectMapper) { - ctx.enable(SerializationFeature.INDENT_OUTPUT) - ctx.setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { - indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) - indentObjectsWith(DefaultIndenter(" ", "\n")) - }) - ctx.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() - ) -} - -private suspend fun getWithdrawal(call: ApplicationCall) { - val op = getWithdrawalOperation(call.expectUriComponent("withdrawal_id")) - if (!op.selectionDone && op.reservePub != null) throw internalServerError( - "Unselected withdrawal has a reserve public key", - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE - ) - call.respond(object { - val amount = op.amount - val aborted = op.aborted - val confirmation_done = op.confirmationDone - val selection_done = op.selectionDone - val selected_reserve_pub = op.reservePub - val selected_exchange_account = op.selectedExchangePayto - }) -} - -private suspend fun confirmWithdrawal(call: ApplicationCall) { - val withdrawalId = call.expectUriComponent("withdrawal_id") - logger.debug("Maybe confirming withdrawal: $withdrawalId") - transaction { - val wo = getWithdrawalOperation(withdrawalId) - if (wo.aborted) throw SandboxError( - HttpStatusCode.Conflict, - "Cannot confirm an aborted withdrawal." - ) - if (!wo.selectionDone) throw SandboxError( - HttpStatusCode.UnprocessableEntity, - "Cannot confirm a unselected withdrawal: " + - "specify exchange and reserve public key via Integration API first." - ) - /** - * The wallet chose not to select any exchange, use the default. - */ - val demobank = ensureDemobank(call) - if (wo.selectedExchangePayto == null) { - wo.selectedExchangePayto = demobank.config.suggestedExchangePayto - } - val exchangeBankAccount = getBankAccountFromPayto( - wo.selectedExchangePayto ?: throw internalServerError( - "Cannot withdraw without an exchange." - ) - ) - logger.debug("Withdrawal ${wo.wopid} confirmed? ${wo.confirmationDone}") - if (!wo.confirmationDone) { - wireTransfer( - debitAccount = wo.walletBankAccount.label, - creditAccount = exchangeBankAccount.label, - amount = wo.amount, - subject = wo.reservePub ?: throw internalServerError( - "Cannot transfer funds without reserve public key." - ), - // provide the currency. - demobank = ensureDemobank(call).name - ) - wo.confirmationDone = true - } - wo.confirmationDone - } - call.respond(object {}) -} - -private suspend fun abortWithdrawal(call: ApplicationCall) { - val withdrawalId = call.expectUriComponent("withdrawal_id") - val operation = getWithdrawalOperation(withdrawalId) - if (operation.confirmationDone) throw conflict("Cannot abort paid withdrawal.") - transaction { operation.aborted = true } - call.respond(object {}) -} - -val sandboxApp: Application.() -> Unit = { - install(CallLogging) { - this.level = Level.DEBUG - this.logger = tech.libeufin.sandbox.logger - this.format { call -> - "${call.response.status()}, ${call.request.httpMethod.value} ${call.request.path()}" - } - } - install(CORS) { - anyHost() - allowHeader(HttpHeaders.Authorization) - allowHeader(HttpHeaders.ContentType) - allowMethod(HttpMethod.Options) - allowMethod(HttpMethod.Patch) - allowMethod(HttpMethod.Delete) - allowCredentials = true - } - install(IgnoreTrailingSlash) - install(ContentNegotiation) { - register(ContentType.Text.Xml, XMLEbicsConverter()) - /** - * Content type "text" must go to the XML parser - * because Nexus can't set explicitly the Content-Type - * (see https://github.com/ktorio/ktor/issues/1127) to - * "xml" and the request made gets somehow assigned the - * "text/plain" type: */ - register(ContentType.Text.Plain, XMLEbicsConverter()) - jackson(contentType = ContentType.Application.Json) { setJsonHandler(this) } - /** - * Make jackson the default parser. It runs also when - * the Content-Type request header is missing. */ - jackson(contentType = ContentType.Any) { setJsonHandler(this) } - } - install(StatusPages) { - // Bank's fault: it should check the operands. Respond 500 - exception { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.stackTraceToString()}") - call.respond( - HttpStatusCode.InternalServerError, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.message ?: "Bank's error: arithmetic exception." - ) - ) - ) - } - // Not necessarily the bank's fault. - exception { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") - call.respond( - cause.statusCode, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.reason - ) - ) - ) - } - // Not necessarily the bank's fault. - exception { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") - call.respond( - cause.statusCode, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "util-error", - description = cause.reason - ) - ) - ) - } - /** - * Happens when a request fails to parse. This branch triggers - * only when a JSON request fails. XML problems are caught within - * the /ebicsweb handler and always ultimately rethrown as "EbicsRequestError", - * hence they do not reach this branch. - */ - exception { 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 SandboxError( - HttpStatusCode.InternalServerError, - "Did not find bad request details." - ) - } - logger.error(errorMessage) - call.respond( - HttpStatusCode.BadRequest, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = errorMessage - ) - ) - ) - } - // Catch-all error, respond 500 because the bank didn't handle it. - exception { call, cause -> - logger.error("Unhandled exception while handling '${call.request.uri}'\n${cause.stackTraceToString()}") - call.respond( - HttpStatusCode.InternalServerError, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.message ?: "Bank's error: unhandled exception." - ) - ) - ) - } - exception { call, cause -> - logger.error("Handling EbicsRequestError: ${cause.message}") - respondEbicsTransfer(call, cause.errorText, cause.errorCode) - } - } - intercept(ApplicationCallPipeline.Setup) { - val ac: ApplicationCall = call - ac.attributes.put(WITH_AUTH_ATTRIBUTE_KEY, WITH_AUTH) - if (WITH_AUTH) { - if(adminPassword == null) { - throw internalServerError( - "Sandbox has no admin password defined." + - " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in the environment, " + - "or launch with --no-auth." - - ) - } - ac.attributes.put(ADMIN_PASSWORD_ATTRIBUTE_KEY, adminPassword) - } - return@intercept - } - intercept(ApplicationCallPipeline.Fallback) { - if (this.call.response.status() == null) { - call.respondText( - "Not found (no route matched).\n", - io.ktor.http.ContentType.Text.Plain, - io.ktor.http.HttpStatusCode.NotFound - ) - return@intercept finish() - } - } - routing { - get("/") { - call.respondText( - "Hello, this is the Sandbox\n", - ContentType.Text.Plain - ) - } - // Respond with the last statement of the requesting account. - // Query details in the body. - post("/admin/payments/camt") { - val username = call.request.basicAuth() - val body = call.receive() - if (body.type != 53) throw SandboxError( - HttpStatusCode.NotFound, - "Only Camt.053 documents can be generated." - ) - if (!allowOwnerOrAdmin(username, body.bankaccount)) - throw unauthorized("User '${username}' has no rights over" + - " bank account '${body.bankaccount}'") - val camtMessage = transaction { - val bankaccount = getBankAccountFromLabel( - body.bankaccount, - getDefaultDemobank() - ) - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankaccount.id - }.lastOrNull()?.xmlMessage ?: throw SandboxError( - HttpStatusCode.NotFound, - "Could not find any statements; please wait next tick" - ) - } - call.respondText( - camtMessage, ContentType.Text.Xml, HttpStatusCode.OK - ) - return@post - } - - /** - * Create a new bank account, no EBICS relation. Okay - * to let a user, since having a particular username allocates - * already a bank account with such label. - */ - post("/admin/bank-accounts/{label}") { - val username = call.request.basicAuth() - val body = call.receive() - if (!allowOwnerOrAdmin(username, body.label)) - throw unauthorized("User '$username' has no rights over" + - " bank account '${body.label}'" - ) - if (body.label == "admin" || body.label == "bank") throw forbidden( - "Requested bank account label '${body.label}' not allowed." - ) - transaction { - val maybeBankAccount = BankAccountEntity.find { - BankAccountsTable.label eq body.label - }.firstOrNull() - if (maybeBankAccount != null) - throw conflict("Bank account '${body.label}' exist already") - // owner username == bank account label - val maybeCustomer = DemobankCustomerEntity.find { - DemobankCustomersTable.username eq body.label - }.firstOrNull() - if (maybeCustomer == null) - throw notFound("Customer '${body.label}' not found," + - " cannot own any bank account.") - BankAccountEntity.new { - iban = body.iban - bic = body.bic - label = body.label - owner = body.label - demoBank = getDefaultDemobank() - } - } - call.respond(object {}) - return@post - } - - // Information about one bank account. - get("/admin/bank-accounts/{label}") { - val username = call.request.basicAuth() - val label = call.expectUriComponent("label") - val ret = transaction { - val demobank = getDefaultDemobank() - val bankAccount = getBankAccountFromLabel(label, demobank) - if (!allowOwnerOrAdmin(username, label)) - throw unauthorized("'${username}' has no rights over '$label'") - val balance = getBalance(bankAccount) - object { - val balance = "${bankAccount.demoBank.config.currency}:${balance}" - val iban = bankAccount.iban - val bic = bankAccount.bic - val label = bankAccount.label - } - } - call.respond(ret) - return@get - } - - // Book one incoming payment for the requesting account. - // The debtor is not required to have a customer account at this Sandbox. - post("/admin/bank-accounts/{label}/simulate-incoming-transaction") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive() - val accountLabel = ensureNonNull(call.parameters["label"]) - val reqDebtorBic = body.debtorBic - if (reqDebtorBic != null && !validateBic(reqDebtorBic)) { - throw SandboxError( - HttpStatusCode.BadRequest, - "invalid BIC" - ) - } - val amount = parseAmount(body.amount) - transaction { - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel( - accountLabel, demobank - ) - val randId = getRandomString(16) - val customer = getCustomer(accountLabel) - BankAccountTransactionEntity.new { - creditorIban = account.iban - creditorBic = account.bic - creditorName = customer.name ?: "Name not given." - debtorIban = body.debtorIban - debtorBic = reqDebtorBic - debtorName = body.debtorName - subject = body.subject - this.amount = amount.amount - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = "sandbox-$randId" - this.account = account - direction = "CRDT" - this.demobank = demobank - this.currency = demobank.config.currency - } - } - call.respond(object {}) - } - // Associates a new bank account with an existing Ebics subscriber. - post("/admin/ebics/bank-accounts") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive() - val subscriber = getEbicsSubscriberFromDetails( - body.subscriber.userID, - body.subscriber.partnerID, - body.subscriber.hostID - ) - val res = insertNewAccount( - username = body.label, - /** - * This value makes only happy the account creator helper. - * Logic using this OBSOLETE HTTP handler would NOT expect - * to use this password anyway. The reason is that such obsolete - * tests access their banking data always through the EBICS - * subscriber, needing therefore no HTTP basic password to operate. - */ - password = "not-used", - iban = body.iban - ) - transaction { subscriber.bankAccount = res.bankAccount } - call.respond({}) - return@post - } - - // Information about all the default demobank's bank accounts - get("/admin/bank-accounts") { - call.request.basicAuth(onlyAdmin = true) - val accounts = mutableListOf() - transaction { - val demobank = getDefaultDemobank() - // Finds all the accounts of this demobank. - BankAccountEntity.find { BankAccountsTable.demoBank eq demobank.id }.forEach { - accounts.add( - BankAccountInfo( - label = it.label, - bic = it.bic, - iban = it.iban, - name = "Bank account owner's name" - ) - ) - } - } - call.respond(accounts) - } - - // Details of all the transactions of one bank account. - get("/admin/bank-accounts/{label}/transactions") { - val username = call.request.basicAuth() - val ret = AccountTransactions() - val accountLabel = ensureNonNull(call.parameters["label"]) - if (!allowOwnerOrAdmin(username, accountLabel)) - throw unauthorized("Requesting user '${username}'" + - " has no rights over bank account '${accountLabel}'" - ) - transaction { - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel(accountLabel, demobank) - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq account.id - }.forEach { - ret.payments.add( - PaymentInfo( - accountLabel = account.label, - creditorIban = it.creditorIban, - accountServicerReference = it.accountServicerReference, - paymentInformationId = it.pmtInfId, - debtorIban = it.debtorIban, - subject = it.subject, - date = GMTDate(it.date).toHttpDate(), - amount = it.amount, - creditorBic = it.creditorBic, - creditorName = it.creditorName, - debtorBic = it.debtorBic, - debtorName = it.debtorName, - currency = it.currency, - creditDebitIndicator = when (it.direction) { - "CRDT" -> "credit" - "DBIT" -> "debit" - else -> throw Error("invalid direction") - } - ) - ) - } - } - call.respond(ret) - } - /** - * Generate one incoming and one outgoing transactions for - * one bank account. Counterparts do not need to have an account - * at this Sandbox. - */ - post("/admin/bank-accounts/{label}/generate-transactions") { - call.request.basicAuth(onlyAdmin = true) - transaction { - val accountLabel = ensureNonNull(call.parameters["label"]) - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel(accountLabel, demobank) - val transactionReferenceCrdt = getRandomString(8) - val transactionReferenceDbit = getRandomString(8) - - run { - val amount = kotlin.random.Random.nextLong(5, 25) - BankAccountTransactionEntity.new { - creditorIban = account.iban - creditorBic = account.bic - creditorName = "Creditor Name" - debtorIban = "DE64500105178797276788" - debtorBic = "DEUTDEBB101" - debtorName = "Max Mustermann" - subject = "sample transaction $transactionReferenceCrdt" - this.amount = amount.toString() - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = transactionReferenceCrdt - this.account = account - direction = "CRDT" - this.demobank = demobank - currency = demobank.config.currency - } - } - - run { - val amount = kotlin.random.Random.nextLong(5, 25) - - BankAccountTransactionEntity.new { - debtorIban = account.iban - debtorBic = account.bic - debtorName = "Debitor Name" - creditorIban = "DE64500105178797276788" - creditorBic = "DEUTDEBB101" - creditorName = "Max Mustermann" - subject = "sample transaction $transactionReferenceDbit" - this.amount = amount.toString() - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = transactionReferenceDbit - this.account = account - direction = "DBIT" - this.demobank = demobank - currency = demobank.config.currency - } - } - } - call.respond(object {}) - } - - /** - * Create a new EBICS subscriber without associating - * a bank account to it. Currently every registered - * user is allowed to call this. - */ - post("/admin/ebics/subscribers") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive() - transaction { - // Check the host ID exists. - EbicsHostEntity.find { - EbicsHostsTable.hostID eq body.hostID - }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") - // Check it exists first. - val maybeSubscriber = EbicsSubscriberEntity.find { - EbicsSubscribersTable.userId eq body.userID and ( - EbicsSubscribersTable.partnerId eq body.partnerID - ) and (EbicsSubscribersTable.systemId eq body.systemID) and - (EbicsSubscribersTable.hostId eq body.hostID) - }.firstOrNull() - if (maybeSubscriber != null) throw conflict("EBICS subscriber exists already") - EbicsSubscriberEntity.new { - partnerId = body.partnerID - userId = body.userID - systemId = null - hostId = body.hostID - state = SubscriberState.NEW - nextOrderID = 1 - } - } - call.respondText( - "Subscriber created.", - ContentType.Text.Plain, HttpStatusCode.OK - ) - return@post - } - - // Shows details of all the EBICS subscribers of this Sandbox. - get("/admin/ebics/subscribers") { - call.request.basicAuth(onlyAdmin = true) - val ret = AdminGetSubscribers() - transaction { - EbicsSubscriberEntity.all().forEach { - ret.subscribers.add( - EbicsSubscriberInfo( - userID = it.userId, - partnerID = it.partnerId, - hostID = it.hostId, - demobankAccountLabel = it.bankAccount?.label ?: "not associated yet" - ) - ) - } - } - call.respond(ret) - return@get - } - - // Change keys used in the EBICS communications. - post("/admin/ebics/hosts/{hostID}/rotate-keys") { - call.request.basicAuth(onlyAdmin = true) - val hostID: String = call.parameters["hostID"] ?: throw SandboxError( - io.ktor.http.HttpStatusCode.BadRequest, "host ID missing in URL" - ) - transaction { - val host = EbicsHostEntity.find { - EbicsHostsTable.hostID eq hostID - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Host $hostID not found" - ) - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - host.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) - host.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) - host.signaturePrivateKey = ExposedBlob(pairC.private.encoded) - } - call.respondText( - "Keys of '${hostID}' rotated.", - ContentType.Text.Plain, - HttpStatusCode.OK - ) - return@post - } - - // Create a new EBICS host - post("/admin/ebics/hosts") { - call.request.basicAuth(onlyAdmin = true) - val req = call.receive() - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - transaction { - val maybeHost = EbicsHostEntity.find { - EbicsHostsTable.hostID eq req.hostID - }.firstOrNull() - if (maybeHost != null) { - logger.info("EBICS host '${req.hostID}' exists already, this request conflicts.") - throw conflict("EBICS host '${req.hostID}' exists already") - } - EbicsHostEntity.new { - this.ebicsVersion = req.ebicsVersion - this.hostId = req.hostID - this.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) - this.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) - this.signaturePrivateKey = ExposedBlob(pairC.private.encoded) - } - } - call.respondText( - "Host '${req.hostID}' created.", - ContentType.Text.Plain, - HttpStatusCode.OK - ) - return@post - } - - // Show the names of all the Ebics hosts - get("/admin/ebics/hosts") { - call.request.basicAuth(onlyAdmin = true) - val ebicsHosts = transaction { - EbicsHostEntity.all().map { it.hostId } - } - call.respond(EbicsHostsResponse(ebicsHosts)) - } - // Process one EBICS request - post("/ebicsweb") { - try { call.ebicsweb() } - /** - * The catch blocks try to extract a EBICS error message from the - * exception type being handled. NOT logging under each catch block - * as ultimately the registered exception handler is expected to log. */ - catch (e: UtilError) { - throw EbicsProcessingError("Serving EBICS threw unmanaged UtilError: ${e.reason}") - } - catch (e: SandboxError) { - val errorInfo: String = e.message ?: e.stackTraceToString() - logger.info(errorInfo) - // Should translate to EBICS error code. - when (e.errorCode) { - LibeufinErrorCode.LIBEUFIN_EC_INVALID_STATE -> throw EbicsProcessingError("Invalid bank state.") - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE -> throw EbicsProcessingError("Inconsistent bank state.") - else -> throw EbicsProcessingError("Unknown Libeufin error code: ${e.errorCode}.") - } - } - catch (e: EbicsNoDownloadDataAvailable) { - respondEbicsTransfer(call, e.errorText, e.errorCode) - } - catch (e: EbicsRequestError) { - /** - * Preventing the last catch-all block from handling - * a known error type. Rethrowing here to let the top-level - * handler take action. - */ - throw e - } - catch (e: Exception) { - logger.error(e.stackTraceToString()) - throw EbicsProcessingError(e.message) - } - return@post - } - - /** - * Create a new demobank instance with a particular currency, - * debt limit and possibly other configuration - * (could also be a CLI command for now) - */ - post("/demobanks") { - throw NotImplementedError("Feature only available at the libeufin-sandbox CLI") - } - - get("/demobanks") { - expectAdmin(call.request.basicAuth()) - val ret = object { val demoBanks = mutableListOf() } - transaction { - DemobankConfigEntity.all().forEach { - ret.demoBanks.add(getJsonFromDemobankConfig(it)) - } - } - call.respond(ret) - return@get - } - - get("/demobanks/{demobankid}") { - val demobank = ensureDemobank(call) - expectAdmin(call.request.basicAuth()) - call.respond(getJsonFromDemobankConfig(demobank)) - return@get - } - - route("/demobanks/{demobankid}") { - // NOTE: TWG assumes that username == bank account label. - route("/taler-wire-gateway") { - post("/{exchangeUsername}/admin/add-incoming") { - val username = call.expectUriComponent("exchangeUsername") - val usernameAuth = call.request.basicAuth() - if (username != usernameAuth) - throw forbidden("Bank account name and username differ: $username vs $usernameAuth") - logger.debug("TWG add-incoming passed authentication") - val body = try { call.receive() } - catch (e: Exception) { - logger.error("/admin/add-incoming failed at parsing the request body") - throw SandboxError( - HttpStatusCode.BadRequest, - "Invalid request" - ) - } - val singletonTx = transaction { - val demobank = ensureDemobank(call) - val bankAccountCredit = getBankAccountFromLabel(username, demobank) - if (bankAccountCredit.owner != username) throw forbidden( - "User '$username' cannot access bank account with label: $username." - ) - val bankAccountDebit = getBankAccountFromPayto(body.debit_account) - logger.debug("TWG add-incoming about to wire transfer") - val ref = wireTransfer( - bankAccountDebit.label, - bankAccountCredit.label, - demobank.name, - body.reserve_pub, - body.amount - ) - /** - * The remaining part aims at returning an x-libeufin-bank-formatted - * message to Nexus, to let it ingest the (incoming side of the) payment - * information. The format choice makes it more practical for Nexus, - * because it handles this format already for the x-libeufin-bank connection - * type. - */ - val incomingTx = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.accountServicerReference eq ref and ( - BankAccountTransactionsTable.direction eq "CRDT" - ) // closes the 'and'. - }.firstOrNull() - if (incomingTx == null) - throw internalServerError("Just created transaction not found in DB. AcctSvcrRef: $ref") - val incomingHistoryElement = getHistoryElementFromTransactionRow(incomingTx) - logger.debug("TWG add-incoming has wire transferred, AcctSvcrRef: $ref") - incomingHistoryElement - } - val resp = object { - val transactions = listOf(singletonTx) - } - call.respond(resp) - return@post - } - } - // Talk to wallets. - route("/integration-api") { - get("/config") { - val demobank = ensureDemobank(call) - call.respond(SandboxConfig( - name = "taler-bank-integration", - version = PROTOCOL_VERSION_UNIFIED, - currency = demobank.config.currency - )) - return@get - } - post("/withdrawal-operation/{wopid}") { - val arg = ensureNonNull(call.parameters["wopid"]) - val withdrawalUuid = parseUuid(arg) - val body = call.receive() - val transferDone = transaction { - val wo = TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq withdrawalUuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Withdrawal operation $withdrawalUuid not found." - ) - if (wo.confirmationDone) { - return@transaction true - } - if (wo.selectionDone) { - if (body.reserve_pub != wo.reservePub) throw SandboxError( - HttpStatusCode.Conflict, - "Selecting a different reserve from the one already selected" - ) - if (body.selected_exchange != wo.selectedExchangePayto) throw SandboxError( - HttpStatusCode.Conflict, - "Selecting a different exchange from the one already selected" - ) - return@transaction false - } - // Flow here means never selected, hence must as well never be paid. - if (wo.confirmationDone) throw internalServerError( - "Withdrawal ${wo.wopid} knew NO exchange and reserve pub, " + - "but is marked as paid!" - ) - wo.reservePub = body.reserve_pub - wo.selectedExchangePayto = body.selected_exchange - wo.selectionDone = true - false - } - call.respond(object { - val transfer_done: Boolean = transferDone - }) - return@post - } - get("/withdrawal-operation/{wopid}") { - val arg = ensureNonNull(call.parameters["wopid"]) - val maybeWithdrawalUuid = parseUuid(arg) - val maybeWithdrawalOp = transaction { - TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq maybeWithdrawalUuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Withdrawal operation: $arg not found" - ) - } - val demobank = ensureDemobank(call) - val captchaPage: String? = demobank.config.captchaUrl?.replace("{wopid}",arg) - if (captchaPage == null) - throw internalServerError("demobank ${demobank.name} lacks the CAPTCHA URL from the configuration.") - val ret = TalerWithdrawalStatus( - selection_done = maybeWithdrawalOp.selectionDone, - transfer_done = maybeWithdrawalOp.confirmationDone, - amount = maybeWithdrawalOp.amount, - suggested_exchange = demobank.config.suggestedExchangeBaseUrl, - aborted = maybeWithdrawalOp.aborted, - confirm_transfer_url = captchaPage - ) - call.respond(ret) - return@get - } - } - route("/circuit-api") { - circuitApi(this) - } - // Talk to Web UI. - route("/access-api") { - post("/accounts/{account_name}/transactions") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - // note: admin has no rights to create transactions on non-admin accounts. - val authGranted: Boolean = !WITH_AUTH - if (!authGranted && username != bankAccount.label) - throw unauthorized("Username '$username' has no rights over bank account ${bankAccount.label}") - val req = call.receive() - val payto = parsePayto(req.paytoUri) - val amount: String? = payto.amount ?: req.amount - if (amount == null) throw badRequest("Amount is missing") - /** - * The transaction block below lets the 'demoBank' field - * of 'bankAccount' be correctly accessed. */ - transaction { - wireTransfer( - debitAccount = bankAccount.label, - creditAccount = getBankAccountFromIban(payto.iban).label, - demobank = bankAccount.demoBank.name, - subject = payto.message ?: throw badRequest( - "'message' query parameter missing in Payto address" - ), - amount = amount, - pmtInfId = req.pmtInfId - ) - } - call.respond(object {}) - return@post - } - // Information about one withdrawal. - get("/accounts/{account_name}/withdrawals/{withdrawal_id}") { - getWithdrawal(call) - return@get - } - // account-less style: - get("/withdrawals/{withdrawal_id}") { - getWithdrawal(call) - return@get - } - // Create a new withdrawal operation. - post("/accounts/{account_name}/withdrawals") { - var username = call.request.basicAuth() - val demobank = ensureDemobank(call) - /** - * Check here if the user has the right over the claimed bank account. After - * this check, the withdrawal operation will be allowed only by providing its - * UID. */ - val maybeOwnedAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted = !WITH_AUTH // note: admin not allowed on non-admin accounts - if (!authGranted && maybeOwnedAccount.owner != username) - throw unauthorized("Customer '$username' has no rights over bank account '${maybeOwnedAccount.label}'") - val req = call.receive() - // Check for currency consistency - val amount = parseAmount(req.amount) - if (amount.currency != demobank.config.currency) - throw badRequest("Currency ${amount.currency} differs from Demobank's: ${demobank.config.currency}") - // Check funds are sufficient. - if ( - maybeDebit( - maybeOwnedAccount.label, - BigDecimal(amount.amount), - transaction { maybeOwnedAccount.demoBank.name } - )) { - logger.error("Account ${maybeOwnedAccount.label} would surpass debit threshold. Not withdrawing") - throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") - } - val wo: TalerWithdrawalEntity = transaction { - TalerWithdrawalEntity.new { - this.amount = req.amount - walletBankAccount = maybeOwnedAccount - } - } - val baseUrl = URL(call.request.getBaseUrl()) - val withdrawUri = url { - protocol = URLProtocol( - name = "taler".plus(if (baseUrl.protocol.lowercase() == "http") "+http" else ""), - defaultPort = -1 - ) - host = "withdraw" - val pathSegments = mutableListOf( - /** - * encodes the hostname(+port) of the actual - * bank that will serve the withdrawal request. - */ - baseUrl.host.plus( - if (baseUrl.port != -1) - ":${baseUrl.port}" - else "" - ) - ) - /** - * Slashes can only be intermediate and single, - * any other combination results in badly formed URIs. - * The following loop ensure this for the current URI path. - * This might even come from X-Forwarded-Prefix. - */ - baseUrl.path.split("/").forEach { - if (it.isNotEmpty()) pathSegments.add(it) - } - pathSegments.add("demobanks/${demobank.name}/integration-api/${wo.wopid}") - this.appendPathSegments(pathSegments) - } - call.respond(object { - val withdrawal_id = wo.wopid.toString() - val taler_withdraw_uri = withdrawUri - }) - return@post - } - // Confirm a withdrawal: no basic auth, because the ID should be unguessable. - post("/accounts/{account_name}/withdrawals/{withdrawal_id}/confirm") { - confirmWithdrawal(call) - return@post - } - // account-less style: - post("/withdrawals/{withdrawal_id}/confirm") { - confirmWithdrawal(call) - return@post - } - // Aborting withdrawals: - post("/accounts/{account_name}/withdrawals/{withdrawal_id}/abort") { - abortWithdrawal(call) - return@post - } - // account-less style: - post("/withdrawals/{withdrawal_id}/abort") { - abortWithdrawal(call) - return@post - } - // Bank account basic information. - get("/accounts/{account_name}") { - val username = call.request.basicAuth() - val accountAccessed = call.expectUriComponent("account_name") - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel(accountAccessed, demobank) - val authGranted = !WITH_AUTH || bankAccount.isPublic || username == "admin" - if (!authGranted && bankAccount.owner != username) - throw forbidden("Customer '$username' cannot access bank account '$accountAccessed'") - val balance = getBalance(bankAccount) - logger.debug("Balance of '$username': ${balance.toPlainString()}") - call.respond(object { - val balance = object { - val amount = "${demobank.config.currency}:${balance.abs().toPlainString()}" - val credit_debit_indicator = if (balance < BigDecimal.ZERO) "debit" else "credit" - } - val paytoUri = buildIbanPaytoUri( - iban = bankAccount.iban, - bic = bankAccount.bic, - // username 'null' should only happen when auth is disabled. - receiverName = getPersonNameFromCustomer(bankAccount.owner) - ) - val iban = bankAccount.iban - // The Elvis operator helps the --no-auth case, - // where username would be empty - val debitThreshold = getMaxDebitForUser( - username = username ?: "admin", - demobankName = demobank.name - ).toString() - }) - return@get - } - get("/accounts/{account_name}/transactions/{tId}") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" - if (!authGranted && username != bankAccount.owner) - throw forbidden("Cannot access bank account ${bankAccount.label}") - val tId = call.parameters["tId"] ?: throw badRequest("URI didn't contain the transaction ID") - val tx: BankAccountTransactionEntity? = transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.accountServicerReference eq tId - }.firstOrNull() - } - if (tx == null) throw notFound("Transaction $tId wasn't found") - call.respond(getHistoryElementFromTransactionRow(tx)) - return@get - } - get("/accounts/{account_name}/transactions") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" - if (!authGranted && bankAccount.owner != username) - throw forbidden("Cannot access bank account ${bankAccount.label}") - // Paging values. - val page: Int = expectInt(call.request.queryParameters["page"] ?: "1") - if (page < 1) throw badRequest("'page' param is less than 1") - val size: Int = expectInt(call.request.queryParameters["size"] ?: "5") - if (size < 1) throw badRequest("'size' param is less than 1") - // Time range filter values - val fromMs: Long = expectLong(call.request.queryParameters["from_ms"] ?: "0") - if (fromMs < 0) throw badRequest("'from_ms' param is less than 0") - val untilMs: Long = expectLong(call.request.queryParameters["until_ms"] ?: Long.MAX_VALUE.toString()) - if (untilMs < 0) throw badRequest("'until_ms' param is less than 0") - val longPollMs: Long? = call.maybeLong("long_poll_ms") - // LISTEN, if Postgres. - val listenHandle = if (isPostgres() && longPollMs != null) { - val channelName = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - call.expectUriComponent("account_name") - ) - val listenHandle = PostgresListenHandle(channelName) - // Can't LISTEN on the same DB TX that checks for data, as Exposed - // closes that connection and the notification getter would fail. - // Can't invoke the notification getter in the same DB TX either, - // as it would block the DB. - listenHandle.postgresListen() - listenHandle - } else null - val historyParams = HistoryParams( - pageNumber = page, - pageSize = size, - bankAccount = bankAccount, - fromMs = fromMs, - untilMs = untilMs - ) - var ret: List = transaction { - extractTxHistory(historyParams) - } - logger.debug("Is payment data empty? ${ret.isEmpty()}") - // Data was found already, UNLISTEN and respond. - if (listenHandle != null && ret.isNotEmpty()) { - logger.debug("No need to wait DB events, payment data found.") - listenHandle.postgresUnlisten() - call.respond(object {val transactions = ret}) - return@get - } - // No data was found, sleep until the timeout or getting woken up. - // Third condition only silences the compiler. - if (listenHandle != null && longPollMs != null) { - logger.debug("Waiting DB event for new payment data.") - val notificationArrived = listenHandle.waitOnIODispatchers(longPollMs) - // Only if the awaited event fired, query again the DB. - if (notificationArrived) - { - ret = transaction { - // Refreshing to update the index to the very last transaction. - historyParams.bankAccount.refresh() - extractTxHistory(historyParams) - } - } - } - call.respond(object {val transactions = ret}) - return@get - } - get("/public-accounts") { - val demobank = ensureDemobank(call) - val ret = object { - val publicAccounts = mutableListOf() - } - transaction { - BankAccountEntity.find { - BankAccountsTable.isPublic eq true and( - BankAccountsTable.demoBank eq demobank.id - ) - }.forEach { - val balanceIter = getBalance(it) - ret.publicAccounts.add( - PublicAccountInfo( - balance = "${demobank.config.currency}:$balanceIter", - iban = it.iban, - accountLabel = it.label - ) - ) - } - } - call.respond(ret) - return@get - } - delete("accounts/{account_name}") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val authGranted = !WITH_AUTH || username == "admin" - val bankAccountLabel = call.expectUriComponent("account_name") - /** - * This helper fails if the demobank that is mentioned in the URI - * is not hosting the account to be deleted. - */ - val bankAccount = getBankAccountFromLabel( - bankAccountLabel, - demobank - ) - if (!authGranted && username != bankAccount.owner) - throw unauthorized("User '$username' has no rights to delete bank account '$bankAccountLabel'") - transaction { - val customerAccount = getCustomer(bankAccount.owner) - bankAccount.delete() - customerAccount.delete() - } - call.respond(object {}) - return@delete - } - // Keeping the prefix "testing" not to break tests. - post("/testing/register") { - // Check demobank was created. - val demobank = ensureDemobank(call) - if (!demobank.config.allowRegistrations) { - throw SandboxError( - HttpStatusCode.UnprocessableEntity, - "The bank doesn't allow new registrations at the moment." - ) - } - val req = call.receive() - val newAccount = insertNewAccount( - req.username, - req.password, - name = req.name, - iban = req.iban, - demobank = demobank.name, - isPublic = req.isPublic - ) - val balance = getBalance(newAccount.bankAccount) - call.respond(object { - val balance = getBalanceForJson(balance, demobank.config.currency) - val paytoUri = buildIbanPaytoUri( - iban = newAccount.bankAccount.iban, - bic = newAccount.bankAccount.bic, - receiverName = getPersonNameFromCustomer(req.username) - ) - val iban = newAccount.bankAccount.iban - val debitThreshold = getMaxDebitForUser( - req.username, - demobank.name - ).toString() - }) - return@post - } - } - route("/ebics") { - /** - * Associate an existing bank account to one EBICS subscriber. - * If the subscriber is not found, it is created. - */ - post("/subscribers") { - // Only the admin can create Ebics subscribers. - val user = call.request.basicAuth() - if (WITH_AUTH && (user != "admin")) throw forbidden("Only the Administrator can create Ebics subscribers.") - val body = call.receive() - // Create or get the Ebics subscriber that is found. - transaction { - // Check that host ID exists - EbicsHostEntity.find { - EbicsHostsTable.hostID eq body.hostID - }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") - val subscriber: EbicsSubscriberEntity = EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq body.partnerID).and( - EbicsSubscribersTable.userId eq body.userID - ).and(EbicsSubscribersTable.hostId eq body.hostID) - }.firstOrNull() ?: EbicsSubscriberEntity.new { - partnerId = body.partnerID - userId = body.userID - systemId = null - hostId = body.hostID - state = SubscriberState.NEW - nextOrderID = 1 - } - val bankAccount = getBankAccountFromLabel( - body.demobankAccountLabel, - ensureDemobank(call) - ) - subscriber.bankAccount = bankAccount - } - call.respond(object {}) - return@post - } - } - } - } -} diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt deleted file mode 100644 index f76ad942..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt +++ /dev/null @@ -1,70 +0,0 @@ -package tech.libeufin.sandbox - -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.serialization.* -import io.ktor.util.reflect.* -import io.ktor.utils.io.* -import io.ktor.utils.io.charsets.* -import io.ktor.utils.io.jvm.javaio.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import tech.libeufin.util.XMLUtil - -class XMLEbicsConverter : ContentConverter { - override suspend fun deserialize( - charset: Charset, - typeInfo: TypeInfo, - content: ByteReadChannel - ): Any { - return withContext(Dispatchers.IO) { - try { - receiveEbicsXmlInternal(content.toInputStream().reader().readText()) - } catch (e: Exception) { - throw SandboxError( - HttpStatusCode.BadRequest, - "Document is invalid XML." - ) - } - } - } - - // The following annotation was suggested by Intellij. - @Deprecated( - "Please override and use serializeNullable instead", - replaceWith = ReplaceWith("serializeNullable(charset, typeInfo, contentType, value)"), - level = DeprecationLevel.WARNING - ) - override suspend fun serialize( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any - ): OutgoingContent? { - return super.serializeNullable(contentType, charset, typeInfo, value) - } - - override suspend fun serializeNullable( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any? - ): OutgoingContent? { - val conv = try { - XMLUtil.convertJaxbToString(value) - } catch (e: Exception) { - /** - * Not always an error: the content negotiation might have - * only checked if this handler could convert the response. - */ - return null - } - return OutputStreamContent({ - val out = this; - withContext(Dispatchers.IO) { - out.write(conv.toByteArray()) - }}, - contentType.withCharset(charset) - ) - } -} \ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt deleted file mode 100644 index d82a0eb4..00000000 --- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt +++ /dev/null @@ -1,276 +0,0 @@ -package tech.libeufin.sandbox - -import io.ktor.http.* -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.math.BigDecimal - -/** - * Check whether the given bank account would surpass the - * debit threshold, in case the potential amount gets transferred. - * Returns true when the debit WOULD be surpassed. */ -fun maybeDebit( - accountLabel: String, - requestedAmount: BigDecimal, - demobankName: String = "default" -): Boolean { - val demobank = getDemobank(demobankName) ?: throw notFound( - "Demobank '${demobankName}' not found when trying to check the debit threshold" + - " for user $accountLabel" - ) - val balance = getBalance(accountLabel, demobankName) - val maxDebt = if (accountLabel == "admin") { - demobank.config.bankDebtLimit - } else demobank.config.usersDebtLimit - val balanceCheck = balance - requestedAmount - if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal.valueOf(maxDebt.toLong())) { - logger.warn("User '$accountLabel' would surpass the debit" + - " threshold of $maxDebt, given the requested amount of ${requestedAmount.toPlainString()}") - return true - } - return false -} - -fun getMaxDebitForUser( - username: String, - demobankName: String = "default" -): Int { - val bank = getDemobank(demobankName) ?: throw internalServerError( - "demobank $demobankName not found" - ) - if (username == "admin") return bank.config.bankDebtLimit - return bank.config.usersDebtLimit -} - -fun getBalanceForJson(value: BigDecimal, currency: String): BalanceJson { - return BalanceJson( - amount = "${currency}:${value.abs()}", - credit_debit_indicator = if (value < BigDecimal.ZERO) "debit" else "credit" - ) -} - -fun getBalance(bankAccount: BankAccountEntity): BigDecimal { - return BigDecimal(bankAccount.balance) -} - -/** - * This function balances _in bank account statements_. A statement - * witnesses the bank account after a given business time slot. Therefore - * _this_ type of balance is not guaranteed to hold the _actual_ and - * more up-to-date bank account. It'll be used when Sandbox will support - * the issuing of bank statement. - */ -fun getBalanceForStatement( - bankAccount: BankAccountEntity, - withPending: Boolean = true -): BigDecimal { - val lastStatement = transaction { - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankAccount.id - }.lastOrNull() - } - var lastBalance = if (lastStatement == null) { - BigDecimal.ZERO - } else { BigDecimal(lastStatement.balanceClbd) } - if (!withPending) return lastBalance - /** - * Caller asks to include the pending transactions in the - * balance. The block below gets the transactions happened - * later than the last statement and adds them to the balance - * that was calculated so far. - */ - transaction { - val pendingTransactions = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq bankAccount.id and ( - BankAccountTransactionsTable.date.greater(lastStatement?.creationTime ?: 0L)) - } - pendingTransactions.forEach { tx -> - when (tx.direction) { - "DBIT" -> lastBalance -= parseDecimal(tx.amount) - "CRDT" -> lastBalance += parseDecimal(tx.amount) - else -> { - logger.error("Transaction ${tx.id} is neither debit nor credit.") - throw SandboxError( - HttpStatusCode.InternalServerError, - "Error in transactions state." - ) - } - } - } - } - return lastBalance -} - -// Gets the balance of 'accountLabel', which is hosted at 'demobankName'. -fun getBalance(accountLabel: String, - demobankName: String = "default" -): BigDecimal { - val demobank = getDemobank(demobankName) ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Demobank '$demobankName' not found" - ) - - /** - * Setting withBankFault to true for the following reason: - * when asking for a balance, the bank should have made sure - * that the user has a bank account (together with a customer profile). - * If that's not the case, it's bank's fault, since it didn't check - * earlier. - */ - val account = getBankAccountFromLabel( - accountLabel, - demobank, - withBankFault = true - ) - return getBalance(account) -} - -/** - * 'debitAccount' and 'creditAccount' are customer usernames - * and ALSO labels of the bank accounts owned by them. They are - * used to both resort a bank account and the legal name owning - * the bank accounts. - */ -fun wireTransfer( - debitAccount: String, - creditAccount: String, - demobank: String = "default", - subject: String, - amount: String, // $currency:x.y - pmtInfId: String? = null, - endToEndId: String? = null -): String { - logger.debug("Maybe wire transfer (endToEndId: $endToEndId): $debitAccount -> $creditAccount, $subject, $amount") - return transaction { - val demobankDb = ensureDemobank(demobank) - val debitAccountDb = getBankAccountFromLabel(debitAccount, demobankDb) - val creditAccountDb = getBankAccountFromLabel(creditAccount, demobankDb) - val parsedAmount = parseAmount(amount) - // Potential amount to transfer. - val amountAsNumber = BigDecimal(parsedAmount.amount) - if (amountAsNumber == BigDecimal.ZERO) - throw badRequest("Wire transfers of zero not possible.") - if (parsedAmount.currency != demobankDb.config.currency) - throw badRequest( - "Won't wire transfer with currency: ${parsedAmount.currency}." + - " Only ${demobankDb.config.currency} allowed." - ) - // Check funds are sufficient. - if ( - maybeDebit( - debitAccountDb.label, - amountAsNumber, - demobankDb.name - )) { - logger.error("Account ${debitAccountDb.label} would surpass debit threshold. Rollback wire transfer") - throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") - } - val timeStamp = getNowMillis() - val transactionRef = getRandomString(8) - BankAccountTransactionEntity.new { - creditorIban = creditAccountDb.iban - creditorBic = creditAccountDb.bic - this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) - debtorIban = debitAccountDb.iban - debtorBic = debitAccountDb.bic - debtorName = getPersonNameFromCustomer(debitAccountDb.owner) - this.subject = subject - this.amount = parsedAmount.amount - this.currency = demobankDb.config.currency - date = timeStamp - accountServicerReference = transactionRef - account = creditAccountDb - direction = "CRDT" - this.demobank = demobankDb - this.pmtInfId = pmtInfId - } - BankAccountTransactionEntity.new { - creditorIban = creditAccountDb.iban - creditorBic = creditAccountDb.bic - this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) - debtorIban = debitAccountDb.iban - debtorBic = debitAccountDb.bic - debtorName = getPersonNameFromCustomer(debitAccountDb.owner) - this.subject = subject - this.amount = parsedAmount.amount - this.currency = demobankDb.config.currency - date = timeStamp - accountServicerReference = transactionRef - account = debitAccountDb - direction = "DBIT" - this.demobank = demobankDb - this.pmtInfId = pmtInfId - this.endToEndId = endToEndId - } - - // Adjusting the balances (acceptable debit conditions checked before). - // Debit: - val newDebitBalance = (BigDecimal(debitAccountDb.balance) - amountAsNumber).roundToTwoDigits() - debitAccountDb.balance = newDebitBalance.toPlainString() - // Credit: - val newCreditBalance = (BigDecimal(creditAccountDb.balance) + amountAsNumber).roundToTwoDigits() - creditAccountDb.balance = newCreditBalance.toPlainString() - - // Signaling this wire transfer's event. - if (this.isPostgres()) { - val creditChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - creditAccountDb.label - ) - this.postgresNotify(creditChannel, "CRDT") - val debitChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - debitAccountDb.label - ) - this.postgresNotify(debitChannel, "DBIT") - } - transactionRef - } -} - -/** - * Helper that constructs a transactions history page - * according to the URI parameters passed to Access API's - * GET /transactions. - */ -data class HistoryParams( - val pageNumber: Int, - val pageSize: Int, - val fromMs: Long, - val untilMs: Long, - val bankAccount: BankAccountEntity -) - -fun extractTxHistory(params: HistoryParams): List { - val ret = mutableListOf() - - /** - * Helper that gets transactions earlier than the 'firstElementId' - * transaction AND that match the URI parameters. - */ - fun getPage(firstElementId: Long): Iterable { - return BankAccountTransactionEntity.find { - (BankAccountTransactionsTable.id lessEq firstElementId) and - (BankAccountTransactionsTable.account eq params.bankAccount.id) and - (BankAccountTransactionsTable.date.between(params.fromMs, params.untilMs)) - }.sortedByDescending { it.id.value }.take(params.pageSize) - } - // Gets a pointer to the last transaction of this bank account. - val lastTransaction: BankAccountTransactionEntity? = params.bankAccount.lastTransaction - if (lastTransaction == null) return ret - var nextPageIdUpperLimit: Long = lastTransaction.id.value - - // This loop fetches (and discards) pages until the desired one is found. - for (i in 1..(params.pageNumber)) { - val pageBuf = getPage(nextPageIdUpperLimit) - logger.debug("pageBuf #$i follows. Request wants #${params.pageNumber}:") - pageBuf.forEach { logger.debug("ID: ${it.id}, subject: ${it.subject}, amount: ${it.currency}:${it.amount}") } - if (pageBuf.none()) return ret - nextPageIdUpperLimit = pageBuf.last().id.value - 1 - if (i == params.pageNumber) pageBuf.forEach { - ret.add(getHistoryElementFromTransactionRow(it)) - } - } - return ret -} \ No newline at end of file diff --git a/sandbox/src/main/resources/logback.xml b/sandbox/src/main/resources/logback.xml deleted file mode 100644 index cefb7182..00000000 --- a/sandbox/src/main/resources/logback.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - System.err - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - - - - - - - - diff --git a/sandbox/src/main/resources/static/README.txt b/sandbox/src/main/resources/static/README.txt deleted file mode 100644 index beab625a..00000000 --- a/sandbox/src/main/resources/static/README.txt +++ /dev/null @@ -1 +0,0 @@ -The spa.html file is generated from merchant-backoffice.git -> /packages/bank/ \ No newline at end of file diff --git a/sandbox/src/test/kotlin/BalanceTest.kt b/sandbox/src/test/kotlin/BalanceTest.kt deleted file mode 100644 index eb09cc64..00000000 --- a/sandbox/src/test/kotlin/BalanceTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.sandbox.* -import tech.libeufin.util.millis -import tech.libeufin.util.roundToTwoDigits -import java.math.BigDecimal -import java.time.LocalDateTime - -class BalanceTest { - @Test - fun balanceTest() { - val config = DemobankConfig( - currency = "EUR", - bankDebtLimit = 1000000, - usersDebtLimit = 10000, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = false - ) - withTestDatabase { - transaction { - insertConfigPairs(config) - val demobank = DemobankConfigEntity.new { - name = "default" - } - val one = BankAccountEntity.new { - iban = "IBAN 1" - bic = "BIC" - label = "label 1" - owner = "admin" - this.demoBank = demobank - } - val other = BankAccountEntity.new { - iban = "IBAN 2" - bic = "BIC" - label = "label 2" - owner = "admin" - this.demoBank = demobank - } - BankAccountTransactionEntity.new { - account = one - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "CRDT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - BankAccountTransactionEntity.new { - account = one - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "CRDT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - BankAccountTransactionEntity.new { - account = one - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "DBIT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - wireTransfer( - other.label, one.label, demobank.name, "one gets 1", "EUR:1" - ) - wireTransfer( - other.label, one.label, demobank.name, "one gets another 1", "EUR:1" - ) - wireTransfer( - one.label, other.label, demobank.name, "one gives 1", "EUR:1" - ) - val maybeOneBalance: BigDecimal = getBalance(one) - println(maybeOneBalance) - assert(BigDecimal.ONE.roundToTwoDigits() == maybeOneBalance.roundToTwoDigits()) - } - } - } - @Test - fun balanceAbsTest() { - val minus = BigDecimal.ZERO - BigDecimal.ONE - val plus = BigDecimal.ONE - println(minus.abs().toPlainString()) - println(plus.abs().toPlainString()) - } -} diff --git a/sandbox/src/test/kotlin/DBTest.kt b/sandbox/src/test/kotlin/DBTest.kt deleted file mode 100644 index bc5a33c5..00000000 --- a/sandbox/src/test/kotlin/DBTest.kt +++ /dev/null @@ -1,152 +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 - * - */ - -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.sandbox.* -import tech.libeufin.util.connectWithSchema -import tech.libeufin.util.getCurrentUser -import tech.libeufin.util.getJdbcConnectionFromPg -import tech.libeufin.util.millis -import java.io.File -import java.time.LocalDateTime -import kotlin.reflect.KProperty -import kotlin.reflect.typeOf - -/** - * Run a block after connecting to the test database. - * Cleans up the DB file afterwards. - */ -fun withTestDatabase(f: () -> Unit) { - dbDropTables("postgresql:///libeufincheck") - dbCreateTables("postgresql:///libeufincheck") - f() -} - -class DBTest { - private var config = DemobankConfig( - currency = "EUR", - bankDebtLimit = 1000000, - usersDebtLimit = 10000, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = false, - ) - - /** - * This tests the conversion from a Postgres connection - * string to a JDBC one. - */ - @Test - fun connectionStringTest() { - getJdbcConnectionFromPg("postgres://auditor-basedb") - var conv = getJdbcConnectionFromPg("postgresql:///libeufincheck") - connectWithSchema(getJdbcConnectionFromPg("postgres:///libeufincheck")) - connectWithSchema(conv) - conv = getJdbcConnectionFromPg("postgresql://localhost:5432/libeufincheck?user=${System.getProperty("user.name")}") - connectWithSchema(conv) - conv = getJdbcConnectionFromPg("postgresql:///libeufincheck?host=/tmp/libeufin") - var exception: Exception? = null - try { - connectWithSchema(conv) - } catch (e: Exception) { - exception = e - } - assert(exception is UtilError) - } - - /** - * Storing configuration values into the database, - * then extract them and check that they equal the - * configuration model object. - */ - @Test - fun insertPairsTest() { - withTestDatabase { - // Config model. - val config = DemobankConfig( - currency = "EUR", - bankDebtLimit = 1, - usersDebtLimit = 2, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = true - ) - transaction { - DemobankConfigEntity.new { name = "default" } - insertConfigPairs(config) - val db = getDefaultDemobank() - /** - * db.config extracts config values from the database - * and puts them in a fresh config model object. - */ - assert(config.hashCode() == db.config.hashCode()) - } - } - } - - @Test - fun betweenDates() { - withTestDatabase { - transaction { - insertConfigPairs(config) - val demobank = DemobankConfigEntity.new { - name = "default" - } - val bankAccount = BankAccountEntity.new { - iban = "iban" - bic = "bic" - label = "label" - owner = "test" - demoBank = demobank - } - BankAccountTransactionEntity.new { - account = bankAccount - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "EUR:1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "DBIT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - } - // The block below tests the date range in the database query - transaction { - addLogger(StdOutSqlLogger) - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.date.between( - 0, // 1970-01-01 - LocalDateTime.now().millis() // - ) - }.apply { - assert(this.count() == 1L) - } - } - } - } -} \ No newline at end of file diff --git a/sandbox/src/test/kotlin/DatabaseTest.kt b/sandbox/src/test/kotlin/DatabaseTest.kt deleted file mode 100644 index 7716cd26..00000000 --- a/sandbox/src/test/kotlin/DatabaseTest.kt +++ /dev/null @@ -1,261 +0,0 @@ -import org.junit.Test -import tech.libeufin.sandbox.* -import tech.libeufin.util.execCommand -import java.util.UUID - -class DatabaseTest { - private val customerFoo = Customer( - login = "foo", - passwordHash = "hash", - name = "Foo", - phone = "+00", - email = "foo@b.ar", - cashoutPayto = "payto://external-IBAN", - cashoutCurrency = "KUDOS" - ) - private val customerBar = Customer( - login = "bar", - passwordHash = "hash", - name = "Bar", - phone = "+00", - email = "foo@b.ar", - cashoutPayto = "payto://external-IBAN", - cashoutCurrency = "KUDOS" - ) - private val bankAccountFoo = BankAccount( - iban = "FOO-IBAN-XYZ", - bic = "FOO-BIC", - bankAccountLabel = "foo", - lastNexusFetchRowId = 1L, - owningCustomerId = 1L, - hasDebt = false - ) - private val bankAccountBar = BankAccount( - iban = "BAR-IBAN-ABC", - bic = "BAR-BIC", - bankAccountLabel = "bar", - lastNexusFetchRowId = 1L, - owningCustomerId = 2L, - hasDebt = false - ) - - fun initDb(): Database { - execCommand( - listOf( - "libeufin-bank-dbinit", - "-d", - "libeufincheck", - "-r" - ), - throwIfFails = true - ) - val db = Database("jdbc:postgresql:///libeufincheck") - return db - } - - @Test - fun bankTransactionsTest() { - val db = initDb() - assert(db.customerCreate(customerFoo)) - assert(db.customerCreate(customerBar)) - assert(db.bankAccountCreate(bankAccountFoo)) - assert(db.bankAccountCreate(bankAccountBar)) - var fooAccount = db.bankAccountGetFromLabel("foo") - assert(fooAccount?.hasDebt == false) // Foo has NO debit. - // Preparing the payment data. - db.bankAccountSetMaxDebt( - "foo", - TalerAmount(100, 0) - ) - db.bankAccountSetMaxDebt( - "bar", - TalerAmount(50, 0) - ) - val fooPaysBar = BankInternalTransaction( - creditorAccountId = 2, - debtorAccountId = 1, - subject = "test", - amount = TalerAmount(10, 0), - accountServicerReference = "acct-svcr-ref", - endToEndId = "end-to-end-id", - paymentInformationId = "pmtinfid", - transactionDate = 100000L - ) - val firstSpending = db.bankTransactionCreate(fooPaysBar) // Foo pays Bar and goes debit. - assert(firstSpending == Database.BankTransactionResult.SUCCESS) - fooAccount = db.bankAccountGetFromLabel("foo") - // Foo: credit -> debit - assert(fooAccount?.hasDebt == true) // Asserting Foo's debit. - // Now checking that more spending doesn't get Foo out of debit. - val secondSpending = db.bankTransactionCreate(fooPaysBar) - assert(secondSpending == Database.BankTransactionResult.SUCCESS) - fooAccount = db.bankAccountGetFromLabel("foo") - // Checking that Foo's debit is two times the paid amount - // Foo: debit -> debit - assert(fooAccount?.balance?.value == 20L - && fooAccount.balance?.frac == 0 - && fooAccount.hasDebt - ) - // Asserting Bar has a positive balance and what Foo paid so far. - var barAccount = db.bankAccountGetFromLabel("bar") - val barBalance: TalerAmount? = barAccount?.balance - assert( - barAccount?.hasDebt == false - && barBalance?.value == 20L && barBalance.frac == 0 - ) - // Bar pays so that its balance remains positive. - val barPaysFoo = BankInternalTransaction( - creditorAccountId = 1, - debtorAccountId = 2, - subject = "test", - amount = TalerAmount(10, 0), - accountServicerReference = "acct-svcr-ref", - endToEndId = "end-to-end-id", - paymentInformationId = "pmtinfid", - transactionDate = 100000L - ) - val barPays = db.bankTransactionCreate(barPaysFoo) - assert(barPays == Database.BankTransactionResult.SUCCESS) - barAccount = db.bankAccountGetFromLabel("bar") - val barBalanceTen: TalerAmount? = barAccount?.balance - // Bar: credit -> credit - assert(barAccount?.hasDebt == false && barBalanceTen?.value == 10L && barBalanceTen.frac == 0) - // Bar pays again to let Foo return in credit. - val barPaysAgain = db.bankTransactionCreate(barPaysFoo) - assert(barPaysAgain == Database.BankTransactionResult.SUCCESS) - // Refreshing the two accounts. - barAccount = db.bankAccountGetFromLabel("bar") - fooAccount = db.bankAccountGetFromLabel("foo") - // Foo should have returned to zero and no debt, same for Bar. - // Foo: debit -> credit - assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == false) - assert(fooAccount?.balance?.equals(TalerAmount(0, 0)) == true) - assert(barAccount?.balance?.equals(TalerAmount(0, 0)) == true) - // Bringing Bar to debit. - val barPaysMore = db.bankTransactionCreate(barPaysFoo) - assert(barPaysAgain == Database.BankTransactionResult.SUCCESS) - barAccount = db.bankAccountGetFromLabel("bar") - fooAccount = db.bankAccountGetFromLabel("foo") - // Bar: credit -> debit - assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == true) - assert(fooAccount?.balance?.equals(TalerAmount(10, 0)) == true) - assert(barAccount?.balance?.equals(TalerAmount(10, 0)) == true) - } - @Test - fun customerCreationTest() { - val db = initDb() - assert(db.customerGetFromLogin("foo") == null) - db.customerCreate(customerFoo) - assert(db.customerGetFromLogin("foo")?.name == "Foo") - // Trigger conflict. - assert(!db.customerCreate(customerFoo)) - } - @Test - fun configTest() { - val db = initDb() - assert(db.configGet("bar") == null) - assert(db.configGet("bar") == null) - db.configSet("foo", "bar") - assert(db.configGet("foo") == "bar") - } - @Test - fun bankAccountTest() { - val db = initDb() - assert(db.bankAccountGetFromLabel("foo") == null) - assert(db.customerCreate(customerFoo)) - assert(db.bankAccountCreate(bankAccountFoo)) - assert(!db.bankAccountCreate(bankAccountFoo)) // Triggers conflict. - assert(db.bankAccountGetFromLabel("foo")?.bankAccountLabel == "foo") - assert(db.bankAccountGetFromLabel("foo")?.balance?.equals(TalerAmount(0, 0)) == true) - } - - @Test - fun withdrawalTest() { - val db = initDb() - val uuid = UUID.randomUUID() - assert(db.customerCreate(customerFoo)) - assert(db.bankAccountCreate(bankAccountFoo)) - // insert new. - assert(db.talerWithdrawalCreate( - uuid, - 1L, - TalerAmount(1, 0) - )) - // get it. - val op = db.talerWithdrawalGet(uuid) - assert(op?.walletBankAccount == 1L && op.withdrawalUuid == uuid) - // Setting the details. - assert(db.talerWithdrawalSetDetails( - uuid, - "exchange-payto", - ByteArray(32) - )) - val opSelected = db.talerWithdrawalGet(uuid) - assert(opSelected?.selectionDone == true && !opSelected.confirmationDone) - assert(db.talerWithdrawalConfirm(uuid)) - // Finally confirming the operation (means customer wired funds to the exchange.) - assert(db.talerWithdrawalGet(uuid)?.confirmationDone == true) - } - // Only testing the interaction between Kotlin and the DBMS. No actual logic tested. - @Test - fun historyTest() { - val db = initDb() - val res = db.bankTransactionGetForHistoryPage( - 10L, - 1L, - fromMs = 0, - toMs = Long.MAX_VALUE - ) - assert(res.isEmpty()) - } - @Test - fun cashoutTest() { - val db = initDb() - val op = Cashout( - cashoutUuid = UUID.randomUUID(), - amountDebit = TalerAmount(1, 0), - amountCredit = TalerAmount(2, 0), - bankAccount = 1L, - buyAtRatio = 3, - buyInFee = TalerAmount(0, 22), - sellAtRatio = 2, - sellOutFee = TalerAmount(0, 44), - cashoutAddress = "IBAN", - cashoutCurrency = "KUDOS", - creationTime = 3L, - subject = "31st", - tanChannel = TanChannel.sms, - tanCode = "secret", - ) - assert(db.customerCreate(customerFoo)) - assert(db.bankAccountCreate(bankAccountFoo)) - assert(db.customerCreate(customerBar)) - assert(db.bankAccountCreate(bankAccountBar)) - assert(db.cashoutCreate(op)) - val fromDb = db.cashoutGetFromUuid(op.cashoutUuid) - assert(fromDb?.subject == op.subject && fromDb.tanConfirmationTime == null) - assert(db.cashoutDelete(op.cashoutUuid) == Database.CashoutDeleteResult.SUCCESS) - assert(db.cashoutCreate(op)) - db.bankAccountSetMaxDebt( - "foo", - TalerAmount(100, 0) - ) - assert(db.bankTransactionCreate(BankInternalTransaction( - creditorAccountId = 2, - debtorAccountId = 1, - subject = "backing the cash-out", - amount = TalerAmount(10, 0), - accountServicerReference = "acct-svcr-ref", - endToEndId = "end-to-end-id", - paymentInformationId = "pmtinfid", - transactionDate = 100000L - )) == Database.BankTransactionResult.SUCCESS) - // Confirming the cash-out - assert(db.cashoutConfirm(op.cashoutUuid, 1L, 1L)) - // Checking the confirmation took place. - assert(db.cashoutGetFromUuid(op.cashoutUuid)?.tanConfirmationTime != null) - // Deleting the operation. - assert(db.cashoutDelete(op.cashoutUuid) == Database.CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED) - assert(db.cashoutGetFromUuid(op.cashoutUuid) != null) // previous didn't delete. - } -} \ No newline at end of file diff --git a/sandbox/src/test/kotlin/EbicsErrorTest.kt b/sandbox/src/test/kotlin/EbicsErrorTest.kt deleted file mode 100644 index e0be736b..00000000 --- a/sandbox/src/test/kotlin/EbicsErrorTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -import org.apache.xml.security.binding.xmldsig.SignatureType -import org.junit.Test -import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.XMLUtil -import tech.libeufin.util.ebics_h004.EbicsResponse -import tech.libeufin.util.ebics_h004.EbicsTypes - -class EbicsErrorTest { - - @Test - fun makeEbicsErrorResponse() { - val pair = CryptoUtil.generateRsaKeyPair(2048) - val resp = EbicsResponse.createForUploadWithError( - "[EBICS_ERROR] abc", - "012345", - EbicsTypes.TransactionPhaseType.INITIALISATION - ) - val signedResp = XMLUtil.signEbicsResponse(resp, pair.private) - XMLUtil.validateFromString(signedResp) - assert(resp.header.mutable.reportText == "[EBICS_ERROR] abc") - assert(resp.header.mutable.returnCode == "012345") - assert(resp.body.returnCode.value == "012345") - } -} \ No newline at end of file diff --git a/sandbox/src/test/kotlin/StringsTest.kt b/sandbox/src/test/kotlin/StringsTest.kt deleted file mode 100644 index 892a419c..00000000 --- a/sandbox/src/test/kotlin/StringsTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -import org.junit.Test -import tech.libeufin.util.hasWopidPlaceholder -import tech.libeufin.util.validateBic - -class StringsTest { - - @Test - fun hasWopidTest() { - assert(hasWopidPlaceholder("http://example.com/#/{wopid}")) - assert(!hasWopidPlaceholder("http://example.com")) - assert(hasWopidPlaceholder("http://example.com/#/{WOPID}")) - assert(!hasWopidPlaceholder("{ W O P I D }")) - } - - @Test - fun replaceWopidPlaceholderTest() { - assert( - "http://example.com/#/operation/{wopid}".replace("{wopid}", "987") - == "http://example.com/#/operation/987" - ) - assert("http://example.com".replace("{wopid}", "not-replaced") - == "http://example.com" - ) - } - - @Test - fun bicTest() { - assert(validateBic("GENODEM1GLS")) - assert(validateBic("AUTOATW1XXX")) - } - - @Test - fun booleanToString() { - assert(true.toString() == "true") - assert(false.toString() == "false") - } -} \ No newline at end of file -- cgit v1.2.3