libeufin

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

commit 1606b9952d7eeb67e70656e0c19c9ae3206c5024
parent 36f3e24e88b91903b5d9e821ca88d6fc6c29888a
Author: MS <ms@taler.net>
Date:   Thu, 21 Sep 2023 16:14:39 +0200

Implementing TWG POST /transfer.

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 23+++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/types.kt | 22++++++++++++++++++++++
Mbank/src/test/kotlin/DatabaseTest.kt | 30+++++++++++++++++++++++++++++-
Mbank/src/test/kotlin/TalerApiTest.kt | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdatabase-versioning/libeufin-bank-0001.sql | 16++++++++++++++++
Mdatabase-versioning/procedures.sql | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 377 insertions(+), 3 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -394,7 +394,6 @@ class Database(private val dbConfig: String) { ) } } - // More bankAccountGetFrom*() to come, on a needed basis. // BANK ACCOUNT TRANSACTIONS enum class BankTransactionResult { @@ -900,4 +899,98 @@ class Database(private val dbConfig: String) { ) } } + + // Gets a Taler transfer request, given its UID. + fun talerTransferGetFromUid(requestUid: String): TransferRequest? { + reconnect() + val stmt = prepare(""" + SELECT + wtid + ,(amount).val AS amount_value + ,(amount).frac AS amount_frac + ,exchange_base_url + ,credit_account_payto + FROM taler_exchange_transfers + WHERE request_uid = ?; + """) + stmt.setString(1, requestUid) + val res = stmt.executeQuery() + res.use { + if (!it.next()) return null + return TransferRequest( + wtid = it.getString("wtid"), + amount = TalerAmount( + value = it.getLong("amount_value"), + frac = it.getInt("amount_frac"), + ), + credit_account = it.getString("credit_account_payto"), + exchange_base_url = it.getString("exchange_base_url"), + request_uid = requestUid, + // FIXME: fix the following two after setting the bank_transaction_id on this row. + row_id = 0L, + timestamp = 0L + ) + } + } + + /** + * This function calls the SQL function that (1) inserts the TWG + * requests details into the database and (2) performs the actual + * bank transaction to pay the merchant according to the 'req' parameter. + * + * 'req' contains the same data that was POSTed by the exchange + * to the TWG /transfer endpoint. The exchangeBankAccountId parameter + * is the row ID of the exchange's bank account. The return type + * is the same returned by "bank_wire_transfer()" where however + * the NO_DEBTOR error will hardly take place. + */ + fun talerTransferCreate( + req: TransferRequest, + exchangeBankAccountId: Long, + timestamp: Long, + acctSvcrRef: String = "not used", + pmtInfId: String = "not used", + endToEndId: String = "not used", + ): BankTransactionResult { + reconnect() + // FIXME: future versions should return the exchange's latest bank transaction ID + val stmt = prepare(""" + SELECT + out_exchange_balance_insufficient + ,out_nx_creditor + FROM + taler_transfer ( + ?, + ?, + (?,?)::taler_amount, + ?, + ?, + ?, + ?, + ?, + ?, + ? + ); + """) + stmt.setString(1, req.request_uid) + stmt.setString(2, req.wtid) + stmt.setLong(3, req.amount.value) + stmt.setInt(4, req.amount.frac) + stmt.setString(5, req.exchange_base_url) + stmt.setString(6, req.credit_account) + stmt.setLong(7, exchangeBankAccountId) + stmt.setLong(8, timestamp) + stmt.setString(9, acctSvcrRef) + stmt.setString(10, pmtInfId) + stmt.setString(11, endToEndId) + + val res = stmt.executeQuery() + res.use { + if (!it.next()) + throw internalServerError("SQL function taler_transfer did not return anything.") + if (it.getBoolean("out_exchange_balance_insufficient")) return BankTransactionResult.CONFLICT + if (it.getBoolean("out_nx_creditor")) return BankTransactionResult.NO_CREDITOR + return BankTransactionResult.SUCCESS + } + } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -39,6 +39,7 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import kotlinx.serialization.modules.SerializersModule import net.taler.common.errorcodes.TalerErrorCode +import org.jetbrains.exposed.sql.stringLiteral import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level @@ -88,6 +89,25 @@ object RelativeTimeSerializer : KSerializer<RelativeTime> { } } +object TalerAmountSerializer : KSerializer<TalerAmount> { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: TalerAmount) { + throw internalServerError("Encoding of TalerAmount not implemented.") // API doesn't require this. + } + override fun deserialize(decoder: Decoder): TalerAmount { + val maybeAmount = try { + decoder.decodeString() + } catch (e: Exception) { + throw badRequest( + "Did not find any Taler amount as string: ${e.message}", + TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID + ) + } + return parseTalerAmount(maybeAmount) + } +} /** * This function tries to authenticate the call according @@ -148,6 +168,9 @@ val webApp: Application.() -> Unit = { contextual(RelativeTime::class) { RelativeTimeSerializer } + contextual(TalerAmount::class) { + TalerAmountSerializer + } } }) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt @@ -30,7 +30,7 @@ import net.taler.common.errorcodes.TalerErrorCode import tech.libeufin.util.getNowUs fun Routing.talerWireGatewayHandlers() { - get("/accounts/{USERNAME}/taler-wire-gateway/config") { + get("/taler-wire-gateway/config") { val internalCurrency = db.configGet("internal_currency") ?: throw internalServerError("Could not find bank own currency.") call.respond(TWGConfigResponse(currency = internalCurrency)) @@ -69,6 +69,56 @@ fun Routing.talerWireGatewayHandlers() { return@get } post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { + val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() + val req = call.receive<TransferRequest>() + // Checking for idempotency. + val maybeDoneAlready = db.talerTransferGetFromUid(req.request_uid) + if (maybeDoneAlready != null) { + val isIdempotent = + maybeDoneAlready.amount == req.amount + && maybeDoneAlready.credit_account == req.credit_account + && maybeDoneAlready.exchange_base_url == req.exchange_base_url + && maybeDoneAlready.wtid == req.wtid + if (isIdempotent) { + val timestamp = maybeDoneAlready.timestamp + ?: throw internalServerError("Timestamp not found on idempotent request") + val rowId = maybeDoneAlready.row_id + ?: throw internalServerError("Row ID not found on idempotent request") + call.respond(TransferResponse( + timestamp = timestamp, + row_id = rowId + )) + return@post + } + throw conflict( + hint = "request_uid used already", + talerEc = TalerErrorCode.TALER_EC_END // FIXME: need appropriate Taler EC. + ) + } + // Legitimate request, go on. + val exchangeBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Exchange does not have a bank account") + val transferTimestamp = getNowUs() + val dbRes = db.talerTransferCreate( + req = req, + exchangeBankAccountId = exchangeBankAccount.expectRowId(), + timestamp = transferTimestamp + ) + if (dbRes == Database.BankTransactionResult.CONFLICT) + throw conflict( + "Insufficient balance for exchange", + TalerErrorCode.TALER_EC_END // FIXME + ) + if (dbRes == Database.BankTransactionResult.NO_CREDITOR) + throw notFound( + "Creditor account was not found", + TalerErrorCode.TALER_EC_END // FIXME + ) + call.respond(TransferResponse( + timestamp = transferTimestamp, + row_id = 0 // FIXME! + )) return@post } post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { diff --git a/bank/src/main/kotlin/tech/libeufin/bank/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -23,6 +23,7 @@ import io.ktor.http.* import io.ktor.server.application.* import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import java.io.Serial import java.util.* // Allowed lengths for fractional digits in amounts. @@ -545,4 +546,25 @@ data class IncomingReserveTransaction( val amount: String, val debit_account: String, // Payto of the sender. val reserve_pub: String +) + +// TWG's request to pay a merchant. +@Serializable +data class TransferRequest( + val request_uid: String, + @Contextual + val amount: TalerAmount, + val exchange_base_url: String, + val wtid: String, + val credit_account: String, + // Only used when this type if defined from a DB record + val timestamp: Long? = null, // when this request got finalized with a wire transfer + val row_id: Long? = null // DB row ID of this record +) + +// TWG's response to merchant payouts +@Serializable +data class TransferResponse( + val timestamp: Long, + val row_id: Long ) \ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -17,7 +17,6 @@ * <http://www.gnu.org/licenses/> */ - import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.getNowUs @@ -75,6 +74,35 @@ class DatabaseTest { maxDebt = TalerAmount(10, 1, "KUDOS") ) val fooPaysBar = genTx() + + /** + * Tests the SQL function that performs the instructions + * given by the exchange to pay one merchant. + */ + @Test + fun talerTransferTest() { + val exchangeReq = TransferRequest( + amount = TalerAmount(9, 0, "KUDOS"), + credit_account = "BAR-IBAN-ABC", // foo pays bar + exchange_base_url = "example.com/exchange", + request_uid = "entropic 0", + wtid = "entropic 1" + ) + val db = initDb() + val fooId = db.customerCreate(customerFoo) + assert(fooId != null) + val barId = db.customerCreate(customerBar) + assert(barId != null) + assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.bankAccountCreate(bankAccountBar)) + val res = db.talerTransferCreate( + req = exchangeReq, + exchangeBankAccountId = 1L, + timestamp = getNowUs() + ) + assert(res == Database.BankTransactionResult.SUCCESS) + } + @Test fun bearerTokenTest() { val db = initDb() diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -44,6 +44,63 @@ class TalerApiTest { cashoutPayto = "payto://external-IBAN", cashoutCurrency = "KUDOS" ) + // Testing the POST /transfer call from the TWG API. + @Test + fun transfer() { + val db = initDb() + // Creating the exchange and merchant accounts first. + assert(db.customerCreate(customerFoo) != null) + assert(db.bankAccountCreate(bankAccountFoo)) + assert(db.customerCreate(customerBar) != null) + assert(db.bankAccountCreate(bankAccountBar)) + // Give the exchange reasonable debt allowance: + assert(db.bankAccountSetMaxDebt( + 1L, + TalerAmount(1000, 0) + )) + // Do POST /transfer. + testApplication { + application(webApp) + val req = """ + { + "request_uid": "entropic 0", + "wtid": "entropic 1", + "exchange_base_url": "http://exchange.example.com/", + "amount": "KUDOS:33", + "credit_account": "BAR-IBAN-ABC" + } + """.trimIndent() + client.post("/accounts/foo/taler-wire-gateway/transfer") { + basicAuth("foo", "pw") + contentType(ContentType.Application.Json) + expectSuccess = true + setBody(req) + } + // check idempotency + client.post("/accounts/foo/taler-wire-gateway/transfer") { + basicAuth("foo", "pw") + contentType(ContentType.Application.Json) + expectSuccess = true + setBody(req) + } + // Trigger conflict due to reused request_uid + val r = client.post("/accounts/foo/taler-wire-gateway/transfer") { + basicAuth("foo", "pw") + contentType(ContentType.Application.Json) + expectSuccess = false + setBody(""" + { + "request_uid": "entropic 0", + "wtid": "entropic 1", + "exchange_base_url": "http://different-exchange.example.com/", + "amount": "KUDOS:33", + "credit_account": "BAR-IBAN-ABC" + } + """.trimIndent()) + } + assert(r.status == HttpStatusCode.Conflict) + } + } // Testing the /history/incoming call from the TWG API. @Test fun historyIncoming() { diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -363,6 +363,22 @@ CREATE TABLE IF NOT EXISTS bank_account_statements -- end of: accounts activity report -- start of: Taler integration +CREATE TABLE IF NOT EXISTS taler_exchange_transfers + (exchange_transfer_id BIGINT GENERATED BY DEFAULT AS IDENTITY + ,request_uid TEXT NOT NULL UNIQUE + ,wtid TEXT NOT NULL UNIQUE + ,exchange_base_url TEXT NOT NULL + ,credit_account_payto TEXT NOT NULL + ,amount taler_amount NOT NULL + ,bank_transaction BIGINT UNIQUE -- NOT NULL FIXME: make this not null. + REFERENCES bank_account_transactions(bank_transaction_id) + ON DELETE RESTRICT + ON UPDATE RESTRICT + ); +COMMENT ON TABLE taler_exchange_transfers + IS 'Tracks all the requests made by Taler exchanges to pay merchants'; +COMMENT ON COLUMN taler_exchange_transfers.bank_transaction + IS 'Reference to the (outgoing) bank transaction that finalizes the exchange transfer request.'; CREATE TABLE IF NOT EXISTS taler_withdrawal_operations (taler_withdrawal_id BIGINT GENERATED BY DEFAULT AS IDENTITY diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql @@ -86,6 +86,91 @@ END $$; COMMENT ON PROCEDURE bank_set_config(TEXT, TEXT) IS 'Update or insert configuration values'; +CREATE OR REPLACE FUNCTION taler_transfer( + IN in_request_uid TEXT, + IN in_wtid TEXT, + IN in_amount taler_amount, + IN in_exchange_base_url TEXT, + IN in_credit_account_payto TEXT, + IN in_exchange_bank_account_id BIGINT, + IN in_timestamp BIGINT, + IN in_account_servicer_reference TEXT, + IN in_payment_information_id TEXT, + IN in_end_to_end_id TEXT, + OUT out_exchange_balance_insufficient BOOLEAN, + OUT out_nx_creditor BOOLEAN +) +LANGUAGE plpgsql +AS $$ +DECLARE +maybe_balance_insufficient BOOLEAN; +receiver_bank_account_id BIGINT; +payment_subject TEXT; +BEGIN + +INSERT + INTO taler_exchange_transfers ( + request_uid, + wtid, + exchange_base_url, + credit_account_payto, + amount + -- FIXME: this needs the bank transaction row ID here. +) VALUES ( + in_request_uid, + in_wtid, + in_exchange_base_url, + in_credit_account_payto, + in_amount +); +SELECT + bank_account_id + INTO receiver_bank_account_id + FROM bank_accounts + WHERE internal_payto_uri = in_credit_account_payto; +IF NOT FOUND +THEN + out_nx_creditor=TRUE; + RETURN; +END IF; +out_nx_creditor=FALSE; +SELECT CONCAT(in_wtid, ' ', in_exchange_base_url) + INTO payment_subject; +SELECT + out_balance_insufficient + INTO maybe_balance_insufficient + FROM bank_wire_transfer( + receiver_bank_account_id, + in_exchange_bank_account_id, + payment_subject, + in_amount, + in_timestamp, + in_account_servicer_reference, + in_payment_information_id, + in_end_to_end_id + ); +IF (maybe_balance_insufficient) +THEN + out_exchange_balance_insufficient=TRUE; +END IF; +out_exchange_balance_insufficient=FALSE; +END $$; +COMMENT ON FUNCTION taler_transfer( + text, + text, + taler_amount, + text, + text, + bigint, + bigint, + text, + text, + text + ) + IS 'function that (1) inserts the TWG requests' + 'details into the database and (2) performs ' + 'the actual bank transaction to pay the merchant'; + CREATE OR REPLACE FUNCTION confirm_taler_withdrawal( IN in_withdrawal_uuid uuid, IN in_confirmation_date BIGINT,