[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[libeufin] branch master updated: Implementing TWG POST /transfer.
From: |
gnunet |
Subject: |
[libeufin] branch master updated: Implementing TWG POST /transfer. |
Date: |
Thu, 21 Sep 2023 16:14:58 +0200 |
This is an automated email from the git hooks/post-receive script.
ms pushed a commit to branch master
in repository libeufin.
The following commit(s) were added to refs/heads/master by this push:
new 1606b995 Implementing TWG POST /transfer.
1606b995 is described below
commit 1606b9952d7eeb67e70656e0c19c9ae3206c5024
Author: MS <ms@taler.net>
AuthorDate: Thu Sep 21 16:14:39 2023 +0200
Implementing TWG POST /transfer.
---
.../src/main/kotlin/tech/libeufin/bank/Database.kt | 95 +++++++++++++++++++++-
bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 23 ++++++
.../tech/libeufin/bank/talerWireGatewayHandlers.kt | 52 +++++++++++-
bank/src/main/kotlin/tech/libeufin/bank/types.kt | 22 +++++
bank/src/test/kotlin/DatabaseTest.kt | 30 ++++++-
bank/src/test/kotlin/TalerApiTest.kt | 57 +++++++++++++
database-versioning/libeufin-bank-0001.sql | 16 ++++
database-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
index 1ceb2763..dff0b951 100644
--- 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
index 179f4212..bbe767e3 100644
--- 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
index dc460928..d6ef6910 100644
--- 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
index bd64def0..cda4664d 100644
--- 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
index f38e9559..9083d870 100644
--- 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
index 7617467e..757d1e82 100644
--- 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
index cb5a2f5e..e288f846 100644
--- 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
index 21585ced..3968389e 100644
--- 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,
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.
[Prev in Thread] |
Current Thread |
[Next in Thread] |
- [libeufin] branch master updated: Implementing TWG POST /transfer.,
gnunet <=