commit e900f224cb9c8e5046b8bbb9db46f96cb40c3b9c
parent db76884dee5897197ef0f3a0d8f72c58ea7e7723
Author: Antoine A <>
Date: Mon, 16 Oct 2023 10:48:56 +0000
Cleanup and improve tests, fix procedures.sql
Diffstat:
14 files changed, 1650 insertions(+), 1722 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt
@@ -0,0 +1,101 @@
+/*
+ * 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/>
+ */
+
+/* This file contains the Taler Integration API endpoints,
+* that are typically requested by wallets. */
+package tech.libeufin.bank
+
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import net.taler.common.errorcodes.TalerErrorCode
+
+fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) {
+ get("/taler-integration/config") {
+ val internalCurrency: String = ctx.currency
+ call.respond(TalerIntegrationConfigResponse(
+ currency = internalCurrency,
+ currency_specification = ctx.currencySpecification
+ ))
+ return@get
+ }
+
+ // Note: wopid acts as an authentication token.
+ get("/taler-integration/withdrawal-operation/{wopid}") {
+ val wopid = call.expectUriComponent("wopid")
+ val op = getWithdrawal(db, wopid) // throws 404 if not found.
+ val relatedBankAccount = db.bankAccountGetFromOwnerId(op.walletBankAccount)
+ ?: throw internalServerError("Bank has a withdrawal not related to any bank account.")
+ val suggestedExchange = ctx.suggestedWithdrawalExchange
+ val confirmUrl = if (ctx.spaCaptchaURL == null) null else
+ getWithdrawalConfirmUrl(
+ baseUrl = ctx.spaCaptchaURL,
+ wopId = wopid
+ )
+ call.respond(
+ BankWithdrawalOperationStatus(
+ aborted = op.aborted,
+ selection_done = op.selectionDone,
+ transfer_done = op.confirmationDone,
+ amount = op.amount,
+ sender_wire = relatedBankAccount.internalPaytoUri.canonical,
+ suggested_exchange = suggestedExchange,
+ confirm_transfer_url = confirmUrl
+ )
+ )
+ return@get
+ }
+ post("/taler-integration/withdrawal-operation/{wopid}") {
+ val wopid = call.expectUriComponent("wopid")
+ val req = call.receive<BankWithdrawalOperationPostRequest>()
+ val op = getWithdrawal(db, wopid) // throws 404 if not found.
+ if (op.selectionDone) { // idempotency
+ if (op.selectedExchangePayto != req.selected_exchange && op.reservePub != req.reserve_pub) throw conflict(
+ hint = "Cannot select different exchange and reserve pub. under the same withdrawal operation",
+ talerEc = TalerErrorCode.TALER_EC_BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT
+ )
+ }
+ val dbSuccess: Boolean = if (!op.selectionDone) { // Check if reserve pub. was used in _another_ withdrawal.
+ if (db.bankTransactionCheckExists(req.reserve_pub) != null) throw conflict(
+ "Reserve pub. already used", TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT
+ )
+ db.talerWithdrawalSetDetails(
+ op.withdrawalUuid, req.selected_exchange, req.reserve_pub
+ )
+ } else { // Nothing to do in the database, i.e. we were successful
+ true
+ }
+ if (!dbSuccess)
+ // Whatever the problem, the bank missed it: respond 500.
+ throw internalServerError("Bank failed at selecting the withdrawal.")
+ // Getting user details that MIGHT be used later.
+ val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !op.confirmationDone) {
+ getWithdrawalConfirmUrl(
+ baseUrl = ctx.spaCaptchaURL,
+ wopId = wopid
+ )
+ } else null
+ val resp = BankWithdrawalOperationPostResponse(
+ transfer_done = op.confirmationDone, confirm_transfer_url = confirmUrl
+ )
+ call.respond(resp)
+ return@post
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -0,0 +1,595 @@
+package tech.libeufin.bank
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import net.taler.common.errorcodes.TalerErrorCode
+import net.taler.wallet.crypto.Base32Crockford
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import tech.libeufin.util.*
+import java.time.Duration
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.*
+import kotlin.random.Random
+
+private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers")
+
+/**
+ * This function collects all the /accounts handlers that
+ * create, update, delete, show bank accounts. No histories
+ * and wire transfers should belong here.
+ */
+fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) {
+
+ // TOKEN ENDPOINTS
+ delete("/accounts/{USERNAME}/token") {
+ val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
+ val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.")
+ val resourceName = call.getResourceName("USERNAME")
+ /**
+ * The following check makes sure that the token belongs
+ * to the username contained in {USERNAME}.
+ */
+ if (!resourceName.canI(c, withAdmin = true)) throw forbidden()
+
+ /**
+ * Not sanity-checking the token, as it was used by the authentication already.
+ * If harder errors happen, then they'll get Ktor respond with 500.
+ */
+ db.bearerTokenDelete(Base32Crockford.decode(token))
+ /**
+ * Responding 204 regardless of it being actually deleted or not.
+ * If it wasn't found, then it must have been deleted before we
+ * reached here, but the token was valid as it served the authentication
+ * => no reason to fail the request.
+ */
+ call.respond(HttpStatusCode.NoContent)
+ }
+ post("/accounts/{USERNAME}/token") {
+ val customer =
+ call.authenticateBankRequest(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed")
+ val endpointOwner = call.maybeUriComponent("USERNAME")
+ if (customer.login != endpointOwner) throw forbidden(
+ "User has no rights on this enpoint",
+ TalerErrorCode.TALER_EC_GENERIC_FORBIDDEN
+ )
+ val maybeAuthToken = call.getAuthToken()
+ val req = call.receive<TokenRequest>()
+ /**
+ * This block checks permissions ONLY IF the call was authenticated
+ * with a token. Basic auth gets always granted.
+ */
+ if (maybeAuthToken != null) {
+ val tokenBytes = Base32Crockford.decode(maybeAuthToken)
+ val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError(
+ "Token used to auth not found in the database!"
+ )
+ if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) throw forbidden(
+ "Cannot generate RW token from RO", TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT
+ )
+ }
+ val tokenBytes = ByteArray(32).apply {
+ Random.nextBytes(this)
+ }
+ val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION
+
+ val creationTime = Instant.now()
+ val expirationTimestamp = if (tokenDuration == ChronoUnit.FOREVER.duration) {
+ logger.debug("Creating 'forever' token.")
+ Instant.MAX
+ } else {
+ try {
+ logger.debug("Creating token with days duration: ${tokenDuration.toDays()}")
+ creationTime.plus(tokenDuration)
+ } catch (e: Exception) {
+ logger.error("Could not add token duration to current time: ${e.message}")
+ throw badRequest("Bad token duration: ${e.message}")
+ }
+ }
+ val customerDbRow = customer.dbRowId ?: throw internalServerError(
+ "Could not get customer '${customer.login}' database row ID"
+ )
+ val token = BearerToken(
+ bankCustomer = customerDbRow,
+ content = tokenBytes,
+ creationTime = creationTime,
+ expirationTime = expirationTimestamp,
+ scope = req.scope,
+ isRefreshable = req.refreshable
+ )
+ if (!db.bearerTokenCreate(token))
+ throw internalServerError("Failed at inserting new token in the database")
+ call.respond(
+ TokenSuccessResponse(
+ access_token = Base32Crockford.encode(tokenBytes), expiration = TalerProtocolTimestamp(
+ t_s = expirationTimestamp
+ )
+ )
+ )
+ return@post
+ }
+ // ACCOUNT ENDPOINTS
+ get("/public-accounts") {
+ // no authentication here.
+ val publicAccounts = db.accountsGetPublic(ctx.currency)
+ if (publicAccounts.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ return@get
+ }
+ call.respond(
+ PublicAccountsResponse().apply {
+ publicAccounts.forEach {
+ this.public_accounts.add(it)
+ }
+ }
+ )
+ return@get
+ }
+ get("/accounts") {
+ val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
+ if (c.login != "admin") throw forbidden("Only admin allowed.")
+ // Get optional param.
+ val maybeFilter: String? = call.request.queryParameters["filter_name"]
+ logger.debug("Filtering on '${maybeFilter}'")
+ val queryParam = if (maybeFilter != null) {
+ "%${maybeFilter}%"
+ } else "%"
+ val dbRes = db.accountsGetForAdmin(queryParam)
+ if (dbRes.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ return@get
+ }
+ call.respond(
+ ListBankAccountsResponse().apply {
+ dbRes.forEach { element ->
+ this.accounts.add(element)
+ }
+ }
+ )
+ return@get
+ }
+ post("/accounts") { // check if only admin is allowed to create new accounts
+ if (ctx.restrictRegistration) {
+ val customer: Customer? = call.authenticateBankRequest(db, TokenScope.readwrite)
+ if (customer == null || customer.login != "admin") throw LibeufinBankException(
+ httpStatus = HttpStatusCode.Unauthorized,
+ talerError = TalerError(
+ code = TalerErrorCode.TALER_EC_GENERIC_UNAUTHORIZED.code,
+ hint = "Either 'admin' not authenticated or an ordinary user tried this operation."
+ )
+ )
+ } // auth passed, proceed with activity.
+ val req = call.receive<RegisterAccountRequest>() // Prohibit reserved usernames:
+ if (req.username == "admin" || req.username == "bank") throw LibeufinBankException(
+ httpStatus = HttpStatusCode.Conflict,
+ talerError = TalerError(
+ code = TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT.code,
+ hint = "Username '${req.username}' is reserved."
+ )
+ ) // Checking idempotency.
+ val maybeCustomerExists =
+ db.customerGetFromLogin(req.username) // Can be null if previous call crashed before completion.
+ val maybeHasBankAccount = maybeCustomerExists.run {
+ if (this == null) return@run null
+ db.bankAccountGetFromOwnerId(this.expectRowId())
+ }
+ val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri())
+ if (maybeCustomerExists != null && maybeHasBankAccount != null) {
+ logger.debug("Registering username was found: ${maybeCustomerExists.login}") // Checking _all_ the details are the same.
+ val isIdentic =
+ maybeCustomerExists.name == req.name &&
+ maybeCustomerExists.email == req.challenge_contact_data?.email &&
+ maybeCustomerExists.phone == req.challenge_contact_data?.phone &&
+ maybeCustomerExists.cashoutPayto == req.cashout_payto_uri &&
+ CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) &&
+ maybeHasBankAccount.isPublic == req.is_public &&
+ maybeHasBankAccount.isTalerExchange == req.is_taler_exchange &&
+ maybeHasBankAccount.internalPaytoUri.canonical == internalPayto.canonical
+ if (isIdentic) {
+ call.respond(HttpStatusCode.Created)
+ return@post
+ }
+ throw LibeufinBankException(
+ httpStatus = HttpStatusCode.Conflict, talerError = TalerError(
+ code = GENERIC_UNDEFINED, // FIXME: provide appropriate EC.
+ hint = "Idempotency check failed."
+ )
+ )
+ }
+
+ // From here: fresh user being added.
+ val newCustomer = Customer(
+ login = req.username,
+ name = req.name,
+ email = req.challenge_contact_data?.email,
+ phone = req.challenge_contact_data?.phone,
+ cashoutPayto = req.cashout_payto_uri, // Following could be gone, if included in cashout_payto_uri
+ cashoutCurrency = ctx.cashoutCurrency,
+ passwordHash = CryptoUtil.hashpw(req.password),
+ )
+ val newCustomerRowId = db.customerCreate(newCustomer)
+ ?: throw internalServerError("New customer INSERT failed despite the previous checks") // Crashing here won't break data consistency between customers and bank accounts, because of the idempotency. Client will just have to retry.
+ val maxDebt = ctx.defaultCustomerDebtLimit
+ val newBankAccount = BankAccount(
+ hasDebt = false,
+ internalPaytoUri = internalPayto,
+ owningCustomerId = newCustomerRowId,
+ isPublic = req.is_public,
+ isTalerExchange = req.is_taler_exchange,
+ maxDebt = maxDebt
+ )
+ val newBankAccountId = db.bankAccountCreate(newBankAccount)
+ ?: throw internalServerError("Could not INSERT bank account despite all the checks.")
+
+ // The new account got created, now optionally award the registration
+ // bonus to it.
+ val bonusAmount = if (ctx.registrationBonusEnabled && !req.is_taler_exchange) ctx.registrationBonus else null
+ if (bonusAmount != null) {
+ val adminCustomer =
+ db.customerGetFromLogin("admin") ?: throw internalServerError("Admin customer not found")
+ val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId())
+ ?: throw internalServerError("Admin bank account not found")
+ val adminPaysBonus = BankInternalTransaction(
+ creditorAccountId = newBankAccountId,
+ debtorAccountId = adminBankAccount.expectRowId(),
+ amount = bonusAmount,
+ subject = "Registration bonus.",
+ transactionDate = Instant.now()
+ )
+ when (db.bankTransactionCreate(adminPaysBonus)) {
+ BankTransactionResult.NO_CREDITOR -> throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.")
+ BankTransactionResult.NO_DEBTOR -> throw internalServerError("Bonus impossible: admin not found.")
+ BankTransactionResult.BALANCE_INSUFFICIENT -> throw internalServerError("Bonus impossible: admin has insufficient balance.")
+ BankTransactionResult.SAME_ACCOUNT -> throw internalServerError("Bonus impossible: admin should not be creditor.")
+ BankTransactionResult.SUCCESS -> {/* continue the execution */
+ }
+ }
+ }
+ call.respond(HttpStatusCode.Created)
+ return@post
+ }
+ get("/accounts/{USERNAME}") {
+ val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized("Login failed")
+ val resourceName = call.expectUriComponent("USERNAME")
+ if (!resourceName.canI(c, withAdmin = true)) throw forbidden()
+ val customerData = db.customerGetFromLogin(resourceName) ?: throw notFound(
+ "Customer '$resourceName' not found in the database.",
+ talerEc = TalerErrorCode.TALER_EC_END
+ )
+ val bankAccountData = db.bankAccountGetFromOwnerId(customerData.expectRowId())
+ ?: throw internalServerError("Customer '$resourceName' had no bank account despite they are customer.'")
+ val balance = Balance(
+ amount = bankAccountData.balance ?: throw internalServerError("Account '${customerData.login}' lacks balance!"),
+ credit_debit_indicator = if (bankAccountData.hasDebt) {
+ CorebankCreditDebitInfo.debit
+ } else {
+ CorebankCreditDebitInfo.credit
+ }
+ )
+ call.respond(
+ AccountData(
+ name = customerData.name,
+ balance = balance,
+ debit_threshold = bankAccountData.maxDebt,
+ payto_uri = bankAccountData.internalPaytoUri,
+ contact_data = ChallengeContactData(
+ email = customerData.email, phone = customerData.phone
+ ),
+ cashout_payto_uri = customerData.cashoutPayto,
+ )
+ )
+ return@get
+ }
+ delete("/accounts/{USERNAME}") {
+ val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
+ val resourceName = call.expectUriComponent("USERNAME")
+ // Checking rights.
+ if (c.login != "admin" && ctx.restrictAccountDeletion)
+ throw forbidden("Only admin allowed.")
+ if (!resourceName.canI(c, withAdmin = true))
+ throw forbidden("Insufficient rights on this account.")
+ // Not deleting reserved names.
+ if (resourceName == "bank" || resourceName == "admin")
+ throw forbidden("Cannot delete reserved accounts.")
+ val res = db.customerDeleteIfBalanceIsZero(resourceName)
+ when (res) {
+ CustomerDeletionResult.CUSTOMER_NOT_FOUND ->
+ throw notFound(
+ "Customer '$resourceName' not found",
+ talerEc = TalerErrorCode.TALER_EC_NONE // FIXME: need EC.
+ )
+ CustomerDeletionResult.BALANCE_NOT_ZERO ->
+ throw LibeufinBankException(
+ httpStatus = HttpStatusCode.PreconditionFailed,
+ talerError = TalerError(
+ hint = "Balance is not zero.",
+ code = TalerErrorCode.TALER_EC_NONE.code // FIXME: need EC.
+ )
+ )
+ CustomerDeletionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
+ }
+ return@delete
+ }
+ patch("/accounts/{USERNAME}/auth") {
+ val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
+ val accountName = call.getResourceName("USERNAME")
+ if (!accountName.canI(c, withAdmin = true)) throw forbidden()
+ val req = call.receive<AccountPasswordChange>()
+ val hashedPassword = CryptoUtil.hashpw(req.new_password)
+ if (!db.customerChangePassword(
+ accountName,
+ hashedPassword
+ )) throw notFound(
+ "Account '$accountName' not found (despite it being authenticated by this call)",
+ talerEc = TalerErrorCode.TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND.
+ )
+ call.respond(HttpStatusCode.NoContent)
+ return@patch
+ }
+ patch("/accounts/{USERNAME}") {
+ val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
+ val accountName = call.getResourceName("USERNAME")
+ // preventing user non-admin X trying on resource Y.
+ if (!accountName.canI(c, withAdmin = true)) throw forbidden()
+ // admin is not allowed itself to change its own details.
+ if (accountName == "admin") throw forbidden("admin account not patchable")
+ // authentication OK, go on.
+ val req = call.receive<AccountReconfiguration>()
+ /**
+ * This object holds the details of the customer that's affected
+ * by this operation, as it MAY differ from the one being authenticated.
+ * This typically happens when admin did the request.
+ */
+ val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound(
+ "Account $accountName not found",
+ talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC.
+ )
+ // Check if a non-admin user tried to change their legal name
+ if ((c.login != "admin") && (req.name != null) && (req.name != accountCustomer.name))
+ throw forbidden("non-admin user cannot change their legal name")
+ // Preventing identical data to be overridden.
+ val bankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId())
+ ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.")
+ if (
+ (req.is_exchange == bankAccount.isTalerExchange) &&
+ (req.cashout_address == accountCustomer.cashoutPayto) &&
+ (req.name == c.name) &&
+ (req.challenge_contact_data?.phone == accountCustomer.phone) &&
+ (req.challenge_contact_data?.email == accountCustomer.email)
+ ) {
+ call.respond(HttpStatusCode.NoContent)
+ return@patch
+ }
+ val dbRes = db.accountReconfig(
+ login = accountCustomer.login,
+ name = req.name,
+ cashoutPayto = req.cashout_address,
+ emailAddress = req.challenge_contact_data?.email,
+ isTalerExchange = req.is_exchange,
+ phoneNumber = req.challenge_contact_data?.phone
+ )
+ when (dbRes) {
+ AccountReconfigDBResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
+ AccountReconfigDBResult.CUSTOMER_NOT_FOUND -> {
+ // Rare case. Only possible if a deletion happened before the flow reaches here.
+ logger.warn("Authenticated customer wasn't found any more in the database")
+ throw notFound("Customer not found", TalerErrorCode.TALER_EC_END) // FIXME: needs EC
+ }
+ AccountReconfigDBResult.BANK_ACCOUNT_NOT_FOUND -> {
+ // Bank's fault: no customer should lack a bank account.
+ throw internalServerError("Customer '${accountCustomer.login}' lacks bank account")
+ }
+ }
+ return@patch
+ }
+ // WITHDRAWAL ENDPOINTS
+ post("/accounts/{USERNAME}/withdrawals") {
+ val c = call.authenticateBankRequest(db, TokenScope.readwrite)
+ ?: throw unauthorized() // Admin not allowed to withdraw in the name of customers:
+ val accountName = call.expectUriComponent("USERNAME")
+ if (c.login != accountName) throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'")
+ val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds.
+ if(req.amount.currency != ctx.currency)
+ throw badRequest("Wrong currency: ${req.amount.currency}")
+ val b = db.bankAccountGetFromOwnerId(c.expectRowId())
+ ?: throw internalServerError("Customer '${c.login}' lacks bank account.")
+ if (!isBalanceEnough(
+ balance = b.expectBalance(), due = req.amount, maxDebt = b.maxDebt, hasBalanceDebt = b.hasDebt
+ )
+ ) throw forbidden(
+ hint = "Insufficient funds to withdraw with Taler",
+ talerErrorCode = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ ) // Auth and funds passed, create the operation now!
+ val opId = UUID.randomUUID()
+ if (!db.talerWithdrawalCreate(
+ opId, b.expectRowId(), req.amount
+ )
+ ) throw internalServerError("Bank failed at creating the withdraw operation.")
+
+ val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL")
+ call.respond(
+ BankAccountCreateWithdrawalResponse(
+ withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString())
+ )
+ )
+ return@post
+ }
+ get("/withdrawals/{withdrawal_id}") {
+ val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id"))
+ call.respond(
+ BankAccountGetWithdrawalResponse(
+ amount = op.amount,
+ aborted = op.aborted,
+ confirmation_done = op.confirmationDone,
+ selection_done = op.selectionDone,
+ selected_exchange_account = op.selectedExchangePayto,
+ selected_reserve_pub = op.reservePub
+ )
+ )
+ return@get
+ }
+ post("/withdrawals/{withdrawal_id}/abort") {
+ val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Idempotency:
+ if (op.aborted) {
+ call.respondText("{}", ContentType.Application.Json)
+ return@post
+ } // Op is found, it'll now fail only if previously confirmed (DB checks).
+ if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict(
+ hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END
+ )
+ call.respondText("{}", ContentType.Application.Json)
+ return@post
+ }
+ post("/withdrawals/{withdrawal_id}/confirm") {
+ val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency:
+ if (op.confirmationDone) {
+ call.respondText("{}", ContentType.Application.Json)
+ return@post
+ }
+ if (op.aborted) throw conflict(
+ hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT
+ ) // Checking that reserve GOT indeed selected.
+ if (!op.selectionDone) throw LibeufinBankException(
+ httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError(
+ hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code
+ )
+ ) // Confirmation conditions are all met, now put the operation
+ // to the selected state _and_ wire the funds to the exchange.
+ // Note: 'when' helps not to omit more result codes, should more
+ // be added.
+ when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) {
+ WithdrawalConfirmationResult.BALANCE_INSUFFICIENT ->
+ throw conflict(
+ "Insufficient funds",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ WithdrawalConfirmationResult.OP_NOT_FOUND ->
+ /**
+ * Despite previous checks, the database _still_ did not
+ * find the withdrawal operation, that's on the bank.
+ */
+ throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found")
+
+ WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND ->
+ /**
+ * That can happen because the bank did not check the exchange
+ * exists when POST /withdrawals happened, or because the exchange
+ * bank account got removed before this confirmation.
+ */
+ throw conflict(
+ hint = "Exchange to withdraw from not found",
+ talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+
+ WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency")
+
+ WithdrawalConfirmationResult.SUCCESS -> call.respondText(
+ "{}", ContentType.Application.Json
+ )
+ }
+ return@post
+ }
+ // TRANSACTION ENDPOINT
+ get("/accounts/{USERNAME}/transactions") {
+ val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
+ val resourceName = call.getResourceName("USERNAME")
+ if (!resourceName.canI(c, withAdmin = true)) throw forbidden()
+ val historyParams = getHistoryParams(call.request.queryParameters)
+ val resourceCustomer = db.customerGetFromLogin(resourceName) ?: throw notFound(
+ hint = "Customer '$resourceName' not found in the database",
+ talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC.
+ )
+ val bankAccount = db.bankAccountGetFromOwnerId(resourceCustomer.expectRowId())
+ ?: throw internalServerError("Customer '${resourceCustomer.login}' lacks bank account.")
+ val history: List<BankAccountTransactionInfo> = db.bankPoolHistory(historyParams, bankAccount.expectRowId())
+ call.respond(BankAccountTransactionsResponse(history))
+ }
+ // Creates a bank transaction.
+ post("/accounts/{USERNAME}/transactions") {
+ val c: Customer = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
+ val resourceName = call.expectUriComponent("USERNAME") // admin has no rights here.
+ if ((c.login != resourceName) && (call.getAuthToken() == null)) throw forbidden()
+ val tx = call.receive<BankAccountTransactionCreate>()
+ val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
+ val debtorBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId())
+ ?: throw internalServerError("Debtor bank account not found")
+ if (tx.amount.currency != ctx.currency) throw badRequest(
+ "Wrong currency: ${tx.amount.currency}",
+ talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
+ )
+ if (!isBalanceEnough(
+ balance = debtorBankAccount.expectBalance(),
+ due = tx.amount,
+ hasBalanceDebt = debtorBankAccount.hasDebt,
+ maxDebt = debtorBankAccount.maxDebt
+ ))
+ throw conflict(hint = "Insufficient balance.", talerEc = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT)
+ logger.info("creditor payto: ${tx.payto_uri}")
+ val creditorBankAccount = db.bankAccountGetFromInternalPayto(tx.payto_uri)
+ ?: throw notFound(
+ "Creditor account not found",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ val dbInstructions = BankInternalTransaction(
+ debtorAccountId = debtorBankAccount.expectRowId(),
+ creditorAccountId = creditorBankAccount.expectRowId(),
+ subject = subject,
+ amount = tx.amount,
+ transactionDate = Instant.now()
+ )
+ val res = db.bankTransactionCreate(dbInstructions)
+ when (res) {
+ BankTransactionResult.BALANCE_INSUFFICIENT -> throw conflict(
+ "Insufficient funds",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ BankTransactionResult.SAME_ACCOUNT -> throw conflict(
+ "Wire transfer attempted with credit and debit party being the same bank account",
+ TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
+ )
+ BankTransactionResult.NO_CREDITOR -> throw internalServerError("Creditor not found despite previous checks.")
+ BankTransactionResult.NO_DEBTOR -> throw internalServerError("Debtor not found despite the request was authenticated.")
+ BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK)
+ }
+ return@post
+ }
+ get("/accounts/{USERNAME}/transactions/{T_ID}") {
+ val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
+ val accountName = call.getResourceName("USERNAME")
+ if (!accountName.canI(c, withAdmin = true)) throw forbidden()
+ val tId = call.expectUriComponent("T_ID")
+ val txRowId = try {
+ tId.toLong()
+ } catch (e: Exception) {
+ logger.error(e.message)
+ throw badRequest("TRANSACTION_ID is not a number: ${tId}")
+ }
+ val tx = db.bankTransactionGetFromInternalId(txRowId) ?: throw notFound(
+ "Bank transaction '$tId' not found",
+ TalerErrorCode.TALER_EC_BANK_TRANSACTION_NOT_FOUND
+ )
+ val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound(
+ hint = "Customer $accountName not found",
+ talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC.
+ )
+ val customerBankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId())
+ ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.")
+ if (tx.bankAccountId != customerBankAccount.bankAccountId) throw forbidden("Client has no rights over the bank transaction: $tId")
+ call.respond(
+ BankAccountTransactionInfo(
+ amount = tx.amount,
+ creditor_payto_uri = tx.creditorPaytoUri,
+ debtor_payto_uri = tx.debtorPaytoUri,
+ date = TalerProtocolTimestamp(tx.transactionDate),
+ direction = tx.direction,
+ subject = tx.subject,
+ row_id = txRowId
+ )
+ )
+ return@get
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt
@@ -1,594 +0,0 @@
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import net.taler.common.errorcodes.TalerErrorCode
-import net.taler.wallet.crypto.Base32Crockford
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import tech.libeufin.util.*
-import java.time.Duration
-import java.time.Instant
-import java.time.temporal.ChronoUnit
-import java.util.*
-
-private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers")
-
-/**
- * This function collects all the /accounts handlers that
- * create, update, delete, show bank accounts. No histories
- * and wire transfers should belong here.
- */
-fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) {
-
- // TOKEN ENDPOINTS
- delete("/accounts/{USERNAME}/token") {
- val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
- val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.")
- val resourceName = call.getResourceName("USERNAME")
- /**
- * The following check makes sure that the token belongs
- * to the username contained in {USERNAME}.
- */
- if (!resourceName.canI(c, withAdmin = true)) throw forbidden()
-
- /**
- * Not sanity-checking the token, as it was used by the authentication already.
- * If harder errors happen, then they'll get Ktor respond with 500.
- */
- db.bearerTokenDelete(Base32Crockford.decode(token))
- /**
- * Responding 204 regardless of it being actually deleted or not.
- * If it wasn't found, then it must have been deleted before we
- * reached here, but the token was valid as it served the authentication
- * => no reason to fail the request.
- */
- call.respond(HttpStatusCode.NoContent)
- }
- post("/accounts/{USERNAME}/token") {
- val customer =
- call.authenticateBankRequest(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed")
- val endpointOwner = call.maybeUriComponent("USERNAME")
- if (customer.login != endpointOwner) throw forbidden(
- "User has no rights on this enpoint",
- TalerErrorCode.TALER_EC_GENERIC_FORBIDDEN
- )
- val maybeAuthToken = call.getAuthToken()
- val req = call.receive<TokenRequest>()
- /**
- * This block checks permissions ONLY IF the call was authenticated
- * with a token. Basic auth gets always granted.
- */
- if (maybeAuthToken != null) {
- val tokenBytes = Base32Crockford.decode(maybeAuthToken)
- val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError(
- "Token used to auth not found in the database!"
- )
- if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) throw forbidden(
- "Cannot generate RW token from RO", TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT
- )
- }
- val tokenBytes = ByteArray(32).apply {
- Random().nextBytes(this)
- }
- val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION
-
- val creationTime = Instant.now()
- val expirationTimestamp = if (tokenDuration == ChronoUnit.FOREVER.duration) {
- logger.debug("Creating 'forever' token.")
- Instant.MAX
- } else {
- try {
- logger.debug("Creating token with days duration: ${tokenDuration.toDays()}")
- creationTime.plus(tokenDuration)
- } catch (e: Exception) {
- logger.error("Could not add token duration to current time: ${e.message}")
- throw badRequest("Bad token duration: ${e.message}")
- }
- }
- val customerDbRow = customer.dbRowId ?: throw internalServerError(
- "Could not get customer '${customer.login}' database row ID"
- )
- val token = BearerToken(
- bankCustomer = customerDbRow,
- content = tokenBytes,
- creationTime = creationTime,
- expirationTime = expirationTimestamp,
- scope = req.scope,
- isRefreshable = req.refreshable
- )
- if (!db.bearerTokenCreate(token))
- throw internalServerError("Failed at inserting new token in the database")
- call.respond(
- TokenSuccessResponse(
- access_token = Base32Crockford.encode(tokenBytes), expiration = TalerProtocolTimestamp(
- t_s = expirationTimestamp
- )
- )
- )
- return@post
- }
- // ACCOUNT ENDPOINTS
- get("/public-accounts") {
- // no authentication here.
- val publicAccounts = db.accountsGetPublic(ctx.currency)
- if (publicAccounts.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- return@get
- }
- call.respond(
- PublicAccountsResponse().apply {
- publicAccounts.forEach {
- this.public_accounts.add(it)
- }
- }
- )
- return@get
- }
- get("/accounts") {
- val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
- if (c.login != "admin") throw forbidden("Only admin allowed.")
- // Get optional param.
- val maybeFilter: String? = call.request.queryParameters["filter_name"]
- logger.debug("Filtering on '${maybeFilter}'")
- val queryParam = if (maybeFilter != null) {
- "%${maybeFilter}%"
- } else "%"
- val dbRes = db.accountsGetForAdmin(queryParam)
- if (dbRes.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- return@get
- }
- call.respond(
- ListBankAccountsResponse().apply {
- dbRes.forEach { element ->
- this.accounts.add(element)
- }
- }
- )
- return@get
- }
- post("/accounts") { // check if only admin is allowed to create new accounts
- if (ctx.restrictRegistration) {
- val customer: Customer? = call.authenticateBankRequest(db, TokenScope.readwrite)
- if (customer == null || customer.login != "admin") throw LibeufinBankException(
- httpStatus = HttpStatusCode.Unauthorized,
- talerError = TalerError(
- code = TalerErrorCode.TALER_EC_GENERIC_UNAUTHORIZED.code,
- hint = "Either 'admin' not authenticated or an ordinary user tried this operation."
- )
- )
- } // auth passed, proceed with activity.
- val req = call.receive<RegisterAccountRequest>() // Prohibit reserved usernames:
- if (req.username == "admin" || req.username == "bank") throw LibeufinBankException(
- httpStatus = HttpStatusCode.Conflict,
- talerError = TalerError(
- code = TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT.code,
- hint = "Username '${req.username}' is reserved."
- )
- ) // Checking idempotency.
- val maybeCustomerExists =
- db.customerGetFromLogin(req.username) // Can be null if previous call crashed before completion.
- val maybeHasBankAccount = maybeCustomerExists.run {
- if (this == null) return@run null
- db.bankAccountGetFromOwnerId(this.expectRowId())
- }
- val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri())
- if (maybeCustomerExists != null && maybeHasBankAccount != null) {
- logger.debug("Registering username was found: ${maybeCustomerExists.login}") // Checking _all_ the details are the same.
- val isIdentic =
- maybeCustomerExists.name == req.name &&
- maybeCustomerExists.email == req.challenge_contact_data?.email &&
- maybeCustomerExists.phone == req.challenge_contact_data?.phone &&
- maybeCustomerExists.cashoutPayto == req.cashout_payto_uri &&
- CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) &&
- maybeHasBankAccount.isPublic == req.is_public &&
- maybeHasBankAccount.isTalerExchange == req.is_taler_exchange &&
- maybeHasBankAccount.internalPaytoUri.stripped == internalPayto.stripped
- if (isIdentic) {
- call.respond(HttpStatusCode.Created)
- return@post
- }
- throw LibeufinBankException(
- httpStatus = HttpStatusCode.Conflict, talerError = TalerError(
- code = GENERIC_UNDEFINED, // FIXME: provide appropriate EC.
- hint = "Idempotency check failed."
- )
- )
- }
-
- // From here: fresh user being added.
- val newCustomer = Customer(
- login = req.username,
- name = req.name,
- email = req.challenge_contact_data?.email,
- phone = req.challenge_contact_data?.phone,
- cashoutPayto = req.cashout_payto_uri, // Following could be gone, if included in cashout_payto_uri
- cashoutCurrency = ctx.cashoutCurrency,
- passwordHash = CryptoUtil.hashpw(req.password),
- )
- val newCustomerRowId = db.customerCreate(newCustomer)
- ?: throw internalServerError("New customer INSERT failed despite the previous checks") // Crashing here won't break data consistency between customers and bank accounts, because of the idempotency. Client will just have to retry.
- val maxDebt = ctx.defaultCustomerDebtLimit
- val newBankAccount = BankAccount(
- hasDebt = false,
- internalPaytoUri = internalPayto,
- owningCustomerId = newCustomerRowId,
- isPublic = req.is_public,
- isTalerExchange = req.is_taler_exchange,
- maxDebt = maxDebt
- )
- val newBankAccountId = db.bankAccountCreate(newBankAccount)
- ?: throw internalServerError("Could not INSERT bank account despite all the checks.")
-
- // The new account got created, now optionally award the registration
- // bonus to it.
- val bonusAmount = if (ctx.registrationBonusEnabled && !req.is_taler_exchange) ctx.registrationBonus else null
- if (bonusAmount != null) {
- val adminCustomer =
- db.customerGetFromLogin("admin") ?: throw internalServerError("Admin customer not found")
- val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId())
- ?: throw internalServerError("Admin bank account not found")
- val adminPaysBonus = BankInternalTransaction(
- creditorAccountId = newBankAccountId,
- debtorAccountId = adminBankAccount.expectRowId(),
- amount = bonusAmount,
- subject = "Registration bonus.",
- transactionDate = Instant.now()
- )
- when (db.bankTransactionCreate(adminPaysBonus)) {
- BankTransactionResult.NO_CREDITOR -> throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.")
- BankTransactionResult.NO_DEBTOR -> throw internalServerError("Bonus impossible: admin not found.")
- BankTransactionResult.BALANCE_INSUFFICIENT -> throw internalServerError("Bonus impossible: admin has insufficient balance.")
- BankTransactionResult.SAME_ACCOUNT -> throw internalServerError("Bonus impossible: admin should not be creditor.")
- BankTransactionResult.SUCCESS -> {/* continue the execution */
- }
- }
- }
- call.respond(HttpStatusCode.Created)
- return@post
- }
- get("/accounts/{USERNAME}") {
- val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized("Login failed")
- val resourceName = call.expectUriComponent("USERNAME")
- if (!resourceName.canI(c, withAdmin = true)) throw forbidden()
- val customerData = db.customerGetFromLogin(resourceName) ?: throw notFound(
- "Customer '$resourceName' not found in the database.",
- talerEc = TalerErrorCode.TALER_EC_END
- )
- val bankAccountData = db.bankAccountGetFromOwnerId(customerData.expectRowId())
- ?: throw internalServerError("Customer '$resourceName' had no bank account despite they are customer.'")
- val balance = Balance(
- amount = bankAccountData.balance ?: throw internalServerError("Account '${customerData.login}' lacks balance!"),
- credit_debit_indicator = if (bankAccountData.hasDebt) {
- CorebankCreditDebitInfo.debit
- } else {
- CorebankCreditDebitInfo.credit
- }
- )
- call.respond(
- AccountData(
- name = customerData.name,
- balance = balance,
- debit_threshold = bankAccountData.maxDebt,
- payto_uri = bankAccountData.internalPaytoUri,
- contact_data = ChallengeContactData(
- email = customerData.email, phone = customerData.phone
- ),
- cashout_payto_uri = customerData.cashoutPayto,
- )
- )
- return@get
- }
- delete("/accounts/{USERNAME}") {
- val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
- val resourceName = call.expectUriComponent("USERNAME")
- // Checking rights.
- if (c.login != "admin" && ctx.restrictAccountDeletion)
- throw forbidden("Only admin allowed.")
- if (!resourceName.canI(c, withAdmin = true))
- throw forbidden("Insufficient rights on this account.")
- // Not deleting reserved names.
- if (resourceName == "bank" || resourceName == "admin")
- throw forbidden("Cannot delete reserved accounts.")
- val res = db.customerDeleteIfBalanceIsZero(resourceName)
- when (res) {
- CustomerDeletionResult.CUSTOMER_NOT_FOUND ->
- throw notFound(
- "Customer '$resourceName' not found",
- talerEc = TalerErrorCode.TALER_EC_NONE // FIXME: need EC.
- )
- CustomerDeletionResult.BALANCE_NOT_ZERO ->
- throw LibeufinBankException(
- httpStatus = HttpStatusCode.PreconditionFailed,
- talerError = TalerError(
- hint = "Balance is not zero.",
- code = TalerErrorCode.TALER_EC_NONE.code // FIXME: need EC.
- )
- )
- CustomerDeletionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
- }
- return@delete
- }
- patch("/accounts/{USERNAME}/auth") {
- val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
- val accountName = call.getResourceName("USERNAME")
- if (!accountName.canI(c, withAdmin = true)) throw forbidden()
- val req = call.receive<AccountPasswordChange>()
- val hashedPassword = CryptoUtil.hashpw(req.new_password)
- if (!db.customerChangePassword(
- accountName,
- hashedPassword
- )) throw notFound(
- "Account '$accountName' not found (despite it being authenticated by this call)",
- talerEc = TalerErrorCode.TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND.
- )
- call.respond(HttpStatusCode.NoContent)
- return@patch
- }
- patch("/accounts/{USERNAME}") {
- val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
- val accountName = call.getResourceName("USERNAME")
- // preventing user non-admin X trying on resource Y.
- if (!accountName.canI(c, withAdmin = true)) throw forbidden()
- // admin is not allowed itself to change its own details.
- if (accountName == "admin") throw forbidden("admin account not patchable")
- // authentication OK, go on.
- val req = call.receive<AccountReconfiguration>()
- /**
- * This object holds the details of the customer that's affected
- * by this operation, as it MAY differ from the one being authenticated.
- * This typically happens when admin did the request.
- */
- val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound(
- "Account $accountName not found",
- talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC.
- )
- // Check if a non-admin user tried to change their legal name
- if ((c.login != "admin") && (req.name != null) && (req.name != accountCustomer.name))
- throw forbidden("non-admin user cannot change their legal name")
- // Preventing identical data to be overridden.
- val bankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId())
- ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.")
- if (
- (req.is_exchange == bankAccount.isTalerExchange) &&
- (req.cashout_address == accountCustomer.cashoutPayto) &&
- (req.name == c.name) &&
- (req.challenge_contact_data?.phone == accountCustomer.phone) &&
- (req.challenge_contact_data?.email == accountCustomer.email)
- ) {
- call.respond(HttpStatusCode.NoContent)
- return@patch
- }
- val dbRes = db.accountReconfig(
- login = accountCustomer.login,
- name = req.name,
- cashoutPayto = req.cashout_address,
- emailAddress = req.challenge_contact_data?.email,
- isTalerExchange = req.is_exchange,
- phoneNumber = req.challenge_contact_data?.phone
- )
- when (dbRes) {
- AccountReconfigDBResult.SUCCESS -> call.respond(HttpStatusCode.NoContent)
- AccountReconfigDBResult.CUSTOMER_NOT_FOUND -> {
- // Rare case. Only possible if a deletion happened before the flow reaches here.
- logger.warn("Authenticated customer wasn't found any more in the database")
- throw notFound("Customer not found", TalerErrorCode.TALER_EC_END) // FIXME: needs EC
- }
- AccountReconfigDBResult.BANK_ACCOUNT_NOT_FOUND -> {
- // Bank's fault: no customer should lack a bank account.
- throw internalServerError("Customer '${accountCustomer.login}' lacks bank account")
- }
- }
- return@patch
- }
- // WITHDRAWAL ENDPOINTS
- post("/accounts/{USERNAME}/withdrawals") {
- val c = call.authenticateBankRequest(db, TokenScope.readwrite)
- ?: throw unauthorized() // Admin not allowed to withdraw in the name of customers:
- val accountName = call.expectUriComponent("USERNAME")
- if (c.login != accountName) throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'")
- val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds.
- if(req.amount.currency != ctx.currency)
- throw badRequest("Wrong currency: ${req.amount.currency}")
- val b = db.bankAccountGetFromOwnerId(c.expectRowId())
- ?: throw internalServerError("Customer '${c.login}' lacks bank account.")
- if (!isBalanceEnough(
- balance = b.expectBalance(), due = req.amount, maxDebt = b.maxDebt, hasBalanceDebt = b.hasDebt
- )
- ) throw forbidden(
- hint = "Insufficient funds to withdraw with Taler",
- talerErrorCode = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
- ) // Auth and funds passed, create the operation now!
- val opId = UUID.randomUUID()
- if (!db.talerWithdrawalCreate(
- opId, b.expectRowId(), req.amount
- )
- ) throw internalServerError("Bank failed at creating the withdraw operation.")
-
- val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL")
- call.respond(
- BankAccountCreateWithdrawalResponse(
- withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString())
- )
- )
- return@post
- }
- get("/withdrawals/{withdrawal_id}") {
- val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id"))
- call.respond(
- BankAccountGetWithdrawalResponse(
- amount = op.amount,
- aborted = op.aborted,
- confirmation_done = op.confirmationDone,
- selection_done = op.selectionDone,
- selected_exchange_account = op.selectedExchangePayto,
- selected_reserve_pub = op.reservePub
- )
- )
- return@get
- }
- post("/withdrawals/{withdrawal_id}/abort") {
- val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Idempotency:
- if (op.aborted) {
- call.respondText("{}", ContentType.Application.Json)
- return@post
- } // Op is found, it'll now fail only if previously confirmed (DB checks).
- if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict(
- hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END
- )
- call.respondText("{}", ContentType.Application.Json)
- return@post
- }
- post("/withdrawals/{withdrawal_id}/confirm") {
- val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency:
- if (op.confirmationDone) {
- call.respondText("{}", ContentType.Application.Json)
- return@post
- }
- if (op.aborted) throw conflict(
- hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT
- ) // Checking that reserve GOT indeed selected.
- if (!op.selectionDone) throw LibeufinBankException(
- httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError(
- hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code
- )
- ) // Confirmation conditions are all met, now put the operation
- // to the selected state _and_ wire the funds to the exchange.
- // Note: 'when' helps not to omit more result codes, should more
- // be added.
- when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) {
- WithdrawalConfirmationResult.BALANCE_INSUFFICIENT ->
- throw conflict(
- "Insufficient funds",
- TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
- )
- WithdrawalConfirmationResult.OP_NOT_FOUND ->
- /**
- * Despite previous checks, the database _still_ did not
- * find the withdrawal operation, that's on the bank.
- */
- throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found")
-
- WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND ->
- /**
- * That can happen because the bank did not check the exchange
- * exists when POST /withdrawals happened, or because the exchange
- * bank account got removed before this confirmation.
- */
- throw conflict(
- hint = "Exchange to withdraw from not found",
- talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
-
- WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency")
-
- WithdrawalConfirmationResult.SUCCESS -> call.respondText(
- "{}", ContentType.Application.Json
- )
- }
- return@post
- }
- // TRANSACTION ENDPOINT
- get("/accounts/{USERNAME}/transactions") {
- val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
- val resourceName = call.getResourceName("USERNAME")
- if (!resourceName.canI(c, withAdmin = true)) throw forbidden()
- val historyParams = getHistoryParams(call.request.queryParameters)
- val resourceCustomer = db.customerGetFromLogin(resourceName) ?: throw notFound(
- hint = "Customer '$resourceName' not found in the database",
- talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC.
- )
- val bankAccount = db.bankAccountGetFromOwnerId(resourceCustomer.expectRowId())
- ?: throw internalServerError("Customer '${resourceCustomer.login}' lacks bank account.")
- val history: List<BankAccountTransactionInfo> = db.bankPoolHistory(historyParams, bankAccount.expectRowId())
- call.respond(BankAccountTransactionsResponse(history))
- }
- // Creates a bank transaction.
- post("/accounts/{USERNAME}/transactions") {
- val c: Customer = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized()
- val resourceName = call.expectUriComponent("USERNAME") // admin has no rights here.
- if ((c.login != resourceName) && (call.getAuthToken() == null)) throw forbidden()
- val tx = call.receive<BankAccountTransactionCreate>()
- val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
- val debtorBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId())
- ?: throw internalServerError("Debtor bank account not found")
- if (tx.amount.currency != ctx.currency) throw badRequest(
- "Wrong currency: ${tx.amount.currency}",
- talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
- )
- if (!isBalanceEnough(
- balance = debtorBankAccount.expectBalance(),
- due = tx.amount,
- hasBalanceDebt = debtorBankAccount.hasDebt,
- maxDebt = debtorBankAccount.maxDebt
- ))
- throw conflict(hint = "Insufficient balance.", talerEc = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT)
- logger.info("creditor payto: ${tx.payto_uri}")
- val creditorBankAccount = db.bankAccountGetFromInternalPayto(tx.payto_uri)
- ?: throw notFound(
- "Creditor account not found",
- TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
- val dbInstructions = BankInternalTransaction(
- debtorAccountId = debtorBankAccount.expectRowId(),
- creditorAccountId = creditorBankAccount.expectRowId(),
- subject = subject,
- amount = tx.amount,
- transactionDate = Instant.now()
- )
- val res = db.bankTransactionCreate(dbInstructions)
- when (res) {
- BankTransactionResult.BALANCE_INSUFFICIENT -> throw conflict(
- "Insufficient funds",
- TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
- )
- BankTransactionResult.SAME_ACCOUNT -> throw conflict(
- "Wire transfer attempted with credit and debit party being the same bank account",
- TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
- )
- BankTransactionResult.NO_CREDITOR -> throw internalServerError("Creditor not found despite previous checks.")
- BankTransactionResult.NO_DEBTOR -> throw internalServerError("Debtor not found despite the request was authenticated.")
- BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK)
- }
- return@post
- }
- get("/accounts/{USERNAME}/transactions/{T_ID}") {
- val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized()
- val accountName = call.getResourceName("USERNAME")
- if (!accountName.canI(c, withAdmin = true)) throw forbidden()
- val tId = call.expectUriComponent("T_ID")
- val txRowId = try {
- tId.toLong()
- } catch (e: Exception) {
- logger.error(e.message)
- throw badRequest("TRANSACTION_ID is not a number: ${tId}")
- }
- val tx = db.bankTransactionGetFromInternalId(txRowId) ?: throw notFound(
- "Bank transaction '$tId' not found",
- TalerErrorCode.TALER_EC_BANK_TRANSACTION_NOT_FOUND
- )
- val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound(
- hint = "Customer $accountName not found",
- talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC.
- )
- val customerBankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId())
- ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.")
- if (tx.bankAccountId != customerBankAccount.bankAccountId) throw forbidden("Client has no rights over the bank transaction: $tId")
- call.respond(
- BankAccountTransactionInfo(
- amount = tx.amount,
- creditor_payto_uri = tx.creditorPaytoUri,
- debtor_payto_uri = tx.debtorPaytoUri,
- date = TalerProtocolTimestamp(tx.transactionDate),
- direction = tx.direction,
- subject = tx.subject,
- row_id = txRowId
- )
- )
- return@get
- }
-}
-\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -391,11 +391,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(),
expirationTime = it.getLong("expiration_time").microsToJavaInstant() ?: throw faultyDurationByClient(),
bankCustomer = it.getLong("bank_customer"),
- scope = when (it.getString("scope")) {
- TokenScope.readwrite.name -> TokenScope.readwrite
- TokenScope.readonly.name -> TokenScope.readonly
- else -> throw internalServerError("Wrong token scope found in the database: $this")
- },
+ scope = TokenScope.valueOf(it.getString("scope")),
isRefreshable = it.getBoolean("is_refreshable")
)
}
@@ -575,7 +571,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
(?, ?, ?, ?, (?, ?)::taler_amount)
RETURNING bank_account_id;
""")
- stmt.setString(1, bankAccount.internalPaytoUri.stripped)
+ stmt.setString(1, bankAccount.internalPaytoUri.canonical)
stmt.setLong(2, bankAccount.owningCustomerId)
stmt.setBoolean(3, bankAccount.isPublic)
stmt.setBoolean(4, bankAccount.isTalerExchange)
@@ -701,7 +697,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
FROM bank_accounts
WHERE internal_payto_uri=?
""")
- stmt.setString(1, internalPayto.stripped)
+ stmt.setString(1, internalPayto.canonical)
stmt.oneOrNull {
BankAccount(
@@ -863,13 +859,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
),
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")
- }
- },
+ direction = TransactionDirection.valueOf(it.getString("direction")),
bankAccountId = it.getLong("bank_account_id"),
paymentInformationId = it.getString("payment_information_id"),
subject = it.getString("subject"),
@@ -989,13 +979,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
getCurrency()
),
subject = it.getString("subject"),
- direction = it.getString("direction").run {
- when(this) {
- "credit" -> TransactionDirection.credit
- "debit" -> TransactionDirection.debit
- else -> throw internalServerError("Wrong direction in transaction: $this")
- }
- }
+ direction = TransactionDirection.valueOf(it.getString("direction"))
)
}
}
@@ -1117,11 +1101,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
),
accountServicerReference = it.getString("account_servicer_reference"),
endToEndId = it.getString("end_to_end_id"),
- direction = when (it.getString("direction")) {
- "credit" -> TransactionDirection.credit
- "debit" -> TransactionDirection.debit
- else -> throw internalServerError("Wrong direction in transaction: $this")
- },
+ direction = TransactionDirection.valueOf(it.getString("direction")),
bankAccountId = it.getLong("bank_account_id"),
paymentInformationId = it.getString("payment_information_id"),
subject = it.getString("subject"),
@@ -1217,7 +1197,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
WHERE withdrawal_uuid=?
"""
)
- stmt.setString(1, exchangePayto.stripped)
+ stmt.setString(1, exchangePayto.canonical)
stmt.setString(2, reservePub)
stmt.setObject(3, opUuid)
stmt.executeUpdateViolation()
@@ -1427,12 +1407,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
getCurrency()
),
subject = it.getString("subject"),
- tanChannel = when(it.getString("tan_channel")) {
- "sms" -> TanChannel.sms
- "email" -> TanChannel.email
- "file" -> TanChannel.file
- else -> throw internalServerError("TAN channel $this unsupported")
- },
+ tanChannel = TanChannel.valueOf(it.getString("tan_channel")),
tanCode = it.getString("tan_code"),
localTransaction = it.getLong("local_transaction"),
tanConfirmationTime = when (val timestamp = it.getLong("tan_confirmation_time")) {
@@ -1516,7 +1491,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
stmt.setLong(4, req.amount.value)
stmt.setInt(5, req.amount.frac)
stmt.setString(6, req.exchange_base_url.url)
- stmt.setString(7, req.credit_account.stripped)
+ stmt.setString(7, req.credit_account.canonical)
stmt.setString(8, username)
stmt.setLong(9, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
stmt.setString(10, acctSvcrRef)
@@ -1601,7 +1576,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
stmt.setString(2, subject)
stmt.setLong(3, req.amount.value)
stmt.setInt(4, req.amount.frac)
- stmt.setString(5, req.debit_account.stripped)
+ stmt.setString(5, req.debit_account.canonical)
stmt.setString(6, username)
stmt.setLong(7, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
stmt.setString(8, acctSvcrRef)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt
@@ -1,101 +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/>
- */
-
-/* This file contains the Taler Integration API endpoints,
-* that are typically requested by wallets. */
-package tech.libeufin.bank
-
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import net.taler.common.errorcodes.TalerErrorCode
-
-fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) {
- get("/taler-integration/config") {
- val internalCurrency: String = ctx.currency
- call.respond(TalerIntegrationConfigResponse(
- currency = internalCurrency,
- currency_specification = ctx.currencySpecification
- ))
- return@get
- }
-
- // Note: wopid acts as an authentication token.
- get("/taler-integration/withdrawal-operation/{wopid}") {
- val wopid = call.expectUriComponent("wopid")
- val op = getWithdrawal(db, wopid) // throws 404 if not found.
- val relatedBankAccount = db.bankAccountGetFromOwnerId(op.walletBankAccount)
- ?: throw internalServerError("Bank has a withdrawal not related to any bank account.")
- val suggestedExchange = ctx.suggestedWithdrawalExchange
- val confirmUrl = if (ctx.spaCaptchaURL == null) null else
- getWithdrawalConfirmUrl(
- baseUrl = ctx.spaCaptchaURL,
- wopId = wopid
- )
- call.respond(
- BankWithdrawalOperationStatus(
- aborted = op.aborted,
- selection_done = op.selectionDone,
- transfer_done = op.confirmationDone,
- amount = op.amount,
- sender_wire = relatedBankAccount.internalPaytoUri.stripped,
- suggested_exchange = suggestedExchange,
- confirm_transfer_url = confirmUrl
- )
- )
- return@get
- }
- post("/taler-integration/withdrawal-operation/{wopid}") {
- val wopid = call.expectUriComponent("wopid")
- val req = call.receive<BankWithdrawalOperationPostRequest>()
- val op = getWithdrawal(db, wopid) // throws 404 if not found.
- if (op.selectionDone) { // idempotency
- if (op.selectedExchangePayto != req.selected_exchange && op.reservePub != req.reserve_pub) throw conflict(
- hint = "Cannot select different exchange and reserve pub. under the same withdrawal operation",
- talerEc = TalerErrorCode.TALER_EC_BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT
- )
- }
- val dbSuccess: Boolean = if (!op.selectionDone) { // Check if reserve pub. was used in _another_ withdrawal.
- if (db.bankTransactionCheckExists(req.reserve_pub) != null) throw conflict(
- "Reserve pub. already used", TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT
- )
- db.talerWithdrawalSetDetails(
- op.withdrawalUuid, req.selected_exchange, req.reserve_pub
- )
- } else { // Nothing to do in the database, i.e. we were successful
- true
- }
- if (!dbSuccess)
- // Whatever the problem, the bank missed it: respond 500.
- throw internalServerError("Bank failed at selecting the withdrawal.")
- // Getting user details that MIGHT be used later.
- val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !op.confirmationDone) {
- getWithdrawalConfirmUrl(
- baseUrl = ctx.spaCaptchaURL,
- wopId = wopid
- )
- } else null
- val resp = BankWithdrawalOperationPostResponse(
- transfer_done = op.confirmationDone, confirm_transfer_url = confirmUrl
- )
- call.respond(resp)
- return@post
- }
-}
-\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -259,9 +259,9 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) {
call.respond(Config(ctx.currencySpecification))
return@get
}
- this.accountsMgmtHandlers(db, ctx)
- this.talerIntegrationHandlers(db, ctx)
- this.talerWireGatewayHandlers(db, ctx)
+ this.accountsMgmtApi(db, ctx)
+ this.bankIntegrationApi(db, ctx)
+ this.wireGatewayApi(db, ctx)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
@@ -384,50 +384,26 @@ class ExchangeUrl {
@Serializable(with = IbanPayTo.Serializer::class)
class IbanPayTo {
val parsed: URI
- val stripped: String
- // represent query param "sender-name" or "receiver-name".
- val receiverName: String?
- val iban: String
- val bic: String?
- // Typically, a wire transfer's subject.
+ val canonical: String
+ val amount: TalerAmount?
val message: String?
- val amount: String?
+ val receiverName: String?
constructor(raw: String) {
parsed = URI(raw)
require(parsed.scheme == "payto") { "expect a payto URI" }
require(parsed.host == "iban") { "expect a IBAN payto URI" }
+
val splitPath = parsed.path.split("/").filter { it.isNotEmpty() }
require(splitPath.size < 3 && splitPath.isNotEmpty()) { "too many path segments" }
- val parts = if (splitPath.size == 1) {
- Pair(splitPath[0], null)
- } else Pair(splitPath[1], splitPath[0])
- // TODO normalize IBAN & BIC ?
- iban = parts.first.uppercase()
- bic = parts.second?.uppercase()
- stripped = "payto://iban/$iban"
-
- val params: List<Pair<String, String>>? = if (parsed.query != null) {
- val queryString: List<String> = parsed.query.split("&")
- queryString.map {
- val split = it.split("=");
- require(split.size == 2) { "parameter '$it' was malformed" }
- Pair(split[0], split[1])
- }
- } else null
+ val iban = (if (splitPath.size == 1) splitPath[0] else splitPath[1]).replace("-", "").uppercase()
+ // TODO normalize && check IBAN ?
+ canonical = "payto://iban/$iban"
- // Return the value of query string parameter 'name', or null if not found.
- // 'params' is the list of key-value elements of all the query parameters found in the URI.
- fun getQueryParamOrNull(name: String): String? {
- if (params == null) return null
- return params.firstNotNullOfOrNull { pair ->
- URLDecoder.decode(pair.second, Charsets.UTF_8).takeIf { pair.first == name }
- }
- }
-
- amount = getQueryParamOrNull("amount")
- message = getQueryParamOrNull("message")
- receiverName = getQueryParamOrNull("receiver-name")
+ val params = (parsed.query ?: "").parseUrlEncodedParameters();
+ amount = params["amount"]?.run { TalerAmount(this) }
+ message = params["message"]
+ receiverName = params["receiver-name"]
}
internal object Serializer : KSerializer<IbanPayTo> {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt
@@ -0,0 +1,196 @@
+/*
+ * 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/>
+ */
+
+// This file contains the Taler Wire Gateway API handlers.
+
+package tech.libeufin.bank
+
+import io.ktor.http.*
+import io.ktor.server.application.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import net.taler.common.errorcodes.TalerErrorCode
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import tech.libeufin.util.extractReservePubFromSubject
+import tech.libeufin.util.stripIbanPayto
+import java.time.Instant
+import kotlin.math.abs
+
+private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus")
+
+fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) {
+ /** Authenticate and check access rights */
+ suspend fun ApplicationCall.authCheck(scope: TokenScope, withAdmin: Boolean): String {
+ val authCustomer = authenticateBankRequest(db, scope) ?: throw unauthorized("Bad login")
+ val username = getResourceName("USERNAME")
+ if (!username.canI(authCustomer, withAdmin)) throw unauthorized("No right on $username account")
+ return username
+ }
+
+ /** Retrieve the bank account info for the selected username*/
+ suspend fun ApplicationCall.bankAccount(): Database.BankInfo {
+ val username = getResourceName("USERNAME")
+ return db.bankAccountInfoFromCustomerLogin(username) ?: throw notFound(
+ hint = "Customer $username not found",
+ talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC.
+ )
+ }
+
+ get("/taler-wire-gateway/config") {
+ call.respond(TWGConfigResponse(currency = ctx.currency))
+ return@get
+ }
+
+ post("/accounts/{USERNAME}/taler-wire-gateway/transfer") {
+ val username = call.authCheck(TokenScope.readwrite, true)
+ val req = call.receive<TransferRequest>()
+ if (req.amount.currency != ctx.currency)
+ throw badRequest(
+ "Currency mismatch",
+ TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
+ )
+ val dbRes = db.talerTransferCreate(
+ req = req,
+ username = username,
+ timestamp = Instant.now()
+ )
+ when (dbRes.txResult) {
+ TalerTransferResult.NO_DEBITOR -> throw notFound(
+ "Customer $username not found",
+ TalerErrorCode.TALER_EC_END // FIXME: need EC.
+ )
+ TalerTransferResult.NOT_EXCHANGE -> throw conflict(
+ "$username is not an exchange account.",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ TalerTransferResult.NO_CREDITOR -> throw notFound(
+ "Creditor account was not found",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ TalerTransferResult.SAME_ACCOUNT -> throw conflict(
+ "Wire transfer attempted with credit and debit party being the same bank account",
+ TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
+ )
+ TalerTransferResult.BOTH_EXCHANGE -> throw conflict(
+ "Wire transfer attempted with credit and debit party being both exchange account",
+ TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
+ )
+ TalerTransferResult.REQUEST_UID_REUSE -> throw conflict(
+ "request_uid used already",
+ TalerErrorCode.TALER_EC_BANK_TRANSFER_REQUEST_UID_REUSED
+ )
+ TalerTransferResult.BALANCE_INSUFFICIENT -> throw conflict(
+ "Insufficient balance for exchange",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ TalerTransferResult.SUCCESS -> call.respond(
+ TransferResponse(
+ timestamp = dbRes.timestamp!!,
+ row_id = dbRes.txRowId!!
+ )
+ )
+ }
+ }
+
+ suspend fun <T> historyEndpoint(
+ call: ApplicationCall,
+ reduce: (List<T>, String) -> Any,
+ dbLambda: suspend Database.(HistoryParams, Long) -> List<T>
+ ) {
+ val username = call.authCheck(TokenScope.readonly, true)
+ val params = getHistoryParams(call.request.queryParameters)
+ val bankAccount = call.bankAccount()
+
+ if (!bankAccount.isTalerExchange)
+ throw conflict(
+ "$username is not an exchange account.",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+
+ val items = db.dbLambda(params, bankAccount.id);
+
+ if (items.isEmpty()) {
+ call.respond(HttpStatusCode.NoContent)
+ } else {
+ call.respond(reduce(items, bankAccount.internalPaytoUri))
+ }
+ }
+
+ get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") {
+ historyEndpoint(call, ::IncomingHistory, Database::exchangeIncomingPoolHistory)
+ }
+
+ get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") {
+ historyEndpoint(call, ::OutgoingHistory, Database::exchangeOutgoingPoolHistory)
+ }
+
+ post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") {
+ val username = call.authCheck(TokenScope.readwrite, false)
+ val req = call.receive<AddIncomingRequest>()
+ if (req.amount.currency != ctx.currency)
+ throw badRequest(
+ "Currency mismatch",
+ TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
+ )
+ val timestamp = Instant.now()
+ val dbRes = db.talerAddIncomingCreate(
+ req = req,
+ username = username,
+ timestamp = timestamp
+ )
+ when (dbRes.txResult) {
+ TalerAddIncomingResult.NO_CREDITOR -> throw notFound(
+ "Customer $username not found",
+ TalerErrorCode.TALER_EC_END // FIXME: need EC.
+ )
+ TalerAddIncomingResult.NOT_EXCHANGE -> throw conflict(
+ "$username is not an exchange account.",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ TalerAddIncomingResult.NO_DEBITOR -> throw notFound(
+ "Debitor account was not found",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ TalerAddIncomingResult.SAME_ACCOUNT -> throw conflict(
+ "Wire transfer attempted with credit and debit party being the same bank account",
+ TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
+ )
+ TalerAddIncomingResult.BOTH_EXCHANGE -> throw conflict(
+ "Wire transfer attempted with credit and debit party being both exchange account",
+ TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
+ )
+ TalerAddIncomingResult.RESERVE_PUB_REUSE -> throw conflict(
+ "reserve_pub used already",
+ TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT
+ )
+ TalerAddIncomingResult.BALANCE_INSUFFICIENT -> throw conflict(
+ "Insufficient balance for debitor",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ TalerAddIncomingResult.SUCCESS -> call.respond(
+ AddIncomingResponse(
+ timestamp = TalerProtocolTimestamp(timestamp),
+ row_id = dbRes.txRowId!!
+ )
+ )
+ }
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt
@@ -1,196 +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/>
- */
-
-// This file contains the Taler Wire Gateway API handlers.
-
-package tech.libeufin.bank
-
-import io.ktor.http.*
-import io.ktor.server.application.*
-import io.ktor.server.request.*
-import io.ktor.server.response.*
-import io.ktor.server.routing.*
-import net.taler.common.errorcodes.TalerErrorCode
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import tech.libeufin.util.extractReservePubFromSubject
-import tech.libeufin.util.stripIbanPayto
-import java.time.Instant
-import kotlin.math.abs
-
-private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus")
-
-fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) {
- /** Authenticate and check access rights */
- suspend fun ApplicationCall.authCheck(scope: TokenScope, withAdmin: Boolean): String {
- val authCustomer = authenticateBankRequest(db, scope) ?: throw unauthorized("Bad login")
- val username = getResourceName("USERNAME")
- if (!username.canI(authCustomer, withAdmin)) throw unauthorized("No right on $username account")
- return username
- }
-
- /** Retrieve the bank account info for the selected username*/
- suspend fun ApplicationCall.bankAccount(): Database.BankInfo {
- val username = getResourceName("USERNAME")
- return db.bankAccountInfoFromCustomerLogin(username) ?: throw notFound(
- hint = "Customer $username not found",
- talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC.
- )
- }
-
- get("/taler-wire-gateway/config") {
- call.respond(TWGConfigResponse(currency = ctx.currency))
- return@get
- }
-
- post("/accounts/{USERNAME}/taler-wire-gateway/transfer") {
- val username = call.authCheck(TokenScope.readwrite, true)
- val req = call.receive<TransferRequest>()
- if (req.amount.currency != ctx.currency)
- throw badRequest(
- "Currency mismatch",
- TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
- )
- val dbRes = db.talerTransferCreate(
- req = req,
- username = username,
- timestamp = Instant.now()
- )
- when (dbRes.txResult) {
- TalerTransferResult.NO_DEBITOR -> throw notFound(
- "Customer $username not found",
- TalerErrorCode.TALER_EC_END // FIXME: need EC.
- )
- TalerTransferResult.NOT_EXCHANGE -> throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
- TalerTransferResult.NO_CREDITOR -> throw notFound(
- "Creditor account was not found",
- TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
- TalerTransferResult.SAME_ACCOUNT -> throw conflict(
- "Wire transfer attempted with credit and debit party being the same bank account",
- TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
- )
- TalerTransferResult.BOTH_EXCHANGE -> throw conflict(
- "Wire transfer attempted with credit and debit party being both exchange account",
- TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
- )
- TalerTransferResult.REQUEST_UID_REUSE -> throw conflict(
- "request_uid used already",
- TalerErrorCode.TALER_EC_BANK_TRANSFER_REQUEST_UID_REUSED
- )
- TalerTransferResult.BALANCE_INSUFFICIENT -> throw conflict(
- "Insufficient balance for exchange",
- TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
- )
- TalerTransferResult.SUCCESS -> call.respond(
- TransferResponse(
- timestamp = dbRes.timestamp!!,
- row_id = dbRes.txRowId!!
- )
- )
- }
- }
-
- suspend fun <T> historyEndpoint(
- call: ApplicationCall,
- reduce: (List<T>, String) -> Any,
- dbLambda: suspend Database.(HistoryParams, Long) -> List<T>
- ) {
- val username = call.authCheck(TokenScope.readonly, true)
- val params = getHistoryParams(call.request.queryParameters)
- val bankAccount = call.bankAccount()
-
- if (!bankAccount.isTalerExchange)
- throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
-
- val items = db.dbLambda(params, bankAccount.id);
-
- if (items.isEmpty()) {
- call.respond(HttpStatusCode.NoContent)
- } else {
- call.respond(reduce(items, bankAccount.internalPaytoUri))
- }
- }
-
- get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") {
- historyEndpoint(call, ::IncomingHistory, Database::exchangeIncomingPoolHistory)
- }
-
- get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") {
- historyEndpoint(call, ::OutgoingHistory, Database::exchangeOutgoingPoolHistory)
- }
-
- post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") {
- val username = call.authCheck(TokenScope.readwrite, false)
- val req = call.receive<AddIncomingRequest>()
- if (req.amount.currency != ctx.currency)
- throw badRequest(
- "Currency mismatch",
- TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
- )
- val timestamp = Instant.now()
- val dbRes = db.talerAddIncomingCreate(
- req = req,
- username = username,
- timestamp = timestamp
- )
- when (dbRes.txResult) {
- TalerAddIncomingResult.NO_CREDITOR -> throw notFound(
- "Customer $username not found",
- TalerErrorCode.TALER_EC_END // FIXME: need EC.
- )
- TalerAddIncomingResult.NOT_EXCHANGE -> throw conflict(
- "$username is not an exchange account.",
- TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
- TalerAddIncomingResult.NO_DEBITOR -> throw notFound(
- "Debitor account was not found",
- TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
- TalerAddIncomingResult.SAME_ACCOUNT -> throw conflict(
- "Wire transfer attempted with credit and debit party being the same bank account",
- TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
- )
- TalerAddIncomingResult.BOTH_EXCHANGE -> throw conflict(
- "Wire transfer attempted with credit and debit party being both exchange account",
- TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
- )
- TalerAddIncomingResult.RESERVE_PUB_REUSE -> throw conflict(
- "reserve_pub used already",
- TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT
- )
- TalerAddIncomingResult.BALANCE_INSUFFICIENT -> throw conflict(
- "Insufficient balance for debitor",
- TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
- )
- TalerAddIncomingResult.SUCCESS -> call.respond(
- AddIncomingResponse(
- timestamp = TalerProtocolTimestamp(timestamp),
- row_id = dbRes.txRowId!!
- )
- )
- }
- }
-}
-\ No newline at end of file
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -0,0 +1,145 @@
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.client.HttpClient
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import kotlinx.serialization.json.*
+import kotlinx.coroutines.*
+import net.taler.wallet.crypto.Base32Crockford
+import org.junit.Test
+import tech.libeufin.bank.*
+import tech.libeufin.util.CryptoUtil
+import tech.libeufin.util.stripIbanPayto
+import java.util.*
+import java.time.Instant
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import randHashCode
+
+class BankIntegrationApiTest {
+ // Selecting withdrawal details from the Integration API endpoint.
+ @Test
+ fun intSelect() = bankSetup { db ->
+ val uuid = UUID.randomUUID()
+ // insert new.
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+
+ val r = client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(BankWithdrawalOperationPostRequest(
+ reserve_pub = "RESERVE-FOO",
+ selected_exchange = IbanPayTo("payto://iban/ABC123")
+ ))
+ }.assertOk()
+ println(r.bodyAsText())
+ }
+
+ // Showing withdrawal details from the Integrtion API endpoint.
+ @Test
+ fun intGet() = bankSetup { db ->
+ val uuid = UUID.randomUUID()
+ // insert new.
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+
+ val r = client.get("/taler-integration/withdrawal-operation/${uuid}").assertOk()
+ println(r.bodyAsText())
+ }
+
+ // Testing withdrawal abort
+ @Test
+ fun withdrawalAbort() = bankSetup { db ->
+ val uuid = UUID.randomUUID()
+ // insert new.
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+ val op = db.talerWithdrawalGet(uuid)
+ assert(op?.aborted == false)
+ assert(db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/exchange-payto"), "reserve_pub"))
+
+ client.post("/withdrawals/${uuid}/abort") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
+
+ val opAbo = db.talerWithdrawalGet(uuid)
+ assert(opAbo?.aborted == true && opAbo.selectionDone == true)
+ }
+
+ // Testing withdrawal creation
+ @Test
+ fun withdrawalCreation() = bankSetup { _ ->
+ // Creating the withdrawal as if the SPA did it.
+ val r = client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(BankAccountCreateWithdrawalRequest(TalerAmount(value = 9, frac = 0, currency = "KUDOS")))
+ }.assertOk()
+ val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(r.bodyAsText())
+ // Getting the withdrawal from the bank. Throws (failing the test) if not found.
+ client.get("/withdrawals/${opId.withdrawal_id}") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
+ }
+
+ // Testing withdrawal confirmation
+ @Test
+ fun withdrawalConfirmation() = bankSetup { db ->
+ // Artificially making a withdrawal operation for merchant.
+ val uuid = UUID.randomUUID()
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+ // Specifying the exchange via its Payto URI.
+ assert(db.talerWithdrawalSetDetails(
+ opUuid = uuid,
+ exchangePayto = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"),
+ reservePub = "UNCHECKED-RESERVE-PUB"
+ ))
+
+ // Starting the bank and POSTing as Foo to /confirm the operation.
+ client.post("/withdrawals/${uuid}/confirm") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
+ }
+
+ // Testing the generation of taler://withdraw-URIs.
+ @Test
+ fun testWithdrawUri() {
+ // Checking the taler+http://-style.
+ val withHttp = getTalerWithdrawUri(
+ "http://example.com",
+ "my-id"
+ )
+ assertEquals(withHttp, "taler+http://withdraw/example.com/taler-integration/my-id")
+ // Checking the taler://-style
+ val onlyTaler = getTalerWithdrawUri(
+ "https://example.com/",
+ "my-id"
+ )
+ // Note: this tests as well that no double slashes belong to the result
+ assertEquals(onlyTaler, "taler://withdraw/example.com/taler-integration/my-id")
+ // Checking the removal of subsequent slashes
+ val manySlashes = getTalerWithdrawUri(
+ "https://www.example.com//////",
+ "my-id"
+ )
+ assertEquals(manySlashes, "taler://withdraw/www.example.com/taler-integration/my-id")
+ // Checking with specified port number
+ val withPort = getTalerWithdrawUri(
+ "https://www.example.com:9876",
+ "my-id"
+ )
+ assertEquals(withPort, "taler://withdraw/www.example.com:9876/taler-integration/my-id")
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt
@@ -1,751 +0,0 @@
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.client.HttpClient
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import kotlinx.serialization.json.*
-import kotlinx.coroutines.*
-import net.taler.wallet.crypto.Base32Crockford
-import org.junit.Test
-import tech.libeufin.bank.*
-import tech.libeufin.util.CryptoUtil
-import tech.libeufin.util.stripIbanPayto
-import java.util.*
-import java.time.Instant
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import randHashCode
-
-class TalerApiTest {
- private val customerFoo = Customer(
- login = "foo",
- passwordHash = CryptoUtil.hashpw("pw"),
- name = "Foo",
- phone = "+00",
- email = "foo@b.ar",
- cashoutPayto = "payto://external-IBAN",
- cashoutCurrency = "KUDOS"
- )
- private val bankAccountFoo = BankAccount(
- internalPaytoUri = IbanPayTo("payto://iban/FOO-IBAN-XYZ"),
- lastNexusFetchRowId = 1L,
- owningCustomerId = 1L,
- hasDebt = false,
- maxDebt = TalerAmount(10, 1, "KUDOS"),
- isTalerExchange = false
- )
- val customerBar = Customer(
- login = "bar",
- passwordHash = CryptoUtil.hashpw("secret"),
- name = "Bar",
- phone = "+00",
- email = "foo@b.ar",
- cashoutPayto = "payto://external-IBAN",
- cashoutCurrency = "KUDOS"
- )
- val bankAccountBar = BankAccount(
- internalPaytoUri = IbanPayTo("payto://iban/BAR-IBAN-ABC"),
- lastNexusFetchRowId = 1L,
- owningCustomerId = 2L,
- hasDebt = false,
- maxDebt = TalerAmount(10, 1, "KUDOS"),
- isTalerExchange = true
- )
-
-
- suspend fun Database.genTransfer(from: String, to: BankAccount, amount: String = "KUDOS:10") {
- talerTransferCreate(
- req = TransferRequest(
- request_uid = randHashCode(),
- amount = TalerAmount(amount),
- exchange_base_url = ExchangeUrl("http://exchange.example.com/"),
- wtid = randShortHashCode(),
- credit_account = to.internalPaytoUri
- ),
- username = from,
- timestamp = Instant.now()
- ).run {
- assertEquals(TalerTransferResult.SUCCESS, txResult)
- }
- }
-
- suspend fun Database.genIncoming(to: String, from: BankAccount) {
- talerAddIncomingCreate(
- req = AddIncomingRequest(
- reserve_pub = randShortHashCode(),
- amount = TalerAmount( 10, 0, "KUDOS"),
- debit_account = from.internalPaytoUri,
- ),
- username = to,
- timestamp = Instant.now()
- ).run {
- assertEquals(TalerAddIncomingResult.SUCCESS, txResult)
- }
- }
-
- fun commonSetup(lambda: suspend (Database, BankApplicationContext) -> Unit) {
- setup { db, ctx ->
- // Creating the exchange and merchant accounts first.
- assertNotNull(db.customerCreate(customerFoo))
- assertNotNull(db.bankAccountCreate(bankAccountFoo))
- assertNotNull(db.customerCreate(customerBar))
- assertNotNull(db.bankAccountCreate(bankAccountBar))
- lambda(db, ctx)
- }
- }
-
- // Test endpoint is correctly authenticated
- suspend fun authRoutine(client: HttpClient, path: String, body: JsonObject? = null, method: HttpMethod = HttpMethod.Post) {
- // No body when authentication must happen before parsing the body
-
- // Unknown account
- client.request(path) {
- this.method = method
- basicAuth("unknown", "password")
- }.assertStatus(HttpStatusCode.Unauthorized)
-
- // Wrong password
- client.request(path) {
- this.method = method
- basicAuth("foo", "wrong_password")
- }.assertStatus(HttpStatusCode.Unauthorized)
-
- // Wrong account
- client.request(path) {
- this.method = method
- basicAuth("bar", "secret")
- }.assertStatus(HttpStatusCode.Unauthorized)
-
- // Not exchange account
- client.request(path) {
- this.method = method
- if (body != null) jsonBody(body)
- basicAuth("foo", "pw")
- }.assertStatus(HttpStatusCode.Conflict)
- }
-
- // Testing the POST /transfer call from the TWG API.
- @Test
- fun transfer() = commonSetup { db, ctx ->
- // Do POST /transfer.
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- val valid_req = json {
- "request_uid" to randHashCode()
- "amount" to "KUDOS:55"
- "exchange_base_url" to "http://exchange.example.com/"
- "wtid" to randShortHashCode()
- "credit_account" to bankAccountFoo.internalPaytoUri
- };
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/transfer", valid_req)
-
- // Checking exchange debt constraint.
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Giving debt allowance and checking the OK case.
- assert(db.bankAccountSetMaxDebt(
- 2L,
- TalerAmount(1000, 0, "KUDOS")
- ))
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertOk()
-
- // check idempotency
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertOk()
-
- // Trigger conflict due to reused request_uid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "wtid" to randShortHashCode()
- "exchange_base_url" to "http://different-exchange.example.com/"
- }
- )
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Currency mismatch
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "amount" to "EUR:33"
- }
- )
- }.assertBadRequest()
-
- // Unknown account
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "request_uid" to randHashCode()
- "wtid" to randShortHashCode()
- "credit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
- }
- )
- }.assertStatus(HttpStatusCode.NotFound)
-
- // Bad BASE32 wtid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "wtid" to "I love chocolate"
- }
- )
- }.assertBadRequest()
-
- // Bad BASE32 len wtid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "wtid" to randBase32Crockford(31)
- }
- )
- }.assertBadRequest()
-
- // Bad BASE32 request_uid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "request_uid" to "I love chocolate"
- }
- )
- }.assertBadRequest()
-
- // Bad BASE32 len wtid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "request_uid" to randBase32Crockford(65)
- }
- )
- }.assertBadRequest()
- }
- }
-
- /**
- * Testing the /history/incoming call from the TWG API.
- */
- @Test
- fun historyIncoming() = commonSetup { db, ctx ->
- // Give Foo reasonable debt allowance:
- assert(
- db.bankAccountSetMaxDebt(
- 1L,
- TalerAmount(1000000, 0, "KUDOS")
- )
- )
-
- suspend fun HttpResponse.assertHistory(size: Int) {
- assertOk()
- val txt = this.bodyAsText()
- val history = Json.decodeFromString<IncomingHistory>(txt)
- val params = getHistoryParams(this.call.request.url.parameters)
-
- // testing the size is like expected.
- assert(history.incoming_transactions.size == size) {
- println("incoming_transactions has wrong size: ${history.incoming_transactions.size}")
- println("Response was: ${txt}")
- }
- if (params.delta < 0) {
- // testing that the first row_id is at most the 'start' query param.
- assert(history.incoming_transactions[0].row_id <= params.start)
- // testing that the row_id decreases.
- assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
- } else {
- // testing that the first row_id is at least the 'start' query param.
- assert(history.incoming_transactions[0].row_id >= params.start)
- // testing that the row_id increases.
- assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
- }
- }
-
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/history/incoming?delta=7", method = HttpMethod.Get)
-
- // Check error when no transactions
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=7") {
- basicAuth("bar", "secret")
- }.assertStatus(HttpStatusCode.NoContent)
-
- // Gen three transactions using clean add incoming logic
- repeat(3) {
- db.genIncoming("bar", bankAccountFoo)
- }
- // Should not show up in the taler wire gateway API history
- db.bankTransactionCreate(genTx("bogus foobar")).assertSuccess()
- // Bar pays Foo once, but that should not appear in the result.
- db.bankTransactionCreate(genTx("payout", creditorId = 1, debtorId = 2)).assertSuccess()
- // Gen two transactions using row bank transaction logic
- repeat(2) {
- db.bankTransactionCreate(
- genTx(IncomingTxMetadata(randShortHashCode()).encode(), 2, 1)
- ).assertSuccess()
- }
-
- // Check ignore bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=7") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check skip bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=5") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check no useless polling
- assertTime(0, 300) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-6&start=15&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
- }
-
- // Check polling end
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=60") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- runBlocking {
- joinAll(
- launch { // Check polling succeed forward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling succeed backward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout forward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout backward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch {
- delay(200)
- db.genIncoming("bar", bankAccountFoo)
- }
- )
- }
-
- // Testing ranges.
- repeat(300) {
- db.genIncoming("bar", bankAccountFoo)
- }
-
- // forward range:
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=10&start=30") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
-
- // backward range:
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-10&start=300") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
- }
- }
-
-
- /**
- * Testing the /history/outgoing call from the TWG API.
- */
- @Test
- fun historyOutgoing() = commonSetup { db, ctx ->
- // Give Bar reasonable debt allowance:
- assert(
- db.bankAccountSetMaxDebt(
- 2L,
- TalerAmount(1000000, 0, "KUDOS")
- )
- )
-
- suspend fun HttpResponse.assertHistory(size: Int) {
- assertOk()
- val txt = this.bodyAsText()
- val history = Json.decodeFromString<OutgoingHistory>(txt)
- val params = getHistoryParams(this.call.request.url.parameters)
-
- // testing the size is like expected.
- assert(history.outgoing_transactions.size == size) {
- println("outgoing_transactions has wrong size: ${history.outgoing_transactions.size}")
- println("Response was: ${txt}")
- }
- if (params.delta < 0) {
- // testing that the first row_id is at most the 'start' query param.
- assert(history.outgoing_transactions[0].row_id <= params.start)
- // testing that the row_id decreases.
- assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
- } else {
- // testing that the first row_id is at least the 'start' query param.
- assert(history.outgoing_transactions[0].row_id >= params.start)
- // testing that the row_id increases.
- assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
- }
- }
-
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/history/outgoing?delta=7", method = HttpMethod.Get)
-
- // Check error when no transactions
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=7") {
- basicAuth("bar", "secret")
- }.assertStatus(HttpStatusCode.NoContent)
-
- // Gen three transactions using clean transfer logic
- repeat(3) {
- db.genTransfer("bar", bankAccountFoo)
- }
- // Should not show up in the taler wire gateway API history
- db.bankTransactionCreate(genTx("bogus foobar", 1, 2)).assertSuccess()
- // Foo pays Bar once, but that should not appear in the result.
- db.bankTransactionCreate(genTx("payout")).assertSuccess()
- // Gen two transactions using row bank transaction logic
- repeat(2) {
- db.bankTransactionCreate(
- genTx(OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode(), 1, 2)
- ).assertSuccess()
- }
-
- // Check ignore bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=7") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check skip bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=5") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check no useless polling
- assertTime(0, 300) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-6&start=15&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
- }
-
- // Check polling end
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=60") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- runBlocking {
- joinAll(
- launch { // Check polling succeed forward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling succeed backward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout forward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout backward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch {
- delay(200)
- db.genTransfer("bar", bankAccountFoo)
- }
- )
- }
-
- // Testing ranges.
- repeat(300) {
- db.genTransfer("bar", bankAccountFoo)
- }
-
- // forward range:
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=10&start=30") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
-
- // backward range:
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-10&start=300") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
- }
- }
-
- // Testing the /admin/add-incoming call from the TWG API.
- @Test
- fun addIncoming() = commonSetup { db, ctx ->
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- val valid_req = json {
- "amount" to "KUDOS:44"
- "reserve_pub" to randEddsaPublicKey()
- "debit_account" to bankAccountFoo.internalPaytoUri
- };
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/admin/add-incoming", valid_req)
-
- // Checking exchange debt constraint.
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Giving debt allowance and checking the OK case.
- assert(db.bankAccountSetMaxDebt(
- 1L,
- TalerAmount(1000, 0, "KUDOS")
- ))
-
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(valid_req, deflate = true)
- }.assertOk()
-
- // Trigger conflict due to reused reserve_pub
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Currency mismatch
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "amount" to "EUR:33"
- }
- )
- }.assertBadRequest()
-
- // Unknown account
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "reserve_pub" to randEddsaPublicKey()
- "debit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
- }
- )
- }.assertStatus(HttpStatusCode.NotFound)
-
- // Bad BASE32 reserve_pub
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(json(valid_req) {
- "reserve_pub" to "I love chocolate"
- })
- }.assertBadRequest()
-
- // Bad BASE32 len reserve_pub
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(json(valid_req) {
- "reserve_pub" to randBase32Crockford(31)
- })
- }.assertBadRequest()
- }
- }
- // Selecting withdrawal details from the Integration API endpoint.
- @Test
- fun intSelect() = setup { db, ctx ->
- val uuid = UUID.randomUUID()
- assertNotNull(db.customerCreate(customerFoo))
- assertNotNull(db.bankAccountCreate(bankAccountFoo))
- // insert new.
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- val r = client.post("/taler-integration/withdrawal-operation/${uuid}") {
- jsonBody(BankWithdrawalOperationPostRequest(
- reserve_pub = "RESERVE-FOO",
- selected_exchange = IbanPayTo("payto://iban/ABC123")
- ))
- }.assertOk()
- println(r.bodyAsText())
- }
- }
- // Showing withdrawal details from the Integrtion API endpoint.
- @Test
- fun intGet() = setup { db, ctx ->
- val uuid = UUID.randomUUID()
- assert(db.customerCreate(customerFoo) != null)
- assert(db.bankAccountCreate(bankAccountFoo) != null)
- // insert new.
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- val r = client.get("/taler-integration/withdrawal-operation/${uuid}").assertOk()
- println(r.bodyAsText())
- }
- }
- // Testing withdrawal abort
- @Test
- fun withdrawalAbort() = setup { db, ctx ->
- val uuid = UUID.randomUUID()
- assert(db.customerCreate(customerFoo) != null)
- assert(db.bankAccountCreate(bankAccountFoo) != null)
- // insert new.
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- val op = db.talerWithdrawalGet(uuid)
- assert(op?.aborted == false)
- assert(db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/exchange-payto"), "reserve_pub"))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- client.post("/withdrawals/${uuid}/abort") {
- basicAuth("foo", "pw")
- }.assertOk()
- }
- val opAbo = db.talerWithdrawalGet(uuid)
- assert(opAbo?.aborted == true && opAbo.selectionDone == true)
- }
- // Testing withdrawal creation
- @Test
- fun withdrawalCreation() = setup { db, ctx ->
- assertNotNull(db.customerCreate(customerFoo))
- assertNotNull(db.bankAccountCreate(bankAccountFoo))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- // Creating the withdrawal as if the SPA did it.
- val r = client.post("/accounts/foo/withdrawals") {
- basicAuth("foo", "pw")
- jsonBody(BankAccountCreateWithdrawalRequest(TalerAmount(value = 9, frac = 0, currency = "KUDOS")))
- }.assertOk()
- val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(r.bodyAsText())
- // Getting the withdrawal from the bank. Throws (failing the test) if not found.
- client.get("/withdrawals/${opId.withdrawal_id}") {
- basicAuth("foo", "pw")
- }.assertOk()
- }
- }
- // Testing withdrawal confirmation
- @Test
- fun withdrawalConfirmation() = commonSetup { db, ctx ->
-
- // Artificially making a withdrawal operation for Foo.
- val uuid = UUID.randomUUID()
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- // Specifying Bar as the exchange, via its Payto URI.
- assert(db.talerWithdrawalSetDetails(
- opUuid = uuid,
- exchangePayto = IbanPayTo("payto://iban/BAR-IBAN-ABC"),
- reservePub = "UNCHECKED-RESERVE-PUB"
- ))
-
- // Starting the bank and POSTing as Foo to /confirm the operation.
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- client.post("/withdrawals/${uuid}/confirm") {
- basicAuth("foo", "pw")
- }.assertOk()
- }
- }
- // Testing the generation of taler://withdraw-URIs.
- @Test
- fun testWithdrawUri() {
- // Checking the taler+http://-style.
- val withHttp = getTalerWithdrawUri(
- "http://example.com",
- "my-id"
- )
- assertEquals(withHttp, "taler+http://withdraw/example.com/taler-integration/my-id")
- // Checking the taler://-style
- val onlyTaler = getTalerWithdrawUri(
- "https://example.com/",
- "my-id"
- )
- // Note: this tests as well that no double slashes belong to the result
- assertEquals(onlyTaler, "taler://withdraw/example.com/taler-integration/my-id")
- // Checking the removal of subsequent slashes
- val manySlashes = getTalerWithdrawUri(
- "https://www.example.com//////",
- "my-id"
- )
- assertEquals(manySlashes, "taler://withdraw/www.example.com/taler-integration/my-id")
- // Checking with specified port number
- val withPort = getTalerWithdrawUri(
- "https://www.example.com:9876",
- "my-id"
- )
- assertEquals(withPort, "taler://withdraw/www.example.com:9876/taler-integration/my-id")
- }
-}
-\ No newline at end of file
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt
@@ -0,0 +1,529 @@
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.client.HttpClient
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import kotlinx.serialization.json.*
+import kotlinx.coroutines.*
+import org.junit.Test
+import tech.libeufin.bank.*
+import tech.libeufin.util.CryptoUtil
+import java.util.*
+import java.time.Instant
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import randHashCode
+
+class WireGatewayApiTest {
+ suspend fun Database.genTransfer(from: String, to: BankAccount, amount: String = "KUDOS:10") {
+ talerTransferCreate(
+ req = TransferRequest(
+ request_uid = randHashCode(),
+ amount = TalerAmount(amount),
+ exchange_base_url = ExchangeUrl("http://exchange.example.com/"),
+ wtid = randShortHashCode(),
+ credit_account = to.internalPaytoUri
+ ),
+ username = from,
+ timestamp = Instant.now()
+ ).run {
+ assertEquals(TalerTransferResult.SUCCESS, txResult)
+ }
+ }
+
+ suspend fun Database.genIncoming(to: String, from: BankAccount) {
+ talerAddIncomingCreate(
+ req = AddIncomingRequest(
+ reserve_pub = randShortHashCode(),
+ amount = TalerAmount(10, 0, "KUDOS"),
+ debit_account = from.internalPaytoUri,
+ ),
+ username = to,
+ timestamp = Instant.now()
+ ).run {
+ assertEquals(TalerAddIncomingResult.SUCCESS, txResult)
+ }
+ }
+
+ // Test endpoint is correctly authenticated
+ suspend fun authRoutine(client: HttpClient, path: String, body: JsonObject? = null, method: HttpMethod = HttpMethod.Post) {
+ // No body when authentication must happen before parsing the body
+
+ // Unknown account
+ client.request(path) {
+ this.method = method
+ basicAuth("unknown", "password")
+ }.assertStatus(HttpStatusCode.Unauthorized)
+
+ // Wrong password
+ client.request(path) {
+ this.method = method
+ basicAuth("merchant", "wrong-password")
+ }.assertStatus(HttpStatusCode.Unauthorized)
+
+ // Wrong account
+ client.request(path) {
+ this.method = method
+ basicAuth("exchange", "merchant-password")
+ }.assertStatus(HttpStatusCode.Unauthorized)
+
+ // Not exchange account
+ client.request(path) {
+ this.method = method
+ if (body != null) jsonBody(body)
+ basicAuth("merchant", "merchant-password")
+ }.assertStatus(HttpStatusCode.Conflict)
+ }
+
+ // Testing the POST /transfer call from the TWG API.
+ @Test
+ fun transfer() = bankSetup { db ->
+ val valid_req = json {
+ "request_uid" to randHashCode()
+ "amount" to "KUDOS:55"
+ "exchange_base_url" to "http://exchange.example.com/"
+ "wtid" to randShortHashCode()
+ "credit_account" to "payto://iban/MERCHANT-IBAN-XYZ"
+ };
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/transfer", valid_req)
+
+ // Checking exchange debt constraint.
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Giving debt allowance and checking the OK case.
+ assert(db.bankAccountSetMaxDebt(
+ 2L,
+ TalerAmount(1000, 0, "KUDOS")
+ ))
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertOk()
+
+ // check idempotency
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertOk()
+
+ // Trigger conflict due to reused request_uid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "wtid" to randShortHashCode()
+ "exchange_base_url" to "http://different-exchange.example.com/"
+ }
+ )
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Currency mismatch
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "amount" to "EUR:33"
+ }
+ )
+ }.assertBadRequest()
+
+ // Unknown account
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "request_uid" to randHashCode()
+ "wtid" to randShortHashCode()
+ "credit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
+ }
+ )
+ }.assertStatus(HttpStatusCode.NotFound)
+
+ // Bad BASE32 wtid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "wtid" to "I love chocolate"
+ }
+ )
+ }.assertBadRequest()
+
+ // Bad BASE32 len wtid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "wtid" to randBase32Crockford(31)
+ }
+ )
+ }.assertBadRequest()
+
+ // Bad BASE32 request_uid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "request_uid" to "I love chocolate"
+ }
+ )
+ }.assertBadRequest()
+
+ // Bad BASE32 len wtid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "request_uid" to randBase32Crockford(65)
+ }
+ )
+ }.assertBadRequest()
+ }
+
+ /**
+ * Testing the /history/incoming call from the TWG API.
+ */
+ @Test
+ fun historyIncoming() = bankSetup { db ->
+ // Give Foo reasonable debt allowance:
+ assert(
+ db.bankAccountSetMaxDebt(
+ 1L,
+ TalerAmount(1000000, 0, "KUDOS")
+ )
+ )
+
+ suspend fun HttpResponse.assertHistory(size: Int) {
+ assertOk()
+ val txt = this.bodyAsText()
+ val history = Json.decodeFromString<IncomingHistory>(txt)
+ val params = getHistoryParams(this.call.request.url.parameters)
+
+ // testing the size is like expected.
+ assert(history.incoming_transactions.size == size) {
+ println("incoming_transactions has wrong size: ${history.incoming_transactions.size}")
+ println("Response was: ${txt}")
+ }
+ if (params.delta < 0) {
+ // testing that the first row_id is at most the 'start' query param.
+ assert(history.incoming_transactions[0].row_id <= params.start)
+ // testing that the row_id decreases.
+ assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
+ } else {
+ // testing that the first row_id is at least the 'start' query param.
+ assert(history.incoming_transactions[0].row_id >= params.start)
+ // testing that the row_id increases.
+ assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
+ }
+ }
+
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/history/incoming?delta=7", method = HttpMethod.Get)
+
+ // Check error when no transactions
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertStatus(HttpStatusCode.NoContent)
+
+ // Gen three transactions using clean add incoming logic
+ repeat(3) {
+ db.genIncoming("exchange", bankAccountMerchant)
+ }
+ // Should not show up in the taler wire gateway API history
+ db.bankTransactionCreate(genTx("bogus foobar")).assertSuccess()
+ // Bar pays Foo once, but that should not appear in the result.
+ db.bankTransactionCreate(genTx("payout", creditorId = 1, debtorId = 2)).assertSuccess()
+ // Gen two transactions using row bank transaction logic
+ repeat(2) {
+ db.bankTransactionCreate(
+ genTx(IncomingTxMetadata(randShortHashCode()).encode(), 2, 1)
+ ).assertSuccess()
+ }
+
+ // Check ignore bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check skip bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=5") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check no useless polling
+ assertTime(0, 300) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&start=15&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+ }
+
+ // Check polling end
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=60") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ runBlocking {
+ joinAll(
+ launch { // Check polling succeed forward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling succeed backward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout forward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout backward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch {
+ delay(200)
+ db.genIncoming("exchange", bankAccountMerchant)
+ }
+ )
+ }
+
+ // Testing ranges.
+ repeat(300) {
+ db.genIncoming("exchange", bankAccountMerchant)
+ }
+
+ // forward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10&start=30") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+
+ // backward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10&start=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+ }
+
+
+ /**
+ * Testing the /history/outgoing call from the TWG API.
+ */
+ @Test
+ fun historyOutgoing() = bankSetup { db ->
+ // Give Bar reasonable debt allowance:
+ assert(
+ db.bankAccountSetMaxDebt(
+ 2L,
+ TalerAmount(1000000, 0, "KUDOS")
+ )
+ )
+
+ suspend fun HttpResponse.assertHistory(size: Int) {
+ assertOk()
+ val txt = this.bodyAsText()
+ val history = Json.decodeFromString<OutgoingHistory>(txt)
+ val params = getHistoryParams(this.call.request.url.parameters)
+
+ // testing the size is like expected.
+ assert(history.outgoing_transactions.size == size) {
+ println("outgoing_transactions has wrong size: ${history.outgoing_transactions.size}")
+ println("Response was: ${txt}")
+ }
+ if (params.delta < 0) {
+ // testing that the first row_id is at most the 'start' query param.
+ assert(history.outgoing_transactions[0].row_id <= params.start)
+ // testing that the row_id decreases.
+ assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
+ } else {
+ // testing that the first row_id is at least the 'start' query param.
+ assert(history.outgoing_transactions[0].row_id >= params.start)
+ // testing that the row_id increases.
+ assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
+ }
+ }
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/history/outgoing?delta=7", method = HttpMethod.Get)
+
+ // Check error when no transactions
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertStatus(HttpStatusCode.NoContent)
+
+ // Gen three transactions using clean transfer logic
+ repeat(3) {
+ db.genTransfer("exchange", bankAccountMerchant)
+ }
+ // Should not show up in the taler wire gateway API history
+ db.bankTransactionCreate(genTx("bogus foobar", 1, 2)).assertSuccess()
+ // Foo pays Bar once, but that should not appear in the result.
+ db.bankTransactionCreate(genTx("payout")).assertSuccess()
+ // Gen two transactions using row bank transaction logic
+ repeat(2) {
+ db.bankTransactionCreate(
+ genTx(OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode(), 1, 2)
+ ).assertSuccess()
+ }
+
+ // Check ignore bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check skip bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=5") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check no useless polling
+ assertTime(0, 300) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&start=15&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+ }
+
+ // Check polling end
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=60") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ runBlocking {
+ joinAll(
+ launch { // Check polling succeed forward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling succeed backward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout forward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout backward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch {
+ delay(200)
+ db.genTransfer("exchange", bankAccountMerchant)
+ }
+ )
+ }
+
+ // Testing ranges.
+ repeat(300) {
+ db.genTransfer("exchange", bankAccountMerchant)
+ }
+
+ // forward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10&start=30") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+
+ // backward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10&start=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+ }
+
+ // Testing the /admin/add-incoming call from the TWG API.
+ @Test
+ fun addIncoming() = bankSetup { db ->
+ val valid_req = json {
+ "amount" to "KUDOS:44"
+ "reserve_pub" to randEddsaPublicKey()
+ "debit_account" to "payto://iban/MERCHANT-IBAN-XYZ"
+ };
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req)
+
+ // Checking exchange debt constraint.
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Giving debt allowance and checking the OK case.
+ assert(db.bankAccountSetMaxDebt(
+ 1L,
+ TalerAmount(1000, 0, "KUDOS")
+ ))
+
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req, deflate = true)
+ }.assertOk()
+
+ // Trigger conflict due to reused reserve_pub
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Currency mismatch
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "amount" to "EUR:33"
+ }
+ )
+ }.assertBadRequest()
+
+ // Unknown account
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "reserve_pub" to randEddsaPublicKey()
+ "debit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
+ }
+ )
+ }.assertStatus(HttpStatusCode.NotFound)
+
+ // Bad BASE32 reserve_pub
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(json(valid_req) {
+ "reserve_pub" to "I love chocolate"
+ })
+ }.assertBadRequest()
+
+ // Bad BASE32 len reserve_pub
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(json(valid_req) {
+ "reserve_pub" to randBase32Crockford(31)
+ })
+ }.assertBadRequest()
+ }
+}
+\ No newline at end of file
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -1,16 +1,68 @@
import io.ktor.http.*
import io.ktor.client.statement.*
import io.ktor.client.request.*
+import io.ktor.server.testing.*
import kotlinx.coroutines.*
import kotlinx.serialization.json.*
import net.taler.wallet.crypto.Base32Crockford
-import kotlin.test.assertEquals
+import kotlin.test.*
import tech.libeufin.bank.*
import java.io.ByteArrayOutputStream
import java.util.zip.DeflaterOutputStream
+import tech.libeufin.util.CryptoUtil
/* ----- Setup ----- */
+val customerMerchant = Customer(
+ login = "merchant",
+ passwordHash = CryptoUtil.hashpw("merchant-password"),
+ name = "Merchant",
+ phone = "+00",
+ email = "merchant@libeufin-bank.com",
+ cashoutPayto = "payto://external-IBAN",
+ cashoutCurrency = "KUDOS"
+)
+val bankAccountMerchant = BankAccount(
+ internalPaytoUri = IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ"),
+ lastNexusFetchRowId = 1L,
+ owningCustomerId = 1L,
+ hasDebt = false,
+ maxDebt = TalerAmount(10, 1, "KUDOS"),
+)
+val customerExchange = Customer(
+ login = "exchange",
+ passwordHash = CryptoUtil.hashpw("exchange-password"),
+ name = "Exchange",
+ phone = "+00",
+ email = "exchange@libeufin-bank.com",
+ cashoutPayto = "payto://external-IBAN",
+ cashoutCurrency = "KUDOS"
+)
+val bankAccountExchange = BankAccount(
+ internalPaytoUri = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"),
+ lastNexusFetchRowId = 1L,
+ owningCustomerId = 2L,
+ hasDebt = false,
+ maxDebt = TalerAmount(10, 1, "KUDOS"),
+ isTalerExchange = true
+)
+
+fun bankSetup(lambda: suspend ApplicationTestBuilder.(Database) -> Unit) {
+ setup { db, ctx ->
+ // Creating the exchange and merchant accounts first.
+ assertNotNull(db.customerCreate(customerMerchant))
+ assertNotNull(db.bankAccountCreate(bankAccountMerchant))
+ assertNotNull(db.customerCreate(customerExchange))
+ assertNotNull(db.bankAccountCreate(bankAccountExchange))
+ testApplication {
+ application {
+ corebankWebApp(db, ctx)
+ }
+ lambda(db)
+ }
+ }
+}
+
fun setup(
conf: String = "test.conf",
lambda: suspend (Database, BankApplicationContext) -> Unit
diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql
@@ -11,7 +11,7 @@ BEGIN
normalized.val = amount.val + amount.frac / 100000000;
normalized.frac = amount.frac % 100000000;
END $$;
-COMMENT ON PROCEDURE amount_normalize
+COMMENT ON FUNCTION amount_normalize
IS 'Returns the normalized amount by adding to the .val the value of (.frac / 100000000) and removing the modulus 100000000 from .frac.';
CREATE OR REPLACE FUNCTION amount_add(
@@ -29,7 +29,7 @@ BEGIN
RAISE EXCEPTION 'addition overflow';
END IF;
END $$;
-COMMENT ON PROCEDURE amount_add
+COMMENT ON FUNCTION amount_add
IS 'Returns the normalized sum of two amounts. It raises an exception when the resulting .val is larger than 2^52';
CREATE OR REPLACE FUNCTION amount_left_minus_right(