libeufin

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

commit 0a3956ee07af2d66b1f726198b2dd8def187b372
parent 71d6f7a6f77cd2279a7d80d04067efc4a77cdd4c
Author: MS <ms@taler.net>
Date:   Mon, 18 Sep 2023 13:23:52 +0200

Bank API.

Implementing:

GET /accounts/{USERNAME}
GET /transactions/{T_ID}
POST /transactions

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 171++++---------------------------------------------------------------------------
Abank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 44++++++++++++++++++++++++++++++++++++++++----
Abank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/types.kt | 49+++++++++++++++++++++++++++++++++++++++++++++----
Mutil/src/main/kotlin/HTTP.kt | 2+-
8 files changed, 521 insertions(+), 174 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -177,6 +177,7 @@ class Database(private val dbConfig: String) { ) } } + fun customerGetFromLogin(login: String): Customer? { reconnect() val stmt = prepare(""" @@ -187,7 +188,8 @@ class Database(private val dbConfig: String) { email, phone, cashout_payto, - cashout_currency + cashout_currency, + has_debit FROM customers WHERE login=? """) @@ -317,6 +319,7 @@ class Database(private val dbConfig: String) { ,has_debt ,(max_debt).val AS max_debt_val ,(max_debt).frac AS max_debt_frac + ,bank_account_id FROM bank_accounts WHERE owning_customer_id=? """) @@ -338,7 +341,48 @@ class Database(private val dbConfig: String) { maxDebt = TalerAmount( value = it.getLong("max_debt_val"), frac = it.getInt("max_debt_frac") - ) + ), + bankAccountId = it.getLong("bank_account_id") + ) + } + } + fun bankAccountGetFromInternalPayto(internalPayto: String): BankAccount? { + reconnect() + val stmt = prepare(""" + SELECT + ,bank_account_id + ,owning_customer_id + ,is_public + ,is_taler_exchange + ,last_nexus_fetch_row_id + ,(balance).val AS balance_val + ,(balance).frac AS balance_frac + ,has_debt + ,(max_debt).val AS max_debt_val + ,(max_debt).frac AS max_debt_frac + FROM bank_accounts + WHERE internal_payto_uri=? + """) + stmt.setString(1, internalPayto) + + val rs = stmt.executeQuery() + rs.use { + if (!it.next()) return null + return BankAccount( + internalPaytoUri = internalPayto, + balance = TalerAmount( + it.getLong("balance_val"), + it.getInt("balance_frac") + ), + lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"), + owningCustomerId = it.getLong("owning_customer_id"), + hasDebt = it.getBoolean("has_debt"), + isTalerExchange = it.getBoolean("is_taler_exchange"), + maxDebt = TalerAmount( + value = it.getLong("max_debt_val"), + frac = it.getInt("max_debt_frac") + ), + bankAccountId = it.getLong("bank_account_id") ) } } @@ -388,6 +432,57 @@ class Database(private val dbConfig: String) { } } + // Get the bank transaction whose row ID is rowId + fun bankTransactionGetFromInternalId(rowId: Long): BankAccountTransaction? { + reconnect() + val stmt = prepare(""" + SELECT + creditor_payto_uri + ,creditor_name + ,debtor_payto_uri + ,debtor_name + ,subject + ,(amount).val AS amount_val + ,(amount).frac AS amount_frac + ,transaction_date + ,account_servicer_reference + ,payment_information_id + ,end_to_end_id + ,direction + ,owning_customer_id + FROM bank_account_transactions + WHERE bank_transaction_id=? + """) + stmt.setLong(1, rowId) + val rs = stmt.executeQuery() + rs.use { + if (!it.next()) return null + return BankAccountTransaction( + creditorPaytoUri = it.getString("creditor_payto_uri"), + creditorName = it.getString("creditor_name"), + debtorPaytoUri = it.getString("debtor_payto_uri"), + debtorName = it.getString("debtor_name"), + amount = TalerAmount( + it.getLong("amount_val"), + it.getInt("amount_frac") + ), + accountServicerReference = it.getString("account_servicer_reference"), + endToEndId = it.getString("end_to_end_id"), + direction = it.getString("direction").run { + when(this) { + "credit" -> TransactionDirection.credit + "debit" -> TransactionDirection.debit + else -> throw internalServerError("Wrong direction in transaction: $this") + } + }, + bankAccountId = it.getLong("owning_customer_id"), + paymentInformationId = it.getString("payment_information_id"), + subject = it.getString("subject"), + transactionDate = it.getLong("transaction_date") + ) + } + } + fun bankTransactionGetForHistoryPage( upperBound: Long, bankAccountId: Long, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -39,13 +39,11 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import kotlinx.serialization.modules.SerializersModule import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level import tech.libeufin.util.* import java.time.Duration -import kotlin.random.Random // GLOBALS val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank") @@ -207,167 +205,14 @@ val webApp: Application.() -> Unit = { } } routing { - post("/accounts/{USERNAME}/token") { - val customer = call.myAuth(TokenScope.refreshable) ?: throw unauthorized("Authentication failed") - val endpointOwner = call.expectUriComponent("USERNAME") - if (customer.login != endpointOwner) - throw forbidden( - "User has no rights on this enpoint", - TalerErrorCode.TALER_EC_END // FIXME: need 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 { - java.util.Random().nextBytes(this) - } - val maxDurationTime: Long = db.configGet("token_max_duration").run { - if (this == null) - return@run Long.MAX_VALUE - return@run try { - this.toLong() - } catch (e: Exception) { - logger.error("Could not convert config's token_max_duration to Long") - throw internalServerError(e.message) - } - } - if (req.duration != null && req.duration.d_us.compareTo(maxDurationTime) == 1) - throw forbidden( - "Token duration bigger than bank's limit", - // FIXME: define new EC for this case. - TalerErrorCode.TALER_EC_END - ) - val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US - val customerDbRow = customer.dbRowId ?: throw internalServerError( - "Coud not resort customer '${customer.login}' database row ID" - ) - val expirationTimestampUs: Long = getNowUs() + tokenDurationUs - if (expirationTimestampUs < tokenDurationUs) - throw badRequest( - "Token duration caused arithmetic overflow", - // FIXME: need dedicate EC (?) - talerErrorCode = TalerErrorCode.TALER_EC_END - ) - val token = BearerToken( - bankCustomer = customerDbRow, - content = tokenBytes, - creationTime = expirationTimestampUs, - expirationTime = expirationTimestampUs, - 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 = Timestamp( - t_s = expirationTimestampUs / 1000000L - ) - )) - return@post - } - post("/accounts") { - // check if only admin. - val maybeOnlyAdmin = db.configGet("only_admin_registrations") - if (maybeOnlyAdmin?.lowercase() == "yes") { - val customer: Customer? = call.myAuth(TokenScope.readwrite) - if (customer == null || customer.login != "admin") - throw LibeufinBankException( - httpStatus = HttpStatusCode.Unauthorized, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.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 = GENERIC_UNDEFINED, // FIXME: this waits GANA. - hint = "Username '${req.username}' is reserved." - ) - ) - // Checking imdepotency. - 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()) - } - 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 == req.internal_payto_uri - if (isIdentic) { - call.respond(HttpStatusCode.Created) - return@post - } - throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, - talerError = TalerError( - code = GENERIC_UNDEFINED, // GANA needs this. - 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 = db.configGet("cashout_currency"), - 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 = db.configGet("max_debt_ordinary_customers").run { - if (this == null) throw internalServerError("Max debt not configured") - parseTalerAmount(this) - } - val newBankAccount = BankAccount( - hasDebt = false, - internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), - owningCustomerId = newCustomerRowId, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = maxDebt - ) - if (!db.bankAccountCreate(newBankAccount)) - throw internalServerError("Could not INSERT bank account despite all the checks.") - call.respond(HttpStatusCode.Created) - return@post + get("/config") { + call.respond(Config()) + return@get } + this.accountsMgmtHandlers() + this.tokenHandlers() + this.transactionsHandlers() + // this.talerHandlers() + // this.walletIntegrationHandlers() } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt @@ -0,0 +1,141 @@ +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 tech.libeufin.util.CryptoUtil +import tech.libeufin.util.maybeUriComponent + +/** + * 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() { + post("/accounts") { + // check if only admin. + val maybeOnlyAdmin = db.configGet("only_admin_registrations") + if (maybeOnlyAdmin?.lowercase() == "yes") { + val customer: Customer? = call.myAuth(TokenScope.readwrite) + if (customer == null || customer.login != "admin") + throw LibeufinBankException( + httpStatus = HttpStatusCode.Unauthorized, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_LOGIN_FAILED.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 = GENERIC_UNDEFINED, // FIXME: this waits GANA. + hint = "Username '${req.username}' is reserved." + ) + ) + // Checking imdepotency. + 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()) + } + if (maybeCustomerExists != null && maybeHasBankAccount != null) { + tech.libeufin.bank.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 == req.internal_payto_uri + if (isIdentic) { + call.respond(HttpStatusCode.Created) + return@post + } + throw LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, + talerError = TalerError( + code = GENERIC_UNDEFINED, // GANA needs this. + 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 = db.configGet("cashout_currency"), + 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 = db.configGet("max_debt_ordinary_customers").run { + if (this == null) throw internalServerError("Max debt not configured") + parseTalerAmount(this) + } + val bonus = db.configGet("registration_bonus") + val initialBalance = if (bonus != null) parseTalerAmount(bonus) else TalerAmount(0, 0) + val newBankAccount = BankAccount( + hasDebt = false, + internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), + owningCustomerId = newCustomerRowId, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = maxDebt, + balance = initialBalance + ) + if (!db.bankAccountCreate(newBankAccount)) + throw internalServerError("Could not INSERT bank account despite all the checks.") + call.respond(HttpStatusCode.Created) + return@post + } + get("/accounts/{USERNAME}") { + val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized("Login failed") + val resourceName = call.maybeUriComponent("USERNAME") ?: throw badRequest( + hint = "No username found in the URI", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING + ) + // Checking resource name only if Basic auth was used. + // Successful tokens do not need this check, they just pass. + if ( + ((c.login != resourceName) + && (c.login != "admin")) + && (call.getAuthToken() == null) + ) + throw forbidden("No rights on the resource.") + val customerData = db.customerGetFromLogin(c.login) ?: throw internalServerError("Customer '${c.login} despite being authenticated.'") + val customerInternalId = customerData.dbRowId ?: throw internalServerError("Customer '${c.login} had no row ID despite it was found in the database.'") + val bankAccountData = db.bankAccountGetFromOwnerId(customerInternalId) ?: throw internalServerError("Customer '${c.login} had no bank account despite they are customer.'") + call.respond(AccountData( + name = customerData.name, + balance = bankAccountData.balance, + debit_threshold = bankAccountData.maxDebt, + payto_uri = bankAccountData.internalPaytoUri, + contact_data = ChallengeContactData( + email = customerData.email, + phone = customerData.phone + ), + cashout_payto_uri = customerData.cashoutPayto, + has_debit = bankAccountData.hasDebt + )) + return@get + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -26,6 +26,11 @@ import net.taler.wallet.crypto.Base32Crockford import tech.libeufin.util.* import java.lang.NumberFormatException +fun ApplicationCall.expectUriComponent(componentName: String) = + this.maybeUriComponent(componentName) ?: throw badRequest( + hint = "No username found in the URI", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING +) // Get the auth token (stripped of the bearer-token:-prefix) // IF the call was authenticated with it. fun ApplicationCall.getAuthToken(): String? { @@ -131,7 +136,11 @@ fun doTokenAuth( )) } -fun forbidden(hint: String? = null, talerErrorCode: TalerErrorCode): LibeufinBankException = +fun forbidden( + hint: String = "No rights on the resource", + // FIXME: create a 'generic forbidden' Taler EC. + talerErrorCode: TalerErrorCode = TalerErrorCode.TALER_EC_END +): LibeufinBankException = LibeufinBankException( httpStatus = HttpStatusCode.Forbidden, talerError = TalerError( @@ -140,7 +149,7 @@ fun forbidden(hint: String? = null, talerErrorCode: TalerErrorCode): LibeufinBan ) ) -fun unauthorized(hint: String? = null): LibeufinBankException = +fun unauthorized(hint: String = "Login failed"): LibeufinBankException = LibeufinBankException( httpStatus = HttpStatusCode.Unauthorized, talerError = TalerError( @@ -156,6 +165,31 @@ fun internalServerError(hint: String?): LibeufinBankException = hint = hint ) ) + + +fun notFound( + hint: String?, + talerEc: TalerErrorCode +): LibeufinBankException = + LibeufinBankException( + httpStatus = HttpStatusCode.NotFound, + talerError = TalerError( + code = talerEc.code, + hint = hint + ) + ) + +fun conflict( + hint: String?, + talerEc: TalerErrorCode +): LibeufinBankException = + LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, + talerError = TalerError( + code = talerEc.code, + hint = hint + ) + ) fun badRequest( hint: String? = null, talerErrorCode: TalerErrorCode = TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID @@ -221,4 +255,6 @@ fun parseTalerAmount( frac = fraction, maybeCurrency = match.destructured.component1() ) -} -\ No newline at end of file +} + +fun getBankCurrency(): String = db.configGet("internal_currency") ?: throw internalServerError("Bank lacks currency") +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt @@ -0,0 +1,91 @@ +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 +import net.taler.wallet.crypto.Base32Crockford +import tech.libeufin.util.maybeUriComponent +import tech.libeufin.util.getNowUs + +fun Routing.tokenHandlers() { + delete("/accounts/{USERNAME}/token") { + throw internalServerError("Token deletion not implemented.") + } + post("/accounts/{USERNAME}/token") { + val customer = call.myAuth(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_END // FIXME: need 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 { + java.util.Random().nextBytes(this) + } + val maxDurationTime: Long = db.configGet("token_max_duration").run { + if (this == null) + return@run Long.MAX_VALUE + return@run try { + this.toLong() + } catch (e: Exception) { + tech.libeufin.bank.logger.error("Could not convert config's token_max_duration to Long") + throw internalServerError(e.message) + } + } + if (req.duration != null && req.duration.d_us.compareTo(maxDurationTime) == 1) + throw forbidden( + "Token duration bigger than bank's limit", + // FIXME: define new EC for this case. + TalerErrorCode.TALER_EC_END + ) + val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US + val customerDbRow = customer.dbRowId ?: throw internalServerError( + "Coud not resort customer '${customer.login}' database row ID" + ) + val expirationTimestampUs: Long = getNowUs() + tokenDurationUs + if (expirationTimestampUs < tokenDurationUs) + throw badRequest( + "Token duration caused arithmetic overflow", + // FIXME: need dedicate EC (?) + talerErrorCode = TalerErrorCode.TALER_EC_END + ) + val token = BearerToken( + bankCustomer = customerDbRow, + content = tokenBytes, + creationTime = expirationTimestampUs, + expirationTime = expirationTimestampUs, + 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 = Timestamp( + t_s = expirationTimestampUs / 1000000L + ) + ) + ) + return@post + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt @@ -0,0 +1,95 @@ +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 tech.libeufin.util.getNowUs +import tech.libeufin.util.parsePayto + +fun Routing.transactionsHandlers() { + // Creates a bank transaction. + post("/accounts/{USERNAME}/transactions") { + val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + val resourceName = call.expectUriComponent("USERNAME") + // admin has no rights here. + if ((c.login != resourceName) && (call.getAuthToken() == null)) + throw forbidden() + val txData = call.receive<BankAccountTransactionCreate>() + val payto = parsePayto(txData.payto_uri) + val subject = payto?.message ?: throw badRequest("Wire transfer lacks subject") + val debtorId = c.dbRowId ?: throw internalServerError("Debtor database ID not found") + // This performs already a SELECT on the bank account, + // like the wire transfer will do as well later! + val creditorCustomerData = db.bankAccountGetFromInternalPayto(txData.payto_uri) + ?: throw notFound( + "Creditor account not found", + TalerErrorCode.TALER_EC_END // FIXME: define this EC. + ) + val amount = parseTalerAmount(txData.amount) + if (amount.currency != getBankCurrency()) + throw badRequest( + "Wrong currency: ${amount.currency}", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + val dbInstructions = BankInternalTransaction( + debtorAccountId = debtorId, + creditorAccountId = creditorCustomerData.owningCustomerId, + subject = subject, + amount = amount, + transactionDate = getNowUs() + ) + val res = db.bankTransactionCreate(dbInstructions) + when(res) { + Database.BankTransactionResult.CONFLICT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_END // FIXME: need bank 'insufficient funds' EC. + ) + Database.BankTransactionResult.NO_CREDITOR -> + throw internalServerError("Creditor not found despite previous checks.") + Database.BankTransactionResult.NO_DEBTOR -> + throw internalServerError("Debtor not found despite the request was authenticated.") + Database.BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) + } + return@post + } + get("/accounts/{USERNAME}/transactions/{T_ID}") { + val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + val accountOwner = call.expectUriComponent("USERNAME") + // auth ok, check rights. + if (c.login != "admin" && c.login != accountOwner) + throw forbidden() + // rights ok, check tx exists. + 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 customerRowId = c.dbRowId ?: throw internalServerError("Authenticated client lacks database entry") + val tx = db.bankTransactionGetFromInternalId(txRowId) + ?: throw notFound( + "Bank transaction '$tId' not found", + TalerErrorCode.TALER_EC_NONE // FIXME: need def. + ) + val customerBankAccount = db.bankAccountGetFromOwnerId(customerRowId) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + if (tx.bankAccountId != customerBankAccount.bankAccountId) + throw forbidden("Client has no rights over the bank transaction: $tId") + // auth and rights, respond. + call.respond(BankAccountTransactionInfo( + amount = "${tx.amount.currency}:${tx.amount.value}.${tx.amount.frac}", + creditor_payto_uri = tx.creditorPaytoUri, + debtor_payto_uri = tx.debtorPaytoUri, + date = 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/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -182,6 +182,7 @@ data class BankAccount( val internalPaytoUri: String, // Database row ID of the customer that owns this bank account. val owningCustomerId: Long, + val bankAccountId: Long? = null, // null at INSERT. val isPublic: Boolean = false, val isTalerExchange: Boolean = false, /** @@ -195,7 +196,7 @@ data class BankAccount( * being wired by wallet owners. */ val lastNexusFetchRowId: Long = 0L, - val balance: TalerAmount? = null, + val balance: TalerAmount, val hasDebt: Boolean, val maxDebt: TalerAmount ) @@ -254,9 +255,9 @@ data class BankInternalTransaction( val subject: String, val amount: TalerAmount, val transactionDate: Long, - val accountServicerReference: String, // ISO20022 - val endToEndId: String, // ISO20022 - val paymentInformationId: String // ISO20022 + val accountServicerReference: String = "not used", // ISO20022 + val endToEndId: String = "not used", // ISO20022 + val paymentInformationId: String = "not used" // ISO20022 ) /** @@ -326,4 +327,44 @@ data class Cashout( val bankAccount: Long, val credit_payto_uri: String, val cashoutCurrency: String +) + +// Type to return as GET /config response +@Serializable // Never used to parse JSON. +data class Config( + val name: String = "libeufin-bank", + val version: String = "0:0:0", + val have_cashout: Boolean = false, + // Following might probably get renamed: + val fiat_currency: String? = null +) + +// GET /accounts/$USERNAME response. +data class AccountData( + val name: String, + val balance: TalerAmount, + val payto_uri: String, + val debit_threshold: TalerAmount, + val contact_data: ChallengeContactData? = null, + val cashout_payto_uri: String? = null, + val has_debit: Boolean +) + +// Type of POST /transactions +@Serializable +data class BankAccountTransactionCreate( + val payto_uri: String, + val amount: String +) + +// GET /transactions/T_ID +@Serializable +data class BankAccountTransactionInfo( + val creditor_payto_uri: String, + val debtor_payto_uri: String, + val amount: String, + val direction: TransactionDirection, + val subject: String, + val row_id: Long, // is T_ID + val date: Long ) \ No newline at end of file diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -50,7 +50,7 @@ fun ApplicationRequest.getBaseUrl(): String? { * Get the URI (path's) component or throw Internal server error. * @param component the name of the URI component to return. */ -fun ApplicationCall.expectUriComponent(name: String): String? { +fun ApplicationCall.maybeUriComponent(name: String): String? { val ret: String? = this.parameters[name] if (ret == null) { logger.error("Component $name not found in URI")