summaryrefslogtreecommitdiff
path: root/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt
diff options
context:
space:
mode:
Diffstat (limited to 'bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt')
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt841
1 files changed, 841 insertions, 0 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt
new file mode 100644
index 00000000..4d8d36d9
--- /dev/null
+++ b/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt
@@ -0,0 +1,841 @@
+package tech.libeufin.sandbox
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import io.ktor.server.application.*
+import io.ktor.http.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import org.jetbrains.exposed.sql.*
+import org.jetbrains.exposed.sql.transactions.transaction
+import tech.libeufin.sandbox.CashoutOperationsTable.uuid
+import tech.libeufin.util.*
+import java.io.File
+import java.io.InputStreamReader
+import java.math.BigDecimal
+import java.util.concurrent.TimeUnit
+import kotlin.text.toByteArray
+
+// CIRCUIT API TYPES
+/**
+ * This type is used by clients to ask the bank a cash-out
+ * estimate to show to the customer before they confirm the
+ * cash-out creation.
+ */
+data class CircuitCashoutEstimateRequest(
+ /**
+ * This is the amount that the customer will get deducted
+ * from their regio bank account to fuel the cash-out operation.
+ */
+ val amount_debit: String
+)
+data class CircuitCashoutRequest(
+ val subject: String?,
+ val amount_debit: String, // As specified by the user via the SPA.
+ val amount_credit: String, // What actually to transfer after the rates.
+ /**
+ * The String type here allows more flexibility with regard to
+ * the supported TAN methods. This way, supported TAN methods
+ * can be specified via the configuration or when starting the
+ * bank. OTOH, catching unsupported TAN methods only via the
+ * 'enum' type would require to change the source code upon every
+ * change in the TAN policy.
+ */
+ val tan_channel: String?
+)
+const val FIAT_CURRENCY = "CHF" // FIXME: make configurable.
+// Configuration response:
+data class ConfigResp(
+ val name: String = "circuit",
+ val version: String = PROTOCOL_VERSION_UNIFIED,
+ val ratios_and_fees: RatioAndFees,
+ val fiat_currency: String = FIAT_CURRENCY
+)
+
+// After fixing #7527, the values held by this
+// type must be read from the configuration.
+data class RatioAndFees(
+ val buy_at_ratio: Float = 1F,
+ val sell_at_ratio: Float = 0.95F,
+ val buy_in_fee: Float = 0F,
+ val sell_out_fee: Float = 0F
+)
+val ratiosAndFees = RatioAndFees()
+
+// User registration request
+data class CircuitAccountRequest(
+ val username: String,
+ val password: String,
+ val contact_data: CircuitContactData,
+ val name: String,
+ val cashout_address: String, // payto
+ val internal_iban: String? // Shall be "= null" ?
+)
+// User contact data to send the TAN.
+data class CircuitContactData(
+ val email: String?,
+ val phone: String?
+)
+
+data class CircuitAccountReconfiguration(
+ val contact_data: CircuitContactData,
+ val cashout_address: String?,
+ val name: String? = null
+)
+
+data class AccountPasswordChange(
+ val new_password: String
+)
+
+/**
+ * That doesn't belong to the Access API because it
+ * contains the cash-out address and the contact data.
+ */
+data class CircuitAccountInfo(
+ val username: String,
+ val iban: String,
+ val contact_data: CircuitContactData,
+ val name: String,
+ val cashout_address: String?
+)
+
+data class CashoutOperationInfo(
+ val status: CashoutOperationStatus,
+ val amount_credit: String,
+ val amount_debit: String,
+ val subject: String,
+ val creation_time: Long, // milliseconds
+ val confirmation_time: Long?, // milliseconds
+ val tan_channel: SupportedTanChannels,
+ val account: String,
+ val cashout_address: String,
+ val ratios_and_fees: RatioAndFees
+)
+
+data class CashoutConfirmation(val tan: String)
+
+// Validate phone number
+fun checkPhoneNumber(phoneNumber: String): Boolean {
+ // From Taler TypeScript
+ // /^\+[0-9 ]*$/;
+ val regex = "^\\+[1-9][0-9]+$"
+ val R = Regex(regex)
+ return R.matches(phoneNumber)
+}
+
+// Validate e-mail address
+fun checkEmailAddress(emailAddress: String): Boolean {
+ // From Taler TypeScript:
+ // /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+ val regex = "^[a-zA-Z0-9\\.]+@[a-zA-Z0-9\\.]+$"
+ val R = Regex(regex)
+ return R.matches(emailAddress)
+}
+
+fun throwIfInstitutionalName(resourceName: String) {
+ if (resourceName == "bank" || resourceName == "admin")
+ throw forbidden("Can't operate on institutional resource '$resourceName'")
+}
+
+fun generateCashoutSubject(
+ amountCredit: AmountWithCurrency,
+ amountDebit: AmountWithCurrency
+): String {
+ return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" +
+ " to ${amountCredit.currency}:${amountCredit.amount}"
+}
+
+/**
+ * By default, it takes the amount in the regional currency
+ * and applies ratio and fees to convert it to fiat. If the
+ * 'fromCredit' parameter is true, then it does the inverse
+ * operation: returns the regional amount that would lead to
+ * such fiat amount given in the 'amount' parameter.
+ */
+fun applyCashoutRatioAndFee(
+ amount: BigDecimal,
+ ratiosAndFees: RatioAndFees,
+ fromCredit: Boolean = false
+): BigDecimal {
+ // Normal case, when the calculation starts from the regional amount.
+ if (!fromCredit) {
+ val maybeCashoutAmount = ((amount * ratiosAndFees.sell_at_ratio.toBigDecimal()) -
+ ratiosAndFees.sell_out_fee.toBigDecimal()).roundToTwoDigits()
+ // throws 500, since bank should not allow to get negative fiat amounts.
+ if (maybeCashoutAmount < BigDecimal.ZERO) {
+ logger.error("Cash-out operation caused a negative fiat output." +
+ " Regional amount was '$amount', cash-out ratio is '${ratiosAndFees.sell_at_ratio}," +
+ " cash-out fee is '${ratiosAndFees.sell_out_fee}''"
+ )
+ throw internalServerError("Applying cash-out fees yielded negative fiat amount.")
+ }
+ return maybeCashoutAmount
+ }
+ // UI convenient case, when the calculation starts from the
+ // desired fiat amount that the user wants eventually be paid.
+ return ((amount + ratiosAndFees.sell_out_fee.toBigDecimal()) /
+ ratiosAndFees.sell_at_ratio.toBigDecimal()).roundToTwoDigits()
+}
+
+/**
+ * NOTE: future versions take the supported TAN method from
+ * the configuration, or options passed when starting the bank.
+ */
+const val LIBEUFIN_TAN_TMP_FILE = "/tmp/libeufin-cashout-tan.txt"
+enum class SupportedTanChannels {
+ SMS,
+ EMAIL,
+ FILE // Test channel writing the TAN to the LIBEUFIN_TAN_TMP_FILE location.
+}
+fun isTanChannelSupported(tanChannel: String): Boolean {
+ enumValues<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