libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 7a6f2665600cf3c33e0f51507fb28a53c08a39d8
parent d43df5630d4d1a1097ba1552d8e950a9056b860d
Author: ms <ms@taler.net>
Date:   Wed, 20 Oct 2021 13:15:00 +0200

Group Integration/Access API under Demobank route.

Diffstat:
Msandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt | 31++++++++++++++++++++++---------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt | 141+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt | 14++++----------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt | 285+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt | 19++++++-------------
Mutil/src/main/kotlin/HTTP.kt | 2+-
Mutil/src/main/kotlin/JSON.kt | 2++
Mutil/src/main/kotlin/Payto.kt | 6+++++-
8 files changed, 257 insertions(+), 243 deletions(-)

diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt @@ -94,6 +94,7 @@ object DemobankConfigsTable : LongIdTable() { val bankDebtLimit = integer("bankDebtLimit") val usersDebtLimit = integer("usersDebtLimit") val name = text("hostname") + val suggestedExchange = text("suggestedExchange").nullable() } class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) { @@ -103,6 +104,7 @@ class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) { var bankDebtLimit by DemobankConfigsTable.bankDebtLimit var usersDebtLimit by DemobankConfigsTable.usersDebtLimit var name by DemobankConfigsTable.name + var suggestedExchange by DemobankConfigsTable.suggestedExchange } /** @@ -110,9 +112,7 @@ class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) { * Created via the /demobanks/{demobankname}/register endpoint. */ object DemobankCustomersTable : LongIdTable() { - val isPublic = bool("isPublic").default(false) val demobankConfig = reference("demobankConfig", DemobankConfigsTable) - val bankAccount = reference("bankAccount", BankAccountsTable) val username = text("username") val passwordHash = text("passwordHash") val name = text("name").nullable() @@ -120,9 +120,7 @@ object DemobankCustomersTable : LongIdTable() { class DemobankCustomerEntity(id: EntityID<Long>) : LongEntity(id) { companion object : LongEntityClass<DemobankCustomerEntity>(DemobankCustomersTable) - var isPublic by DemobankCustomersTable.isPublic var demobankConfig by DemobankConfigEntity referencedOn DemobankCustomersTable.demobankConfig - var bankAccount by BankAccountEntity referencedOn DemobankCustomersTable.bankAccount var username by DemobankCustomersTable.username var passwordHash by DemobankCustomersTable.passwordHash var name by DemobankCustomersTable.name @@ -316,7 +314,10 @@ object BankAccountTransactionsTable : LongIdTable() { val debtorBic = text("debtorBic").nullable() val debtorName = text("debtorName") val subject = text("subject") - val amount = text("amount") // NOT the usual $currency:x.y, but a BigInt as string + /** + * Amount is a stringified BigInt + */ + val amount = text("amount") val currency = text("currency") val date = long("date") @@ -344,7 +345,6 @@ class BankAccountTransactionEntity(id: EntityID<Long>) : LongEntity(id) { return freshTx } } - var creditorIban by BankAccountTransactionsTable.creditorIban var creditorBic by BankAccountTransactionsTable.creditorBic var creditorName by BankAccountTransactionsTable.creditorName @@ -372,6 +372,13 @@ object BankAccountsTable : IntIdTable() { val currency = text("currency") val isDebit = bool("isDebit").default(false) val balance = text("balance") + /** + * Allow to assign "admin" - who doesn't have a customer DB entry - + * as the owner. That allows tests using the --no-auth option to go on. + */ + val owner = text("owner") + val isPublic = bool("isPublic") + val demoBank = reference("demoBank", DemobankConfigsTable) } class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) { @@ -383,6 +390,9 @@ class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) { var currency by BankAccountsTable.currency var isDebit by BankAccountsTable.isDebit var balance by BankAccountsTable.balance + var owner by BankAccountsTable.owner + var isPublic by BankAccountsTable.isPublic + var demoBank by BankAccountsTable.demoBank } object BankAccountStatementsTable : IntIdTable() { @@ -410,13 +420,13 @@ class BankAccountStatementEntity(id: EntityID<Int>) : IntEntity(id) { 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 @@ -425,7 +435,7 @@ object TalerWithdrawalsTable : LongIdTable() { val transferDone = bool("transferDone").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) @@ -434,6 +444,9 @@ class TalerWithdrawalEntity(id: EntityID<Long>) : LongEntity(id) { var transferDone by TalerWithdrawalsTable.transferDone 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() { diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt @@ -23,7 +23,8 @@ import io.ktor.http.HttpStatusCode import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.internalServerError +import tech.libeufin.util.* +import javax.security.auth.Subject /** * Helps to communicate Camt values without having @@ -57,11 +58,81 @@ fun getOrderTypeFromTransactionId(transactionID: String): String { return uploadTransaction.orderType } +/** + * Book a CRDT and a DBIT transaction and return the unique reference thereof. + * + * At the moment there is redundancy because all the creditor / debtor details + * are contained (directly or indirectly) already in the BankAccount parameters. + * + * This is kept both not to break the existing tests and to allow future versions + * where one party of the transaction is not a customer of the running Sandbox. + */ + +fun wireTransfer( + debitAccount: BankAccountEntity, + creditAccount: BankAccountEntity, + demoBank: DemobankConfigEntity, + subject: String, + amount: String, +): String { + + fun getOwnerName(ownerUsername: String): String { + return if (creditAccount.owner == "admin") "admin" else { + val creditorCustomer = DemobankCustomerEntity.find( + DemobankCustomersTable.username eq creditAccount.owner + ).firstOrNull() ?: throw internalServerError( + "Owner of bank account '${creditAccount.label}' not found" + ) + creditorCustomer.name ?: "Name not given" + } + } + val timeStamp = getUTCnow().toInstant().toEpochMilli() + val transactionRef = getRandomString(8) + transaction { + BankAccountTransactionEntity.new { + creditorIban = creditAccount.iban + creditorBic = creditAccount.bic + this.creditorName = getOwnerName(creditAccount.owner) + debtorIban = debitAccount.iban + debtorBic = debitAccount.bic + debtorName = getOwnerName(debitAccount.owner) + this.subject = subject + this.amount = amount + this.currency = demoBank.currency + date = timeStamp + accountServicerReference = transactionRef + account = creditAccount + direction = "CRDT" + } + BankAccountTransactionEntity.new { + creditorIban = creditAccount.iban + creditorBic = creditAccount.bic + this.creditorName = getOwnerName(creditAccount.owner) + debtorIban = debitAccount.iban + debtorBic = debitAccount.bic + debtorName = getOwnerName(debitAccount.owner) + this.subject = subject + this.amount = amount + this.currency = demoBank.currency + date = timeStamp + accountServicerReference = transactionRef + account = debitAccount + direction = "DBIT" + } + } + return transactionRef +} + + +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 - ) + BankAccountEntity.find(BankAccountsTable.iban eq iban) }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Did not find a bank account for ${iban}" @@ -120,62 +191,4 @@ fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID: Str "Ebics subscriber not found" ) } -} - -/** - * FIXME: commenting out until a solution for i18n is found. - * -private fun initJinjava(): Jinjava { - class JinjaFunctions { - // Used by templates to retrieve configuration values. - fun settings_value(name: String): String { - return "foo" - } - fun gettext(translatable: String): String { - // temporary, just to make the compiler happy. - return translatable - } - fun url(name: String): String { - val map = mapOf<String, String>( - "login" to "todo", - "profile" to "todo", - "register" to "todo", - "public-accounts" to "todo" - ) - return map[name] ?: throw SandboxError(HttpStatusCode.InternalServerError, "URL name unknown") - } - } - val jinjava = Jinjava() - val settingsValueFunc = ELFunctionDefinition( - "tech.libeufin.sandbox", "settings_value", - JinjaFunctions::class.java, "settings_value", String::class.java - ) - val gettextFuncAlias = ELFunctionDefinition( - "tech.libeufin.sandbox", "_", - JinjaFunctions::class.java, "gettext", String::class.java - ) - val gettextFunc = ELFunctionDefinition( - "", "gettext", - JinjaFunctions::class.java, "gettext", String::class.java - ) - val urlFunc = ELFunctionDefinition( - "tech.libeufin.sandbox", "url", - JinjaFunctions::class.java, "url", String::class.java - ) - - jinjava.globalContext.registerFunction(settingsValueFunc) - jinjava.globalContext.registerFunction(gettextFunc) - jinjava.globalContext.registerFunction(gettextFuncAlias) - jinjava.globalContext.registerFunction(urlFunc) - - return jinjava -} - -val jinjava = initJinjava() - -fun renderTemplate(templateName: String, context: Map<String, String>): String { - val template = Resources.toString(Resources.getResource( - "templates/$templateName"), Charsets.UTF_8 - ) - return jinjava.render(template, context) -} **/ -\ No newline at end of file +} +\ No newline at end of file diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/JSON.kt @@ -82,16 +82,10 @@ data class CustomerRegistration( val password: String ) -/** - * More detailed information about one customer. This type - * is mainly required along public histories and/or customer - * data unrelated to the Access API. - */ -data class CustomerInfo( - val username: String, - val name: String, +// Could be used as a general bank account info container. +data class PublicAccountInfo( val balance: String, - val iban: String, + val iban: String // more ..? ) @@ -112,7 +106,7 @@ data class TalerWithdrawalStatus( val aborted: Boolean = false, ) -data class TalerWithdrawalConfirmation( +data class TalerWithdrawalSelection( val reserve_pub: String, val selected_exchange: String? ) diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt @@ -63,6 +63,7 @@ import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.util.* import io.ktor.util.date.* +import io.ktor.util.pipeline.* import kotlinx.coroutines.newSingleThreadContext import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.statements.api.ExposedBlob @@ -519,7 +520,7 @@ val sandboxApp: Application.() -> Unit = { post("/admin/payments/camt") { call.request.basicAuth() val body = call.receiveJson<CamtParams>() - val bankaccount = getAccountFromLabel(body.bankaccount) + val bankaccount = getBankAccountFromLabel(body.bankaccount) if (body.type != 53) throw SandboxError( HttpStatusCode.NotFound, "Only Camt.053 documents can be generated." @@ -540,7 +541,7 @@ val sandboxApp: Application.() -> Unit = { // create a new bank account, no EBICS relation. post("/admin/bank-accounts/{label}") { - call.request.basicAuth() + val username = call.request.basicAuth() val body = call.receiveJson<BankAccountInfo>() transaction { BankAccountEntity.new { @@ -548,6 +549,7 @@ val sandboxApp: Application.() -> Unit = { bic = body.bic label = body.label currency = body.currency ?: "EUR" + owner = username ?: "admin" // allows } } call.respond(object {}) @@ -622,7 +624,7 @@ val sandboxApp: Application.() -> Unit = { // Associates a new bank account with an existing Ebics subscriber. post("/admin/ebics/bank-accounts") { - call.request.basicAuth() + val username = call.request.basicAuth() val body = call.receiveJson<BankAccountRequest>() if (!validateBic(body.bic)) { throw SandboxError(io.ktor.http.HttpStatusCode.BadRequest, "invalid BIC (${body.bic})") @@ -633,18 +635,19 @@ val sandboxApp: Application.() -> Unit = { body.subscriber.partnerID, body.subscriber.hostID ) - val check = tech.libeufin.sandbox.BankAccountEntity.find { - tech.libeufin.sandbox.BankAccountsTable.iban eq body.iban or (tech.libeufin.sandbox.BankAccountsTable.label eq body.label) + val check = BankAccountEntity.find { + BankAccountsTable.iban eq body.iban or (BankAccountsTable.label eq body.label) }.count() if (check > 0) throw SandboxError( - io.ktor.http.HttpStatusCode.BadRequest, + HttpStatusCode.BadRequest, "Either IBAN or account label were already taken; please choose fresh ones" ) - subscriber.bankAccount = tech.libeufin.sandbox.BankAccountEntity.new { + subscriber.bankAccount = BankAccountEntity.new { iban = body.iban bic = body.bic label = body.label currency = body.currency.uppercase(java.util.Locale.ROOT) + owner = username ?: "admin" } } call.respondText("Bank account created") @@ -656,7 +659,7 @@ val sandboxApp: Application.() -> Unit = { call.request.basicAuth() val accounts = mutableListOf<BankAccountInfo>() transaction { - tech.libeufin.sandbox.BankAccountEntity.all().forEach { + BankAccountEntity.all().forEach { accounts.add( BankAccountInfo( label = it.label, @@ -725,7 +728,7 @@ val sandboxApp: Application.() -> Unit = { run { val amount = kotlin.random.Random.nextLong(5, 25) - tech.libeufin.sandbox.BankAccountTransactionEntity.new { + BankAccountTransactionEntity.new { creditorIban = account.iban creditorBic = account.bic creditorName = "Creditor Name" @@ -911,90 +914,6 @@ val sandboxApp: Application.() -> Unit = { val currency = currencyEnv }) } - /** - * not regulating the access here, as the wopid was only granted - * to logged-in users before (at the /taler endpoint) and has enough - * entropy to prevent guesses. - */ - get("/api/withdrawal-operation/{wopid}") { - val wopid: String = ensureNonNull(call.parameters["wopid"]) - val wo = transaction { - - tech.libeufin.sandbox.TalerWithdrawalEntity.find { - tech.libeufin.sandbox.TalerWithdrawalsTable.wopid eq java.util.UUID.fromString(wopid) - }.firstOrNull() ?: throw SandboxError( - io.ktor.http.HttpStatusCode.NotFound, - "Withdrawal operation: $wopid not found" - ) - } - SandboxAssert( - envName != null, - "Env name not found, cannot suggest Exchange." - ) - val ret = TalerWithdrawalStatus( - selection_done = wo.selectionDone, - transfer_done = wo.transferDone, - amount = "${currencyEnv}:5", - suggested_exchange = "https://exchange.${envName}.taler.net/" - ) - call.respond(ret) - return@get - } - /** - * Here Sandbox collects the reserve public key to be used - * as the wire transfer subject, and pays the exchange - which - * is as well collected in this request. - */ - post("/api/withdrawal-operation/{wopid}") { - - val wopid: String = ensureNonNull(call.parameters["wopid"]) - val body = call.receiveJson<TalerWithdrawalConfirmation>() - - newSuspendedTransaction(context = singleThreadContext) { - var wo = tech.libeufin.sandbox.TalerWithdrawalEntity.find { - tech.libeufin.sandbox.TalerWithdrawalsTable.wopid eq java.util.UUID.fromString(wopid) - }.firstOrNull() ?: throw SandboxError( - io.ktor.http.HttpStatusCode.NotFound, "Withdrawal operation $wopid not found." - ) - if (wo.selectionDone) { - if (wo.transferDone) { - logger.info("Wallet performs again this operation that was paid out earlier: idempotent") - return@newSuspendedTransaction - } - // reservePub+exchange selected but not payed: check consistency - if (body.reserve_pub != wo.reservePub) throw SandboxError( - io.ktor.http.HttpStatusCode.Conflict, - "Selecting a different reserve from the one already selected" - ) - if (body.selected_exchange != wo.selectedExchangePayto) throw SandboxError( - io.ktor.http.HttpStatusCode.Conflict, - "Selecting a different exchange from the one already selected" - ) - } - // here only if (1) no selection done or (2) _only_ selection done: - // both ways no transfer must have happened. - SandboxAssert(!wo.transferDone, "Sandbox allowed paid but unselected reserve") - - wireTransfer( - "sandbox-account-customer", - "sandbox-account-exchange", - "$currencyEnv:5", - body.reserve_pub - ) - wo.reservePub = body.reserve_pub - wo.selectedExchangePayto = body.selected_exchange - wo.selectionDone = true - wo.transferDone = true - } - /** - * NOTE: is this always guaranteed to run AFTER the suspended - * transaction block above? - */ - call.respond(object { - val transfer_done = true - }) - return@post - } // Create a new demobank instance with a particular currency, // debt limit and possibly other configuration @@ -1029,39 +948,93 @@ val sandboxApp: Application.() -> Unit = { route("/demobanks/{demobankid}") { + // Talk to wallets. + route("/integration-api") { + post("/api/withdrawal-operation/{wopid}") { + val wopid: String = ensureNonNull(call.parameters["wopid"]) + val body = call.receiveJson<TalerWithdrawalSelection>() + val transferDone = newSuspendedTransaction(context = singleThreadContext) { + val wo = TalerWithdrawalEntity.find { + TalerWithdrawalsTable.wopid eq java.util.UUID.fromString(wopid) + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, "Withdrawal operation $wopid not found." + ) + if (wo.selectionDone) { + if (wo.transferDone) { + logger.info("Wallet performs again this operation that was paid out earlier: idempotent") + return@newSuspendedTransaction + } + // Selected already but NOT paid, check consistency. + 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" + ) + } + wo.reservePub = body.reserve_pub + wo.selectedExchangePayto = body.selected_exchange + wo.selectionDone = true + wo.transferDone + } + call.respond(object { + val transfer_done = transferDone + }) + return@post + } + get("/withdrawal-operation/{wopid}") { + val wopid: String = ensureNonNull(call.parameters["wopid"]) + val wo = transaction { + TalerWithdrawalEntity.find { + TalerWithdrawalsTable.wopid eq java.util.UUID.fromString(wopid) + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, + "Withdrawal operation: $wopid not found" + ) + } + val demobank = ensureDemobank(call.getUriComponent("demobankid")) + val ret = TalerWithdrawalStatus( + selection_done = wo.selectionDone, + transfer_done = wo.transferDone, + amount = wo.amount, + suggested_exchange = demobank.suggestedExchange + ) + call.respond(ret) + return@get + } + } + // Talk to Web UI. route("/access-api") { - + // Create a new withdrawal operation. post("/accounts/{account_name}/withdrawals") { val username = call.request.basicAuth() - ensureDemobank(call.getUriComponent("demobankid")) + if (username == null) throw badRequest( + "Taler withdrawal tried with authentication disabled. " + + "That is impossible, because no bank account can get this operation debited." + ) + val demobank = ensureDemobank(call.getUriComponent("demobankid")) /** - * Check that the three canonical accounts exist. The names - * below match those used in the testing harnesses. + * 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 wo: TalerWithdrawalEntity = transaction { - val exchange = BankAccountEntity.find { - BankAccountsTable.label eq "sandbox-account-exchange" - }.firstOrNull() - val customer = BankAccountEntity.find { - BankAccountsTable.label eq "sandbox-account-customer" - }.firstOrNull() - val merchant = BankAccountEntity.find { - BankAccountsTable.label eq "sandbox-account-merchant" - }.firstOrNull() - SandboxAssert(exchange != null, "exchange has no bank account") - SandboxAssert(customer != null, "customer has no bank account") - SandboxAssert(merchant != null, "merchant has no bank account") - // At this point, the three actors exist and a new withdraw operation can be created. - val wo = TalerWithdrawalEntity.new { /* wopid is autogenerated, and momentarily the only column */ } - wo - } + val maybeOwnedAccount = getBankAccountFromLabel(call.getUriComponent("account_name")) + if (maybeOwnedAccount.owner != username) throw unauthorized( + "Customer '$username' has no rights over bank account '${maybeOwnedAccount.label}'" + ) + val wo: TalerWithdrawalEntity = transaction { TalerWithdrawalEntity.new { + amount = "${demobank.currency}:5" + walletBankAccount = maybeOwnedAccount + } } val baseUrl = URL(call.request.getBaseUrl()) val withdrawUri = call.url { protocol = URLProtocol( "taler".plus(if (baseUrl.protocol.lowercase() == "http") "+http" else ""), -1 ) - pathComponents(baseUrl.path, "api", wo.wopid.toString()) + pathComponents(baseUrl.path, "access-api", wo.wopid.toString()) encodedPath += "/" } call.respond(object { @@ -1070,13 +1043,48 @@ val sandboxApp: Application.() -> Unit = { }) return@post } - - // Confirm the wire transfer to the exchange. Idempotent - post("/accounts/{account_name}/withdrawals/confirm") { - - + // Confirm a withdrawal. + post("/accounts/{account_name}/withdrawals/{withdrawal_id}/confirm") { + val withdrawalId = call.getUriComponent("withdrawal_id") + transaction { + val wo = TalerWithdrawalEntity.find { + TalerWithdrawalsTable.wopid eq java.util.UUID.fromString(withdrawalId) + }.firstOrNull() ?: throw SandboxError( + HttpStatusCode.NotFound, "Withdrawal operation $withdrawalId not found." + ) + 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." + ) + val exchangeBankAccount = getBankAccountFromPayto( + wo.selectedExchangePayto ?: throw internalServerError( + "Cannot withdraw without an exchange." + ) + ) + if (!wo.transferDone) { + // Need the exchange bank account! + wireTransfer( + debitAccount = wo.walletBankAccount, + creditAccount = exchangeBankAccount, + amount = wo.amount, + subject = wo.reservePub ?: throw internalServerError( + "Cannot transfer funds without reserve public key." + ), + demoBank = ensureDemobank(call.getUriComponent("demobankid")) + ) + wo.transferDone = true + } + wo.transferDone + } + call.respond(object {}) return@post } + // Bank account basic information. get("/accounts/{account_name}") { val username = call.request.basicAuth() val accountAccessed = call.getUriComponent("account_name") @@ -1086,17 +1094,12 @@ val sandboxApp: Application.() -> Unit = { }.firstOrNull() res } ?: throw notFound("Account '$accountAccessed' not found") - // Check rights. if (WITH_AUTH) { - val customer = getCustomerFromDb(username ?: throw internalServerError( - "Optional authentication broken!" - )) - if (customer.bankAccount.label != accountAccessed) throw forbidden( + if (bankAccount.owner != username) throw forbidden( "Customer '$username' cannot access bank account '$accountAccessed'" ) } - val creditDebitIndicator = if (bankAccount.isDebit) { "debit" } else { @@ -1110,33 +1113,25 @@ val sandboxApp: Application.() -> Unit = { }) return@get } - get("/accounts/{account_name}/history") { // New endpoint, access account history to display in the SPA // (could be merged with GET /accounts/{account_name} } - - // [...] - - get("/public-accounts") { + get("/accounts/public") { val demobank = ensureDemobank(call.getUriComponent("demobankid")) val ret = object { - val publicAccounts = mutableListOf<CustomerInfo>() + val publicAccounts = mutableListOf<PublicAccountInfo>() } transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.isPublic eq true and( - DemobankCustomersTable.demobankConfig eq demobank.id + BankAccountEntity.find { + BankAccountsTable.isPublic eq true and( + BankAccountsTable.demoBank eq demobank.id ) }.forEach { ret.publicAccounts.add( - CustomerInfo( - username = it.username, - balance = it.bankAccount.balance, - iban = it.bankAccount.iban, - name = it.name ?: throw internalServerError( - "Found name-less public account, username: ${it.username}" - ) + PublicAccountInfo( + balance = it.balance, + iban = it.iban ) ) } @@ -1145,11 +1140,11 @@ val sandboxApp: Application.() -> Unit = { return@get } - get("/public-accounts/{account_name}/history") { + get("/accounts/public/{account_name}/history") { // Get transaction history of a public account } - // Keeping the prefix "testing" to allow integration tests using this endpoint. + // Keeping the prefix "testing" not to break tests. post("/testing/register") { // Check demobank was created. val demobank = ensureDemobank(call.getUriComponent("demobankid")) @@ -1173,12 +1168,12 @@ val sandboxApp: Application.() -> Unit = { label = req.username + "acct" // multiple accounts per username not allowed. currency = demobank.currency balance = "${demobank.currency}:0" + owner = req.username } DemobankCustomerEntity.new { username = req.username passwordHash = CryptoUtil.hashpw(req.password) demobankConfig = demobank - this.bankAccount = bankAccount } } call.respondText("Registration successful") diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt @@ -12,17 +12,6 @@ import java.math.BigDecimal private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.sandbox") -fun getAccountFromLabel(accountLabel: String): BankAccountEntity { - return transaction { - val account = BankAccountEntity.find { - BankAccountsTable.label eq accountLabel - }.firstOrNull() - if (account == null) throw SandboxError( - HttpStatusCode.NotFound, "Account '$accountLabel' not found" - ) - account - } -} // Mainly useful inside the CAMT generator. fun balanceForAccount( history: MutableList<RawPayment>, @@ -120,10 +109,14 @@ fun historyForAccount(bankAccount: BankAccountEntity): MutableList<RawPayment> { /** * https://github.com/JetBrains/Exposed/wiki/Transactions#working-with-coroutines * https://medium.com/androiddevelopers/threading-models-in-coroutines-and-android-sqlite-api-6cab11f7eb90 + * + * FIXME: This version will be deprecated. It was made before introducing the demobank configuration */ fun wireTransfer( - debitAccount: String, creditAccount: String, - amount: String, subjectArg: String + debitAccount: String, + creditAccount: String, + amount: String, + subjectArg: String ) { transaction { // check accounts exist diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -8,7 +8,7 @@ import io.ktor.util.* import logger import java.net.URLDecoder -private fun unauthorized(msg: String): UtilError { +fun unauthorized(msg: String): UtilError { return UtilError( HttpStatusCode.Unauthorized, msg, diff --git a/util/src/main/kotlin/JSON.kt b/util/src/main/kotlin/JSON.kt @@ -23,6 +23,8 @@ package tech.libeufin.util * (Very) generic information about one payment. Can be * derived from a CAMT response, or from a prepared PAIN * document. + * + * Note: */ data class RawPayment( val creditorIban: String, diff --git a/util/src/main/kotlin/Payto.kt b/util/src/main/kotlin/Payto.kt @@ -6,6 +6,7 @@ import java.net.URI * Helper data structures. */ data class Payto( + // Can represent a the sender or a receiver. val name: String?, val iban: String, val bic: String? @@ -23,7 +24,10 @@ fun parsePayto(paytoLine: String): Payto { if (javaParsedUri.scheme != "payto") { throw InvalidPaytoError("'${paytoLine}' is not payto") } - + val wireMethod = javaParsedUri.host + if (wireMethod != "sepa") { + throw InvalidPaytoError("Only SEPA is supported, not '$wireMethod'") + } val accountOwner = if (javaParsedUri.query != null) { val queryStringAsList = javaParsedUri.query.split("&") // admit only ONE parameter: receiver-name.