summaryrefslogtreecommitdiff
path: root/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
diff options
context:
space:
mode:
Diffstat (limited to 'bank/src/main/kotlin/tech/libeufin/bank/Main.kt')
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Main.kt1711
1 files changed, 1711 insertions, 0 deletions
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
+ * <http://www.gnu.org/licenses/>
+ */
+
+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<String, MutableList<XLibeufinBankTransaction>>()
+ /**
+ * 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 <reified T> 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<String>) {
+ 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<ArithmeticException> { 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<SandboxError> { 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<UtilError> { 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<BadRequestException> { call, wrapper ->
+ var rootCause = wrapper.cause
+ while (rootCause?.cause != null) rootCause = rootCause.cause
+ val errorMessage: String? = rootCause?.message ?: wrapper.message
+ if (errorMessage == null) {
+ logger.error("The bank didn't detect the cause of a bad request, fail.")
+ logger.error(wrapper.stackTraceToString())
+ throw 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<Throwable> { 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<EbicsRequestError> { 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<CamtParams>()
+ 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<BankAccountInfo>()
+ 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<IncomingPaymentInfo>()
+ 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<EbicsBankAccountRequest>()
+ 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<BankAccountInfo>()
+ 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<EbicsSubscriberObsoleteApi>()
+ 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<EbicsHostCreateRequest>()
+ 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<Demobank>() }
+ 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<TWGAdminAddIncoming>() }
+ 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<TalerWithdrawalSelection>()
+ 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<XLibeufinBankPaytoReq>()
+ 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<WithdrawalRequest>()
+ // 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<XLibeufinBankTransaction> = 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<PublicAccountInfo>()
+ }
+ 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<CustomerRegistration>()
+ 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<EbicsSubscriberInfo>()
+ // 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
+ }
+ }
+ }
+ }
+}