/*
* 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.engine.*
import io.ktor.server.netty.*
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.SqlExpressionBuilder.eq
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.BindException
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 SANDBOX_VERSION = "0:0:0"
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)
}
maybeDemobank.suggestedExchangeBaseUrl = exchangeBaseUrl
maybeDemobank.suggestedExchangePayto = exchangePayto
}
}
}
}
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 = "(default: true)" /* 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! (default: false)"
).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)
transaction {
val maybeDemobank = BankAccountEntity.find(
BankAccountsTable.label eq "admin"
).firstOrNull()
if (showOption) {
if (maybeDemobank != null) {
val ret = ObjectMapper()
ret.configure(SerializationFeature.INDENT_OUTPUT, true)
println(
ret.writeValueAsString(object {
val currency = maybeDemobank.demoBank.currency
val bankDebtLimit = maybeDemobank.demoBank.bankDebtLimit
val usersDebtLimit = maybeDemobank.demoBank.usersDebtLimit
val allowRegistrations = maybeDemobank.demoBank.allowRegistrations
val name = maybeDemobank.demoBank.name // always 'default'
val withSignupBonus = maybeDemobank.demoBank.withSignupBonus
val captchaUrl = maybeDemobank.demoBank.captchaUrl
})
)
return@transaction
}
println("Nothing to show.")
return@transaction
}
if (maybeDemobank == null) {
val demoBank = DemobankConfigEntity.new {
currency = currencyOption
bankDebtLimit = bankDebtLimitOption
usersDebtLimit = usersDebtLimitOption
allowRegistrations = allowRegistrationsOption
name = nameArgument
this.withSignupBonus = withSignupBonusOption
captchaUrl = captchaUrlOption
}
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
}
return@transaction
}
maybeDemobank.demoBank.currency = currencyOption
maybeDemobank.demoBank.bankDebtLimit = bankDebtLimitOption
maybeDemobank.demoBank.usersDebtLimit = usersDebtLimitOption
maybeDemobank.demoBank.allowRegistrations = allowRegistrationsOption
maybeDemobank.demoBank.withSignupBonus = withSignupBonusOption
maybeDemobank.demoBank.name = nameArgument
maybeDemobank.demoBank.captchaUrl = captchaUrlOption
}
}
}
}
/**
* 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)
Database.connect(dbConnString)
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 lastBalance = getBalance(accountIter, withPending = false)
val balanceClbd = getBalance(accountIter, withPending = true)
val camtData = buildCamtString(
53,
accountIter.iban,
newStatements[accountIter.label]!!,
balanceClbd = balanceClbd,
balancePrcd = lastBalance
)
BankAccountStatementEntity.new {
statementId = camtData.messageId
creationTime = getUTCnow().toInstant().epochSecond
xmlMessage = camtData.camtMessage
bankAccount = accountIter
this.balanceClbd = balanceClbd.toPlainString()
}
}
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() {
val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)
Database.connect(dbConnString)
// 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()
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
serverMain(port, localhostOnly, ipv4Only)
}
}
private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank {
return Demobank(
currency = fromDb.currency,
userDebtLimit = fromDb.usersDebtLimit,
bankDebtLimit = fromDb.bankDebtLimit,
allowRegistrations = fromDb.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()
)
}
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)
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 = "util-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.getUriComponent("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, withPending = true)
object {
val balance = "${bankAccount.demoBank.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 = getUTCnow().toInstant().toEpochMilli()
accountServicerReference = "sandbox-$randId"
this.account = account
direction = "CRDT"
this.demobank = demobank
this.currency = demobank.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()
if (!validateBic(body.bic)) {
throw SandboxError(HttpStatusCode.BadRequest, "invalid BIC (${body.bic})")
}
transaction {
val subscriber = getEbicsSubscriberFromDetails(
body.subscriber.userID,
body.subscriber.partnerID,
body.subscriber.hostID
)
if (subscriber.bankAccount != null)
throw conflict("subscriber has already a bank account: ${subscriber.bankAccount?.label}")
val demobank = getDefaultDemobank()
// Forbid institutional names for bank account.
if (body.label == "admin" || body.label == "bank") throw forbidden(
"Requested bank account label '${body.label}' not allowed."
)
/**
* Checking that the default demobank doesn't have already the
* requested IBAN and bank account label.
*/
val check = BankAccountEntity.find {
BankAccountsTable.iban eq body.iban or (
(BankAccountsTable.label eq body.label) and (
BankAccountsTable.demoBank eq demobank.id
)
)
}.count()
if (check > 0) throw SandboxError(
HttpStatusCode.BadRequest,
"Either IBAN or account label were already taken; please choose fresh ones"
)
subscriber.bankAccount = BankAccountEntity.new {
iban = body.iban
bic = body.bic
label = body.label
/* Current version invariant:
owner's username == bank account label. */
owner = body.label
demoBank = demobank
}
}
call.respondText("Bank account created")
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 = getUTCnow().toInstant().toEpochMilli()
accountServicerReference = transactionReferenceCrdt
this.account = account
direction = "CRDT"
this.demobank = demobank
currency = demobank.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 = getUTCnow().toInstant().toEpochMilli()
accountServicerReference = transactionReferenceDbit
this.account = account
direction = "DBIT"
this.demobank = demobank
currency = demobank.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 it exists first.
val maybeSubscriber = EbicsSubscriberEntity.find {
EbicsSubscribersTable.userId eq body.userID and (
EbicsSubscribersTable.partnerId eq body.partnerID
) and (
EbicsSubscribersTable.systemId eq body.systemID
)
}.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) {
throw EbicsProcessingError("Could not map error to EBICS code: $e")
}
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.getUriComponent("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"
)
}
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")
wireTransfer(
bankAccountDebit.label,
bankAccountCredit.label,
demobank.name,
body.reserve_pub,
body.amount
)
logger.debug("TWG add-incoming has wire transferred")
}
call.respond(object {})
return@post
}
}
// Talk to wallets.
route("/integration-api") {
get("/config") {
val demobank = ensureDemobank(call)
call.respond(SandboxConfig(
name = "taler-bank-integration",
version = SANDBOX_VERSION,
currency = demobank.currency
))
return@get
}
post("/withdrawal-operation/{wopid}") {
val arg = ensureNonNull(call.parameters["wopid"])
val maybeWithdrawalUUid = try {
java.util.UUID.fromString(arg)
} catch (e: Exception) {
logger.debug(e.message)
throw badRequest("Withdrawal operation UUID was invalid: $arg")
}
val body = call.receive()
val transferDone = transaction {
val wo = TalerWithdrawalEntity.find {
TalerWithdrawalsTable.wopid eq maybeWithdrawalUUid
}.firstOrNull() ?: throw SandboxError(
HttpStatusCode.NotFound, "Withdrawal operation $maybeWithdrawalUUid 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 = try {
java.util.UUID.fromString(arg)
} catch (e: Exception) {
logger.debug(e.message)
throw badRequest("Withdrawal UUID invalid: $arg")
}
val maybeWithdrawalOp = transaction {
TalerWithdrawalEntity.find {
TalerWithdrawalsTable.wopid eq maybeWithdrawalUuid
}.firstOrNull() ?: throw SandboxError(
HttpStatusCode.NotFound,
"Withdrawal operation: $arg not found"
)
}
val demobank = ensureDemobank(call)
var captcha_page = demobank.captchaUrl
if (captcha_page == null) logger.warn("CAPTCHA URL not found")
val ret = TalerWithdrawalStatus(
selection_done = maybeWithdrawalOp.selectionDone,
transfer_done = maybeWithdrawalOp.confirmationDone,
amount = maybeWithdrawalOp.amount,
suggested_exchange = demobank.suggestedExchangeBaseUrl,
aborted = maybeWithdrawalOp.aborted,
confirm_transfer_url = captcha_page
)
call.respond(ret)
return@get
}
}
route("/circuit-api") {
circuitApi(this)
}
// Talk to Web UI.
route("/access-api") {
post("/accounts/{account_name}/transactions") {
val bankAccount = getBankAccountWithAuth(call)
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,
creditAccount = getBankAccountFromIban(payto.iban),
demobank = bankAccount.demoBank,
subject = payto.message ?: throw badRequest(
"'message' query parameter missing in Payto address"
),
amount = amount
)
}
call.respond(object {})
return@post
}
// Information about one withdrawal.
get("/accounts/{account_name}/withdrawals/{withdrawal_id}") {
val op = getWithdrawalOperation(call.getUriComponent("withdrawal_id"))
ensureDemobank(call)
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
})
return@get
}
// Create a new withdrawal operation.
post("/accounts/{account_name}/withdrawals") {
var username = call.request.basicAuth()
if (username == null && (!WITH_AUTH)) {
logger.info("Authentication is disabled to facilitate tests, defaulting to 'admin' username")
username = "admin"
}
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.getUriComponent("account_name"),
demobank
)
if (maybeOwnedAccount.owner != username && WITH_AUTH) 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.currency)
throw badRequest("Currency ${amount.currency} differs from Demobank's: ${demobank.currency}")
// Check funds are sufficient.
if (maybeDebit(maybeOwnedAccount.label, BigDecimal(amount.amount))) {
logger.error("Account ${maybeOwnedAccount.label} would surpass debit threshold. Not withdrawing")
throw SandboxError(HttpStatusCode.Forbidden, "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(
"taler".plus(if (baseUrl.protocol.lowercase() == "http") "+http" else ""),
-1
)
host = "withdraw"
this.appendPathSegments(
listOf(
/**
* encodes the hostname(+port) of the actual
* bank that will serve the withdrawal request.
*/
baseUrl.host.plus(
if (baseUrl.port != -1)
":${baseUrl.port}"
else ""
),
baseUrl.path, // has x-forwarded-prefix, or single slash.
"demobanks",
demobank.name,
"integration-api",
wo.wopid.toString()
)
)
}
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") {
val withdrawalId = call.getUriComponent("withdrawal_id")
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.suggestedExchangePayto
}
val exchangeBankAccount = getBankAccountFromPayto(
wo.selectedExchangePayto ?: throw internalServerError(
"Cannot withdraw without an exchange."
)
)
if (!wo.confirmationDone) {
wireTransfer(
debitAccount = wo.walletBankAccount,
creditAccount = exchangeBankAccount,
amount = wo.amount,
subject = wo.reservePub ?: throw internalServerError(
"Cannot transfer funds without reserve public key."
),
// provide the currency.
demobank = ensureDemobank(call)
)
wo.confirmationDone = true
}
wo.confirmationDone
}
call.respond(object {})
return@post
}
post("/accounts/{account_name}/withdrawals/{withdrawal_id}/abort") {
val withdrawalId = call.getUriComponent("withdrawal_id")
val operation = getWithdrawalOperation(withdrawalId)
if (operation.confirmationDone) throw conflict("Cannot abort paid withdrawal.")
transaction { operation.aborted = true }
call.respond(object {})
return@post
}
// Bank account basic information.
get("/accounts/{account_name}") {
val username = call.request.basicAuth()
val accountAccessed = call.getUriComponent("account_name")
val demobank = ensureDemobank(call)
val bankAccount = getBankAccountFromLabel(accountAccessed, demobank)
// Check rights.
if (
WITH_AUTH
&& (bankAccount.owner != username && username != "admin")
) throw forbidden(
"Customer '$username' cannot access bank account '$accountAccessed'"
)
val balance = getBalance(bankAccount, withPending = true)
call.respond(object {
val balance = object {
val amount = "${demobank.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(
username ?: "Not given."
)
)
val iban = bankAccount.iban
})
return@get
}
get("/accounts/{account_name}/transactions/{tId}") {
val demobank = ensureDemobank(call)
val bankAccount = getBankAccountFromLabel(
call.getUriComponent("account_name"),
demobank
)
val authOk: Boolean = bankAccount.isPublic || (!WITH_AUTH)
if (!authOk && (call.request.basicAuth() != bankAccount.owner)) throw forbidden(
"Cannot access bank account ${bankAccount.label}"
)
// Flow here == Right on the bank account.
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 demobank = ensureDemobank(call)
val bankAccount = getBankAccountFromLabel(
call.getUriComponent("account_name"),
demobank
)
val authOk: Boolean = bankAccount.isPublic || (!WITH_AUTH)
if (!authOk && (call.request.basicAuth() != bankAccount.owner)) throw forbidden(
"Cannot access bank account ${bankAccount.label}"
)
val page: Int = Integer.decode(call.request.queryParameters["page"] ?: "0")
val size: Int = Integer.decode(call.request.queryParameters["size"] ?: "5")
val ret = mutableListOf()
/**
* Case where page number wasn't given,
* therefore the results starts from the last transaction. */
transaction {
/**
* Get a history page - from the calling bank account - having
* 'firstElementId' as the latest transaction in it. */
fun getPage(firstElementId: Long): Iterable {
logger.debug("History page from tx $firstElementId, including $size txs in the past.")
return BankAccountTransactionEntity.find {
(BankAccountTransactionsTable.id lessEq firstElementId) and
(BankAccountTransactionsTable.account eq bankAccount.id)
}.sortedByDescending { it.id.value }.take(size)
}
val lt: BankAccountTransactionEntity? = bankAccount.lastTransaction
if (lt == null) return@transaction
var nextPageIdUpperLimit: Long = lt.id.value
/**
* This loop fetches (and discards) pages until the
* desired one is found. */
for (i in 0..(page)) {
val pageBuf = getPage(nextPageIdUpperLimit)
logger.debug("Processing page:")
pageBuf.forEach { logger.debug("${it.id} ${it.subject} ${it.amount}") }
if (pageBuf.none()) return@transaction
nextPageIdUpperLimit = pageBuf.last().id.value - 1
if (i == page) pageBuf.forEach {
ret.add(getHistoryElementFromTransactionRow(it))
}
}
}
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, withPending = true)
ret.publicAccounts.add(
PublicAccountInfo(
balance = "${demobank.currency}:$balanceIter",
iban = it.iban,
accountLabel = it.label
)
)
}
}
call.respond(ret)
return@get
}
delete("accounts/{account_name}") {
// Check demobank was created.
ensureDemobank(call)
transaction {
val bankAccount = getBankAccountWithAuth(call)
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.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,
isPublic = req.isPublic
)
val balance = getBalance(newAccount.bankAccount, withPending = true)
call.respond(object {
val balance = object {
val amount = "${demobank.currency}:${balance.abs()}"
val credit_debit_indicator = if (balance < BigDecimal.ZERO) "DBIT" else "CRDT"
}
val paytoUri = buildIbanPaytoUri(
iban = newAccount.bankAccount.iban,
bic = newAccount.bankAccount.bic,
receiverName = getPersonNameFromCustomer(req.username)
)
val iban = newAccount.bankAccount.iban
})
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 {
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
}
}
}
}
}
fun serverMain(port: Int, localhostOnly: Boolean, ipv4Only: Boolean) {
val server = embeddedServer(
Netty,
environment = applicationEngineEnvironment{
connector {
this.port = port
this.host = if (localhostOnly) "127.0.0.1" else "0.0.0.0"
}
if (!ipv4Only) connector {
this.port = port
this.host = if (localhostOnly) "[::1]" else "[::]"
}
// parentCoroutineContext = Dispatchers.Main
module(sandboxApp)
},
configure = {
connectionGroupSize = 1
workerGroupSize = 1
callGroupSize = 1
}
)
logger.info("LibEuFin Sandbox running on port $port")
try {
server.start(wait = true)
} catch (e: BindException) {
logger.error(e.message)
exitProcess(1)
}
}