diff options
author | MS <ms@taler.net> | 2023-09-21 09:02:52 +0200 |
---|---|---|
committer | MS <ms@taler.net> | 2023-09-21 09:02:52 +0200 |
commit | 36f3e24e88b91903b5d9e821ca88d6fc6c29888a (patch) | |
tree | 3993f90818d628180f120c88313426a424040b0f | |
parent | 10190a79c63b97ca9e2c241775050e18c06f07ea (diff) | |
download | libeufin-36f3e24e88b91903b5d9e821ca88d6fc6c29888a.tar.gz libeufin-36f3e24e88b91903b5d9e821ca88d6fc6c29888a.tar.bz2 libeufin-36f3e24e88b91903b5d9e821ca88d6fc6c29888a.zip |
Implementing TWG /history/incoming.
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/Database.kt | 49 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 39 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt | 30 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt | 26 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/types.kt | 24 | ||||
-rw-r--r-- | bank/src/test/kotlin/DatabaseTest.kt | 10 | ||||
-rw-r--r-- | bank/src/test/kotlin/LibeuFinApiTest.kt | 2 | ||||
-rw-r--r-- | bank/src/test/kotlin/TalerApiTest.kt | 41 |
8 files changed, 178 insertions, 43 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt index ebc827bd..1ceb2763 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -33,6 +33,8 @@ private const val DB_CTR_LIMIT = 1000000 fun Customer.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Cutsomer '$login' had no DB row ID.") fun BankAccount.expectBalance(): TalerAmount = this.balance ?: throw internalServerError("Bank account '${this.internalPaytoUri}' lacks balance.") fun BankAccount.expectRowId(): Long = this.bankAccountId ?: throw internalServerError("Bank account '${this.internalPaytoUri}' lacks database row ID.") +fun BankAccountTransaction.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Bank account transaction (${this.subject}) lacks database row ID.") + class Database(private val dbConfig: String) { @@ -510,6 +512,15 @@ class Database(private val dbConfig: String) { ) } } + + /** + * The following function returns the list of transactions, according + * to the history parameters. The parameters take at least the 'start' + * and 'delta' values, and _optionally_ the payment direction. At the + * moment, only the TWG uses the direction, to provide the /incoming + * and /outgoing endpoints. + */ + // Helper type to collect the history parameters. private data class HistoryParams( val cmpOp: String, // < or > val orderBy: String // ASC or DESC @@ -518,6 +529,7 @@ class Database(private val dbConfig: String) { start: Long, delta: Long, bankAccountId: Long, + withDirection: TransactionDirection? = null ): List<BankAccountTransaction> { reconnect() val ops = if (delta < 0) @@ -536,22 +548,45 @@ class Database(private val dbConfig: String) { ,account_servicer_reference ,payment_information_id ,end_to_end_id - ,direction + ${if (withDirection != null) "" else ",direction"} ,bank_account_id ,bank_transaction_id FROM bank_account_transactions - WHERE bank_transaction_id ${ops.cmpOp} ? AND bank_account_id=? + WHERE bank_transaction_id ${ops.cmpOp} ? + AND bank_account_id=? + ${if (withDirection != null) "AND direction=?::direction_enum" else ""} ORDER BY bank_transaction_id ${ops.orderBy} LIMIT ? """) stmt.setLong(1, start) stmt.setLong(2, bankAccountId) - stmt.setLong(3, abs(delta)) + /** + * The LIMIT parameter index might change, according to + * the presence of the direction filter. + */ + val limitParamIndex = if (withDirection != null) { + stmt.setString(3, withDirection.name) + 4 + } + else + 3 + stmt.setLong(limitParamIndex, abs(delta)) val rs = stmt.executeQuery() rs.use { val ret = mutableListOf<BankAccountTransaction>() if (!it.next()) return ret do { + val direction = if (withDirection == null) { + it.getString("direction").run { + when (this) { + "credit" -> TransactionDirection.credit + "debit" -> TransactionDirection.debit + else -> throw internalServerError("Wrong direction in transaction: $this") + } + } + } + else + withDirection ret.add( BankAccountTransaction( creditorPaytoUri = it.getString("creditor_payto_uri"), @@ -564,13 +599,7 @@ class Database(private val dbConfig: String) { ), 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 = direction, bankAccountId = it.getLong("bank_account_id"), paymentInformationId = it.getString("payment_information_id"), subject = it.getString("subject"), diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt index a71cf992..240b454d 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -20,7 +20,10 @@ package tech.libeufin.bank import io.ktor.http.* +import io.ktor.http.cio.* import io.ktor.server.application.* +import io.ktor.server.plugins.* +import io.ktor.server.request.* import io.ktor.server.util.* import net.taler.common.errorcodes.TalerErrorCode import net.taler.wallet.crypto.Base32Crockford @@ -51,7 +54,6 @@ fun ApplicationCall.getAuthToken(): String? { return null // Not a Bearer token case. } - /** * Performs the HTTP basic authentication. Returns the * authenticated customer on success, or null otherwise. @@ -77,7 +79,10 @@ fun doBasicAuth(encodedCredentials: String): Customer? { ) val login = userAndPassSplit[0] val plainPassword = userAndPassSplit[1] - val maybeCustomer = db.customerGetFromLogin(login) ?: return null + val maybeCustomer = db.customerGetFromLogin(login) ?: throw notFound( + "User not found", + TalerErrorCode.TALER_EC_END // FIXME: define EC. + ) if (!CryptoUtil.checkpw(plainPassword, maybeCustomer.passwordHash)) return null return maybeCustomer } @@ -372,6 +377,7 @@ fun getTalerWithdrawUri(baseUrl: String, woId: String) = this.appendPathSegments(pathSegments) } +// Builds a withdrawal confirm URL. fun getWithdrawalConfirmUrl( baseUrl: String, wopId: String, @@ -411,3 +417,32 @@ fun getWithdrawal(opIdParam: String): TalerWithdrawalOperation { ) return op } + +data class HistoryParams( + val delta: Long, + val start: Long +) +/** + * Extracts the query parameters from "history-like" endpoints, + * providing the defaults according to the API specification. + */ +fun getHistoryParams(req: ApplicationRequest): HistoryParams { + val deltaParam: String = req.queryParameters["delta"] ?: throw MissingRequestParameterException(parameterName = "delta") + val delta: Long = try { + deltaParam.toLong() + } catch (e: Exception) { + logger.error(e.message) + throw badRequest("Param 'delta' not a number") + } + // Note: minimum 'start' is zero, as database IDs start from 1. + val start: Long = when (val param = req.queryParameters["start"]) { + null -> if (delta >= 0) 0L else Long.MAX_VALUE + else -> try { + param.toLong() + } catch (e: Exception) { + logger.error(e.message) + throw badRequest("Param 'start' not a number") + } + } + return HistoryParams(delta = delta, start = start) +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt index 672d9bdb..dc460928 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt @@ -21,6 +21,7 @@ package tech.libeufin.bank +import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -36,6 +37,35 @@ fun Routing.talerWireGatewayHandlers() { return@get } get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { + val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + if (!call.getResourceName("USERNAME").canI(c, withAdmin = true)) throw forbidden() + val params = getHistoryParams(call.request) + val bankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + if (!bankAccount.isTalerExchange) throw forbidden("History is not related to a Taler exchange.") + val bankAccountId = bankAccount.expectRowId() + + val history: List<BankAccountTransaction> = db.bankTransactionGetHistory( + start = params.start, + delta = params.delta, + bankAccountId = bankAccountId, + withDirection = TransactionDirection.credit + ) + if (history.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + return@get + } + val resp = IncomingHistory(credit_account = bankAccount.internalPaytoUri) + history.forEach { + resp.incoming_transactions.add(IncomingReserveTransaction( + row_id = it.expectRowId(), + amount = it.amount.toString(), + date = it.transactionDate, + debit_account = it.debtorPaytoUri, + reserve_pub = it.subject + )) + } + call.respond(resp) return@get } post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt index aafd6682..8b2f1a7d 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt @@ -17,32 +17,14 @@ fun Routing.transactionsHandlers() { val resourceName = call.expectUriComponent("USERNAME") if (c.login != resourceName && c.login != "admin") throw forbidden() // Collecting params. - val deltaParam: String = call.request.queryParameters["delta"] ?: throw MissingRequestParameterException(parameterName = "delta") - val delta: Long = try { - deltaParam.toLong() - } catch (e: Exception) { - logger.error(e.message) - throw badRequest("Param 'delta' not a number") - } - // Note: minimum 'start' is zero, as database IDs start from 1. - val start: Long = when (val param = call.request.queryParameters["start"]) { - null -> if (delta >= 0) 0L else Long.MAX_VALUE - else -> try { - param.toLong() - } catch (e: Exception) { - logger.error(e.message) - throw badRequest("Param 'start' not a number") - } - } - logger.info("Param long_poll_ms not supported") + val historyParams = getHistoryParams(call.request) // Making the query. val bankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - val bankAccountId = bankAccount.bankAccountId - ?: throw internalServerError("Bank account lacks row ID.") + val bankAccountId = bankAccount.expectRowId() val history: List<BankAccountTransaction> = db.bankTransactionGetHistory( - start = start, - delta = delta, + start = historyParams.start, + delta = historyParams.delta, bankAccountId = bankAccountId ) val res = BankAccountTransactionsResponse(transactions = mutableListOf()) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt index 6311e3e9..bd64def0 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/types.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -410,8 +410,11 @@ data class BankAccountGetWithdrawalResponse( typealias ResourceName = String - -// Checks if the input Customer has the rights over ResourceName +/** + * Checks if the input Customer has the rights over ResourceName. + * FIXME: myAuth() gives null on failures, but this gives false. + * Should they return the same, for consistency? + */ fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { if (c.login == this) return true if (c.login == "admin" && withAdmin) return true @@ -525,4 +528,21 @@ data class TWGConfigResponse( val name: String = "taler-wire-gateway", val version: String = "0:0:0:", val currency: String +) + +// Response of a TWG /history/incoming call. +@Serializable +data class IncomingHistory( + val incoming_transactions: MutableList<IncomingReserveTransaction> = mutableListOf(), + val credit_account: String // Receiver's Payto URI. +) +// TWG's incoming payment record. +@Serializable +data class IncomingReserveTransaction( + val type: String = "RESERVE", + val row_id: Long, // DB row ID of the payment. + val date: Long, // microseconds timestamp. + val amount: String, + val debit_account: String, // Payto of the sender. + val reserve_pub: String )
\ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt index 35b0b9d5..f38e9559 100644 --- a/bank/src/test/kotlin/DatabaseTest.kt +++ b/bank/src/test/kotlin/DatabaseTest.kt @@ -25,10 +25,14 @@ import java.util.Random import java.util.UUID // Foo pays Bar with custom subject. -fun genTx(subject: String = "test"): BankInternalTransaction = +fun genTx( + subject: String = "test", + creditorId: Long = 2, + debtorId: Long = 1 +): BankInternalTransaction = BankInternalTransaction( - creditorAccountId = 2, - debtorAccountId = 1, + creditorAccountId = creditorId, + debtorAccountId = debtorId, subject = subject, amount = TalerAmount( 10, 0, "KUDOS"), accountServicerReference = "acct-svcr-ref", diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt index cdbb2c0c..048947b6 100644 --- a/bank/src/test/kotlin/LibeuFinApiTest.kt +++ b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -197,7 +197,7 @@ class LibeuFinApiTest { basicAuth("not", "not") expectSuccess = false } - assert(shouldNot.status == HttpStatusCode.Unauthorized) + assert(shouldNot.status == HttpStatusCode.NotFound) } } /** diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt index 7f5c407b..7617467e 100644 --- a/bank/src/test/kotlin/TalerApiTest.kt +++ b/bank/src/test/kotlin/TalerApiTest.kt @@ -32,17 +32,52 @@ class TalerApiTest { lastNexusFetchRowId = 1L, owningCustomerId = 2L, hasDebt = false, - maxDebt = TalerAmount(10, 1, "KUDOS") + maxDebt = TalerAmount(10, 1, "KUDOS"), + isTalerExchange = true ) val customerBar = Customer( login = "bar", - passwordHash = "hash", + passwordHash = CryptoUtil.hashpw("secret"), name = "Bar", phone = "+00", email = "foo@b.ar", cashoutPayto = "payto://external-IBAN", cashoutCurrency = "KUDOS" ) + // Testing the /history/incoming call from the TWG API. + @Test + fun historyIncoming() { + val db = initDb() + assert(db.customerCreate(customerFoo) != null) + assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.customerCreate(customerBar) != null) + assert(db.bankAccountCreate(bankAccountBar)) + // Give Foo reasonable debt allowance: + assert(db.bankAccountSetMaxDebt( + 1L, + TalerAmount(1000, 0) + )) + // Foo pays Bar (the exchange) twice. + assert(db.bankTransactionCreate(genTx("withdrawal 1")) == Database.BankTransactionResult.SUCCESS) + assert(db.bankTransactionCreate(genTx("withdrawal 2")) == Database.BankTransactionResult.SUCCESS) + // Bar pays Foo once, but that should not appear in the result. + assert( + db.bankTransactionCreate(genTx("payout", creditorId = 1, debtorId = 2)) == + Database.BankTransactionResult.SUCCESS + ) + // Bar expects two entries in the incoming history + testApplication { + application(webApp) + val resp = client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=5") { + basicAuth("bar", "secret") + expectSuccess = true + } + val j: IncomingHistory = Json.decodeFromString(resp.bodyAsText()) + assert(j.incoming_transactions.size == 2) + } + } + + // Testing the /admin/add-incoming call from the TWG API. @Test fun addIncoming() { val db = initDb() @@ -50,6 +85,7 @@ class TalerApiTest { assert(db.bankAccountCreate(bankAccountFoo)) assert(db.customerCreate(customerBar) != null) assert(db.bankAccountCreate(bankAccountBar)) + // Give Bar reasonable debt allowance: assert(db.bankAccountSetMaxDebt( 2L, TalerAmount(1000, 0) @@ -68,7 +104,6 @@ class TalerApiTest { """.trimIndent()) } } - } // Selecting withdrawal details from the Integrtion API endpoint. @Test |