summaryrefslogtreecommitdiff
path: root/sandbox/src/main/kotlin/tech/libeufin
diff options
context:
space:
mode:
authorChristian Grothoff <grothoff@gnunet.org>2023-09-07 15:24:27 +0200
committerChristian Grothoff <grothoff@gnunet.org>2023-09-07 15:24:27 +0200
commitea3ceef3740ce30400bc20c7aae09b25d3e0f0c3 (patch)
tree1ef59be5a16ea9982e012efcb7a8a35c8260ed4f /sandbox/src/main/kotlin/tech/libeufin
parentd2206434da014de23d8d96109882d059a60a0cdc (diff)
downloadlibeufin-ea3ceef3740ce30400bc20c7aae09b25d3e0f0c3.tar.gz
libeufin-ea3ceef3740ce30400bc20c7aae09b25d3e0f0c3.tar.bz2
libeufin-ea3ceef3740ce30400bc20c7aae09b25d3e0f0c3.zip
sandbox -> bank
Diffstat (limited to 'sandbox/src/main/kotlin/tech/libeufin')
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt841
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt433
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt747
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt665
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt1436
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt472
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt154
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt1711
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt70
-rw-r--r--sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt276
10 files changed, 0 insertions, 6805 deletions
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
deleted file mode 100644
index 4d8d36d9..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
+++ /dev/null
@@ -1,841 +0,0 @@
-package tech.libeufin.sandbox
-
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.server.application.*
-import io.ktor.http.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.sandbox.CashoutOperationsTable.uuid
-import tech.libeufin.util.*
-import java.io.File
-import java.io.InputStreamReader
-import java.math.BigDecimal
-import java.util.concurrent.TimeUnit
-import kotlin.text.toByteArray
-
-// CIRCUIT API TYPES
-/**
- * This type is used by clients to ask the bank a cash-out
- * estimate to show to the customer before they confirm the
- * cash-out creation.
- */
-data class CircuitCashoutEstimateRequest(
- /**
- * This is the amount that the customer will get deducted
- * from their regio bank account to fuel the cash-out operation.
- */
- val amount_debit: String
-)
-data class CircuitCashoutRequest(
- val subject: String?,
- val amount_debit: String, // As specified by the user via the SPA.
- val amount_credit: String, // What actually to transfer after the rates.
- /**
- * The String type here allows more flexibility with regard to
- * the supported TAN methods. This way, supported TAN methods
- * can be specified via the configuration or when starting the
- * bank. OTOH, catching unsupported TAN methods only via the
- * 'enum' type would require to change the source code upon every
- * change in the TAN policy.
- */
- val tan_channel: String?
-)
-const val FIAT_CURRENCY = "CHF" // FIXME: make configurable.
-// Configuration response:
-data class ConfigResp(
- val name: String = "circuit",
- val version: String = PROTOCOL_VERSION_UNIFIED,
- val ratios_and_fees: RatioAndFees,
- val fiat_currency: String = FIAT_CURRENCY
-)
-
-// After fixing #7527, the values held by this
-// type must be read from the configuration.
-data class RatioAndFees(
- val buy_at_ratio: Float = 1F,
- val sell_at_ratio: Float = 0.95F,
- val buy_in_fee: Float = 0F,
- val sell_out_fee: Float = 0F
-)
-val ratiosAndFees = RatioAndFees()
-
-// User registration request
-data class CircuitAccountRequest(
- val username: String,
- val password: String,
- val contact_data: CircuitContactData,
- val name: String,
- val cashout_address: String, // payto
- val internal_iban: String? // Shall be "= null" ?
-)
-// User contact data to send the TAN.
-data class CircuitContactData(
- val email: String?,
- val phone: String?
-)
-
-data class CircuitAccountReconfiguration(
- val contact_data: CircuitContactData,
- val cashout_address: String?,
- val name: String? = null
-)
-
-data class AccountPasswordChange(
- val new_password: String
-)
-
-/**
- * That doesn't belong to the Access API because it
- * contains the cash-out address and the contact data.
- */
-data class CircuitAccountInfo(
- val username: String,
- val iban: String,
- val contact_data: CircuitContactData,
- val name: String,
- val cashout_address: String?
-)
-
-data class CashoutOperationInfo(
- val status: CashoutOperationStatus,
- val amount_credit: String,
- val amount_debit: String,
- val subject: String,
- val creation_time: Long, // milliseconds
- val confirmation_time: Long?, // milliseconds
- val tan_channel: SupportedTanChannels,
- val account: String,
- val cashout_address: String,
- val ratios_and_fees: RatioAndFees
-)
-
-data class CashoutConfirmation(val tan: String)
-
-// Validate phone number
-fun checkPhoneNumber(phoneNumber: String): Boolean {
- // From Taler TypeScript
- // /^\+[0-9 ]*$/;
- val regex = "^\\+[1-9][0-9]+$"
- val R = Regex(regex)
- return R.matches(phoneNumber)
-}
-
-// Validate e-mail address
-fun checkEmailAddress(emailAddress: String): Boolean {
- // From Taler TypeScript:
- // /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
- val regex = "^[a-zA-Z0-9\\.]+@[a-zA-Z0-9\\.]+$"
- val R = Regex(regex)
- return R.matches(emailAddress)
-}
-
-fun throwIfInstitutionalName(resourceName: String) {
- if (resourceName == "bank" || resourceName == "admin")
- throw forbidden("Can't operate on institutional resource '$resourceName'")
-}
-
-fun generateCashoutSubject(
- amountCredit: AmountWithCurrency,
- amountDebit: AmountWithCurrency
-): String {
- return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" +
- " to ${amountCredit.currency}:${amountCredit.amount}"
-}
-
-/**
- * By default, it takes the amount in the regional currency
- * and applies ratio and fees to convert it to fiat. If the
- * 'fromCredit' parameter is true, then it does the inverse
- * operation: returns the regional amount that would lead to
- * such fiat amount given in the 'amount' parameter.
- */
-fun applyCashoutRatioAndFee(
- amount: BigDecimal,
- ratiosAndFees: RatioAndFees,
- fromCredit: Boolean = false
-): BigDecimal {
- // Normal case, when the calculation starts from the regional amount.
- if (!fromCredit) {
- val maybeCashoutAmount = ((amount * ratiosAndFees.sell_at_ratio.toBigDecimal()) -
- ratiosAndFees.sell_out_fee.toBigDecimal()).roundToTwoDigits()
- // throws 500, since bank should not allow to get negative fiat amounts.
- if (maybeCashoutAmount < BigDecimal.ZERO) {
- logger.error("Cash-out operation caused a negative fiat output." +
- " Regional amount was '$amount', cash-out ratio is '${ratiosAndFees.sell_at_ratio}," +
- " cash-out fee is '${ratiosAndFees.sell_out_fee}''"
- )
- throw internalServerError("Applying cash-out fees yielded negative fiat amount.")
- }
- return maybeCashoutAmount
- }
- // UI convenient case, when the calculation starts from the
- // desired fiat amount that the user wants eventually be paid.
- return ((amount + ratiosAndFees.sell_out_fee.toBigDecimal()) /
- ratiosAndFees.sell_at_ratio.toBigDecimal()).roundToTwoDigits()
-}
-
-/**
- * NOTE: future versions take the supported TAN method from
- * the configuration, or options passed when starting the bank.
- */
-const val LIBEUFIN_TAN_TMP_FILE = "/tmp/libeufin-cashout-tan.txt"
-enum class SupportedTanChannels {
- SMS,
- EMAIL,
- FILE // Test channel writing the TAN to the LIBEUFIN_TAN_TMP_FILE location.
-}
-fun isTanChannelSupported(tanChannel: String): Boolean {
- enumValues<SupportedTanChannels>().forEach {
- if (tanChannel.uppercase() == it.name) return true
- }
- return false
-}
-
-var EMAIL_TAN_CMD: String? = null
-var SMS_TAN_CMD: String? = null
-
-// Convenience class to collect TAN data.
-private data class TanData(
- val cmd: String,
- val address: String,
- val msg: String
-)
-
-/**
- * Runs the command and returns True/False if that succeeded/failed.
- * A failed command causes "500 Internal Server Error" to be responded
- * along a cash-out creation. 'address' is a phone number or a e-mail address,
- * according to which TAN channel is used. 'message' carries the TAN.
- *
- * The caller is expected to manage the exceptions thrown by this function.
- */
-fun runTanCommand(command: String, address: String, message: String): Boolean {
- val prep = ProcessBuilder(command, address)
- prep.redirectErrorStream(true) // merge STDOUT and STDERR
- val proc = prep.start()
- proc.outputStream.write(message.toByteArray())
- proc.outputStream.flush(); proc.outputStream.close()
- var isSuccessful = false
- // Wait the command to finish.
- proc.waitFor(10L, TimeUnit.SECONDS)
- // Check if timed out. Kill if so.
- if (proc.isAlive) {
- logger.error("TAN command '$command' timed out, killing it.")
- proc.destroy()
- // Check if exited gracefully. Kill forcibly if not.
- proc.waitFor(5L, TimeUnit.SECONDS)
- if (proc.isAlive) {
- logger.error("TAN command '$command' didn't terminate after killing it. Try forcefully.")
- proc.destroyForcibly()
- }
- }
- // Check if successful. Switch the state if so.
- if (proc.exitValue() == 0) isSuccessful = true
- // Log STDOUT and STDERR if failed.
- if (!isSuccessful)
- logger.error(InputStreamReader(proc.inputStream).readText())
- return isSuccessful
-}
-
-fun circuitApi(circuitRoute: Route) {
- // Abort a cash-out operation.
- circuitRoute.post("/cashouts/{uuid}/abort") {
- call.request.basicAuth() // both admin and author allowed
- val arg = call.expectUriComponent("uuid")
- // Parse and check the UUID.
- val maybeUuid = parseUuid(arg)
- val maybeOperation = transaction {
- CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull()
- }
- if (maybeOperation == null)
- throw notFound("Cash-out operation $uuid not found.")
- if (maybeOperation.status == CashoutOperationStatus.CONFIRMED)
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out operation '$uuid' was confirmed already."
- )
- if (maybeOperation.status != CashoutOperationStatus.PENDING)
- throw internalServerError("Found an unsupported cash-out operation state: ${maybeOperation.status}")
- // Operation found and pending: delete from the database.
- transaction { maybeOperation.delete() }
- call.respond(HttpStatusCode.NoContent)
- return@post
- }
- // Confirm a cash-out operation
- circuitRoute.post("/cashouts/{uuid}/confirm") {
- val user = call.request.basicAuth()
- // Exclude admin from this operation.
- if (user == "admin" || user == "bank")
- throw conflict("Institutional user '$user' shouldn't confirm any cash-out.")
- // Get the operation identifier.
- val operationUuid = parseUuid(call.expectUriComponent("uuid"))
- val op = transaction {
- CashoutOperationEntity.find {
- uuid eq operationUuid
- }.firstOrNull()
- }
- // 404 if the operation is not found.
- if (op == null)
- throw notFound("Cash-out operation $operationUuid not found")
- /**
- * Check the TAN. Give precedence to the TAN found
- * in the environment, for testing purposes. If that's
- * not found, then check with the actual TAN found in
- * the database.
- */
- val req = call.receive<CashoutConfirmation>()
- val maybeTanFromEnv = System.getenv("LIBEUFIN_CASHOUT_TEST_TAN")
- if (maybeTanFromEnv != null)
- logger.warn("TAN being read from the environment. Assuming tests are being run")
- val checkTan = maybeTanFromEnv ?: op.tan
- if (req.tan != checkTan)
- throw forbidden("The confirmation of '${op.uuid}' has a wrong TAN '${req.tan}'")
- /**
- * Correct TAN. Wire the funds to the admin's bank account. After
- * this step, the conversion monitor should detect this payment and
- * soon initiate the final transfer towards the user fiat bank account.
- * NOTE: the funds availability got already checked when this operation
- * was created. On top of that, the 'wireTransfer()' helper does also
- * check for funds availability. */
- val customer = maybeGetCustomer(user ?: throw SandboxError(
- HttpStatusCode.ServiceUnavailable,
- "This endpoint isn't served when the authentication is disabled."
- ))
- transaction {
- if (op.cashoutAddress != customer?.cashout_address) throw conflict(
- "Inconsistent cash-out address: ${op.cashoutAddress} vs ${customer?.cashout_address}"
- )
- // 412 if the operation got already confirmed.
- if (op.status == CashoutOperationStatus.CONFIRMED)
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out operation $operationUuid was already confirmed."
- )
- wireTransfer(
- debitAccount = op.account,
- creditAccount = "admin",
- subject = op.subject,
- amount = op.amountDebit
- )
- op.status = CashoutOperationStatus.CONFIRMED
- op.confirmationTime = getSystemTimeNow().toInstant().toEpochMilli()
- // TODO(signal this payment over LIBEUFIN_REGIO_INCOMING)
- }
- call.respond(HttpStatusCode.NoContent)
- return@post
- }
- // Retrieve the status of a cash-out operation.
- circuitRoute.get("/cashouts/{uuid}") {
- call.request.basicAuth() // both admin and author
- val operationUuid = call.expectUriComponent("uuid")
- // Parse and check the UUID.
- val maybeUuid = parseUuid(operationUuid)
- // Get the operation from the database.
- val maybeOperation = transaction {
- CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull()
- }
- if (maybeOperation == null)
- throw notFound("Cash-out operation $operationUuid not found.")
- val ret = CashoutOperationInfo(
- amount_credit = maybeOperation.amountCredit,
- amount_debit = maybeOperation.amountDebit,
- subject = maybeOperation.subject,
- status = maybeOperation.status,
- creation_time = maybeOperation.creationTime,
- confirmation_time = maybeOperation.confirmationTime,
- tan_channel = maybeOperation.tanChannel,
- account = maybeOperation.account,
- cashout_address = maybeOperation.cashoutAddress,
- ratios_and_fees = RatioAndFees(
- buy_in_fee = maybeOperation.buyInFee.toFloat(),
- buy_at_ratio = maybeOperation.buyAtRatio.toFloat(),
- sell_out_fee = maybeOperation.sellOutFee.toFloat(),
- sell_at_ratio = maybeOperation.sellAtRatio.toFloat()
- )
- )
- call.respond(ret)
- return@get
- }
- // Gets the list of all the cash-out operations,
- // or those belonging to the account given as a parameter.
- circuitRoute.get("/cashouts") {
- val user = call.request.basicAuth()
- val whichAccount = call.request.queryParameters["account"]
- /**
- * Only admin's allowed to omit the target account (= get
- * all the accounts) or to check other customers cash-out
- * operations.
- */
- if (user != "admin" && whichAccount != user) throw forbidden(
- "Ordinary users can only request their own account"
- )
- /**
- * At this point, the client has the rights over the account(s)
- * whose operations are to be returned. Double-checking that
- * Admin doesn't ask its own cash-outs, since that's not supported.
- */
- if (whichAccount == "admin") throw badRequest("Cash-out for admin is not supported")
-
- // Preparing the response.
- val node = jacksonObjectMapper().createObjectNode()
- val maybeArray = node.putArray("cashouts")
-
- if (whichAccount == null) { // no target account, return all the cash-outs
- transaction {
- CashoutOperationEntity.all().forEach {
- maybeArray.add(it.uuid.toString())
- }
- }
- } else { // do filter on the target account.
- transaction {
- CashoutOperationEntity.find {
- CashoutOperationsTable.account eq whichAccount
- }.forEach {
- maybeArray.add(it.uuid.toString())
- }
- }
- }
- if (maybeArray.size() == 0) {
- call.respond(HttpStatusCode.NoContent)
- return@get
- }
- call.respond(node)
- return@get
- }
- circuitRoute.get("/cashouts/estimates") {
- call.request.basicAuth()
- val demobank = ensureDemobank(call)
- // Optionally parsing param 'amount_debit' into number and checking its currency
- val maybeAmountDebit: String? = call.request.queryParameters["amount_debit"]
- val amountDebit: BigDecimal? = if (maybeAmountDebit != null) {
- val amount = parseAmount(maybeAmountDebit)
- if (amount.currency != demobank.config.currency) throw badRequest(
- "parameter 'amount_debit' has the wrong currency: ${amount.currency}"
- )
- try { amount.amount.toBigDecimal() } catch (e: Exception) {
- throw badRequest("Cannot extract a number from 'amount_debit'")
- }
- } else null
- // Optionally parsing param 'amount_credit' into number and checking its currency
- val maybeAmountCredit: String? = call.request.queryParameters["amount_credit"]
- val amountCredit: BigDecimal? = if (maybeAmountCredit != null) {
- val amount = parseAmount(maybeAmountCredit)
- if (amount.currency != FIAT_CURRENCY) throw badRequest(
- "parameter 'amount_credit' has the wrong currency: ${amount.currency}"
- )
- try { amount.amount.toBigDecimal() } catch (e: Exception) {
- throw badRequest("Cannot extract a number from 'amount_credit'")
- }
- } else null
- val respAmountCredit = if (amountDebit != null) {
- val estimate = applyCashoutRatioAndFee(amountDebit, ratiosAndFees)
- if (amountCredit != null && estimate != amountCredit) throw badRequest(
- "Wrong calculation found in 'amount_credit', bank estimates: $estimate"
- )
- estimate
- } else null
- if (amountDebit == null && amountCredit == null) throw badRequest(
- "Both 'amount_credit' and 'amount_debit' are missing"
- )
- val respAmountDebit = if (amountCredit != null) {
- val estimate = applyCashoutRatioAndFee(
- amountCredit,
- ratiosAndFees,
- fromCredit = true
- )
- if (amountDebit != null && estimate != amountDebit) throw badRequest(
- "Wrong calculation found in 'amount_credit', bank estimates: $estimate"
- )
- estimate
- } else null
- call.respond(object {
- val amount_credit = "$FIAT_CURRENCY:$respAmountCredit"
- val amount_debit = "${demobank.config.currency}:$respAmountDebit"
- })
- return@get
- }
-
- // Create a cash-out operation.
- circuitRoute.post("/cashouts") {
- val user = call.request.basicAuth()
- if (user == "admin" || user == "bank") throw forbidden("$user can't cash-out.")
- // No suitable default user, when the authentication is disabled.
- if (user == null) throw SandboxError(
- HttpStatusCode.ServiceUnavailable,
- "This endpoint isn't served when the authentication is disabled."
- )
- val req = call.receive<CircuitCashoutRequest>()
-
- // validate amounts: well-formed and supported currency.
- val amountDebit = parseAmount(req.amount_debit) // amount before rates.
- val amountCredit = parseAmount(req.amount_credit) // amount after rates, as expected by the client
- val demobank = ensureDemobank(call)
- // Currency check of the cash-out's circuit part.
- if (amountDebit.currency != demobank.config.currency)
- throw badRequest("'${req::amount_debit.name}' (${req.amount_debit})" +
- " doesn't match the regional currency (${demobank.config.currency})"
- )
- // Currency check of the cash-out's fiat part.
- if (amountCredit.currency != FIAT_CURRENCY)
- throw badRequest("'${req::amount_credit.name}' (${req.amount_credit})" +
- " doesn't match the fiat currency ($FIAT_CURRENCY)."
- )
- // check if TAN is supported. Default to SMS, if that's missing.
- val tanChannel = req.tan_channel?.uppercase() ?: SupportedTanChannels.SMS.name
- if (!isTanChannelSupported(tanChannel))
- throw SandboxError(
- HttpStatusCode.ServiceUnavailable,
- "TAN channel '$tanChannel' not supported."
- )
- // check if the user contact data would allow the TAN channel.
- val customer: DemobankCustomerEntity? = maybeGetCustomer(username = user)
- if (customer == null) throw internalServerError(
- "Customer profile '$user' not found after authenticating it."
- )
- if (customer.cashout_address == null) throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out address not found. Did the user register via Circuit API?"
- )
- if ((tanChannel == SupportedTanChannels.EMAIL.name) && (customer.email == null))
- throw conflict("E-mail address not found for '$user'. Can't send the TAN")
- if ((tanChannel == SupportedTanChannels.SMS.name) && (customer.phone == null))
- throw conflict("Phone number not found for '$user'. Can't send the TAN")
- // check rates correctness
- val amountDebitAsNumber = BigDecimal(amountDebit.amount)
- val expectedAmountCredit = applyCashoutRatioAndFee(amountDebitAsNumber, ratiosAndFees)
- val amountCreditAsNumber = BigDecimal(amountCredit.amount).roundToTwoDigits()
- if (expectedAmountCredit != amountCreditAsNumber) {
- throw badRequest("Rates application are incorrect." +
- " The expected amount to credit is: ${expectedAmountCredit}," +
- " but ${amountCredit.amount} was specified.")
- }
- // check that the balance is sufficient
- val balance = getBalance(
- user,
- demobank.name
- )
- val balanceCheck = balance - amountDebitAsNumber
- if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal(demobank.config.usersDebtLimit))
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Cash-out not possible due to insufficient funds. Balance ${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}"
- )
- // generate a subject if that's missing
- val cashoutSubject = req.subject ?: generateCashoutSubject(
- amountCredit = amountCredit,
- amountDebit = amountDebit
- )
- val op = transaction {
- CashoutOperationEntity.new {
- this.amountDebit = req.amount_debit
- this.amountCredit = req.amount_credit
- this.buyAtRatio = ratiosAndFees.buy_at_ratio.toString()
- this.buyInFee = ratiosAndFees.buy_in_fee.toString()
- this.sellAtRatio = ratiosAndFees.sell_at_ratio.toString()
- this.sellOutFee = ratiosAndFees.sell_out_fee.toString()
- this.subject = cashoutSubject
- this.creationTime = getSystemTimeNow().toInstant().toEpochMilli()
- this.tanChannel = SupportedTanChannels.valueOf(tanChannel)
- this.account = user
- this.tan = getRandomString(5)
- this.cashoutAddress = customer.cashout_address ?: throw internalServerError(
- "Cash-out address for '$user' not found, after previous check succeeded"
- )
- }
- }
- when (tanChannel) {
- SupportedTanChannels.EMAIL.name -> {
- val isSuccessful = try {
- runTanCommand(
- command = EMAIL_TAN_CMD ?: throw internalServerError(
- "E-mail TAN supported but the command" +
- " was not found. See the --email-tan option from 'serve'"
- ),
- address = customer.email ?: throw internalServerError(
- "Customer has no e-mail address, but previous check should" +
- " have detected it!"
- ),
- message = op.tan
- )
- } catch (e: Exception) {
- logger.error("Sending the e-mail TAN to ${customer.email} was impossible." +
- " Reason: ${e.message}")
- throw internalServerError("Could not send the e-mail TAN.")
- }
- if (!isSuccessful)
- throw internalServerError("E-mail TAN command failed.")
- }
- SupportedTanChannels.SMS.name -> {
- val isSuccessful = try {
- runTanCommand(
- command = SMS_TAN_CMD ?: throw internalServerError(
- "SMS TAN supported but the command" +
- " was not found. See the --sms-tan option from 'serve'"
- ),
- address = customer.phone ?: throw internalServerError(
- "Customer has no phone number, but previous check should" +
- " have detected it!"
-
- ),
- message = op.tan
- )
-
- } catch (e: Exception) {
- logger.error("Sending the SMS TAN to ${customer.phone} was impossible." +
- " Reason: ${e.message}")
- throw internalServerError("Could not send the SMS TAN.")
- }
- if (!isSuccessful)
- throw internalServerError("SMS TAN command failed.")
- }
- SupportedTanChannels.FILE.name -> {
- try {
- File(LIBEUFIN_TAN_TMP_FILE).writeText(op.tan)
- } catch (e: Exception) {
- logger.error("Could not write to $LIBEUFIN_TAN_TMP_FILE. Reason: ${e.message}")
- throw internalServerError("File TAN failed.")
- }
- }
- else ->
- throw internalServerError("The bank tried an unsupported TAN channel: $tanChannel.")
- }
- call.respond(HttpStatusCode.Accepted, object {val uuid = op.uuid})
- return@post
- }
- // Get Circuit-relevant account data.
- circuitRoute.get("/accounts/{resourceName}") {
- val username = call.request.basicAuth()
- val resourceName = call.expectUriComponent("resourceName")
- throwIfInstitutionalName(resourceName)
- if (!allowOwnerOrAdmin(username, resourceName)) throw forbidden(
- "User $username has no rights over $resourceName"
- )
- val customer = getCustomer(resourceName)
- /**
- * CUSTOMER AND BANK ACCOUNT INVARIANT.
- *
- * After having found a 'customer' associated with the resourceName
- * - see previous line -, the bank must ensure that a 'bank account'
- * exist under the same resourceName. If that fails, the bank broke the
- * invariant and should respond 500.
- */
- val bankAccount = getBankAccountFromLabel(resourceName, withBankFault = true)
- /**
- * Throwing when name or cash-out address aren't found ensures
- * that the customer was indeed added via the Circuit API, as opposed
- * to the Access API.
- */
- call.respond(CircuitAccountInfo(
- username = customer.username,
- name = customer.name ?: throw internalServerError(
- "Account '$resourceName' was found without owner's name."
- ),
- cashout_address = customer.cashout_address,
- contact_data = CircuitContactData(
- email = customer.email,
- phone = customer.phone
- ),
- iban = bankAccount.iban
- ))
- return@get
- }
-
- // Get summary of all the accounts.
- circuitRoute.get("/accounts") {
- call.request.basicAuth(onlyAdmin = true)
- val maybeFilter: String? = call.request.queryParameters["filter"]
- /**
- * Equip the given filter with left and right catch-all wildcards,
- * otherwise use one catch-all wildcard.
- */
- val filter = if (maybeFilter != null) {
- "%${maybeFilter}%"
- } else "%"
- val customers = mutableListOf<Any>()
- val demobank = ensureDemobank(call)
- transaction {
- /**
- * This block builds the DB query so that IF the %-wildcard was
- * given, then BOTH name and name-less accounts are returned.
- */
- val query: Op<Boolean> = SqlExpressionBuilder.run {
- val like = DemobankCustomersTable.name.like(filter)
- /**
- * This IF statement is needed because Postgres would NOT
- * match a null column even with the %-wildcard.
- */
- if (filter == "%") {
- return@run like.or(DemobankCustomersTable.name.isNull())
- }
- return@run like
- }
- DemobankCustomerEntity.find { query }.forEach {
- customers.add(object {
- val username = it.username
- val name = it.name
- val balance = getBalanceForJson(
- getBalance(it.username, demobank.name),
- demobank.config.currency
- )
- val debitThreshold = getMaxDebitForUser(
- it.username,
- demobank.name
- )
- })
- }
- StdOutSqlLogger
- }
- if (customers.size == 0) {
- call.respond(HttpStatusCode.NoContent)
- return@get
- }
- call.respond(object {val customers = customers})
- return@get
- }
-
- // Change password.
- circuitRoute.patch("/accounts/{customerUsername}/auth") {
- val username = call.request.basicAuth()
- val customerUsername = call.expectUriComponent("customerUsername")
- throwIfInstitutionalName(customerUsername)
- if (!allowOwnerOrAdmin(username, customerUsername)) throw forbidden(
- "User $username has no rights over $customerUsername"
- )
- // Flow here means admin or username have the rights for this operation.
- val req = call.receive<AccountPasswordChange>()
- /**
- * The resource/customer might still not exist, in case admin has requested.
- * On the other hand, when ordinary customers request, their existence is checked
- * along the basic authentication check.
- */
- transaction {
- val customer = getCustomer(customerUsername) // throws 404, if not found.
- customer.passwordHash = CryptoUtil.hashpw(req.new_password)
- }
- call.respond(HttpStatusCode.NoContent)
- return@patch
- }
- // Change account (mostly contact) data.
- circuitRoute.patch("/accounts/{resourceName}") {
- val username = call.request.basicAuth()
- if (username == null)
- throw internalServerError("Authentication disabled, don't have a default for this request.")
- val resourceName = call.expectUriComponent("resourceName")
- throwIfInstitutionalName(resourceName)
- if(!allowOwnerOrAdmin(username, resourceName)) throw forbidden(
- "User $username has no rights over $resourceName"
- )
- // account found and authentication succeeded
- val req = call.receive<CircuitAccountReconfiguration>()
- // Only admin's allowed to change the legal name
- if (req.name != null && username != "admin") throw forbidden(
- "Only admin can change the user legal name"
- )
- if ((req.contact_data.email != null) && (!checkEmailAddress(req.contact_data.email)))
- throw badRequest("Invalid e-mail address: ${req.contact_data.email}")
- if ((req.contact_data.phone != null) && (!checkPhoneNumber(req.contact_data.phone)))
- throw badRequest("Invalid phone number: ${req.contact_data.phone}")
- try { if (req.cashout_address != null) parsePayto(req.cashout_address) }
- catch (e: InvalidPaytoError) {
- throw badRequest("Invalid cash-out address: ${req.cashout_address}")
- }
- transaction {
- val user = getCustomer(resourceName)
- user.email = req.contact_data.email
- user.phone = req.contact_data.phone
- user.cashout_address = req.cashout_address
- }
- call.respond(HttpStatusCode.NoContent)
- return@patch
- }
- // Create new account.
- circuitRoute.post("/accounts") {
- call.request.basicAuth(onlyAdmin = true)
- val req = call.receive<CircuitAccountRequest>()
- // Validity and availability check on the input data.
- if (req.contact_data.email != null) {
- if (!checkEmailAddress(req.contact_data.email))
- throw badRequest("Invalid e-mail address: ${req.contact_data.email}. Won't register")
- val maybeEmailConflict = transaction {
- DemobankCustomerEntity.find {
- DemobankCustomersTable.email eq req.contact_data.email
- }.firstOrNull()
- }
- // Warning since two individuals claimed one same e-mail address.
- if (maybeEmailConflict != null)
- throw conflict("Won't register user ${req.username}: e-mail conflict on ${req.contact_data.email}")
- }
- if (req.contact_data.phone != null) {
- if (!checkPhoneNumber(req.contact_data.phone))
- throw badRequest("Invalid phone number: ${req.contact_data.phone}. Won't register")
-
- val maybePhoneConflict = transaction {
- DemobankCustomerEntity.find {
- DemobankCustomersTable.phone eq req.contact_data.phone
- }.firstOrNull()
- }
- // Warning since two individuals claimed one same phone number.
- if (maybePhoneConflict != null)
- throw conflict("Won't register user ${req.username}: phone conflict on ${req.contact_data.phone}")
- }
- /**
- * Check that cash-out address parses. IBAN is not
- * check-summed in this version; the cash-out operation
- * just fails for invalid IBANs and the user has then
- * the chance to update their IBAN.
- */
- try {
- parsePayto(req.cashout_address)
- }
- catch (e: InvalidPaytoError) {
- throw badRequest("Won't register account ${req.username}: invalid cash-out address: ${req.cashout_address}")
- }
- transaction {
- val newAccount = insertNewAccount(
- username = req.username,
- password = req.password,
- name = req.name,
- iban = req.internal_iban,
- demobank = ensureDemobank(call).name
- )
- newAccount.customer.phone = req.contact_data.phone
- newAccount.customer.email = req.contact_data.email
- newAccount.customer.cashout_address = req.cashout_address
- }
- call.respond(HttpStatusCode.NoContent)
- return@post
- }
- // Get (conversion rates via) config values.
- circuitRoute.get("/config") {
- call.respond(ConfigResp(ratios_and_fees = ratiosAndFees))
- return@get
- }
- // Only Admin and only when balance is zero.
- circuitRoute.delete("/accounts/{resourceName}") {
- call.request.basicAuth(onlyAdmin = true)
- val resourceName = call.expectUriComponent("resourceName")
- throwIfInstitutionalName(resourceName)
- val customer = getCustomer(resourceName)
- val bankAccount = getBankAccountFromLabel(
- resourceName,
- withBankFault = true // See comment "CUSTOMER AND BANK ACCOUNT INVARIANT".
- )
- val balance: BigDecimal = getBalance(bankAccount)
- if (!isAmountZero(balance)) {
- logger.error("Account $resourceName has $balance balance. Won't delete it")
- throw SandboxError(
- HttpStatusCode.PreconditionFailed,
- "Account $resourceName doesn't have zero balance. Won't delete it"
- )
- }
- transaction {
- bankAccount.delete()
- customer.delete()
- }
- call.respond(HttpStatusCode.NoContent)
- return@delete
- }
-} \ No newline at end of file
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
deleted file mode 100644
index c760a2b1..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
+++ /dev/null
@@ -1,433 +0,0 @@
-package tech.libeufin.sandbox
-
-import CamtBankAccountEntry
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import io.ktor.client.*
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.runBlocking
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import java.math.BigDecimal
-import kotlin.system.exitProcess
-
-/**
- * This file contains the logic for downloading/submitting incoming/outgoing
- * fiat transactions to Nexus. It needs the following values for operating.
- *
- * 1. Nexus URL.
- * 2. Credentials to authenticate at Nexus JSON API.
- * 3. Long-polling interval.
- * 4. Frequency of the download loop.
- *
- * Notes:
- *
- * 1. The account to credit on incoming transactions is ALWAYS "admin".
- * 2. The time to submit a new payment is as soon as "admin" receives one
- * incoming regional payment.
- * 3. At this time, Nexus does NOT offer long polling when it serves the
- * transactions via its JSON API. => Fixed.
- * 4. At this time, Nexus does NOT offer any filter when it serves the
- * transactions via its JSON API. => Can be fixed by using the TWG.
- */
-
-// DEFINITIONS AND HELPERS
-
-/**
- * Timeout the HTTP client waits for the server to respond,
- * after the request is made.
- */
-val waitTimeout = 30000L
-
-/**
- * Time to wait before HTTP requesting again to the server.
- * This helps to avoid tight cycles in case the server responds
- * quickly or the client doesn't long-poll.
- */
-val newIterationTimeout = 2000L
-
-/**
- * Response format of Nexus GET /transactions.
- */
-data class TransactionItem(
- val index: String,
- val camtData: CamtBankAccountEntry
-)
-data class NexusTransactions(
- val transactions: List<TransactionItem>
-)
-
-/**
- * This exception signals that the buy-in service could NOT
- * GET the list of fiat transactions from Nexus due to a client
- * error. Because this is fatal (e.g. wrong credentials, URL not found..),
- * the service should be stopped.
- */
-class BuyinClientError : Exception()
-
-/**
- * This exception signals that POSTing a cash-out operation
- * to Nexus failed due to the client. This is a fatal condition
- * therefore the monitor should be stopped.
- */
-class CashoutClientError : Exception()
-/**
- * Executes the 'block' function every 'loopNewReqMs' milliseconds.
- * Does not exit/fail the process upon exceptions - just logs them.
- */
-fun downloadLoop(block: () -> Unit) {
- // Needs "runBlocking {}" to call "delay()" and in case 'block'
- // contains suspend functions.
- runBlocking {
- while(true) {
- try { block() }
- catch (e: BuyinClientError) {
- logger.error("The buy-in monitor had a client error while GETting new" +
- " transactions from Neuxs. Stopping it")
- // Rethrowing and let the caller manage it
- throw e
- }
- // Tolerating any other error type that's not due to the client.
- catch (e: Exception) {
- logger.error("Sandbox fiat-incoming monitor excepted: ${e.message}")
- }
- delay(newIterationTimeout)
- }
- }
-}
-
-// BUY-IN SIDE.
-
-/**
- * Applies the buy-in ratio and fees to the fiat amount
- * that came from Nexus. The result is the regional amount
- * that will be wired to the exchange Sandbox account.
- */
-fun applyBuyinRatioAndFees(
- amount: BigDecimal,
- ratiosAndFees: RatioAndFees
-): BigDecimal {
- val maybeBuyinAmount = ((amount * ratiosAndFees.buy_at_ratio.toBigDecimal())
- - ratiosAndFees.buy_in_fee.toBigDecimal()).roundToTwoDigits()
- // Bank's fault, as buying in should never lead to negative.
- if (maybeBuyinAmount < BigDecimal.ZERO) {
- logger.error("Negative buy-in scenario: input fiat amount was '${amount}'" +
- ", buy-in ratio was '${ratiosAndFees.buy_at_ratio}'," +
- " buy-in fee was '${ratiosAndFees.buy_in_fee}'")
- throw internalServerError("Applying buy-in fees yielded negative regional amount")
- }
- return maybeBuyinAmount
-}
-
-private fun ensureDisabledRedirects(client: HttpClient) {
- client.config {
- if (followRedirects) throw Exception(
- "HTTP client follows redirects, please disable."
- )
- }
-}
-/**
- * This function downloads the incoming fiat transactions from Nexus,
- * stores them into the database and triggers the related wire transfer
- * to the Taler exchange (to be specified in 'accountToCredit'). Once
- * started, this function is not supposed to return, except on _client
- * side_ errors. On server side errors it pauses and retries. When
- * it returns, the caller is expected to handle the error.
- */
-fun buyinMonitor(
- demobankName: String, // used to get config values.
- client: HttpClient,
- accountToCredit: String,
- accountToDebit: String = "admin"
-) {
- ensureDisabledRedirects(client)
- val demobank = ensureDemobank(demobankName)
- /**
- * Getting the config values to send authenticated requests
- * to Nexus. Sandbox needs one account at Nexus before being
- * able to use these values.
- */
- val nexusBaseUrl = getConfigValueOrThrow(demobank.config::nexusBaseUrl)
- val usernameAtNexus = getConfigValueOrThrow(demobank.config::usernameAtNexus)
- val passwordAtNexus = getConfigValueOrThrow(demobank.config::passwordAtNexus)
- /**
- * This is the endpoint where Nexus serves all the transactions that
- * have ingested from the fiat bank.
- */
- val endpoint = "bank-accounts/$usernameAtNexus/transactions"
- val uriWithoutStart = joinUrl(nexusBaseUrl, endpoint) + "?long_poll_ms=$waitTimeout"
-
- // downloadLoop does already try-catch (without failing the process).
- downloadLoop {
- /**
- * This bank account will act as the debtor, once a new fiat
- * payment is detected. It's the debtor that pays the related
- * regional amount to the exchange, in order to start a withdrawal
- * operation (in regional coins).
- */
- val debitBankAccount = getBankAccountFromLabel(accountToDebit)
- /**
- * Setting the 'start' URI param in the following command
- * lets Sandbox receive only unseen payments from Nexus.
- */
- val uriWithStart = "$uriWithoutStart&start=${debitBankAccount.lastFiatFetch}"
- runBlocking {
- // Maybe get new fiat transactions.
- logger.debug("GETting fiat transactions from: $uriWithStart")
- val resp = client.get(uriWithStart) {
- expectSuccess = false // Avoids excepting on !2xx
- basicAuth(usernameAtNexus, passwordAtNexus)
- }
- // The server failed, pause and try again
- if (resp.status.value.toString().startsWith('5')) {
- logger.error("Buy-in monitor requested to a failing Nexus. Retry.")
- logger.error("Nexus responded: ${resp.bodyAsText()}")
- return@runBlocking
- }
- // The client failed, fail the process.
- if (resp.status.value.toString().startsWith('4')) {
- logger.error("Buy-in monitor failed at GETting to Nexus. Stopping the buy-in monitor.")
- logger.error("Nexus responded: ${resp.bodyAsText()}")
- throw BuyinClientError()
- }
- // Expect 200 OK. What if 3xx?
- if (resp.status.value != HttpStatusCode.OK.value) {
- logger.error("Unhandled response status ${resp.status.value}, failing Sandbox")
- throw BuyinClientError()
- }
- // Nexus responded 200 OK, analyzing the result.
- /**
- * Wire to "admin" if the subject is a public key, or do
- * nothing otherwise.
- */
- val respObj = jacksonObjectMapper().readValue(
- resp.bodyAsText(),
- NexusTransactions::class.java
- ) // errors are logged by the caller (without failing).
- respObj.transactions.forEach {
- // Ignoring payments with an invalid reserved public key.
- if (extractReservePubFromSubject(it.camtData.getSingletonSubject()) == null)
- return@forEach
- // Extracts the amount and checks it's at most two fractional digits.
- val maybeValidAmount = it.camtData.amount.value
- if (!validatePlainAmount(maybeValidAmount)) {
- logger.error("Nexus gave one amount with invalid fractional digits: $maybeValidAmount." +
- " The transaction has index ${it.index}")
- // Advancing the last fetched pointer, to avoid GETting
- // this invalid payment again.
- transaction {
- debitBankAccount.refresh()
- debitBankAccount.lastFiatFetch = it.index
- }
- }
- val convertedAmount = applyBuyinRatioAndFees(
- maybeValidAmount.toBigDecimal(),
- ratiosAndFees
- )
- transaction {
- wireTransfer(
- debitAccount = accountToDebit,
- creditAccount = accountToCredit,
- demobank = demobankName,
- subject = it.camtData.getSingletonSubject(),
- amount = "${demobank.config.currency}:$convertedAmount"
- )
- // Nexus enqueues the transactions such that the index increases.
- // If Sandbox crashes here, it'll ask again using the last successful
- // index as the start parameter. Being this an exclusive bound, only
- // transactions later than it are expected.
- debitBankAccount.refresh()
- debitBankAccount.lastFiatFetch = it.index
- }
- }
- }
- }
-}
-
-/* DB query helper that fetches the latest cash-out operations that were
- confirmed in the regional currency. A cash-out operation is 'confirmed'
- when the bank account pointed by the parameter 'bankAccountLabel' gets
- one incoming payment.
-
- The List return type (instead of SizedIterable) lets the caller NOT open
- a transaction block to access the values -- although some operations _on
- the values_ may be forbidden.
-*/
-fun getUnsubmittedTransactions(bankAccountLabel: String): List<BankAccountTransactionEntity> {
- return transaction {
- val bankAccount = getBankAccountFromLabel(bankAccountLabel)
- val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.id greater lowerExclusiveLimit and (
- BankAccountTransactionsTable.direction eq "CRDT"
- ) and (BankAccountTransactionsTable.account eq bankAccount.id)
- }.sortedBy { it.id }.map { it }
- /* The latest payment must occupy the highest index,
- to reliably update the 'lastFiatSubmission' column of
- the bank account. */
- }
-}
-
-// CASH-OUT SIDE.
-
-/**
- * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX)
- * on the 'watchedBankAccount' and submits the related cash-out payment
- * to Nexus. The fiat payment will then take place ENTIRELY on Nexus'
- * responsibility.
- */
-suspend fun cashoutMonitor(
- httpClient: HttpClient,
- watchedBankAccount: String = "admin",
- demobankName: String = "default", // used to get config values.
- dbEventTimeout: Long = 0 // 0 waits forever.
-) {
- ensureDisabledRedirects(httpClient)
- // Register for a REGIO_TX event.
- val eventChannel = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
- watchedBankAccount
- )
- val objectMapper = jacksonObjectMapper()
- val demobank = getDemobank(demobankName)
- val bankAccount = getBankAccountFromLabel(watchedBankAccount)
- val config = demobank?.config ?: throw internalServerError(
- "Demobank '$demobankName' has no configuration."
- )
- /**
- * The monitor needs the cash-out currency to correctly POST
- * payment initiations at Nexus. Recall: Nexus bank accounts
- * do not mandate any particular currency, as they serve as mere
- * bridges to the backing bank. And: a backing bank may have
- * multiple currencies, or the backing bank may not explicitly
- * specify any currencies to be _the_ currency of the backed
- * bank account.
- */
- if (config.cashoutCurrency == null) {
- logger.error("Config lacks cash-out currency.")
- exitProcess(1)
- }
- val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl)
- val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus)
- val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus)
- val paymentInitEndpoint = nexusBaseUrl.run {
- var nexusBaseUrlFromConfig = this
- if (!nexusBaseUrlFromConfig.endsWith('/'))
- nexusBaseUrlFromConfig += '/'
- /**
- * WARNING: Nexus gives the possibility to have bank account names
- * DIFFERENT from their owner's username. Sandbox however MUST have
- * its Nexus bank account named THE SAME as its username.
- */
- nexusBaseUrlFromConfig + "bank-accounts/$usernameAtNexus/payment-initiations"
- }
- while (true) {
- val listenHandle = PostgresListenHandle(eventChannel)
- // pessimistically LISTEN
- listenHandle.postgresListen()
- // but optimistically check for data, case some
- // arrived _before_ the LISTEN.
- var newTxs = getUnsubmittedTransactions(watchedBankAccount)
- // Data found, UNLISTEN.
- if (newTxs.isNotEmpty()) {
- logger.debug("Found cash-out's without waiting any DB event.")
- listenHandle.postgresUnlisten()
- }
- // Data not found, wait.
- else {
- logger.debug("Need to wait a DB event for new cash-out's")
- val isNotificationArrived = listenHandle.waitOnIODispatchers(dbEventTimeout)
- if (isNotificationArrived && listenHandle.receivedPayload == "CRDT")
- newTxs = getUnsubmittedTransactions(watchedBankAccount)
- }
- if (newTxs.isEmpty()) {
- logger.debug("DB event timeout expired")
- continue
- }
- logger.debug("POSTing new cash-out's")
- newTxs.forEach {
- logger.debug("POSTing cash-out '${it.subject}' to $paymentInitEndpoint")
- val body = object {
- /**
- * This field is UID of the request _as assigned by the
- * client_. That helps to reconcile transactions or lets
- * Nexus implement idempotency. It will NOT identify the created
- * resource at the server side. The ID of the created resource is
- * assigned _by Nexus_ and communicated in the (successful) response.
- */
- val uid = it.accountServicerReference
- val iban = it.creditorIban
- val bic = it.creditorBic
- val amount = "${config.cashoutCurrency}:${it.amount}"
- val subject = it.subject
- val name = it.creditorName
- }
- val resp = try {
- httpClient.post(paymentInitEndpoint) {
- expectSuccess = false // Avoids excepting on !2xx
- basicAuth(usernameAtNexus, passwordAtNexus)
- contentType(ContentType.Application.Json)
- setBody(objectMapper.writeValueAsString(body))
- }
- }
- // Hard-error, response did not even arrive.
- catch (e: Exception) {
- logger.error("Cash-out monitor could not reach Nexus. Pause and retry")
- logger.error(e.message)
- /**
- * Explicit delaying because the monitor normally
- * waits on DB events, and this retry likely won't
- * wait on a DB event.
- */
- delay(2000)
- return@forEach
- }
- // Server fault. Pause and retry.
- if (resp.status.value.toString().startsWith('5')) {
- logger.error("Cash-out monitor POSTed to a failing Nexus. Pause and retry")
- logger.error("Server responded: ${resp.bodyAsText()}")
- /**
- * Explicit delaying because the monitor normally
- * waits on DB events, and this retry likely won't
- * wait on a DB event.
- */
- delay(2000L)
- return@forEach
- }
- // Client fault, fail Sandbox.
- if (resp.status.value.toString().startsWith('4')) {
- logger.error("Cash-out monitor failed at POSTing to Nexus.")
- logger.error("Nexus responded: ${resp.bodyAsText()}")
- throw CashoutClientError()
- }
- // Expecting 200 OK. What if 3xx?
- if (resp.status.value != HttpStatusCode.OK.value) {
- logger.error("Cash-out monitor, unhandled response status: ${resp.status.value}.")
- throw CashoutClientError()
- }
- // Successful case, mark the wire transfer as submitted,
- // and advance the pointer to the last submitted payment.
- val responseBody = resp.bodyAsText()
- transaction {
- CashoutSubmissionEntity.new {
- localTransaction = it.id
- submissionTime = resp.responseTime.timestamp
- /**
- * The following block associates the submitted payment
- * to the UID that Nexus assigned to it. It is currently not
- * used in Sandbox, but might help for reconciliation.
- */
- if (responseBody.isNotEmpty())
- maybeNexusResposnse = responseBody
- }
- // Advancing the 'last submitted bookmark', to avoid
- // handling the same transaction multiple times.
- bankAccount.lastFiatSubmission = it
- }
- }
- }
-}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
deleted file mode 100644
index 523b1bc3..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
+++ /dev/null
@@ -1,747 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-package tech.libeufin.sandbox
-
-import io.ktor.http.*
-import org.jetbrains.exposed.dao.Entity
-import org.jetbrains.exposed.dao.EntityClass
-import org.jetbrains.exposed.dao.IntEntity
-import org.jetbrains.exposed.dao.LongEntity
-import org.jetbrains.exposed.dao.IntEntityClass
-import org.jetbrains.exposed.dao.LongEntityClass
-import org.jetbrains.exposed.dao.id.EntityID
-import org.jetbrains.exposed.dao.id.IdTable
-import org.jetbrains.exposed.dao.id.IntIdTable
-import org.jetbrains.exposed.dao.id.LongIdTable
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import kotlin.reflect.*
-import kotlin.reflect.full.*
-
-/**
- * All the states to give a subscriber.
- */
-enum class SubscriberState {
- /**
- * No keys at all given to the bank.
- */
- NEW,
-
- /**
- * Only INI electronic message was successfully sent.
- */
- PARTIALLY_INITIALIZED_INI,
-
- /**r
- * Only HIA electronic message was successfully sent.
- */
- PARTIALLY_INITIALIZED_HIA,
-
- /**
- * Both INI and HIA were electronically sent with success.
- */
- INITIALIZED,
-
- /**
- * All the keys accounted in INI and HIA have been confirmed
- * via physical mail.
- */
- READY
-}
-
-/**
- * All the states that one key can be assigned.
- */
-enum class KeyState {
-
- /**
- * The key was never communicated.
- */
- MISSING,
-
- /**
- * The key has been electronically sent.
- */
- NEW,
-
- /**
- * The key has been confirmed (either via physical mail
- * or electronically -- e.g. with certificates)
- */
- RELEASED
-}
-
-/**
- * Stores one config object to the database. Each field
- * name and value populate respectively the configKey and
- * configValue columns. Rows are defined in the following way:
- * demobankName | configKey | configValue
- */
-fun insertConfigPairs(config: DemobankConfig, override: Boolean = false) {
- // Fill the config key-value pairs in the DB.
- config::class.declaredMemberProperties.forEach { configField ->
- val maybeValue = configField.getter.call(config)
- if (override) {
- val maybeConfigPair = DemobankConfigPairEntity.find {
- DemobankConfigPairsTable.configKey eq configField.name
- }.firstOrNull()
- if (maybeConfigPair == null)
- throw internalServerError("Cannot override config value '${configField.name}' not found.")
- maybeConfigPair.configValue = maybeValue?.toString()
- return@forEach
- }
- DemobankConfigPairEntity.new {
- this.demobankName = config.demobankName
- this.configKey = configField.name
- this.configValue = maybeValue?.toString()
- }
- }
-}
-
-object DemobankConfigPairsTable : LongIdTable() {
- val demobankName = text("demobankName")
- val configKey = text("configKey")
- val configValue = text("configValue").nullable()
-}
-
-class DemobankConfigPairEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<DemobankConfigPairEntity>(DemobankConfigPairsTable)
- var demobankName by DemobankConfigPairsTable.demobankName
- var configKey by DemobankConfigPairsTable.configKey
- var configValue by DemobankConfigPairsTable.configValue
-}
-
-object DemobankConfigsTable : LongIdTable() {
- val name = text("hostname")
-}
-
-// Helpers for handling config values in memory.
-typealias DemobankConfigKey = String
-typealias DemobankConfigValue = String?
-fun Pair<DemobankConfigKey, DemobankConfigValue>.expectValue(): String {
- if (this.second == null) throw internalServerError("Config value for '${this.first}' is null in the database.")
- return this.second as String
-}
-
-class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<DemobankConfigEntity>(DemobankConfigsTable)
- var name by DemobankConfigsTable.name
- /**
- * This object gets defined by parsing all the configuration
- * values found in the DB for one demobank. Those values are
- * retrieved from _another_ table.
- */
- val config: DemobankConfig by lazy {
- // Getting all the values for this demobank.
- val configPairs: List<Pair<DemobankConfigKey, DemobankConfigValue>> = transaction {
- val maybeConfigPairs = DemobankConfigPairEntity.find {
- DemobankConfigPairsTable.demobankName.eq(name)
- }
- if (maybeConfigPairs.empty()) throw SandboxError(
- HttpStatusCode.InternalServerError,
- "No config values of $name were found in the database"
- )
- // Copying results to a DB-agnostic list, to later operate out of "transaction {}"
- maybeConfigPairs.map { Pair(it.configKey, it.configValue) }
- }
- // Building the args to instantiate a DemobankConfig (non-Exposed) object.
- val args = mutableMapOf<KParameter, Any?>()
- // For each constructor parameter name, find the same-named database entry.
- val configClass = DemobankConfig::class
- if (configClass.primaryConstructor == null) {
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "${configClass.simpleName} primaryConstructor is null."
- )
- }
- if (configClass.primaryConstructor?.parameters == null) {
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "${configClass.simpleName} primaryConstructor" +
- " arguments is null. Cannot set any config value."
- )
- }
- // For each field in the config object, find the respective DB row.
- configClass.primaryConstructor?.parameters?.forEach { par: KParameter ->
- val configPairFromDb: Pair<DemobankConfigKey, DemobankConfigValue>?
- = configPairs.firstOrNull {
- configPair: Pair<DemobankConfigKey, DemobankConfigValue> ->
- configPair.first == par.name
- }
- if (configPairFromDb == null) {
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Config key '${par.name}' not found in the database."
- )
- }
- when(par.type) {
- // non-nullable
- typeOf<Boolean>() -> { args[par] = configPairFromDb.expectValue().toBoolean() }
- typeOf<Int>() -> { args[par] = configPairFromDb.expectValue().toInt() }
- // nullable
- typeOf<Boolean?>() -> { args[par] = configPairFromDb.second?.toBoolean() }
- typeOf<Int?>() -> { args[par] = configPairFromDb.second?.toInt() }
- else -> args[par] = configPairFromDb.second
- }
- }
- // Proceeding now to instantiate the config class, and make it a field of this type.
- configClass.primaryConstructor!!.callBy(args)
- }
-}
-
-/**
- * Users who are allowed to log into the demo bank.
- * Created via the /demobanks/{demobankname}/register endpoint.
- */
-object DemobankCustomersTable : LongIdTable() {
- val username = text("username")
- val passwordHash = text("passwordHash")
- val name = text("name").nullable()
- val email = text("email").nullable()
- val phone = text("phone").nullable()
- val cashout_address = text("cashout_address").nullable()
-}
-
-class DemobankCustomerEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<DemobankCustomerEntity>(DemobankCustomersTable)
- var username by DemobankCustomersTable.username
- var passwordHash by DemobankCustomersTable.passwordHash
- var name by DemobankCustomersTable.name
- var email by DemobankCustomersTable.email
- var phone by DemobankCustomersTable.phone
- var cashout_address by DemobankCustomersTable.cashout_address
-}
-
-/**
- * This table stores RSA public keys of subscribers.
- */
-object EbicsSubscriberPublicKeysTable : IntIdTable() {
- val rsaPublicKey = blob("rsaPublicKey")
- val state = enumeration("state", KeyState::class)
-}
-
-class EbicsSubscriberPublicKeyEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<EbicsSubscriberPublicKeyEntity>(EbicsSubscriberPublicKeysTable)
- var rsaPublicKey by EbicsSubscriberPublicKeysTable.rsaPublicKey
- var state by EbicsSubscriberPublicKeysTable.state
-}
-
-/**
- * Ebics 'host'(s) that are served by one Sandbox instance.
- */
-object EbicsHostsTable : IntIdTable() {
- val hostID = text("hostID")
- val ebicsVersion = text("ebicsVersion")
- val signaturePrivateKey = blob("signaturePrivateKey")
- val encryptionPrivateKey = blob("encryptionPrivateKey")
- val authenticationPrivateKey = blob("authenticationPrivateKey")
-}
-
-class EbicsHostEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<EbicsHostEntity>(EbicsHostsTable)
- var hostId by EbicsHostsTable.hostID
- var ebicsVersion by EbicsHostsTable.ebicsVersion
- var signaturePrivateKey by EbicsHostsTable.signaturePrivateKey
- var encryptionPrivateKey by EbicsHostsTable.encryptionPrivateKey
- var authenticationPrivateKey by EbicsHostsTable.authenticationPrivateKey
-}
-
-/**
- * Ebics Subscribers table.
- */
-object EbicsSubscribersTable : IntIdTable() {
- val userId = text("userID")
- val partnerId = text("partnerID")
- val systemId = text("systemID").nullable()
- val hostId = text("hostID")
- val signatureKey = reference("signatureKey", EbicsSubscriberPublicKeysTable).nullable()
- val encryptionKey = reference("encryptionKey", EbicsSubscriberPublicKeysTable).nullable()
- val authenticationKey = reference("authorizationKey", EbicsSubscriberPublicKeysTable).nullable()
- val nextOrderID = integer("nextOrderID")
- val state = enumeration("state", SubscriberState::class)
- val bankAccount = reference(
- "bankAccount",
- BankAccountsTable,
- onDelete = ReferenceOption.CASCADE
- ).nullable()
-}
-
-class EbicsSubscriberEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<EbicsSubscriberEntity>(EbicsSubscribersTable)
- var userId by EbicsSubscribersTable.userId
- var partnerId by EbicsSubscribersTable.partnerId
- var systemId by EbicsSubscribersTable.systemId
- var hostId by EbicsSubscribersTable.hostId
- var signatureKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.signatureKey
- var encryptionKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.encryptionKey
- var authenticationKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.authenticationKey
- var nextOrderID by EbicsSubscribersTable.nextOrderID
- var state by EbicsSubscribersTable.state
- var bankAccount by BankAccountEntity optionalReferencedOn EbicsSubscribersTable.bankAccount
-}
-
-/**
- * Details of a download order.
- */
-object EbicsDownloadTransactionsTable : IdTable<String>() {
- override val id = text("transactionID").entityId()
- val orderType = text("orderType")
- val host = reference("host", EbicsHostsTable)
- val subscriber = reference("subscriber", EbicsSubscribersTable)
- val encodedResponse = text("encodedResponse")
- val transactionKeyEnc = blob("transactionKeyEnc")
- val numSegments = integer("numSegments")
- val segmentSize = integer("segmentSize")
- val receiptReceived = bool("receiptReceived")
-}
-
-class EbicsDownloadTransactionEntity(id: EntityID<String>) : Entity<String>(id) {
- companion object : EntityClass<String, EbicsDownloadTransactionEntity>(EbicsDownloadTransactionsTable)
-
- var orderType by EbicsDownloadTransactionsTable.orderType
- var host by EbicsHostEntity referencedOn EbicsDownloadTransactionsTable.host
- var subscriber by EbicsSubscriberEntity referencedOn EbicsDownloadTransactionsTable.subscriber
- var encodedResponse by EbicsDownloadTransactionsTable.encodedResponse
- var numSegments by EbicsDownloadTransactionsTable.numSegments
- var transactionKeyEnc by EbicsDownloadTransactionsTable.transactionKeyEnc
- var segmentSize by EbicsDownloadTransactionsTable.segmentSize
- var receiptReceived by EbicsDownloadTransactionsTable.receiptReceived
-}
-
-/**
- * Details of a upload order.
- */
-object EbicsUploadTransactionsTable : IdTable<String>() {
- override val id = text("transactionID").entityId()
- val orderType = text("orderType")
- val orderID = text("orderID")
- val host = reference("host", EbicsHostsTable)
- val subscriber = reference("subscriber", EbicsSubscribersTable)
- val numSegments = integer("numSegments")
- val lastSeenSegment = integer("lastSeenSegment")
- val transactionKeyEnc = blob("transactionKeyEnc")
-}
-
-class EbicsUploadTransactionEntity(id: EntityID<String>) : Entity<String>(id) {
- companion object : EntityClass<String, EbicsUploadTransactionEntity>(EbicsUploadTransactionsTable)
- var orderType by EbicsUploadTransactionsTable.orderType
- var orderID by EbicsUploadTransactionsTable.orderID
- var host by EbicsHostEntity referencedOn EbicsUploadTransactionsTable.host
- var subscriber by EbicsSubscriberEntity referencedOn EbicsUploadTransactionsTable.subscriber
- var numSegments by EbicsUploadTransactionsTable.numSegments
- var lastSeenSegment by EbicsUploadTransactionsTable.lastSeenSegment
- var transactionKeyEnc by EbicsUploadTransactionsTable.transactionKeyEnc
-}
-
-/**
- * FIXME: document this.
- */
-object EbicsOrderSignaturesTable : IntIdTable() {
- val orderID = text("orderID")
- val orderType = text("orderType")
- val partnerID = text("partnerID")
- val userID = text("userID")
- val signatureAlgorithm = text("signatureAlgorithm")
- val signatureValue = blob("signatureValue")
-}
-
-class EbicsOrderSignatureEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<EbicsOrderSignatureEntity>(EbicsOrderSignaturesTable)
- var orderID by EbicsOrderSignaturesTable.orderID
- var orderType by EbicsOrderSignaturesTable.orderType
- var partnerID by EbicsOrderSignaturesTable.partnerID
- var userID by EbicsOrderSignaturesTable.userID
- var signatureAlgorithm by EbicsOrderSignaturesTable.signatureAlgorithm
- var signatureValue by EbicsOrderSignaturesTable.signatureValue
-}
-
-/**
- * FIXME: document this.
- */
-object EbicsUploadTransactionChunksTable : IdTable<String>() {
- override val id = text("transactionID").entityId()
- val chunkIndex = integer("chunkIndex")
- val chunkContent = blob("chunkContent")
-}
-
-// FIXME: Is upload chunking not implemented somewhere?!
-class EbicsUploadTransactionChunkEntity(id: EntityID<String>) : Entity<String>(id) {
- companion object : EntityClass<String, EbicsUploadTransactionChunkEntity>(EbicsUploadTransactionChunksTable)
- var chunkIndex by EbicsUploadTransactionChunksTable.chunkIndex
- var chunkContent by EbicsUploadTransactionChunksTable.chunkContent
-}
-
-
-/**
- * Holds those transactions that aren't yet reported in a Camt.053 document.
- * After reporting those, the table gets emptied. Rows are merely references
- * to the main ledger.
- */
-object BankAccountFreshTransactionsTable : LongIdTable() {
- val transactionRef = reference(
- "transaction",
- BankAccountTransactionsTable,
- onDelete = ReferenceOption.CASCADE
- )
-}
-class BankAccountFreshTransactionEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<BankAccountFreshTransactionEntity>(BankAccountFreshTransactionsTable)
- var transactionRef by BankAccountTransactionEntity referencedOn BankAccountFreshTransactionsTable.transactionRef
-}
-
-/**
- * Table that keeps all the payments initiated by PAIN.001.
- */
-object BankAccountTransactionsTable : LongIdTable() {
- val creditorIban = text("creditorIban")
- val creditorBic = text("creditorBic").nullable()
- val creditorName = text("creditorName")
- val debtorIban = text("debtorIban")
- val debtorBic = text("debtorBic").nullable()
- val debtorName = text("debtorName")
- val subject = text("subject")
- // Amount is a BigDecimal in String form.
- val amount = text("amount")
- val currency = text("currency")
- // Milliseconds since the Epoch.
- val date = long("date")
-
- /**
- * UID assigned to the payment by Sandbox. Despite the camt-looking
- * name, this UID is always given, even when no EBICS or camt are being
- * served.
- */
- val accountServicerReference = text("accountServicerReference")
- /**
- * The following two values are pain.001 specific. Sandbox stores
- * them when it serves EBICS connections.
- */
- val pmtInfId = text("pmtInfId").nullable()
- val endToEndId = text("EndToEndId").nullable()
- val direction = text("direction")
- /**
- * Bank account of the party whose 'direction' refers. This version allows
- * only both parties to be registered at the running Sandbox.
- */
- val account = reference(
- "account", BankAccountsTable,
- onDelete = ReferenceOption.CASCADE
- )
- // Redundantly storing the demobank for query convenience.
- val demobank = reference("demobank", DemobankConfigsTable)
-}
-
-class BankAccountTransactionEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<BankAccountTransactionEntity>(BankAccountTransactionsTable) {
- override fun new(init: BankAccountTransactionEntity.() -> Unit): BankAccountTransactionEntity {
- /**
- * Fresh transactions are those that wait to be included in a
- * "history" report, likely a Camt.5x message. The "fresh transactions"
- * table keeps a list of such transactions.
- */
- val freshTx = super.new(init)
- BankAccountFreshTransactionsTable.insert {
- it[transactionRef] = freshTx.id
- }
- /**
- * The bank account involved in this transaction points to
- * it as the "last known" transaction, to make it easier to
- * build histories that depend on such record.
- */
- freshTx.account.lastTransaction = freshTx
- return freshTx
- }
- }
- var creditorIban by BankAccountTransactionsTable.creditorIban
- var creditorBic by BankAccountTransactionsTable.creditorBic
- var creditorName by BankAccountTransactionsTable.creditorName
- var debtorIban by BankAccountTransactionsTable.debtorIban
- var debtorBic by BankAccountTransactionsTable.debtorBic
- var debtorName by BankAccountTransactionsTable.debtorName
- var subject by BankAccountTransactionsTable.subject
- var amount by BankAccountTransactionsTable.amount
- var currency by BankAccountTransactionsTable.currency
- var date by BankAccountTransactionsTable.date
- var accountServicerReference by BankAccountTransactionsTable.accountServicerReference
- var pmtInfId by BankAccountTransactionsTable.pmtInfId
- var endToEndId by BankAccountTransactionsTable.endToEndId
- var direction by BankAccountTransactionsTable.direction
- var account by BankAccountEntity referencedOn BankAccountTransactionsTable.account
- var demobank by DemobankConfigEntity referencedOn BankAccountTransactionsTable.demobank
-}
-
-/**
- * Table that keeps information about which bank accounts (iban+bic+name)
- * are active in the system. In the current version, 'label' and 'owner'
- * are always equal; future versions may change this, when one customer can
- * own multiple bank accounts.
- */
-object BankAccountsTable : IntIdTable() {
- val balance = text("balance").default("0")
- val iban = text("iban")
- val bic = text("bic").default("SANDBOXX")
- val label = text("label").uniqueIndex("accountLabelIndex")
- /**
- * This field is the username of the customer that owns the
- * bank account. Admin is the only exception: that can specify
- * this field as "admin" although no customer backs it.
- */
- val owner = text("owner")
- val isPublic = bool("isPublic").default(false)
- val demoBank = reference("demoBank", DemobankConfigsTable)
-
- /**
- * Point to the last transaction related to this account, regardless
- * of it being credit or debit. This reference helps to construct
- * history results that start from / depend on the last transaction.
- */
- val lastTransaction = reference("lastTransaction", BankAccountTransactionsTable).nullable()
-
- /**
- * Points to the transaction that was last submitted by the conversion
- * service to Nexus, in order to initiate a fiat payment related to a
- * cash-out operation.
- */
- val lastFiatSubmission = reference("lastFiatSubmission", BankAccountTransactionsTable).nullable()
-
- /**
- * Tracks the last fiat payment that was read from Nexus. This tracker
- * gets updated ONLY IF the exchange gets successfully paid with the related
- * amount in the regional currency.
- */
- val lastFiatFetch = text("lastFiatFetch").default("0")
-}
-
-class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<BankAccountEntity>(BankAccountsTable)
-
- var balance by BankAccountsTable.balance
- var iban by BankAccountsTable.iban
- var bic by BankAccountsTable.bic
- var label by BankAccountsTable.label
- var owner by BankAccountsTable.owner
- var isPublic by BankAccountsTable.isPublic
- var demoBank by DemobankConfigEntity referencedOn BankAccountsTable.demoBank
- var lastTransaction by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastTransaction
- var lastFiatSubmission by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastFiatSubmission
- var lastFiatFetch by BankAccountsTable.lastFiatFetch
-}
-
-object BankAccountStatementsTable : IntIdTable() {
- val statementId = text("statementId")
- val creationTime = long("creationTime")
- val xmlMessage = text("xmlMessage")
- val bankAccount = reference("bankAccount", BankAccountsTable)
- // Signed BigDecimal representing a Camt.053 CLBD field.
- val balanceClbd = text("balanceClbd").nullable()
-}
-
-class BankAccountStatementEntity(id: EntityID<Int>) : IntEntity(id) {
- companion object : IntEntityClass<BankAccountStatementEntity>(BankAccountStatementsTable)
- var statementId by BankAccountStatementsTable.statementId
- var creationTime by BankAccountStatementsTable.creationTime
- var xmlMessage by BankAccountStatementsTable.xmlMessage
- var bankAccount by BankAccountEntity referencedOn BankAccountStatementsTable.bankAccount
- var balanceClbd by BankAccountStatementsTable.balanceClbd
-}
-
-enum class CashoutOperationStatus { CONFIRMED, PENDING }
-object CashoutOperationsTable : LongIdTable() {
- val uuid = uuid("uuid").autoGenerate()
- /**
- * This amount is the one the user entered in the cash-out
- * dialog. That will show up as the outgoing transfer in their
- * local currency bank account.
- */
- val amountDebit = text("amountDebit")
- val amountCredit = text("amountCredit")
- val buyAtRatio = text("buyAtRatio")
- val buyInFee = text("buyInFee")
- val sellAtRatio = text("sellAtRatio")
- val sellOutFee = text("sellOutFee")
- val subject = text("subject")
- val creationTime = long("creationTime") // in milliseconds.
- val confirmationTime = long("confirmationTime").nullable() // in milliseconds.
- val tanChannel = enumeration("tanChannel", SupportedTanChannels::class)
- val account = text("account")
- val cashoutAddress = text("cashoutAddress")
- val tan = text("tan")
- val status = enumeration("status", CashoutOperationStatus::class).default(CashoutOperationStatus.PENDING)
-}
-
-class CashoutOperationEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<CashoutOperationEntity>(CashoutOperationsTable)
- var uuid by CashoutOperationsTable.uuid
- var amountDebit by CashoutOperationsTable.amountDebit
- var amountCredit by CashoutOperationsTable.amountCredit
- var buyAtRatio by CashoutOperationsTable.buyAtRatio
- var buyInFee by CashoutOperationsTable.buyInFee
- var sellAtRatio by CashoutOperationsTable.sellAtRatio
- var sellOutFee by CashoutOperationsTable.sellOutFee
- var subject by CashoutOperationsTable.subject
- var creationTime by CashoutOperationsTable.creationTime
- var confirmationTime by CashoutOperationsTable.confirmationTime
- var tanChannel by CashoutOperationsTable.tanChannel
- var account by CashoutOperationsTable.account
- var cashoutAddress by CashoutOperationsTable.cashoutAddress
- var tan by CashoutOperationsTable.tan
- var status by CashoutOperationsTable.status
-}
-object TalerWithdrawalsTable : LongIdTable() {
- val wopid = uuid("wopid").autoGenerate()
- val amount = text("amount") // $currency:x.y
- /**
- * Turns to true after the wallet gave the reserve public key
- * and the exchange details to the bank.
- */
- val selectionDone = bool("selectionDone").default(false)
- val aborted = bool("aborted").default(false)
- /**
- * Turns to true after the wire transfer to the exchange bank account
- * gets completed _on the bank's side_. This does never guarantees that
- * the payment arrived at the exchange's bank yet.
- */
- val confirmationDone = bool("confirmationDone").default(false)
- val reservePub = text("reservePub").nullable()
- val selectedExchangePayto = text("selectedExchangePayto").nullable()
- val walletBankAccount = reference("walletBankAccount", BankAccountsTable)
-}
-class TalerWithdrawalEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<TalerWithdrawalEntity>(TalerWithdrawalsTable)
- var wopid by TalerWithdrawalsTable.wopid
- var selectionDone by TalerWithdrawalsTable.selectionDone
- var confirmationDone by TalerWithdrawalsTable.confirmationDone
- var reservePub by TalerWithdrawalsTable.reservePub
- var selectedExchangePayto by TalerWithdrawalsTable.selectedExchangePayto
- var amount by TalerWithdrawalsTable.amount
- var walletBankAccount by BankAccountEntity referencedOn TalerWithdrawalsTable.walletBankAccount
- var aborted by TalerWithdrawalsTable.aborted
-}
-
-object BankAccountReportsTable : IntIdTable() {
- val reportId = text("reportId")
- val creationTime = long("creationTime")
- val xmlMessage = text("xmlMessage")
- val bankAccount = reference("bankAccount", BankAccountsTable)
-}
-
-/**
- * This table tracks the cash-out requests that Sandbox sends to Nexus.
- * Only successful requests make it to this table. Failed request would
- * either _stop_ the conversion service (for client-side errors) or get retried
- * at a later time (for server-side errors.)
- */
-object CashoutSubmissionsTable: LongIdTable() {
- val localTransaction = reference("localTransaction", BankAccountTransactionsTable).uniqueIndex()
- val maybeNexusResponse = text("maybeNexusResponse").nullable()
- val submissionTime = long("submissionTime").nullable() // failed don't have it.
-}
-
-class CashoutSubmissionEntity(id: EntityID<Long>) : LongEntity(id) {
- companion object : LongEntityClass<CashoutSubmissionEntity>(CashoutSubmissionsTable)
- var localTransaction by CashoutSubmissionsTable.localTransaction
- var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse
- var submissionTime by CashoutSubmissionsTable.submissionTime
-}
-
-fun dbDropTables(connStringFromEnv: String) {
- connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv))
- if (isPostgres()) {
- val ret = execCommand(
- listOf(
- "libeufin-load-sql",
- "-d",
- connStringFromEnv,
- "-s",
- "sandbox",
- "-r" // the drop option
- ),
- /**
- * Tolerating a failure here helps to manage the case
- * where an empty database is attempted to be dropped.
- */
- throwIfFails = false
- )
- if (ret != 0)
- logger.warn("Dropping the sandbox tables failed. Was the DB filled before?")
- return
- }
- transaction {
- SchemaUtils.drop(
- CashoutSubmissionsTable,
- EbicsSubscribersTable,
- EbicsSubscriberPublicKeysTable,
- EbicsHostsTable,
- EbicsDownloadTransactionsTable,
- EbicsUploadTransactionsTable,
- EbicsUploadTransactionChunksTable,
- EbicsOrderSignaturesTable,
- BankAccountTransactionsTable,
- BankAccountFreshTransactionsTable,
- BankAccountsTable,
- BankAccountReportsTable,
- BankAccountStatementsTable,
- DemobankConfigsTable,
- DemobankConfigPairsTable,
- TalerWithdrawalsTable,
- DemobankCustomersTable,
- CashoutOperationsTable
- )
- }
-
-}
-
-fun dbCreateTables(connStringFromEnv: String) {
- connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv))
- if (isPostgres()) {
- execCommand(listOf(
- "libeufin-load-sql",
- "-d",
- connStringFromEnv,
- "-s",
- "sandbox"
- ))
- return
- }
- // Still using the legacy way for other DBMSs, like SQLite.
- transaction {
- SchemaUtils.create(
- CashoutSubmissionsTable,
- DemobankConfigsTable,
- DemobankConfigPairsTable,
- EbicsSubscribersTable,
- EbicsSubscriberPublicKeysTable,
- EbicsHostsTable,
- EbicsDownloadTransactionsTable,
- EbicsUploadTransactionsTable,
- EbicsUploadTransactionChunksTable,
- EbicsOrderSignaturesTable,
- BankAccountTransactionsTable,
- BankAccountFreshTransactionsTable,
- BankAccountsTable,
- BankAccountReportsTable,
- BankAccountStatementsTable,
- TalerWithdrawalsTable,
- DemobankCustomersTable,
- CashoutOperationsTable
- )
- }
-}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt
deleted file mode 100644
index 79f7a404..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Database.kt
+++ /dev/null
@@ -1,665 +0,0 @@
-package tech.libeufin.sandbox
-
-import org.postgresql.jdbc.PgConnection
-import tech.libeufin.util.internalServerError
-
-import java.sql.DriverManager
-import java.sql.PreparedStatement
-import java.sql.SQLException
-import java.util.*
-
-private const val DB_CTR_LIMIT = 1000000
-
-data class Customer(
- val login: String,
- val passwordHash: String,
- val name: String,
- val email: String,
- val phone: String,
- val cashoutPayto: String,
- val cashoutCurrency: String
-)
-
-data class TalerAmount(
- val value: Long,
- val frac: Int
-)
-
-data class BankAccount(
- val iban: String,
- val bic: String,
- val bankAccountLabel: String,
- val owningCustomerId: Long,
- val isPublic: Boolean = false,
- val lastNexusFetchRowId: Long,
- val balance: TalerAmount? = null,
- val hasDebt: Boolean
-)
-
-enum class TransactionDirection {
- credit, debit
-}
-
-enum class TanChannel {
- sms, email, file
-}
-
-data class BankInternalTransaction(
- val creditorAccountId: Long,
- val debtorAccountId: Long,
- val subject: String,
- val amount: TalerAmount,
- val transactionDate: Long,
- val accountServicerReference: String,
- val endToEndId: String,
- val paymentInformationId: String
-)
-
-data class BankAccountTransaction(
- val creditorIban: String,
- val creditorBic: String,
- val creditorName: String,
- val debtorIban: String,
- val debtorBic: String,
- val debtorName: String,
- val subject: String,
- val amount: TalerAmount,
- val transactionDate: Long, // microseconds
- val accountServicerReference: String,
- val paymentInformationId: String,
- val endToEndId: String,
- val direction: TransactionDirection,
- val bankAccountId: Long,
-)
-
-data class TalerWithdrawalOperation(
- val withdrawalUuid: UUID,
- val amount: TalerAmount,
- val selectionDone: Boolean = false,
- val aborted: Boolean = false,
- val confirmationDone: Boolean = false,
- val reservePub: ByteArray?,
- val selectedExchangePayto: String?,
- val walletBankAccount: Long
-)
-
-data class Cashout(
- val cashoutUuid: UUID,
- val localTransaction: Long? = null,
- val amountDebit: TalerAmount,
- val amountCredit: TalerAmount,
- val buyAtRatio: Int,
- val buyInFee: TalerAmount,
- val sellAtRatio: Int,
- val sellOutFee: TalerAmount,
- val subject: String,
- val creationTime: Long,
- val tanConfirmationTime: Long? = null,
- val tanChannel: TanChannel,
- val tanCode: String,
- val bankAccount: Long,
- val cashoutAddress: String,
- val cashoutCurrency: String
-)
-
-class Database(private val dbConfig: String) {
- private var dbConn: PgConnection? = null
- private var dbCtr: Int = 0
- private val preparedStatements: MutableMap<String, PreparedStatement> = mutableMapOf()
-
- init {
- Class.forName("org.postgresql.Driver")
- }
- private fun reconnect() {
- dbCtr++
- val myDbConn = dbConn
- if ((dbCtr < DB_CTR_LIMIT && myDbConn != null) && !(myDbConn.isClosed))
- return
- dbConn?.close()
- preparedStatements.clear()
- dbConn = DriverManager.getConnection(dbConfig).unwrap(PgConnection::class.java)
- dbCtr = 0
- dbConn?.execSQLUpdate("SET search_path TO libeufin_bank;")
- }
-
- private fun prepare(sql: String): PreparedStatement {
- var ps = preparedStatements[sql]
- if (ps != null) return ps
- val myDbConn = dbConn
- if (myDbConn == null) throw internalServerError("DB connection down")
- ps = myDbConn.prepareStatement(sql)
- preparedStatements[sql] = ps
- return ps
- }
-
- /**
- * Helper that returns false if the row to be inserted
- * hits a unique key constraint violation, true when it
- * succeeds. Any other error (re)throws exception.
- */
- private fun myExecute(stmt: PreparedStatement): Boolean {
- try {
- stmt.execute()
- } catch (e: SQLException) {
- logger.error(e.message)
- // NOTE: it seems that _every_ error gets the 0 code.
- if (e.errorCode == 0) return false
- // rethrowing, not to hide other types of errors.
- throw e
- }
- return true
- }
-
- // CONFIG
- fun configGet(configKey: String): String? {
- reconnect()
- val stmt = prepare("SELECT config_value FROM configuration WHERE config_key=?;")
- stmt.setString(1, configKey)
- val rs = stmt.executeQuery()
- rs.use {
- if(!it.next()) return null
- return it.getString("config_value")
- }
- }
- fun configSet(configKey: String, configValue: String) {
- reconnect()
- val stmt = prepare("CALL bank_set_config(TEXT(?), TEXT(?))")
- stmt.setString(1, configKey)
- stmt.setString(2, configValue)
- stmt.execute()
- }
-
- // CUSTOMERS
- fun customerCreate(customer: Customer): Boolean {
- reconnect()
- val stmt = prepare("""
- INSERT INTO customers (
- login
- ,password_hash
- ,name
- ,email
- ,phone
- ,cashout_payto
- ,cashout_currency
- )
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """
- )
- stmt.setString(1, customer.login)
- stmt.setString(2, customer.passwordHash)
- stmt.setString(3, customer.name)
- stmt.setString(4, customer.email)
- stmt.setString(5, customer.phone)
- stmt.setString(6, customer.cashoutPayto)
- stmt.setString(7, customer.cashoutCurrency)
-
- return myExecute(stmt)
- }
- fun customerGetFromLogin(login: String): Customer? {
- reconnect()
- val stmt = prepare("""
- SELECT
- password_hash,
- name,
- email,
- phone,
- cashout_payto,
- cashout_currency
- FROM customers
- WHERE login=?
- """)
- stmt.setString(1, login)
- val rs = stmt.executeQuery()
- rs.use {
- if (!rs.next()) return null
- return Customer(
- login = login,
- passwordHash = it.getString("password_hash"),
- name = it.getString("name"),
- phone = it.getString("phone"),
- email = it.getString("email"),
- cashoutCurrency = it.getString("cashout_currency"),
- cashoutPayto = it.getString("cashout_payto")
- )
- }
- }
- // Possibly more "customerGetFrom*()" to come.
-
- // BANK ACCOUNTS
- // Returns false on conflicts.
- fun bankAccountCreate(bankAccount: BankAccount): Boolean {
- reconnect()
- val stmt = prepare("""
- INSERT INTO bank_accounts
- (iban
- ,bic
- ,bank_account_label
- ,owning_customer_id
- ,is_public
- ,last_nexus_fetch_row_id
- )
- VALUES (?, ?, ?, ?, ?, ?)
- """)
- stmt.setString(1, bankAccount.iban)
- stmt.setString(2, bankAccount.bic)
- stmt.setString(3, bankAccount.bankAccountLabel)
- stmt.setLong(4, bankAccount.owningCustomerId)
- stmt.setBoolean(5, bankAccount.isPublic)
- stmt.setLong(6, bankAccount.lastNexusFetchRowId)
- // using the default zero value for the balance.
- return myExecute(stmt)
- }
-
- fun bankAccountSetMaxDebt(
- bankAccountLabel: String,
- maxDebt: TalerAmount
- ): Boolean {
- reconnect()
- val stmt = prepare("""
- UPDATE bank_accounts
- SET max_debt=(?,?)::taler_amount
- WHERE bank_account_label=?
- """)
- stmt.setLong(1, maxDebt.value)
- stmt.setInt(2, maxDebt.frac)
- stmt.setString(3, bankAccountLabel)
- return myExecute(stmt)
- }
-
- fun bankAccountGetFromLabel(bankAccountLabel: String): BankAccount? {
- reconnect()
- val stmt = prepare("""
- SELECT
- iban
- ,bic
- ,owning_customer_id
- ,is_public
- ,last_nexus_fetch_row_id
- ,(balance).val AS balance_value
- ,(balance).frac AS balance_frac
- ,has_debt
- FROM bank_accounts
- WHERE bank_account_label=?
- """)
- stmt.setString(1, bankAccountLabel)
-
- val rs = stmt.executeQuery()
- rs.use {
- if (!it.next()) return null
- return BankAccount(
- iban = it.getString("iban"),
- bic = it.getString("bic"),
- balance = TalerAmount(
- it.getLong("balance_value"),
- it.getInt("balance_frac")
- ),
- bankAccountLabel = bankAccountLabel,
- lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"),
- owningCustomerId = it.getLong("owning_customer_id"),
- hasDebt = it.getBoolean("has_debt")
- )
- }
- }
- // More bankAccountGetFrom*() to come, on a needed basis.
-
- // BANK ACCOUNT TRANSACTIONS
- enum class BankTransactionResult {
- NO_CREDITOR,
- NO_DEBTOR,
- SUCCESS,
- CONFLICT
- }
- fun bankTransactionCreate(
- tx: BankInternalTransaction
- ): BankTransactionResult {
- reconnect()
- val stmt = prepare("""
- SELECT out_nx_creditor, out_nx_debtor, out_balance_insufficient
- FROM bank_wire_transfer(?,?,TEXT(?),(?,?)::taler_amount,?,TEXT(?),TEXT(?),TEXT(?))
- """
- )
- stmt.setLong(1, tx.creditorAccountId)
- stmt.setLong(2, tx.debtorAccountId)
- stmt.setString(3, tx.subject)
- stmt.setLong(4, tx.amount.value)
- stmt.setInt(5, tx.amount.frac)
- stmt.setLong(6, tx.transactionDate)
- stmt.setString(7, tx.accountServicerReference)
- stmt.setString(8, tx.paymentInformationId)
- stmt.setString(9, tx.endToEndId)
- val rs = stmt.executeQuery()
- rs.use {
- if (!rs.next()) throw internalServerError("Bank transaction didn't properly return")
- if (rs.getBoolean("out_nx_debtor")) {
- logger.error("No debtor account found")
- return BankTransactionResult.NO_DEBTOR
- }
- if (rs.getBoolean("out_nx_creditor")) {
- logger.error("No creditor account found")
- return BankTransactionResult.NO_CREDITOR
- }
- if (rs.getBoolean("out_balance_insufficient")) {
- logger.error("Balance insufficient")
- return BankTransactionResult.CONFLICT
- }
- return BankTransactionResult.SUCCESS
- }
- }
-
- fun bankTransactionGetForHistoryPage(
- upperBound: Long,
- bankAccountId: Long,
- fromMs: Long,
- toMs: Long
- ): List<BankAccountTransaction> {
- reconnect()
- val stmt = prepare("""
- SELECT
- creditor_iban
- ,creditor_bic
- ,creditor_name
- ,debtor_iban
- ,debtor_bic
- ,debtor_name
- ,subject
- ,(amount).val AS amount_val
- ,(amount).frac AS amount_frac
- ,transaction_date
- ,account_servicer_reference
- ,payment_information_id
- ,end_to_end_id
- ,direction
- ,bank_account_id
- FROM bank_account_transactions
- WHERE bank_transaction_id < ?
- AND bank_account_id=?
- AND transaction_date BETWEEN ? AND ?
- """)
- stmt.setLong(1, upperBound)
- stmt.setLong(2, bankAccountId)
- stmt.setLong(3, fromMs)
- stmt.setLong(4, toMs)
- val rs = stmt.executeQuery()
- rs.use {
- val ret = mutableListOf<BankAccountTransaction>()
- if (!it.next()) return ret
- do {
- ret.add(
- BankAccountTransaction(
- creditorIban = it.getString("creditor_iban"),
- creditorBic = it.getString("creditor_bic"),
- creditorName = it.getString("creditor_name"),
- debtorIban = it.getString("debtor_iban"),
- debtorBic = it.getString("debtor_bic"),
- debtorName = it.getString("debtor_name"),
- amount = TalerAmount(
- it.getLong("amount_val"),
- it.getInt("amount_frac")
- ),
- accountServicerReference = it.getString("account_servicer_reference"),
- endToEndId = it.getString("end_to_end_id"),
- direction = it.getString("direction").run {
- when(this) {
- "credit" -> TransactionDirection.credit
- "debit" -> TransactionDirection.debit
- else -> throw internalServerError("Wrong direction in transaction: $this")
- }
- },
- bankAccountId = it.getLong("bank_account_id"),
- paymentInformationId = it.getString("payment_information_id"),
- subject = it.getString("subject"),
- transactionDate = it.getLong("transaction_date")
- ))
- } while (it.next())
- return ret
- }
- }
-
- // WITHDRAWALS
- fun talerWithdrawalCreate(
- opUUID: UUID,
- walletBankAccount: Long,
- amount: TalerAmount
- ): Boolean {
- reconnect()
- val stmt = prepare("""
- INSERT INTO
- taler_withdrawal_operations
- (withdrawal_uuid, wallet_bank_account, amount)
- VALUES (?,?,(?,?)::taler_amount)
- """) // Take all defaults from the SQL.
- stmt.setObject(1, opUUID)
- stmt.setLong(2, walletBankAccount)
- stmt.setLong(3, amount.value)
- stmt.setInt(4, amount.frac)
-
- return myExecute(stmt)
- }
- fun talerWithdrawalGet(opUUID: UUID): TalerWithdrawalOperation? {
- reconnect()
- val stmt = prepare("""
- SELECT
- (amount).val as amount_val
- ,(amount).frac as amount_frac
- ,withdrawal_uuid
- ,selection_done
- ,aborted
- ,confirmation_done
- ,reserve_pub
- ,selected_exchange_payto
- ,wallet_bank_account
- FROM taler_withdrawal_operations
- WHERE withdrawal_uuid=?
- """)
- stmt.setObject(1, opUUID)
- stmt.executeQuery().use {
- if (!it.next()) return null
- return TalerWithdrawalOperation(
- amount = TalerAmount(
- it.getLong("amount_val"),
- it.getInt("amount_frac")
- ),
- selectionDone = it.getBoolean("selection_done"),
- selectedExchangePayto = it.getString("selected_exchange_payto"),
- walletBankAccount = it.getLong("wallet_bank_account"),
- confirmationDone = it.getBoolean("confirmation_done"),
- aborted = it.getBoolean("aborted"),
- reservePub = it.getBytes("reserve_pub"),
- withdrawalUuid = it.getObject("withdrawal_uuid") as UUID
- )
- }
- }
-
- // Values coming from the wallet.
- fun talerWithdrawalSetDetails(
- opUUID: UUID,
- exchangePayto: String,
- reservePub: ByteArray
- ): Boolean {
- reconnect()
- val stmt = prepare("""
- UPDATE taler_withdrawal_operations
- SET selected_exchange_payto = ?, reserve_pub = ?, selection_done = true
- WHERE withdrawal_uuid=?
- """
- )
- stmt.setString(1, exchangePayto)
- stmt.setBytes(2, reservePub)
- stmt.setObject(3, opUUID)
- return myExecute(stmt)
- }
- fun talerWithdrawalConfirm(opUUID: UUID): Boolean {
- reconnect()
- val stmt = prepare("""
- UPDATE taler_withdrawal_operations
- SET confirmation_done = true
- WHERE withdrawal_uuid=?
- """
- )
- stmt.setObject(1, opUUID)
- return myExecute(stmt)
- }
-
- fun cashoutCreate(op: Cashout): Boolean {
- reconnect()
- val stmt = prepare("""
- INSERT INTO cashout_operations (
- cashout_uuid
- ,amount_debit
- ,amount_credit
- ,buy_at_ratio
- ,buy_in_fee
- ,sell_at_ratio
- ,sell_out_fee
- ,subject
- ,creation_time
- ,tan_channel
- ,tan_code
- ,bank_account
- ,cashout_address
- ,cashout_currency
- )
- VALUES (
- ?
- ,(?,?)::taler_amount
- ,(?,?)::taler_amount
- ,?
- ,(?,?)::taler_amount
- ,?
- ,(?,?)::taler_amount
- ,?
- ,?
- ,?::tan_enum
- ,?
- ,?
- ,?
- ,?
- );
- """)
- stmt.setObject(1, op.cashoutUuid)
- stmt.setLong(2, op.amountDebit.value)
- stmt.setInt(3, op.amountDebit.frac)
- stmt.setLong(4, op.amountCredit.value)
- stmt.setInt(5, op.amountCredit.frac)
- stmt.setInt(6, op.buyAtRatio)
- stmt.setLong(7, op.buyInFee.value)
- stmt.setInt(8, op.buyInFee.frac)
- stmt.setInt(9, op.sellAtRatio)
- stmt.setLong(10, op.sellOutFee.value)
- stmt.setInt(11, op.sellOutFee.frac)
- stmt.setString(12, op.subject)
- stmt.setLong(13, op.creationTime)
- stmt.setString(14, op.tanChannel.name)
- stmt.setString(15, op.tanCode)
- stmt.setLong(16, op.bankAccount)
- stmt.setString(17, op.cashoutAddress)
- stmt.setString(18, op.cashoutCurrency)
- return myExecute(stmt)
- }
-
- fun cashoutConfirm(
- opUuid: UUID,
- tanConfirmationTimestamp: Long,
- bankTransaction: Long // regional payment backing the operation
- ): Boolean {
- reconnect()
- val stmt = prepare("""
- UPDATE cashout_operations
- SET tan_confirmation_time = ?, local_transaction = ?
- WHERE cashout_uuid=?;
- """)
- stmt.setLong(1, tanConfirmationTimestamp)
- stmt.setLong(2, bankTransaction)
- stmt.setObject(3, opUuid)
- return myExecute(stmt)
- }
- // used by /abort
- enum class CashoutDeleteResult {
- SUCCESS,
- CONFLICT_ALREADY_CONFIRMED
- }
- fun cashoutDelete(opUuid: UUID): CashoutDeleteResult {
- val stmt = prepare("""
- SELECT out_already_confirmed
- FROM cashout_delete(?)
- """)
- stmt.setObject(1, opUuid)
- stmt.executeQuery().use {
- if (!it.next()) {
- throw internalServerError("Cashout deletion gave no result")
- }
- if (it.getBoolean("out_already_confirmed")) return CashoutDeleteResult.CONFLICT_ALREADY_CONFIRMED
- return CashoutDeleteResult.SUCCESS
- }
- }
- fun cashoutGetFromUuid(opUuid: UUID): Cashout? {
- val stmt = prepare("""
- SELECT
- (amount_debit).val as amount_debit_val
- ,(amount_debit).frac as amount_debit_frac
- ,(amount_credit).val as amount_credit_val
- ,(amount_credit).frac as amount_credit_frac
- ,buy_at_ratio
- ,(buy_in_fee).val as buy_in_fee_val
- ,(buy_in_fee).frac as buy_in_fee_frac
- ,sell_at_ratio
- ,(sell_out_fee).val as sell_out_fee_val
- ,(sell_out_fee).frac as sell_out_fee_frac
- ,subject
- ,creation_time
- ,tan_channel
- ,tan_code
- ,bank_account
- ,cashout_address
- ,cashout_currency
- ,tan_confirmation_time
- ,local_transaction
- FROM cashout_operations
- WHERE cashout_uuid=?;
- """)
- stmt.setObject(1, opUuid)
- stmt.executeQuery().use {
- if (!it.next()) return null
- return Cashout(
- amountDebit = TalerAmount(
- value = it.getLong("amount_debit_val"),
- frac = it.getInt("amount_debit_frac")
- ),
- amountCredit = TalerAmount(
- value = it.getLong("amount_credit_val"),
- frac = it.getInt("amount_credit_frac")
- ),
- bankAccount = it.getLong("bank_account"),
- buyAtRatio = it.getInt("buy_at_ratio"),
- buyInFee = TalerAmount(
- value = it.getLong("buy_in_fee_val"),
- frac = it.getInt("buy_in_fee_frac")
- ),
- cashoutAddress = it.getString("cashout_address"),
- cashoutCurrency = it.getString("cashout_currency"),
- cashoutUuid = opUuid,
- creationTime = it.getLong("creation_time"),
- sellAtRatio = it.getInt("sell_at_ratio"),
- sellOutFee = TalerAmount(
- value = it.getLong("sell_out_fee_val"),
- frac = it.getInt("sell_out_fee_frac")
- ),
- subject = it.getString("subject"),
- tanChannel = it.getString("tan_channel").run {
- when(this) {
- "sms" -> TanChannel.sms
- "email" -> TanChannel.email
- "file" -> TanChannel.file
- else -> throw internalServerError("TAN channel $this unsupported")
- }
- },
- tanCode = it.getString("tan_code"),
- localTransaction = it.getLong("local_transaction"),
- tanConfirmationTime = it.getLong("tan_confirmation_time").run {
- if (this == 0L) return@run null
- return@run this
- }
- )
- }
- }
-}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
deleted file mode 100644
index 57a61f50..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
+++ /dev/null
@@ -1,1436 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-
-package tech.libeufin.sandbox
-
-import io.ktor.server.application.*
-import io.ktor.http.ContentType
-import io.ktor.http.HttpStatusCode
-import io.ktor.server.request.*
-import io.ktor.server.response.respond
-import io.ktor.server.response.respondText
-import io.ktor.util.AttributeKey
-import io.ktor.util.date.*
-import org.apache.xml.security.binding.xmldsig.RSAKeyValueType
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.statements.api.ExposedBlob
-import org.jetbrains.exposed.sql.transactions.transaction
-import org.w3c.dom.Document
-import tech.libeufin.util.*
-import tech.libeufin.util.XMLUtil.Companion.signEbicsResponse
-import tech.libeufin.util.ebics_h004.*
-import tech.libeufin.util.ebics_hev.HEVResponse
-import tech.libeufin.util.ebics_hev.SystemReturnCodeType
-import tech.libeufin.util.ebics_s001.SignatureTypes
-import tech.libeufin.util.ebics_s001.UserSignatureData
-import java.math.BigDecimal
-import java.security.interfaces.RSAPrivateCrtKey
-import java.security.interfaces.RSAPublicKey
-import java.sql.Connection
-import java.util.*
-import java.util.zip.DeflaterInputStream
-import java.util.zip.InflaterInputStream
-
-val EbicsHostIdAttribute = AttributeKey<String>("RequestedEbicsHostID")
-
-data class PainParseResult(
- val creditorIban: String,
- val creditorName: String,
- val creditorBic: String?,
- val debtorIban: String,
- val debtorName: String,
- val debtorBic: String?,
- val subject: String,
- val amount: String,
- val currency: String,
- val pmtInfId: String,
- val endToEndId: String,
- val msgId: String
-)
-
-open class EbicsRequestError(
- val errorText: String,
- val errorCode: String
-) : Exception("$errorText (EBICS error code: $errorCode)")
-
-class EbicsNoDownloadDataAvailable(reason: String? = null) : EbicsRequestError(
- "[EBICS_NO_DOWNLOAD_DATA_AVAILABLE]" + if (reason != null) " $reason" else "",
- "090005"
-)
-
-class EbicsInvalidRequestError : EbicsRequestError(
- "[EBICS_INVALID_REQUEST] Invalid request",
- "060102"
-)
-class EbicsAccountAuthorisationFailed(reason: String) : EbicsRequestError(
- "[EBICS_ACCOUNT_AUTHORISATION_FAILED] $reason",
- "091302"
-)
-
-/**
- * This error is thrown whenever the Subscriber's state is not suitable
- * for the requested action. For example, the subscriber sends a EbicsRequest
- * message without having first uploaded their keys (#5973).
- */
-class EbicsSubscriberStateError : EbicsRequestError(
- "[EBICS_INVALID_USER_OR_USER_STATE] Subscriber unknown or subscriber state inadmissible",
- "091002"
-)
-// hint should mention at least the userID
-class EbicsUserUnknown(hint: String) : EbicsRequestError(
- "[EBICS_USER_UNKNOWN] $hint",
- "091003"
-)
-
-class EbicsOrderParamsIgnored(hint: String) : EbicsRequestError(
- "[EBICS_ORDER_PARAMS_IGNORED] $hint",
- "031001"
-)
-
-
-open class EbicsKeyManagementError(private val errorText: String, private val errorCode: String) :
- Exception("EBICS key management error: $errorText ($errorCode)")
-
-private class EbicsInvalidXmlError : EbicsKeyManagementError(
- "[EBICS_INVALID_XML]",
- "091010"
-)
-
-private class EbicsUnsupportedOrderType : EbicsRequestError(
- "[EBICS_UNSUPPORTED_ORDER_TYPE] Order type not supported",
- "091005"
-)
-
-/**
- * Used here also for "Internal server error". For example, when the
- * sandbox itself generates a invalid XML response.
- */
-class EbicsProcessingError(detail: String?) : EbicsRequestError(
- // a missing detail is already the bank's fault.
- "[EBICS_PROCESSING_ERROR] " + (detail ?: "bank internal error"),
- "091116"
-)
-
-class EbicsAmountCheckError(detail: String): EbicsRequestError(
- "[EBICS_AMOUNT_CHECK_FAILED] $detail",
- "091303"
-)
-
-suspend fun respondEbicsTransfer(
- call: ApplicationCall,
- errorText: String,
- errorCode: String
-) {
- /**
- * Because this handler runs for any error, it could
- * handle the case where the Ebics host ID is unknown due
- * to an invalid request. Recall: Sandbox is multi-host, and
- * which Ebics host was requested belongs to the request document.
- *
- * Therefore, because any Ebics response
- * should speak for one Ebics host, we can't respond any Ebics
- * type when the Ebics host ID remains unknown due to invalid
- * request. Instead, we'll respond plain text:
- */
- if (!call.attributes.contains(EbicsHostIdAttribute)) {
- call.respondText("Invalid document.", status = HttpStatusCode.BadRequest)
- return
- }
- val resp = EbicsResponse.createForUploadWithError(
- errorText,
- errorCode,
- // For now, phase gets hard-coded as TRANSFER,
- // because errors during initialization should have
- // already been caught by the chunking logic.
- EbicsTypes.TransactionPhaseType.TRANSFER
- )
- val hostAuthPriv = transaction {
- val host = EbicsHostEntity.find {
- EbicsHostsTable.hostID.upperCase() eq call.attributes[EbicsHostIdAttribute]
- .uppercase()
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Requested Ebics host ID (${call.attributes[EbicsHostIdAttribute]}) not found."
- )
- CryptoUtil.loadRsaPrivateKey(host.authenticationPrivateKey.bytes)
- }
- call.respondText(
- signEbicsResponse(resp, hostAuthPriv),
- ContentType.Application.Xml,
- HttpStatusCode.OK
- )
-}
-
-private suspend fun ApplicationCall.respondEbicsKeyManagement(
- errorText: String,
- errorCode: String,
- bankReturnCode: String,
- dataTransfer: CryptoUtil.EncryptionResult? = null,
- orderId: String? = null
-) {
- val responseXml = EbicsKeyManagementResponse().apply {
- version = "H004"
- header = EbicsKeyManagementResponse.Header().apply {
- authenticate = true
- mutable = EbicsKeyManagementResponse.MutableHeaderType().apply {
- reportText = errorText
- returnCode = errorCode
- if (orderId != null) {
- this.orderID = orderId
- }
- }
- _static = EbicsKeyManagementResponse.EmptyStaticHeader()
- }
- body = EbicsKeyManagementResponse.Body().apply {
- this.returnCode = EbicsKeyManagementResponse.ReturnCode().apply {
- this.authenticate = true
- this.value = bankReturnCode
- }
- if (dataTransfer != null) {
- this.dataTransfer = EbicsKeyManagementResponse.DataTransfer().apply {
- this.dataEncryptionInfo = EbicsTypes.DataEncryptionInfo().apply {
- this.authenticate = true
- this.transactionKey = dataTransfer.encryptedTransactionKey
- this.encryptionPubKeyDigest = EbicsTypes.PubKeyDigest().apply {
- this.algorithm = "http://www.w3.org/2001/04/xmlenc#sha256"
- this.version = "E002"
- this.value = dataTransfer.pubKeyDigest
- }
- }
- this.orderData = EbicsKeyManagementResponse.OrderData().apply {
- this.value = Base64.getEncoder().encodeToString(dataTransfer.encryptedData)
- }
- }
- }
- }
- }
- val text = XMLUtil.convertJaxbToString(responseXml)
- // logger.info("responding with:\n${text}")
- if (!XMLUtil.validateFromString(text)) throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Outgoint EBICS key management response is invalid"
- )
- respondText(text, ContentType.Application.Xml, HttpStatusCode.OK)
-}
-
-fun <T> expectNonNull(x: T?): T {
- if (x == null) {
- throw EbicsProtocolError(HttpStatusCode.BadRequest, "expected non-null value")
- }
- return x;
-}
-
-private fun getRelatedParty(branch: XmlElementBuilder, payment: XLibeufinBankTransaction) {
- val otherParty = object {
- var ibanPath = "CdtrAcct/Id/IBAN"
- var namePath = "Cdtr/Nm"
- var iban = payment.creditorIban
- var name = payment.creditorName
- var bicPath = "CdtrAgt/FinInstnId/BIC"
- var bic = payment.creditorBic
- }
- if (payment.direction == XLibeufinBankDirection.CREDIT) {
- otherParty.iban = payment.debtorIban
- otherParty.ibanPath = "DbtrAcct/Id/IBAN"
- otherParty.namePath = "Dbtr/Nm"
- otherParty.name = payment.debtorName
- otherParty.bic = payment.debtorBic
- otherParty.bicPath = "DbtrAgt/FinInstnId/BIC"
- }
- branch.element("RltdPties") {
- element(otherParty.namePath) {
- text(otherParty.name)
- }
- element(otherParty.ibanPath) {
- text(otherParty.iban)
- }
- }
- val otherPartyBic = otherParty.bic
- if (otherPartyBic != null) {
- branch.element("RltdAgts") {
- element(otherParty.bicPath) {
- text(otherPartyBic)
- }
- }
- }
-}
-
-// This should fix #6269.
-private fun getCreditDebitInd(balance: BigDecimal): String {
- if (balance < BigDecimal.ZERO) return "DBIT"
- return "CRDT"
-}
-
-fun buildCamtString(
- type: Int,
- subscriberIban: String,
- history: MutableList<XLibeufinBankTransaction>,
- currency: String
-): SandboxCamt {
- /**
- * ID types required:
- *
- * - Message Id
- * - Statement / Report Id
- * - Electronic sequence number
- * - Legal sequence number
- * - Entry Id by the Servicer
- * - Payment information Id
- * - Proprietary code of the bank transaction
- * - Id of the servicer (Issuer and Code)
- */
- val camtCreationTime = getSystemTimeNow() // FIXME: should this be the payment time?
- val dashedDate = camtCreationTime.toDashedDate()
- val zonedDateTime = camtCreationTime.toZonedString()
- val creationTimeMillis = camtCreationTime.toInstant().toEpochMilli()
- val messageId = "sandbox-${creationTimeMillis / 1000}-${getRandomString(10)}"
-
- val camtMessage = constructXml(indent = true) {
- root("Document") {
- attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02")
- attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
- attribute(
- "xsi:schemaLocation",
- "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02 camt.0${type}.001.02.xsd"
- )
- element(if (type == 53) "BkToCstmrStmt" else "BkToCstmrAcctRpt") {
- element("GrpHdr") {
- element("MsgId") {
- text(messageId)
- }
- element("CreDtTm") {
- text(zonedDateTime)
- }
- }
- element(if (type == 52) "Rpt" else "Stmt") {
- element("Id") {
- text("0")
- }
- element("ElctrncSeqNb") {
- text("0")
- }
- element("LglSeqNb") {
- text("0")
- }
- element("CreDtTm") {
- text(zonedDateTime)
- }
- element("Acct") {
- // mandatory account identifier
- element("Id/IBAN") {
- text(subscriberIban)
- }
- element("Ccy") {
- text(currency)
- }
- element("Ownr/Nm") {
- text("Debitor/Owner Name")
- }
- element("Svcr/FinInstnId") {
- element("Nm") {
- text("Libeufin Bank")
- }
- element("Othr") {
- element("Id") {
- text("0")
- }
- element("Issr") {
- text("XY")
- }
- }
- }
- }
- history.forEach {
- this.element("Ntry") {
- element("Amt") {
- attribute("Ccy", it.currency)
- text(it.amount)
- }
- element("CdtDbtInd") {
- text(
- if (subscriberIban.equals(it.creditorIban))
- "CRDT" else "DBIT"
- )
- }
- element("Sts") {
- /* Status of the entry (see 2.4.2.15.5 from the ISO20022 reference document.)
- * From the original text:
- * "Status of an entry on the books of the account servicer" */
- text("BOOK")
- }
- element("BookgDt/Dt") {
- text(dashedDate)
- } // date of the booking
- element("ValDt/Dt") {
- text(dashedDate)
- } // date of assets' actual (un)availability
- element("AcctSvcrRef") {
- text(it.uid)
- }
- element("BkTxCd") {
- /* "Set of elements used to fully identify the type of underlying
- * transaction resulting in an entry". */
- element("Domn") {
- element("Cd") {
- text("PMNT")
- }
- element("Fmly") {
- element("Cd") {
- text("ICDT")
- }
- element("SubFmlyCd") {
- text("ESCT")
- }
- }
- }
- element("Prtry") {
- element("Cd") {
- text("0")
- }
- element("Issr") {
- text("XY")
- }
- }
- }
- element("NtryDtls/TxDtls") {
- element("Refs") {
- element("MsgId") {
- text(it.msgId ?: "NOTPROVIDED")
- }
- element("PmtInfId") {
- text(it.pmtInfId ?: "NOTPROVIDED")
- }
- element("EndToEndId") {
- text(it.endToEndId ?: "NOTPROVIDED")
- }
- }
- element("AmtDtls/TxAmt/Amt") {
- attribute("Ccy", currency)
- text(it.amount)
- }
- element("BkTxCd") {
- element("Domn") {
- element("Cd") {
- text("PMNT")
- }
- element("Fmly") {
- element("Cd") {
- text("ICDT")
- }
- element("SubFmlyCd") {
- text("ESCT")
- }
- }
- }
- element("Prtry") {
- element("Cd") {
- text("0")
- }
- element("Issr") {
- text("XY")
- }
- }
- }
- getRelatedParty(this, it)
- element("RmtInf/Ustrd") {
- text(it.subject)
- }
- }
- }
- }
- }
- }
- }
- }
- return SandboxCamt(
- camtMessage = camtMessage,
- messageId = messageId,
- creationTime = creationTimeMillis
- )
-}
-
-/**
- * Builds CAMT response.
- *
- * @param type 52 or 53.
- */
-private fun constructCamtResponse(
- type: Int,
- subscriber: EbicsSubscriberEntity,
- dateRange: Pair<Long, Long>?
-): List<String> {
- if (type != 53 && type != 52) throw EbicsUnsupportedOrderType()
- val bankAccount = getBankAccountFromSubscriber(subscriber)
- val history = mutableListOf<XLibeufinBankTransaction>()
- if (type == 52) {
- if (dateRange != null) {
- logger.debug("Finding date-ranged transactions for account: ${bankAccount.label}, range: ${dateRange.first}, ${dateRange.second}")
- transaction {
- BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.account eq bankAccount.id and
- BankAccountTransactionsTable.date.between(
- dateRange.first, dateRange.second
- )
- }.forEach { history.add(getHistoryElementFromTransactionRow(it)) }
- }
- } else
- transaction {
- BankAccountFreshTransactionEntity.all().forEach {
- if (it.transactionRef.account.label == bankAccount.label) {
- history.add(getHistoryElementFromTransactionRow(it))
- }
- }
- }
- if (history.size == 0) throw EbicsNoDownloadDataAvailable()
- val camtData = buildCamtString(
- type,
- bankAccount.iban,
- history,
- bankAccount.demoBank.config.currency
- )
- val paymentsList: String = if (logger.isDebugEnabled) {
- var ret = " It includes the payments:"
- for (p in history) ret += "\n- ${p.subject}"
- ret
- } else ""
- logger.debug("camt.052 document '${camtData.messageId}' generated.$paymentsList")
- return listOf(camtData.camtMessage)
- } // end of C52 case.
- val ret = mutableListOf<String>()
- /**
- * Retrieve all the records whose creation date lies into the
- * time range given in the function parameters.
- */
- if (dateRange != null) {
- logger.debug("Serving C53 with date range: $dateRange")
- BankAccountStatementEntity.find {
- BankAccountStatementsTable.creationTime.between(
- dateRange.first,
- dateRange.second) and(
- BankAccountStatementsTable.bankAccount eq bankAccount.id)
- }.forEach {
- logger.debug("Including Camt.053: ${it.statementId}")
- ret.add(it.xmlMessage)
- }
- } else {
- logger.debug("Serving C53 without date range.")
- // No time range was given, hence pick the latest statement.
- BankAccountStatementEntity.find {
- BankAccountStatementsTable.bankAccount eq bankAccount.id
- }.lastOrNull().apply {
- if (this != null) {
- logger.debug("Including Camt.053: ${this.statementId}")
- ret.add(this.xmlMessage)
- }
- }
- }
- if (ret.size == 0) throw EbicsNoDownloadDataAvailable()
- return ret
-}
-
-/**
- * TSD (test download) message.
- *
- * This is a non-standard EBICS order type use by LibEuFin to
- * test download transactions.
- *
- * In the future, additional parameters (size, chunking, inject fault for retry) might
- * be added to the order parameters.
- */
-private fun handleEbicsTSD(): ByteArray {
- return "Hello World\n".repeat(1024).toByteArray()
-}
-
-private fun handleEbicsPTK(): ByteArray {
- return "Hello I am a dummy PTK response.".toByteArray()
-}
-
-private fun parsePain001(paymentRequest: String): PainParseResult {
- val painDoc = XMLUtil.parseStringIntoDom(paymentRequest)
- return destructXml(painDoc) {
- requireRootElement("Document") {
- requireUniqueChildNamed("CstmrCdtTrfInitn") {
- val msgId = requireUniqueChildNamed("GrpHdr") {
- requireUniqueChildNamed("MsgId") { focusElement.textContent }
- }
- requireUniqueChildNamed("PmtInf") {
- val debtorName = requireUniqueChildNamed("Dbtr"){
- requireUniqueChildNamed("Nm") {
- focusElement.textContent
- }
- }
- val debtorIban = requireUniqueChildNamed("DbtrAcct"){
- requireUniqueChildNamed("Id") {
- requireUniqueChildNamed("IBAN") {
- focusElement.textContent
- }
- }
- }
- val debtorBic = requireUniqueChildNamed("DbtrAgt"){
- requireUniqueChildNamed("FinInstnId") {
- requireUniqueChildNamed("BIC") {
- focusElement.textContent
- }
- }
- }
- val pmtInfId = requireUniqueChildNamed("PmtInfId") { focusElement.textContent }
- val txDetails = requireUniqueChildNamed("CdtTrfTxInf") {
- object {
- val creditorIban = requireUniqueChildNamed("CdtrAcct") {
- requireUniqueChildNamed("Id") {
- requireUniqueChildNamed("IBAN") { focusElement.textContent }
- }
- }
- val creditorName = requireUniqueChildNamed("Cdtr") {
- requireUniqueChildNamed("Nm") {
- focusElement.textContent
- }
- }
- val creditorBic = maybeUniqueChildNamed("CdtrAgt") {
- requireUniqueChildNamed("FinInstnId") {
- requireUniqueChildNamed("BIC") {
- focusElement.textContent
- }
- }
- }
- val amt = requireUniqueChildNamed("Amt") {
- requireOnlyChild { focusElement }
- }
- val subject = requireUniqueChildNamed("RmtInf") {
- requireUniqueChildNamed("Ustrd") { focusElement.textContent }
- }
- val endToEndId = requireUniqueChildNamed("PmtId") {
- requireUniqueChildNamed("EndToEndId") { focusElement.textContent }
- }
- }
- }
- /**
- * NOTE: this check breaks the compatibility with pain.001,
- * because that allows up to 5 fractional digits. For Taler
- * compatibility however, we enforce the max 2 fractional digits policy.
- */
- if (!validatePlainAmount(txDetails.amt.textContent)) {
- throw EbicsProcessingError(
- "Amount number malformed: ${txDetails.amt.textContent}"
- )
- }
- PainParseResult(
- currency = txDetails.amt.getAttribute("Ccy"),
- amount = txDetails.amt.textContent,
- subject = txDetails.subject,
- debtorIban = debtorIban,
- debtorName = debtorName,
- debtorBic = debtorBic,
- creditorName = txDetails.creditorName,
- creditorIban = txDetails.creditorIban,
- creditorBic = txDetails.creditorBic,
- pmtInfId = pmtInfId,
- endToEndId = txDetails.endToEndId,
- msgId = msgId
- )
- }
- }
- }
- }
-}
-
-/**
- * Process a payment request in the pain.001 format. Note:
- * the receiver IBAN is NOT checked to have one account at
- * the Sandbox. That's because (1) it leaves open to send
- * payments outside of the running Sandbox and (2) may ease
- * tests where the preparation logic can skip creating also
- * the receiver account. */
-private fun handleCct(
- paymentRequest: String,
- requestingSubscriber: EbicsSubscriberEntity
-) {
- val parseResult = parsePain001(paymentRequest)
- logger.debug("Handling Pain.001: ${parseResult.pmtInfId}, " +
- "for payment: ${parseResult.subject}")
- transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) {
- // Check that subscriber has a bank account
- // and that they have rights over the debtor IBAN
- if (requestingSubscriber.bankAccount == null) throw EbicsProcessingError(
- "Subscriber '${requestingSubscriber.userId}' does not have a bank account."
- )
- if (requestingSubscriber.bankAccount!!.iban != parseResult.debtorIban) throw
- EbicsAccountAuthorisationFailed(
- "Subscriber '${requestingSubscriber.userId}' does not have rights" +
- " over the debtor IBAN '${parseResult.debtorIban}'"
- )
- val maybeExist = BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.pmtInfId eq parseResult.pmtInfId
- }.firstOrNull()
- if (maybeExist != null) {
- logger.info(
- "Nexus submitted twice the Pain: ${maybeExist.pmtInfId}. Not taking any action." +
- " Sandbox gave it this reference: ${maybeExist.accountServicerReference}"
- )
- return@transaction
- }
- val bankAccount = getBankAccountFromIban(parseResult.debtorIban)
- if (parseResult.currency != bankAccount.demoBank.config.currency) throw EbicsRequestError(
- "[EBICS_PROCESSING_ERROR] Currency (${parseResult.currency}) not supported.",
- "091116"
- )
- // Check for the debit case.
- val maybeAmount = try {
- BigDecimal(parseResult.amount)
- } catch (e: Exception) {
- logger.warn("Although PAIN validated, BigDecimal didn't parse its amount (${parseResult.amount})!")
- throw EbicsProcessingError("The CCT request contains an invalid amount: ${parseResult.amount}")
- }
- if (maybeDebit(bankAccount.label, maybeAmount, bankAccount.demoBank.name))
- throw EbicsAmountCheckError("The requested amount (${parseResult.amount}) would exceed the debit threshold")
- logger.debug("Wire-transfer'ing endToEndId: ${parseResult.endToEndId}")
- wireTransfer(
- bankAccount.label,
- getBankAccountFromIban(parseResult.creditorIban).label,
- bankAccount.demoBank.name,
- parseResult.subject,
- "${parseResult.currency}:${parseResult.amount}",
- endToEndId = parseResult.endToEndId
- )
- }
-}
-
-/**
- * This handler reports all the fresh transactions, belonging
- * to the querying subscriber.
- */
-private fun handleEbicsC52(requestContext: RequestContext): ByteArray {
- val maybeDateRange = requestContext.requestObject.header.static.orderDetails?.orderParams
- val dateRange: Pair<Long, Long>? = if (maybeDateRange is EbicsRequest.StandardOrderParams) {
- val start: Long? = maybeDateRange.dateRange?.start?.toGregorianCalendar()?.timeInMillis
- val end: Long? = maybeDateRange.dateRange?.end?.toGregorianCalendar()?.timeInMillis
- Pair(start ?: 0L, end ?: Long.MAX_VALUE)
- } else null
- logger.debug("Date range: $dateRange")
- val report = constructCamtResponse(
- 52,
- requestContext.subscriber,
- dateRange = dateRange
- )
- sandboxAssert(
- report.size == 1,
- "C52 response contains more than one Camt.052 document"
- )
- if (!XMLUtil.validateFromString(report[0])) {
- logger.error("This document was generated invalid:\n${report[0]}")
- throw EbicsProcessingError("One outgoing report was found invalid.")
- }
- return report.map { it.toByteArray() }.zip()
-}
-
-private fun handleEbicsC53(requestContext: RequestContext): ByteArray {
- // Fetch date range.
- val orderParams = requestContext.requestObject.header.static.orderDetails?.orderParams // as EbicsRequest.StandardOrderParams
- val dateRange = if (orderParams != null) {
- val standardOrderParams = orderParams as EbicsRequest.StandardOrderParams
- val start = standardOrderParams.dateRange?.start?.toGregorianCalendar()?.timeInMillis
- val end = standardOrderParams.dateRange?.end?.toGregorianCalendar()?.timeInMillis
- if (start == null || end == null) {
- // only accepting when both start/end are given.
- null
- } else {
- Pair(start, end)
- }
- } else
- null
- /**
- * By multiple statements, this function is responsible to return
- * a list of Strings: one for each statement.
- */
- val camtStatements = constructCamtResponse(
- 53,
- requestContext.subscriber,
- dateRange
- )
- camtStatements.forEach {
- if (!XMLUtil.validateFromString(it)) {
- logger.error("This document was generated invalid:\n$it")
- throw EbicsProcessingError("One outgoing statement was found invalid.")
- }
- }
- return camtStatements.map { it.toByteArray() }.zip()
-}
-
-private suspend fun ApplicationCall.handleEbicsHia(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) {
- InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() }
- val keyObject = EbicsOrderUtil.decodeOrderDataXml<HIARequestOrderData>(orderData)
- val encPubXml = keyObject.encryptionPubKeyInfo.pubKeyValue.rsaKeyValue
- val authPubXml = keyObject.authenticationPubKeyInfo.pubKeyValue.rsaKeyValue
- val encPub = CryptoUtil.loadRsaPublicKeyFromComponents(encPubXml.modulus, encPubXml.exponent)
- val authPub = CryptoUtil.loadRsaPublicKeyFromComponents(authPubXml.modulus, authPubXml.exponent)
-
- val ok = transaction {
- val ebicsSubscriber = findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID)
- if (ebicsSubscriber == null) {
- logger.warn("ebics subscriber not found")
- throw EbicsInvalidRequestError()
- }
- when (ebicsSubscriber.state) {
- SubscriberState.NEW -> {}
- SubscriberState.PARTIALLY_INITIALIZED_INI -> {}
- SubscriberState.PARTIALLY_INITIALIZED_HIA, SubscriberState.INITIALIZED, SubscriberState.READY -> {
- return@transaction false
- }
- }
-
- ebicsSubscriber.authenticationKey = EbicsSubscriberPublicKeyEntity.new {
- this.rsaPublicKey = ExposedBlob(authPub.encoded)
- state = KeyState.NEW
- }
- ebicsSubscriber.encryptionKey = EbicsSubscriberPublicKeyEntity.new {
- this.rsaPublicKey = ExposedBlob(encPub.encoded)
- state = KeyState.NEW
- }
- ebicsSubscriber.state = when (ebicsSubscriber.state) {
- SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_HIA
- SubscriberState.PARTIALLY_INITIALIZED_INI -> SubscriberState.INITIALIZED
- else -> throw Exception("internal invariant failed")
- }
- return@transaction true
- }
- if (ok) {
- respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000")
- } else {
- respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000")
- }
-}
-
-private suspend fun ApplicationCall.handleEbicsIni(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) {
- InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() }
- val keyObject = EbicsOrderUtil.decodeOrderDataXml<SignatureTypes.SignaturePubKeyOrderData>(orderData)
- val sigPubXml = keyObject.signaturePubKeyInfo.pubKeyValue.rsaKeyValue
- val sigPub = CryptoUtil.loadRsaPublicKeyFromComponents(sigPubXml.modulus, sigPubXml.exponent)
-
- val ok = transaction {
- val ebicsSubscriber =
- findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID)
- if (ebicsSubscriber == null) {
- logger.warn("ebics subscriber, ${dumpEbicsSubscriber(header.static)}, not found")
- throw EbicsUserUnknown(dumpEbicsSubscriber(header.static))
- }
- when (ebicsSubscriber.state) {
- SubscriberState.NEW -> {}
- SubscriberState.PARTIALLY_INITIALIZED_HIA -> {}
- SubscriberState.PARTIALLY_INITIALIZED_INI, SubscriberState.INITIALIZED, SubscriberState.READY -> {
- return@transaction false
- }
- }
- ebicsSubscriber.signatureKey = EbicsSubscriberPublicKeyEntity.new {
- this.rsaPublicKey = ExposedBlob(sigPub.encoded)
- state = KeyState.NEW
- }
- ebicsSubscriber.state = when (ebicsSubscriber.state) {
- SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_INI
- SubscriberState.PARTIALLY_INITIALIZED_HIA -> SubscriberState.INITIALIZED
- else -> throw Error("internal invariant failed")
- }
- return@transaction true
- }
- logger.info("Signature key inserted in database _and_ subscriber state changed accordingly")
- if (ok) {
- respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000")
- } else {
- respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000")
- }
-}
-
-private suspend fun ApplicationCall.handleEbicsHpb(
- ebicsHostInfo: EbicsHostPublicInfo,
- requestDocument: Document,
- header: EbicsNpkdRequest.Header
-) {
- val subscriberKeys = transaction {
- val ebicsSubscriber =
- findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID)
- if (ebicsSubscriber == null) {
- throw EbicsInvalidRequestError()
- }
- if (ebicsSubscriber.state != SubscriberState.INITIALIZED) {
- throw EbicsSubscriberStateError()
- }
- val authPubBlob = ebicsSubscriber.authenticationKey!!.rsaPublicKey
- val encPubBlob = ebicsSubscriber.encryptionKey!!.rsaPublicKey
- val sigPubBlob = ebicsSubscriber.signatureKey!!.rsaPublicKey
- SubscriberKeys(
- CryptoUtil.loadRsaPublicKey(authPubBlob.bytes),
- CryptoUtil.loadRsaPublicKey(encPubBlob.bytes),
- CryptoUtil.loadRsaPublicKey(sigPubBlob.bytes)
- )
- }
- val validationResult =
- XMLUtil.verifyEbicsDocument(requestDocument, subscriberKeys.authenticationPublicKey)
- if (!validationResult) {
- throw EbicsKeyManagementError("invalid signature", "90000")
- }
- val hpbRespondeData = HPBResponseOrderData().apply {
- this.authenticationPubKeyInfo = EbicsTypes.AuthenticationPubKeyInfoType().apply {
- this.authenticationVersion = "X002"
- this.pubKeyValue = EbicsTypes.PubKeyValueType().apply {
- this.rsaKeyValue = RSAKeyValueType().apply {
- this.exponent = ebicsHostInfo.authenticationPublicKey.publicExponent.toByteArray()
- this.modulus = ebicsHostInfo.authenticationPublicKey.modulus.toByteArray()
- }
- }
- }
- this.encryptionPubKeyInfo = EbicsTypes.EncryptionPubKeyInfoType().apply {
- this.encryptionVersion = "E002"
- this.pubKeyValue = EbicsTypes.PubKeyValueType().apply {
- this.rsaKeyValue = RSAKeyValueType().apply {
- this.exponent = ebicsHostInfo.encryptionPublicKey.publicExponent.toByteArray()
- this.modulus = ebicsHostInfo.encryptionPublicKey.modulus.toByteArray()
- }
- }
- }
- this.hostID = ebicsHostInfo.hostID
- }
- val compressedOrderData = EbicsOrderUtil.encodeOrderDataXml(hpbRespondeData)
- val encryptionResult = CryptoUtil.encryptEbicsE002(compressedOrderData, subscriberKeys.encryptionPublicKey)
- respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000", encryptionResult, "OR01")
-}
-
-/**
- * Find the ebics host corresponding to the one specified in the header.
- */
-private fun ensureEbicsHost(requestHostID: String): EbicsHostPublicInfo {
- return transaction {
- val ebicsHost =
- EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestHostID.uppercase(Locale.getDefault()) }.firstOrNull()
- if (ebicsHost == null) {
- logger.warn("client requested unknown HostID ${requestHostID}")
- throw EbicsKeyManagementError("[EBICS_INVALID_HOST_ID]", "091011")
- }
- val encryptionPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.encryptionPrivateKey.bytes)
- val authenticationPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.authenticationPrivateKey.bytes)
- EbicsHostPublicInfo(
- requestHostID,
- CryptoUtil.getRsaPublicFromPrivate(encryptionPrivateKey),
- CryptoUtil.getRsaPublicFromPrivate(authenticationPrivateKey)
- )
- }
-}
-fun receiveEbicsXmlInternal(xmlData: String): Document {
- // logger.debug("Data received: $xmlData")
- val requestDocument: Document = XMLUtil.parseStringIntoDom(xmlData)
- if (!XMLUtil.validateFromDom(requestDocument)) {
- println("Problematic document was: $requestDocument")
- throw EbicsInvalidXmlError()
- }
- return requestDocument
-}
-
-private fun makePartnerInfo(subscriber: EbicsSubscriberEntity): EbicsTypes.PartnerInfo {
- val bankAccount = getBankAccountFromSubscriber(subscriber)
- val customerProfile = getCustomer(bankAccount.label)
- return EbicsTypes.PartnerInfo().apply {
- this.accountInfoList = listOf(
- EbicsTypes.AccountInfo().apply {
- this.id = bankAccount.label
- this.accountHolder = customerProfile.name ?: "Never Given"
- this.accountNumberList = listOf(
- EbicsTypes.GeneralAccountNumber().apply {
- this.international = true
- this.value = bankAccount.iban
- }
- )
- this.currency = bankAccount.demoBank.config.currency
- this.description = "Ordinary Bank Account"
- this.bankCodeList = listOf(
- EbicsTypes.GeneralBankCode().apply {
- this.international = true
- this.value = bankAccount.bic
- }
- )
- }
- )
- this.addressInfo = EbicsTypes.AddressInfo().apply {
- this.name = "Address Info Object"
- }
- this.bankInfo = EbicsTypes.BankInfo().apply {
- this.hostID = subscriber.hostId
- }
- this.orderInfoList = listOf(
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Transactions statement"
- this.orderType = "C53"
- this.transferType = "Download"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Transactions report"
- this.orderType = "C52"
- this.transferType = "Download"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Payment initiation (ZIPped payload)"
- this.orderType = "CCC"
- this.transferType = "Upload"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "Payment initiation (plain text payload)"
- this.orderType = "CCT"
- this.transferType = "Upload"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "vmk"
- this.orderType = "VMK"
- this.transferType = "Download"
- },
- EbicsTypes.AuthOrderInfoType().apply {
- this.description = "sta"
- this.orderType = "STA"
- this.transferType = "Download"
- }
- )
- }
-}
-
-private fun handleEbicsHtd(requestContext: RequestContext): ByteArray {
- val htd = HTDResponseOrderData().apply {
- this.partnerInfo = makePartnerInfo(requestContext.subscriber)
- this.userInfo = EbicsTypes.UserInfo().apply {
- this.name = "Some User"
- this.userID = EbicsTypes.UserIDType().apply {
- this.status = 5
- this.value = requestContext.subscriber.userId
- }
- this.permissionList = listOf(
- EbicsTypes.UserPermission().apply {
- this.orderTypes = "C53 C52 CCC VMK STA"
- }
- )
- }
- }
- val str = XMLUtil.convertJaxbToString(htd)
- return str.toByteArray()
-}
-
-private fun handleEbicsHkd(requestContext: RequestContext): ByteArray {
- val hkd = HKDResponseOrderData().apply {
- this.partnerInfo = makePartnerInfo(requestContext.subscriber)
- this.userInfoList = listOf(
- EbicsTypes.UserInfo().apply {
- this.name = "Some User"
- this.userID = EbicsTypes.UserIDType().apply {
- this.status = 1
- this.value = requestContext.subscriber.userId
- }
- this.permissionList = listOf(
- EbicsTypes.UserPermission().apply {
- this.orderTypes = "C54 C53 C52 CCC"
- }
- )
- })
- }
- val str = XMLUtil.convertJaxbToString(hkd)
- return str.toByteArray()
-}
-
-private data class RequestContext(
- val ebicsHost: EbicsHostEntity,
- val subscriber: EbicsSubscriberEntity,
- val clientEncPub: RSAPublicKey,
- val clientAuthPub: RSAPublicKey,
- val clientSigPub: RSAPublicKey,
- val hostEncPriv: RSAPrivateCrtKey,
- val hostAuthPriv: RSAPrivateCrtKey,
- val requestObject: EbicsRequest,
- val uploadTransaction: EbicsUploadTransactionEntity?,
- val downloadTransaction: EbicsDownloadTransactionEntity?
-)
-
-/**
- * Get segmentation values and the EBICS transaction ID, before
- * handing the response to 'createForDownloadTransferPhase()'.
- */
-private fun handleEbicsDownloadTransactionTransfer(requestContext: RequestContext): EbicsResponse {
- val segmentNumber =
- requestContext.requestObject.header.mutable.segmentNumber?.value ?: throw EbicsInvalidRequestError()
- val transactionID = requestContext.requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError()
- val downloadTransaction = requestContext.downloadTransaction ?: throw AssertionError()
- return EbicsResponse.createForDownloadTransferPhase(
- transactionID,
- downloadTransaction.numSegments,
- downloadTransaction.segmentSize,
- downloadTransaction.encodedResponse,
- segmentNumber.toInt()
- )
-}
-
-private fun handleEbicsDownloadTransactionInitialization(requestContext: RequestContext): EbicsResponse {
- val orderType =
- requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError()
- val nonce = requestContext.requestObject.header.static.nonce
- val transactionID = EbicsOrderUtil.generateTransactionId()
- logger.debug(
- "Handling download initialization for order type $orderType, " +
- "nonce: ${nonce?.toHexString() ?: "not given"}, " +
- "transaction ID: $transactionID"
- )
- val response = when (orderType) {
- "HTD" -> handleEbicsHtd(requestContext)
- "HKD" -> handleEbicsHkd(requestContext)
- "C53" -> handleEbicsC53(requestContext)
- "C52" -> handleEbicsC52(requestContext)
- "TSD" -> handleEbicsTSD()
- "PTK" -> handleEbicsPTK()
- else -> throw EbicsInvalidXmlError()
- }
- val compressedResponse = DeflaterInputStream(response.inputStream()).use {
- it.readAllBytes()
- }
- val enc = CryptoUtil.encryptEbicsE002(compressedResponse, requestContext.clientEncPub)
- val encodedResponse = Base64.getEncoder().encodeToString(enc.encryptedData)
-
- val segmentSize = 4096
- val totalSize = encodedResponse.length
- val numSegments = ((totalSize + segmentSize - 1) / segmentSize)
-
- EbicsDownloadTransactionEntity.new(transactionID) {
- this.subscriber = requestContext.subscriber
- this.host = requestContext.ebicsHost
- this.orderType = orderType
- this.segmentSize = segmentSize
- this.transactionKeyEnc = ExposedBlob(enc.encryptedTransactionKey)
- this.encodedResponse = encodedResponse
- this.numSegments = numSegments
- this.receiptReceived = false
- }
- /**
- * In case of C52, the payload (that includes all the pending
- * transactions) got at this point persisted into the database.
- * The next block causes such transactions NOT to be returned
- * along the next C52 request.
- */
- if (orderType == "C52") {
- val account = getBankAccountFromSubscriber(requestContext.subscriber)
- BankAccountFreshTransactionEntity.all().forEach {
- if (it.transactionRef.account.label == account.label)
- it.delete()
- }
- }
- return EbicsResponse.createForDownloadInitializationPhase(
- transactionID,
- numSegments,
- segmentSize,
- enc, // has customer key
- encodedResponse
- )
-}
-
-private fun handleEbicsUploadTransactionInitialization(requestContext: RequestContext): EbicsResponse {
- val orderType =
- requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError()
- val transactionID = EbicsOrderUtil.generateTransactionId()
- logger.debug("Handling upload initialization for order $orderType, " +
- "transactionID $transactionID, nonce: " +
- (requestContext.requestObject.header.static.nonce?.toHexString() ?: "not given")
- )
- val oidn = requestContext.subscriber.nextOrderID++
- if (EbicsOrderUtil.checkOrderIDOverflow(oidn)) throw NotImplementedError()
- val orderID = EbicsOrderUtil.computeOrderIDFromNumber(oidn)
- val numSegments =
- requestContext.requestObject.header.static.numSegments ?: throw EbicsInvalidRequestError()
- val transactionKeyEnc =
- requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.transactionKey
- ?: throw EbicsInvalidRequestError()
- val encPubKeyDigest =
- requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.encryptionPubKeyDigest?.value
- ?: throw EbicsInvalidRequestError()
- val encSigData = requestContext.requestObject.body.dataTransfer?.signatureData?.value
- ?: throw EbicsInvalidRequestError()
- val decryptedSignatureData = CryptoUtil.decryptEbicsE002(
- CryptoUtil.EncryptionResult(
- transactionKeyEnc,
- encPubKeyDigest,
- encSigData
- ), requestContext.hostEncPriv
- )
- val plainSigData = InflaterInputStream(decryptedSignatureData.inputStream()).use {
- it.readAllBytes()
- }
- EbicsUploadTransactionEntity.new(transactionID) {
- this.host = requestContext.ebicsHost
- this.subscriber = requestContext.subscriber
- this.lastSeenSegment = 0
- this.orderType = orderType
- this.orderID = orderID
- this.numSegments = numSegments.toInt()
- this.transactionKeyEnc = ExposedBlob(transactionKeyEnc)
- }
- val sigObj = XMLUtil.convertStringToJaxb<UserSignatureData>(plainSigData.toString(Charsets.UTF_8))
- for (sig in sigObj.value.orderSignatureList ?: listOf()) {
- logger.debug("inserting order signature for orderID $orderID, order type $orderType, transaction '$transactionID'")
- EbicsOrderSignatureEntity.new {
- this.orderID = orderID
- this.orderType = orderType
- this.partnerID = sig.partnerID
- this.userID = sig.userID
- this.signatureAlgorithm = sig.signatureVersion
- this.signatureValue = ExposedBlob(sig.signatureValue)
- }
- }
- return EbicsResponse.createForUploadInitializationPhase(transactionID, orderID)
-}
-
-private fun handleEbicsUploadTransactionTransmission(requestContext: RequestContext): EbicsResponse {
- val uploadTransaction = requestContext.uploadTransaction ?: throw EbicsInvalidRequestError()
- val requestObject = requestContext.requestObject
- val requestSegmentNumber =
- requestContext.requestObject.header.mutable.segmentNumber?.value?.toInt() ?: throw EbicsInvalidRequestError()
- val requestTransactionID = requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError()
- if (requestSegmentNumber == 1 && uploadTransaction.numSegments == 1) {
- val encOrderData =
- requestObject.body.dataTransfer?.orderData ?: throw EbicsInvalidRequestError()
- val zippedData = CryptoUtil.decryptEbicsE002(
- uploadTransaction.transactionKeyEnc.bytes,
- Base64.getDecoder().decode(encOrderData),
- requestContext.hostEncPriv
- )
- val unzippedData =
- InflaterInputStream(zippedData.inputStream()).use { it.readAllBytes() }
-
- val sigs = EbicsOrderSignatureEntity.find {
- (EbicsOrderSignaturesTable.orderID eq uploadTransaction.orderID) and
- (EbicsOrderSignaturesTable.orderType eq uploadTransaction.orderType)
- }
- if (sigs.count() == 0L) {
- throw EbicsInvalidRequestError()
- }
- for (sig in sigs) {
- if (sig.signatureAlgorithm == "A006") {
-
- val signedData = CryptoUtil.digestEbicsOrderA006(unzippedData)
- val res1 = CryptoUtil.verifyEbicsA006(
- sig.signatureValue.bytes,
- signedData,
- requestContext.clientSigPub
- )
- if (!res1) {
- throw EbicsInvalidRequestError()
- }
-
- } else {
- throw NotImplementedError()
- }
- }
- if (getOrderTypeFromTransactionId(requestTransactionID) == "CCT") {
- handleCct(unzippedData.toString(Charsets.UTF_8),
- requestContext.subscriber
- )
- }
- return EbicsResponse.createForUploadTransferPhase(
- requestTransactionID,
- requestSegmentNumber,
- true,
- uploadTransaction.orderID
- )
- } else {
- throw NotImplementedError()
- }
-}
-// req.header.static.hostID.
-private fun makeRequestContext(requestObject: EbicsRequest): RequestContext {
- val staticHeader = requestObject.header.static
- val requestedHostId = staticHeader.hostID
- val ebicsHost =
- EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestedHostId.uppercase(Locale.getDefault()) }
- .firstOrNull()
- val requestTransactionID = requestObject.header.static.transactionID
- var downloadTransaction: EbicsDownloadTransactionEntity? = null
- var uploadTransaction: EbicsUploadTransactionEntity? = null
- val subscriber = if (requestTransactionID != null) {
- downloadTransaction = EbicsDownloadTransactionEntity.findById(requestTransactionID.uppercase(Locale.getDefault()))
- if (downloadTransaction != null) {
- downloadTransaction.subscriber
- } else {
- uploadTransaction = EbicsUploadTransactionEntity.findById(requestTransactionID)
- uploadTransaction?.subscriber
- }
- } else {
- val partnerID = staticHeader.partnerID ?: throw EbicsInvalidRequestError()
- val userID = staticHeader.userID ?: throw EbicsInvalidRequestError()
- findEbicsSubscriber(partnerID, userID, staticHeader.systemID)
- }
-
- if (ebicsHost == null) throw EbicsInvalidRequestError()
-
- /**
- * NOTE: production logic must check against READY state (the
- * one activated after the subscriber confirms their keys via post)
- */
- if (subscriber == null || subscriber.state != SubscriberState.INITIALIZED)
- throw EbicsSubscriberStateError()
-
- val hostAuthPriv = CryptoUtil.loadRsaPrivateKey(
- ebicsHost.authenticationPrivateKey.bytes
- )
- val hostEncPriv = CryptoUtil.loadRsaPrivateKey(
- ebicsHost.encryptionPrivateKey.bytes
- )
- val clientAuthPub =
- CryptoUtil.loadRsaPublicKey(subscriber.authenticationKey!!.rsaPublicKey.bytes)
- val clientEncPub =
- CryptoUtil.loadRsaPublicKey(subscriber.encryptionKey!!.rsaPublicKey.bytes)
- val clientSigPub =
- CryptoUtil.loadRsaPublicKey(subscriber.signatureKey!!.rsaPublicKey.bytes)
-
- return RequestContext(
- hostAuthPriv = hostAuthPriv,
- hostEncPriv = hostEncPriv,
- clientAuthPub = clientAuthPub,
- clientEncPub = clientEncPub,
- clientSigPub = clientSigPub,
- ebicsHost = ebicsHost,
- requestObject = requestObject,
- subscriber = subscriber,
- downloadTransaction = downloadTransaction,
- uploadTransaction = uploadTransaction
- )
-}
-
-suspend fun ApplicationCall.ebicsweb() {
- val requestDocument = this.request.call.receive<Document>()
- val requestedHostID = requestDocument.getElementsByTagName("HostID")
- this.attributes.put(
- EbicsHostIdAttribute,
- requestedHostID.item(0).textContent
- )
- when (requestDocument.documentElement.localName) {
- "ebicsUnsecuredRequest" -> {
- val requestObject = requestDocument.toObject<EbicsUnsecuredRequest>()
- logger.info("Serving a ${requestObject.header.static.orderDetails.orderType} request")
-
- val orderData = requestObject.body.dataTransfer.orderData.value
- val header = requestObject.header
-
- when (header.static.orderDetails.orderType) {
- "INI" -> handleEbicsIni(header, orderData)
- "HIA" -> handleEbicsHia(header, orderData)
- else -> throw EbicsInvalidXmlError()
- }
- }
- "ebicsHEVRequest" -> {
- val hevResponse = HEVResponse().apply {
- this.systemReturnCode = SystemReturnCodeType().apply {
- this.reportText = "[EBICS_OK]"
- this.returnCode = "000000"
- }
- this.versionNumber = listOf(HEVResponse.VersionNumber.create("H004", "02.50"))
- }
-
- val strResp = XMLUtil.convertJaxbToString(hevResponse)
- if (!XMLUtil.validateFromString(strResp)) throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Outgoing HEV response is invalid"
- )
- respondText(strResp, ContentType.Application.Xml, HttpStatusCode.OK)
- }
- // FIXME: should check subscriber state?
- "ebicsNoPubKeyDigestsRequest" -> {
- val requestObject = requestDocument.toObject<EbicsNpkdRequest>()
- val hostInfo = ensureEbicsHost(requestObject.header.static.hostID)
- when (requestObject.header.static.orderDetails.orderType) {
- "HPB" -> handleEbicsHpb(hostInfo, requestDocument, requestObject.header)
- else -> throw EbicsInvalidXmlError()
- }
- }
- // FIXME: must check subscriber state.
- "ebicsRequest" -> {
- val requestObject = requestDocument.toObject<EbicsRequest>()
- val responseXmlStr = transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) {
- // Step 1 of 3: Get information about the host and subscriber
- val requestContext = makeRequestContext(requestObject)
- // Step 2 of 3: Validate the signature
- val verifyResult = XMLUtil.verifyEbicsDocument(requestDocument, requestContext.clientAuthPub)
- if (!verifyResult) {
- throw EbicsAccountAuthorisationFailed("Subscriber's signature did not verify")
- }
- // Step 3 of 3: Generate response
- val ebicsResponse: EbicsResponse = when (requestObject.header.mutable.transactionPhase) {
- EbicsTypes.TransactionPhaseType.INITIALISATION -> {
- if (requestObject.header.static.numSegments == null) {
- handleEbicsDownloadTransactionInitialization(requestContext)
- } else {
- handleEbicsUploadTransactionInitialization(requestContext)
- }
- }
- EbicsTypes.TransactionPhaseType.TRANSFER -> {
- if (requestContext.uploadTransaction != null) {
- handleEbicsUploadTransactionTransmission(requestContext)
- } else if (requestContext.downloadTransaction != null) {
- handleEbicsDownloadTransactionTransfer(requestContext)
- } else {
- throw AssertionError()
- }
- }
- EbicsTypes.TransactionPhaseType.RECEIPT -> {
- val requestTransactionID =
- requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError()
- if (requestContext.downloadTransaction == null)
- throw EbicsInvalidRequestError()
- logger.debug("Handling download receipt for EBICS transaction: " +
- requestTransactionID)
- /**
- * The receipt phase means that the client has already
- * received all the data related to the current download
- * transaction. Hence this data can now be removed from
- * the database.
- */
- val ebicsData = transaction {
- EbicsDownloadTransactionEntity.findById(requestTransactionID)
- }
- if (ebicsData == null)
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "EBICS transaction $requestTransactionID was not" +
- "found in the database for deletion.",
- LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE
- )
- ebicsData.delete()
- val receiptCode =
- requestObject.body.transferReceipt?.receiptCode ?: throw EbicsInvalidRequestError()
- EbicsResponse.createForDownloadReceiptPhase(requestTransactionID, receiptCode == 0)
- }
- }
- signEbicsResponse(ebicsResponse, requestContext.hostAuthPriv)
- }
- if (!XMLUtil.validateFromString(responseXmlStr)) throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Outgoing EBICS XML is invalid"
- )
- respondText(responseXmlStr, ContentType.Application.Xml, HttpStatusCode.OK)
- }
- else -> {
- /* Log to console and return "unknown type" */
- logger.info("Unknown message, just logging it!")
- respond(
- HttpStatusCode.NotImplemented,
- SandboxError(
- HttpStatusCode.NotImplemented,
- "Not Implemented"
- )
- )
- }
- }
-}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
deleted file mode 100644
index 6529b9d1..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
+++ /dev/null
@@ -1,472 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2020 Taler Systems S.A.
- *
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
- *
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-package tech.libeufin.sandbox
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.SerializationFeature
-import io.ktor.server.application.*
-import io.ktor.http.HttpStatusCode
-import io.ktor.server.request.*
-import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import java.security.interfaces.RSAPublicKey
-import java.util.*
-import java.util.zip.DeflaterInputStream
-import kotlin.reflect.KProperty
-
-data class DemobankConfig(
- val allowRegistrations: Boolean,
- val currency: String,
- val cashoutCurrency: String? = null,
- val bankDebtLimit: Int,
- val usersDebtLimit: Int,
- val withSignupBonus: Boolean,
- val demobankName: String, // demobank name.
- val captchaUrl: String? = null,
- val smsTan: String? = null, // fixme: move the config subcommand
- val emailTan: String? = null, // fixme: same as above.
- val suggestedExchangeBaseUrl: String? = null,
- val suggestedExchangePayto: String? = null,
- val nexusBaseUrl: String? = null,
- val usernameAtNexus: String? = null,
- val passwordAtNexus: String? = null,
- val enableConversionService: Boolean = false
-)
-
-fun <T>getConfigValueOrThrow(configKey: KProperty<T?>): T {
- return configKey.getter.call() ?: throw nullConfigValueError(configKey.name)
-}
-
-/**
- * Helps to communicate Camt values without having
- * to parse the XML each time one is needed.
- */
-data class SandboxCamt(
- val camtMessage: String,
- val messageId: String,
- /**
- * That is the number of SECONDS since Epoch. This
- * value is exactly what goes into the Camt document.
- */
- val creationTime: Long
-)
-
-/**
- * DB helper inserting a new "account" into the database.
- * The account is made of a 'customer' and 'bank account'
- * object. The helper checks first that the username is
- * acceptable (chars, no institutional names, available
- * names); then checks that IBAN is available and then adds
- * the two database objects under the given demobank. This
- * function contains the common logic shared by the Access
- * and Circuit API. Additional data that is peculiar to one
- * API should be added separately.
- *
- * It returns a AccountPair type. That contains the customer
- * object and the bank account; the caller may this way add custom
- * values to them. */
-data class AccountPair(
- val customer: DemobankCustomerEntity,
- val bankAccount: BankAccountEntity
-)
-fun insertNewAccount(username: String,
- password: String,
- name: String? = null, // tests and access API may not give one.
- iban: String? = null,
- demobank: String = "default",
- isPublic: Boolean = false): AccountPair {
- requireValidResourceName(username)
- // Forbid institutional usernames.
- if (username == "bank" || username == "admin") {
- logger.info("Username: $username not allowed.")
- throw forbidden("Username: $username is not allowed.")
- }
- return transaction {
- val demobankFromDb = getDemobank(demobank)
- // Bank's fault, because when this function gets
- // called, the demobank must exist.
- if (demobankFromDb == null) {
- logger.error("Demobank '$demobank' not found. Won't add account $username")
- throw internalServerError("Demobank $demobank not found. Won't add account $username")
- }
- // Generate a IBAN if the caller didn't provide one.
- val newIban = iban ?: getIban()
- // Check IBAN collisions.
- val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq newIban).firstOrNull()
- if (checkIbanExist != null) {
- logger.info("IBAN $newIban not available. Won't register username $username")
- throw conflict("IBAN $iban not available.")
- }
- // Check username availability.
- val checkCustomerExist = DemobankCustomerEntity.find {
- DemobankCustomersTable.username eq username
- }.firstOrNull()
- if (checkCustomerExist != null) {
- throw SandboxError(
- HttpStatusCode.Conflict,
- "Username $username not available."
- )
- }
- val newCustomer = DemobankCustomerEntity.new {
- this.username = username
- passwordHash = CryptoUtil.hashpw(password)
- this.name = name // nullable
- }
- // Actual account creation.
- val newBankAccount = BankAccountEntity.new {
- this.iban = newIban
- /**
- * For now, keep same semantics of Pybank: a username
- * is AS WELL a bank account label. In other words, it
- * identifies a customer AND a bank account. The reason
- * to have the two values (label and owner) is to allow
- * multiple bank accounts being owned by one customer.
- */
- label = username
- owner = username
- this.demoBank = demobankFromDb
- this.isPublic = isPublic
- }
- if (demobankFromDb.config.withSignupBonus)
- newBankAccount.bonus("${demobankFromDb.config.currency}:100")
- AccountPair(customer = newCustomer, bankAccount = newBankAccount)
- }
-}
-
-/**
- * Return true if access to the bank account can be granted,
- * false otherwise.
- *
- * Given the policy of having bank account names matching
- * their owner's username, this function enforces such policy
- * with the exception that 'admin' can access every bank
- * account. A null username indicates disabled authentication
- * checks, hence it grants the access.
- */
-fun allowOwnerOrAdmin(username: String?, bankAccountLabel: String): Boolean {
- if (username == null) return true
- if (username == "admin") return true
- return username == bankAccountLabel
-}
-
-/**
- * Throws exception if the credentials are wrong.
- *
- * Return:
- * - null if the authentication is disabled (during tests, for example).
- * This facilitates tests because allows requests to lack entirely an
- * Authorization header.
- * - the username of the authenticated user
- * - throw exception when the authentication fails
- *
- * Note: at this point it is ONLY checked whether the user provided
- * a valid password for the username mentioned in the Authorization header.
- * The actual access to the resources must be later checked by each handler.
- */
-fun ApplicationRequest.basicAuth(onlyAdmin: Boolean = false): String? {
- val withAuth = this.call.ensureAttribute(WITH_AUTH_ATTRIBUTE_KEY)
- if (!withAuth) {
- logger.info("Authentication is disabled - assuming tests currently running.")
- return null
- }
- val credentials = getHTTPBasicAuthCredentials(this)
- if (credentials.first == "admin") {
- // env must contain the admin password, because --with-auth is true.
- val adminPassword: String = this.call.ensureAttribute(ADMIN_PASSWORD_ATTRIBUTE_KEY)
- if (credentials.second != adminPassword) throw unauthorized(
- "Admin authentication failed"
- )
- return credentials.first
- }
- if (onlyAdmin) throw forbidden("Only admin allowed.")
- val passwordHash = transaction {
- val customer = getCustomer(credentials.first)
- customer.passwordHash
- }
- if (!CryptoUtil.checkPwOrThrow(credentials.second, passwordHash))
- throw unauthorized("Customer '${credentials.first}' gave wrong credentials")
- return credentials.first
-}
-
-fun sandboxAssert(condition: Boolean, reason: String) {
- if (!condition) throw SandboxError(HttpStatusCode.InternalServerError, reason)
-}
-
-fun getOrderTypeFromTransactionId(transactionID: String): String {
- val uploadTransaction = transaction {
- EbicsUploadTransactionEntity.findById(transactionID)
- } ?: throw SandboxError(
- /**
- * NOTE: at this point, it might even be the server's fault.
- * For example, if it failed to store a ID earlier.
- */
- HttpStatusCode.NotFound,
- "Could not retrieve order type for transaction: $transactionID"
- )
- return uploadTransaction.orderType
-}
-
-fun getHistoryElementFromTransactionRow(dbRow: BankAccountTransactionEntity): XLibeufinBankTransaction {
- return XLibeufinBankTransaction(
- subject = dbRow.subject,
- creditorIban = dbRow.creditorIban,
- creditorBic = dbRow.creditorBic,
- creditorName = dbRow.creditorName,
- debtorIban = dbRow.debtorIban,
- debtorBic = dbRow.debtorBic,
- debtorName = dbRow.debtorName,
- date = dbRow.date.toString(),
- amount = dbRow.amount,
- currency = dbRow.currency,
- // UID assigned by the bank itself.
- uid = dbRow.accountServicerReference,
- direction = XLibeufinBankDirection.convertCamtDirectionToXLibeufin(dbRow.direction),
- // UIDs as gotten from a pain.001 (from EBICS connections.)
- pmtInfId = dbRow.pmtInfId,
- endToEndId = dbRow.endToEndId
- )
-}
-
-fun printConfig(demobank: DemobankConfigEntity) {
- val ret = ObjectMapper()
- ret.configure(SerializationFeature.INDENT_OUTPUT, true)
- println(
- ret.writeValueAsString(object {
- val currency = demobank.config.currency
- val bankDebtLimit = demobank.config.bankDebtLimit
- val usersDebtLimit = demobank.config.usersDebtLimit
- val allowRegistrations = demobank.config.allowRegistrations
- val name = demobank.name // always 'default'
- val withSignupBonus = demobank.config.withSignupBonus
- val captchaUrl = demobank.config.captchaUrl
- val suggestedExchangeBaseUrl = demobank.config.suggestedExchangeBaseUrl
- val suggestedExchangePayto = demobank.config.suggestedExchangePayto
- })
- )
-}
-
-fun getHistoryElementFromTransactionRow(
- dbRow: BankAccountFreshTransactionEntity
-): XLibeufinBankTransaction {
- return getHistoryElementFromTransactionRow(dbRow.transactionRef)
-}
-
-/**
- * Need to be called within a transaction {} block. It
- * is acceptable to pass a bank account's label as the
- * parameter, because usernames can only own one bank
- * account whose label equals the owner's username.
- *
- * Future versions may relax this policy to allow one
- * customer to own multiple bank accounts.
- */
-fun getCustomer(username: String): DemobankCustomerEntity {
- return maybeGetCustomer(username) ?: throw notFound("Customer '${username}' not found")
-}
-fun maybeGetCustomer(username: String): DemobankCustomerEntity? {
- return transaction {
- DemobankCustomerEntity.find {
- DemobankCustomersTable.username eq username
- }.firstOrNull()
- }
-}
-
-/**
- * Get person name from a customer's username, or throw
- * exception if not found.
- */
-fun getPersonNameFromCustomer(customerUsername: String): String {
- return when (customerUsername) {
- "admin" -> "Admin"
- else -> transaction {
- val ownerCustomer = DemobankCustomerEntity.find(
- DemobankCustomersTable.username eq customerUsername
- ).firstOrNull() ?: run {
- logger.error("Customer '${customerUsername}' not found, couldn't get their name.")
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "'$customerUsername' not a customer."
- )
-
- }
- ownerCustomer.name ?: "Never given."
- }
- }
-}
-
-fun getDefaultDemobank(): DemobankConfigEntity {
- return transaction {
- DemobankConfigEntity.find {
- DemobankConfigsTable.name eq "default"
- }.firstOrNull()
- } ?: throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Default demobank is missing."
- )
-}
-
-fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity {
- val uuid = parseUuid(opId)
- return transaction {
- TalerWithdrawalEntity.find {
- TalerWithdrawalsTable.wopid eq uuid
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.NotFound, "Withdrawal operation $opId not found."
- )
- }
-}
-
-fun getBankAccountFromPayto(paytoUri: String): BankAccountEntity {
- val paytoParse = parsePayto(paytoUri)
- return getBankAccountFromIban(paytoParse.iban)
-}
-
-fun getBankAccountFromIban(iban: String): BankAccountEntity {
- return transaction {
- BankAccountEntity.find(BankAccountsTable.iban eq iban).firstOrNull()
- } ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Did not find a bank account for $iban"
- )
-}
-
-/**
- * The argument 'withBankFault' represents the case where
- * _the bank_ must ensure that a resource (in this case a bank
- * account) exists. For example, every 'customer' should have
- * a 'bank account', and if a customer is found without a bank
- * account, then the bank broke such condition.
- */
-fun getBankAccountFromLabel(
- label: String,
- demobank: String = "default",
- withBankFault: Boolean = false
-): BankAccountEntity {
- val maybeDemobank = getDemobank(demobank)
- if (maybeDemobank == null) {
- logger.error("Demobank '$demobank' not found")
- throw SandboxError(
- HttpStatusCode.NotFound,
- "Demobank '$demobank' not found"
- )
- }
- return getBankAccountFromLabel(
- label,
- maybeDemobank,
- withBankFault
- )
-}
-
-// Get bank account DAO, given its name and demobank.
-fun getBankAccountFromLabel(
- label: String,
- demobank: DemobankConfigEntity,
- withBankFault: Boolean = false // documented along the other same-named function.
-): BankAccountEntity {
- val maybeBankAccount = transaction {
- BankAccountEntity.find(
- BankAccountsTable.label eq label and (
- BankAccountsTable.demoBank eq demobank.id
- )
- ).firstOrNull()
- }
- if (maybeBankAccount == null && withBankFault)
- throw internalServerError(
- "Bank account $label was not found, but it should."
- )
- if (maybeBankAccount == null)
- throw notFound(
- "Bank account $label was not found."
- )
- return maybeBankAccount
-}
-
-fun getBankAccountFromSubscriber(subscriber: EbicsSubscriberEntity): BankAccountEntity {
- return transaction {
- subscriber.bankAccount ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Subscriber doesn't have any bank account"
- )
- }
-}
-
-fun BankAccountEntity.bonus(amount: String) {
- wireTransfer(
- "admin",
- this.label,
- this.demoBank.name,
- "Sign-up bonus",
- amount
- )
-}
-
-fun ensureDemobank(call: ApplicationCall): DemobankConfigEntity {
- return ensureDemobank(call.expectUriComponent("demobankid"))
-}
-
-fun ensureDemobank(name: String): DemobankConfigEntity {
- return transaction {
- DemobankConfigEntity.find {
- DemobankConfigsTable.name eq name
- }.firstOrNull() ?: throw notFound("Demobank '$name' not found. Was it ever created?")
- }
-}
-
-fun getDemobank(name: String?): DemobankConfigEntity? {
- return transaction {
- if (name == null) {
- DemobankConfigEntity.all().firstOrNull()
- } else {
- DemobankConfigEntity.find {
- DemobankConfigsTable.name eq name
- }.firstOrNull()
- }
- }
-}
-
-fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID: String): EbicsSubscriberEntity {
- return transaction {
- EbicsSubscriberEntity.find {
- (EbicsSubscribersTable.userId eq userID) and (EbicsSubscribersTable.partnerId eq partnerID) and
- (EbicsSubscribersTable.hostId eq hostID)
- }.firstOrNull() ?: throw SandboxError(
- HttpStatusCode.NotFound,
- "Ebics subscriber (${userID}, ${partnerID}, ${hostID}) not found"
- )
- }
-}
-
-/**
- * Compress, encrypt, encode a EBICS payload. The payload
- * is assumed to be a Zip archive with only one entry.
- * Return the customer key (second element) along the data.
- */
-fun prepareEbicsPayload(
- payload: String, pub: RSAPublicKey
-): Pair<String, CryptoUtil.EncryptionResult> {
- val zipSingleton = mutableListOf(payload.toByteArray()).zip()
- val compressedResponse = DeflaterInputStream(zipSingleton.inputStream()).use {
- it.readAllBytes()
- }
- val enc = CryptoUtil.encryptEbicsE002(compressedResponse, pub)
- return Pair(Base64.getEncoder().encodeToString(enc.encryptedData), enc)
-}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt
deleted file mode 100644
index dac660da..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-package tech.libeufin.sandbox
-
-import tech.libeufin.util.PaymentInfo
-
-data class WithdrawalRequest(
- /**
- * Note: the currency is redundant, because at each point during
- * the execution the Demobank should have a handle of the currency.
- */
- val amount: String // $CURRENCY:X.Y
-)
-data class BalanceJson(
- val amount: String,
- val credit_debit_indicator: String
-)
-data class Demobank(
- val currency: String,
- val name: String,
- val userDebtLimit: Int,
- val bankDebtLimit: Int,
- val allowRegistrations: Boolean
-)
-/**
- * Used to show the list of Ebics hosts that exist
- * in the system.
- */
-data class EbicsHostsResponse(
- val ebicsHosts: List<String>
-)
-
-data class EbicsHostCreateRequest(
- val hostID: String,
- val ebicsVersion: String
-)
-
-/**
- * List type that show all the payments existing in the system.
- */
-data class AccountTransactions(
- val payments: MutableList<PaymentInfo> = mutableListOf()
-)
-
-/**
- * Used to create AND show one Ebics subscriber.
- */
-data class EbicsSubscriberInfo(
- val hostID: String,
- val partnerID: String,
- val userID: String,
- val systemID: String? = null,
- val demobankAccountLabel: String
-)
-
-data class AdminGetSubscribers(
- var subscribers: MutableList<EbicsSubscriberInfo> = mutableListOf()
-)
-
-/**
- * The following definition is obsolete because it
- * doesn't allow to specify a demobank that will host
- * the Ebics subscriber. */
-data class EbicsSubscriberObsoleteApi(
- val hostID: String,
- val partnerID: String,
- val userID: String,
- val systemID: String? = null
-)
-
-/**
- * Allows the admin to associate a new bank account
- * to a EBICS subscriber.
- */
-data class EbicsBankAccountRequest(
- val subscriber: EbicsSubscriberObsoleteApi,
- val iban: String,
- val bic: String,
- val name: String,
- /**
- * This value labels the bank account to be created
- * AND its owner. The 'owner' is a bank's customer
- * whose username equals this label AND has the rights
- * over such bank accounts.
- */
- val label: String
-)
-
-data class CustomerRegistration(
- val username: String,
- val password: String,
- val isPublic: Boolean = false,
- // When missing, it's autogenerated.
- val iban: String?,
- // When missing, stays null in the DB.
- val name: String?
-)
-
-// Could be used as a general bank account info container.
-data class PublicAccountInfo(
- val balance: String,
- val iban: String,
- // Name / Label of the bank account _and_ of the
- // Sandbox username that owns it.
- val accountLabel: String
- // more ..?
-)
-
-data class CamtParams(
- // name/label of the bank account to query.
- val bankaccount: String,
- val type: Int,
- // need range parameter
-)
-
-data class TalerWithdrawalStatus(
- val selection_done: Boolean,
- val transfer_done: Boolean,
- val amount: String,
- val wire_types: List<String> = listOf("iban"),
- val suggested_exchange: String? = null,
- val sender_wire: String? = null,
- val aborted: Boolean,
- // Not needed with CLI wallets.
- val confirm_transfer_url: String?
-)
-
-data class TalerWithdrawalSelection(
- val reserve_pub: String,
- val selected_exchange: String?
-)
-
-data class SandboxConfig(
- val currency: String,
- val version: String,
- val name: String
-) \ No newline at end of file
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
deleted file mode 100644
index bcd11a49..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
+++ /dev/null
@@ -1,1711 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2019 Stanisci and Dold.
-
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
-
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- * Public License for more details.
-
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING. If not, see
- * <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
- }
- }
- }
- }
-}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt
deleted file mode 100644
index f76ad942..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/XMLEbicsConverter.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package tech.libeufin.sandbox
-
-import io.ktor.http.*
-import io.ktor.http.content.*
-import io.ktor.serialization.*
-import io.ktor.util.reflect.*
-import io.ktor.utils.io.*
-import io.ktor.utils.io.charsets.*
-import io.ktor.utils.io.jvm.javaio.*
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import tech.libeufin.util.XMLUtil
-
-class XMLEbicsConverter : ContentConverter {
- override suspend fun deserialize(
- charset: Charset,
- typeInfo: TypeInfo,
- content: ByteReadChannel
- ): Any {
- return withContext(Dispatchers.IO) {
- try {
- receiveEbicsXmlInternal(content.toInputStream().reader().readText())
- } catch (e: Exception) {
- throw SandboxError(
- HttpStatusCode.BadRequest,
- "Document is invalid XML."
- )
- }
- }
- }
-
- // The following annotation was suggested by Intellij.
- @Deprecated(
- "Please override and use serializeNullable instead",
- replaceWith = ReplaceWith("serializeNullable(charset, typeInfo, contentType, value)"),
- level = DeprecationLevel.WARNING
- )
- override suspend fun serialize(
- contentType: ContentType,
- charset: Charset,
- typeInfo: TypeInfo,
- value: Any
- ): OutgoingContent? {
- return super.serializeNullable(contentType, charset, typeInfo, value)
- }
-
- override suspend fun serializeNullable(
- contentType: ContentType,
- charset: Charset,
- typeInfo: TypeInfo,
- value: Any?
- ): OutgoingContent? {
- val conv = try {
- XMLUtil.convertJaxbToString(value)
- } catch (e: Exception) {
- /**
- * Not always an error: the content negotiation might have
- * only checked if this handler could convert the response.
- */
- return null
- }
- return OutputStreamContent({
- val out = this;
- withContext(Dispatchers.IO) {
- out.write(conv.toByteArray())
- }},
- contentType.withCharset(charset)
- )
- }
-} \ No newline at end of file
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
deleted file mode 100644
index d82a0eb4..00000000
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-package tech.libeufin.sandbox
-
-import io.ktor.http.*
-import org.jetbrains.exposed.sql.and
-import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.*
-import java.math.BigDecimal
-
-/**
- * Check whether the given bank account would surpass the
- * debit threshold, in case the potential amount gets transferred.
- * Returns true when the debit WOULD be surpassed. */
-fun maybeDebit(
- accountLabel: String,
- requestedAmount: BigDecimal,
- demobankName: String = "default"
-): Boolean {
- val demobank = getDemobank(demobankName) ?: throw notFound(
- "Demobank '${demobankName}' not found when trying to check the debit threshold" +
- " for user $accountLabel"
- )
- val balance = getBalance(accountLabel, demobankName)
- val maxDebt = if (accountLabel == "admin") {
- demobank.config.bankDebtLimit
- } else demobank.config.usersDebtLimit
- val balanceCheck = balance - requestedAmount
- if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal.valueOf(maxDebt.toLong())) {
- logger.warn("User '$accountLabel' would surpass the debit" +
- " threshold of $maxDebt, given the requested amount of ${requestedAmount.toPlainString()}")
- return true
- }
- return false
-}
-
-fun getMaxDebitForUser(
- username: String,
- demobankName: String = "default"
-): Int {
- val bank = getDemobank(demobankName) ?: throw internalServerError(
- "demobank $demobankName not found"
- )
- if (username == "admin") return bank.config.bankDebtLimit
- return bank.config.usersDebtLimit
-}
-
-fun getBalanceForJson(value: BigDecimal, currency: String): BalanceJson {
- return BalanceJson(
- amount = "${currency}:${value.abs()}",
- credit_debit_indicator = if (value < BigDecimal.ZERO) "debit" else "credit"
- )
-}
-
-fun getBalance(bankAccount: BankAccountEntity): BigDecimal {
- return BigDecimal(bankAccount.balance)
-}
-
-/**
- * This function balances _in bank account statements_. A statement
- * witnesses the bank account after a given business time slot. Therefore
- * _this_ type of balance is not guaranteed to hold the _actual_ and
- * more up-to-date bank account. It'll be used when Sandbox will support
- * the issuing of bank statement.
- */
-fun getBalanceForStatement(
- bankAccount: BankAccountEntity,
- withPending: Boolean = true
-): BigDecimal {
- val lastStatement = transaction {
- BankAccountStatementEntity.find {
- BankAccountStatementsTable.bankAccount eq bankAccount.id
- }.lastOrNull()
- }
- var lastBalance = if (lastStatement == null) {
- BigDecimal.ZERO
- } else { BigDecimal(lastStatement.balanceClbd) }
- if (!withPending) return lastBalance
- /**
- * Caller asks to include the pending transactions in the
- * balance. The block below gets the transactions happened
- * later than the last statement and adds them to the balance
- * that was calculated so far.
- */
- transaction {
- val pendingTransactions = BankAccountTransactionEntity.find {
- BankAccountTransactionsTable.account eq bankAccount.id and (
- BankAccountTransactionsTable.date.greater(lastStatement?.creationTime ?: 0L))
- }
- pendingTransactions.forEach { tx ->
- when (tx.direction) {
- "DBIT" -> lastBalance -= parseDecimal(tx.amount)
- "CRDT" -> lastBalance += parseDecimal(tx.amount)
- else -> {
- logger.error("Transaction ${tx.id} is neither debit nor credit.")
- throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Error in transactions state."
- )
- }
- }
- }
- }
- return lastBalance
-}
-
-// Gets the balance of 'accountLabel', which is hosted at 'demobankName'.
-fun getBalance(accountLabel: String,
- demobankName: String = "default"
-): BigDecimal {
- val demobank = getDemobank(demobankName) ?: throw SandboxError(
- HttpStatusCode.InternalServerError,
- "Demobank '$demobankName' not found"
- )
-
- /**
- * Setting withBankFault to true for the following reason:
- * when asking for a balance, the bank should have made sure
- * that the user has a bank account (together with a customer profile).
- * If that's not the case, it's bank's fault, since it didn't check
- * earlier.
- */
- val account = getBankAccountFromLabel(
- accountLabel,
- demobank,
- withBankFault = true
- )
- return getBalance(account)
-}
-
-/**
- * 'debitAccount' and 'creditAccount' are customer usernames
- * and ALSO labels of the bank accounts owned by them. They are
- * used to both resort a bank account and the legal name owning
- * the bank accounts.
- */
-fun wireTransfer(
- debitAccount: String,
- creditAccount: String,
- demobank: String = "default",
- subject: String,
- amount: String, // $currency:x.y
- pmtInfId: String? = null,
- endToEndId: String? = null
-): String {
- logger.debug("Maybe wire transfer (endToEndId: $endToEndId): $debitAccount -> $creditAccount, $subject, $amount")
- return transaction {
- val demobankDb = ensureDemobank(demobank)
- val debitAccountDb = getBankAccountFromLabel(debitAccount, demobankDb)
- val creditAccountDb = getBankAccountFromLabel(creditAccount, demobankDb)
- val parsedAmount = parseAmount(amount)
- // Potential amount to transfer.
- val amountAsNumber = BigDecimal(parsedAmount.amount)
- if (amountAsNumber == BigDecimal.ZERO)
- throw badRequest("Wire transfers of zero not possible.")
- if (parsedAmount.currency != demobankDb.config.currency)
- throw badRequest(
- "Won't wire transfer with currency: ${parsedAmount.currency}." +
- " Only ${demobankDb.config.currency} allowed."
- )
- // Check funds are sufficient.
- if (
- maybeDebit(
- debitAccountDb.label,
- amountAsNumber,
- demobankDb.name
- )) {
- logger.error("Account ${debitAccountDb.label} would surpass debit threshold. Rollback wire transfer")
- throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds")
- }
- val timeStamp = getNowMillis()
- val transactionRef = getRandomString(8)
- BankAccountTransactionEntity.new {
- creditorIban = creditAccountDb.iban
- creditorBic = creditAccountDb.bic
- this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner)
- debtorIban = debitAccountDb.iban
- debtorBic = debitAccountDb.bic
- debtorName = getPersonNameFromCustomer(debitAccountDb.owner)
- this.subject = subject
- this.amount = parsedAmount.amount
- this.currency = demobankDb.config.currency
- date = timeStamp
- accountServicerReference = transactionRef
- account = creditAccountDb
- direction = "CRDT"
- this.demobank = demobankDb
- this.pmtInfId = pmtInfId
- }
- BankAccountTransactionEntity.new {
- creditorIban = creditAccountDb.iban
- creditorBic = creditAccountDb.bic
- this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner)
- debtorIban = debitAccountDb.iban
- debtorBic = debitAccountDb.bic
- debtorName = getPersonNameFromCustomer(debitAccountDb.owner)
- this.subject = subject
- this.amount = parsedAmount.amount
- this.currency = demobankDb.config.currency
- date = timeStamp
- accountServicerReference = transactionRef
- account = debitAccountDb
- direction = "DBIT"
- this.demobank = demobankDb
- this.pmtInfId = pmtInfId
- this.endToEndId = endToEndId
- }
-
- // Adjusting the balances (acceptable debit conditions checked before).
- // Debit:
- val newDebitBalance = (BigDecimal(debitAccountDb.balance) - amountAsNumber).roundToTwoDigits()
- debitAccountDb.balance = newDebitBalance.toPlainString()
- // Credit:
- val newCreditBalance = (BigDecimal(creditAccountDb.balance) + amountAsNumber).roundToTwoDigits()
- creditAccountDb.balance = newCreditBalance.toPlainString()
-
- // Signaling this wire transfer's event.
- if (this.isPostgres()) {
- val creditChannel = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
- creditAccountDb.label
- )
- this.postgresNotify(creditChannel, "CRDT")
- val debitChannel = buildChannelName(
- NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
- debitAccountDb.label
- )
- this.postgresNotify(debitChannel, "DBIT")
- }
- transactionRef
- }
-}
-
-/**
- * Helper that constructs a transactions history page
- * according to the URI parameters passed to Access API's
- * GET /transactions.
- */
-data class HistoryParams(
- val pageNumber: Int,
- val pageSize: Int,
- val fromMs: Long,
- val untilMs: Long,
- val bankAccount: BankAccountEntity
-)
-
-fun extractTxHistory(params: HistoryParams): List<XLibeufinBankTransaction> {
- val ret = mutableListOf<XLibeufinBankTransaction>()
-
- /**
- * Helper that gets transactions earlier than the 'firstElementId'
- * transaction AND that match the URI parameters.
- */
- fun getPage(firstElementId: Long): Iterable<BankAccountTransactionEntity> {
- return BankAccountTransactionEntity.find {
- (BankAccountTransactionsTable.id lessEq firstElementId) and
- (BankAccountTransactionsTable.account eq params.bankAccount.id) and
- (BankAccountTransactionsTable.date.between(params.fromMs, params.untilMs))
- }.sortedByDescending { it.id.value }.take(params.pageSize)
- }
- // Gets a pointer to the last transaction of this bank account.
- val lastTransaction: BankAccountTransactionEntity? = params.bankAccount.lastTransaction
- if (lastTransaction == null) return ret
- var nextPageIdUpperLimit: Long = lastTransaction.id.value
-
- // This loop fetches (and discards) pages until the desired one is found.
- for (i in 1..(params.pageNumber)) {
- val pageBuf = getPage(nextPageIdUpperLimit)
- logger.debug("pageBuf #$i follows. Request wants #${params.pageNumber}:")
- pageBuf.forEach { logger.debug("ID: ${it.id}, subject: ${it.subject}, amount: ${it.currency}:${it.amount}") }
- if (pageBuf.none()) return ret
- nextPageIdUpperLimit = pageBuf.last().id.value - 1
- if (i == params.pageNumber) pageBuf.forEach {
- ret.add(getHistoryElementFromTransactionRow(it))
- }
- }
- return ret
-} \ No newline at end of file