gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[libeufin] branch master updated: Conversion service.


From: gnunet
Subject: [libeufin] branch master updated: Conversion service.
Date: Sun, 16 Apr 2023 09:29:50 +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 1b08d4d2 Conversion service.
1b08d4d2 is described below

commit 1b08d4d2de9474d316b8c9eec772c93161131407
Author: MS <ms@taler.net>
AuthorDate: Sun Apr 16 09:19:46 2023 +0200

    Conversion service.
    
    Implementing the cash-out monitor.  The monitor watches one
    particular bank account (the admin's by default) and saubmits
    a fiat payment initiation to Nexus upon every new incoming
    transaction.
    
    Also implementing idempotence for payment initiations at Nexus.
    This helps in case the cash-out monitor fails at keeping track
    of the submitted payments and accidentally submits multiple times
    the same payment.
---
 .idea/kotlinc.xml                                  |   3 +
 .idea/libeufin.iml                                 |  13 --
 .idea/modules.xml                                  |   8 -
 .idea/vcs.xml                                      |   1 +
 contrib/wallet-core                                |   2 +-
 nexus/build.gradle                                 |   1 +
 .../tech/libeufin/nexus/bankaccount/BankAccount.kt |   7 +-
 .../main/kotlin/tech/libeufin/nexus/server/JSON.kt |   7 +-
 .../tech/libeufin/nexus/server/NexusServer.kt      |  41 ++++-
 nexus/src/test/kotlin/ConversionServiceTest.kt     |  76 +++++++++
 nexus/src/test/kotlin/MakeEnv.kt                   |   6 +-
 nexus/src/test/kotlin/NexusApiTest.kt              |  60 +++++++
 .../tech/libeufin/sandbox/ConversionService.kt     | 183 ++++++++++++++++++++-
 .../src/main/kotlin/tech/libeufin/sandbox/DB.kt    |  35 ++++
 .../main/kotlin/tech/libeufin/sandbox/Helpers.kt   |  11 +-
 sandbox/src/test/kotlin/DBTest.kt                  |   1 +
 util/src/main/kotlin/DB.kt                         |   2 +-
 util/src/main/kotlin/HTTP.kt                       |   7 +-
 18 files changed, 423 insertions(+), 41 deletions(-)

diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 0dd4b354..059e602f 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -3,4 +3,7 @@
   <component name="Kotlin2JvmCompilerArguments">
     <option name="jvmTarget" value="1.8" />
   </component>
+  <component name="KotlinJpsPluginSettings">
+    <option name="version" value="1.7.22" />
+  </component>
 </project>
\ No newline at end of file
diff --git a/.idea/libeufin.iml b/.idea/libeufin.iml
deleted file mode 100644
index 186d698a..00000000
--- a/.idea/libeufin.iml
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<module external.linked.project.id="libeufin" 
external.linked.project.path="$MODULE_DIR$" 
external.root.project.path="$MODULE_DIR$" external.system.id="GRADLE" 
external.system.module.group="" external.system.module.version="0.0.1-dev.3" 
type="JAVA_MODULE" version="4">
-  <component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_16" 
inherit-compiler-output="true">
-    <exclude-output />
-    <content url="file://$MODULE_DIR$">
-      <excludeFolder url="file://$MODULE_DIR$/.gradle" />
-      <excludeFolder url="file://$MODULE_DIR$/build" />
-      <excludeFolder url="file://$MODULE_DIR$/frontend" />
-    </content>
-    <orderEntry type="inheritedJdk" />
-    <orderEntry type="sourceFolder" forTests="false" />
-  </component>
-</module>
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index dbca1434..00000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="ProjectModuleManager">
-    <modules>
-      <module fileurl="file://$PROJECT_DIR$/.idea/libeufin.iml" 
filepath="$PROJECT_DIR$/.idea/libeufin.iml" />
-    </modules>
-  </component>
-</project>
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 9a6c7029..7cc7158b 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -3,6 +3,7 @@
   <component name="VcsDirectoryMappings">
     <mapping directory="" vcs="Git" />
     <mapping directory="$PROJECT_DIR$/build-system/taler-build-scripts" 
vcs="Git" />
+    <mapping directory="$PROJECT_DIR$/contrib/wallet-core" vcs="Git" />
     <mapping directory="$PROJECT_DIR$/parsing-tests/samples" vcs="Git" />
   </component>
 </project>
\ No newline at end of file
diff --git a/contrib/wallet-core b/contrib/wallet-core
index 529d588e..fa191419 160000
--- a/contrib/wallet-core
+++ b/contrib/wallet-core
@@ -1 +1 @@
-Subproject commit 529d588e00c63b113633c70623d631d0be6c0470
+Subproject commit fa191419fcf8cc4e2b17400b791dbdf4e673f5aa
diff --git a/nexus/build.gradle b/nexus/build.gradle
index 39e896c5..39a519af 100644
--- a/nexus/build.gradle
+++ b/nexus/build.gradle
@@ -96,6 +96,7 @@ dependencies {
     testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1'
     testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21'
     testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21'
+    testImplementation 'io.ktor:ktor-client-mock:2.2.4'
     testImplementation project(":sandbox")
 }
 
diff --git 
a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt
index 4312e8fb..9c1c8f9f 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/bankaccount/BankAccount.kt
@@ -331,7 +331,10 @@ fun addPaymentInitiation(paymentData: Pain001Data, 
debtorAccount: String): Payme
  * it will be the account whose money will pay the wire transfer being defined
  * by this pain document.
  */
-fun addPaymentInitiation(paymentData: Pain001Data, debtorAccount: 
NexusBankAccountEntity): PaymentInitiationEntity {
+fun addPaymentInitiation(
+    paymentData: Pain001Data,
+    debtorAccount: NexusBankAccountEntity
+): PaymentInitiationEntity {
     return transaction {
         val now = Instant.now().toEpochMilli()
         val nowHex = now.toString(16)
@@ -349,7 +352,7 @@ fun addPaymentInitiation(paymentData: Pain001Data, 
debtorAccount: NexusBankAccou
             preparationDate = now
             endToEndId = "leuf-e-$nowHex-$painHex-$acctHex"
             messageId = "leuf-mp1-$nowHex-$painHex-$acctHex"
-            paymentInformationId = "leuf-p-$nowHex-$painHex-$acctHex"
+            paymentInformationId = paymentData.pmtInfId ?: 
"leuf-p-$nowHex-$painHex-$acctHex"
             instructionId = "leuf-i-$nowHex-$painHex-$acctHex"
         }
     }
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
index 7fdfd526..1db14ab9 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
@@ -289,7 +289,9 @@ data class CreatePaymentInitiationRequest(
     val bic: String,
     val name: String,
     val amount: String,
-    val subject: String
+    val subject: String,
+    // When it's null, the client doesn't expect/need idempotence.
+    val uid: String? = null
 )
 
 /** Response type of "POST /prepared-payments" */
@@ -390,7 +392,8 @@ data class Pain001Data(
     val creditorName: String,
     val sum: String,
     val currency: String,
-    val subject: String
+    val subject: String,
+    val pmtInfId: String? = null
 )
 
 data class AccountTask(
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
index 269d6a32..bca2f0a1 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
@@ -690,11 +690,41 @@ val nexusApp: Application.() -> Unit = {
             if (!validateBic(body.bic)) {
                 throw NexusError(HttpStatusCode.BadRequest, "invalid BIC 
(${body.bic})")
             }
-            val res = transaction {
-                val bankAccount = NexusBankAccountEntity.findByName(accountId)
-                if (bankAccount == null) {
-                    throw unknownBankAccount(accountId)
+            // Handle first idempotence.
+            if (body.uid != null) {
+                val maybeExists: PaymentInitiationEntity? = transaction {
+                    PaymentInitiationEntity.find {
+                        PaymentInitiationsTable.paymentInformationId eq 
body.uid
+                    }.firstOrNull()
                 }
+                // If submitted payment looks exactly the same as the one
+                // found in the database, then respond 200 OK.  Otherwise,
+                // it's 409 Conflict.
+                if (maybeExists != null &&
+                    maybeExists.creditorIban == body.iban &&
+                    maybeExists.creditorName == body.name &&
+                    maybeExists.subject == body.subject &&
+                    maybeExists.creditorBic == body.bic &&
+                    "${maybeExists.currency}:${maybeExists.sum}" == body.amount
+                ) {
+                    call.respond(
+                        HttpStatusCode.OK,
+                        PaymentInitiationResponse(uuid = 
maybeExists.id.value.toString())
+                    )
+                    return@post
+                }
+                // The payment was found, but it didn't fulfill the previous 
check,
+                // conflict.
+                if (maybeExists != null)
+                    throw conflict(
+                        "Payment initiation with UID '${body.uid}' " +
+                                "was found already, with different details."
+                    )
+                // If the flow reaches here, then the payment wasn't found
+                // => proceed to create one.
+            }
+            val res = transaction {
+                val bankAccount = getBankAccount(accountId)
                 val amount = parseAmount(body.amount)
                 val paymentEntity = addPaymentInitiation(
                     Pain001Data(
@@ -703,7 +733,8 @@ val nexusApp: Application.() -> Unit = {
                         creditorName = body.name,
                         sum = amount.amount,
                         currency = amount.currency,
-                        subject = body.subject
+                        subject = body.subject,
+                        pmtInfId = body.uid
                     ),
                     bankAccount
                 )
diff --git a/nexus/src/test/kotlin/ConversionServiceTest.kt 
b/nexus/src/test/kotlin/ConversionServiceTest.kt
new file mode 100644
index 00000000..f04cb0a7
--- /dev/null
+++ b/nexus/src/test/kotlin/ConversionServiceTest.kt
@@ -0,0 +1,76 @@
+import io.ktor.client.*
+import io.ktor.client.engine.mock.*
+import io.ktor.server.testing.*
+import kotlinx.coroutines.*
+import org.jetbrains.exposed.sql.transactions.transaction
+import org.junit.Test
+import tech.libeufin.nexus.server.nexusApp
+import tech.libeufin.sandbox.*
+
+class ConversionServiceTest {
+    /**
+     * Tests whether the conversion service is able to skip
+     * submissions that had problems and proceed to new ones.
+     */
+    @Test
+    fun testWrongSubmissionSkip() {
+        withTestDatabase {
+            prepSandboxDb(); prepNexusDb()
+            val engine400 = MockEngine { respondBadRequest() }
+            val mockedClient = HttpClient(engine400)
+            runBlocking {
+                val monitorJob = async(Dispatchers.IO) { 
cashoutMonitor(mockedClient) }
+                launch {
+                    wireTransfer(
+                        debitAccount = "foo",
+                        creditAccount = "admin",
+                        subject = "fiat",
+                        amount = "TESTKUDOS:3"
+                    )
+                    // Give enough time to let a flawed monitor submit the 
request twice.
+                    delay(6000)
+                    transaction {
+                        // The request was submitted only once.
+                        assert(CashoutSubmissionEntity.all().count() == 1L)
+                        // The monitor marked it as failed.
+                        assert(CashoutSubmissionEntity.all().first().hasErrors)
+                        // The submission pointer got advanced by one.
+                        
assert(getBankAccountFromLabel("admin").lastFiatSubmission?.id?.value == 1L)
+                    }
+                    monitorJob.cancel()
+                }
+            }
+        }
+    }
+
+    /**
+     * Checks that the cash-out monitor reacts after
+     * a CRDT transaction arrives at the designated account.
+     */
+    @Test
+    fun cashoutTest() {
+        withTestDatabase {
+            prepSandboxDb(); prepNexusDb()
+            wireTransfer(
+                debitAccount = "foo",
+                creditAccount = "admin",
+                subject = "fiat",
+                amount = "TESTKUDOS:3"
+            )
+            testApplication {
+                application(nexusApp)
+                runBlocking {
+                    val monitorJob = launch(Dispatchers.IO) { 
cashoutMonitor(client) }
+                    launch {
+                        delay(4000L)
+                        transaction {
+                            assert(CashoutSubmissionEntity.all().count() == 1L)
+                            
assert(CashoutSubmissionEntity.all().first().isSubmitted)
+                        }
+                        monitorJob.cancel()
+                    }
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/MakeEnv.kt b/nexus/src/test/kotlin/MakeEnv.kt
index b3c2e221..7c19d07b 100644
--- a/nexus/src/test/kotlin/MakeEnv.kt
+++ b/nexus/src/test/kotlin/MakeEnv.kt
@@ -203,7 +203,11 @@ fun prepSandboxDb(usersDebtLimit: Int = 1000) {
             demobankName = "default",
             withSignupBonus = false,
             captchaUrl = "http://example.com/";,
-            suggestedExchangePayto = "payto://iban/${BAR_USER_IBAN}"
+            suggestedExchangePayto = "payto://iban/${BAR_USER_IBAN}",
+            nexusBaseUrl = "http://localhost/";,
+            usernameAtNexus = "foo",
+            passwordAtNexus = "foo",
+            enableConversionService = true
         )
         insertConfigPairs(config)
         val demoBank = DemobankConfigEntity.new { name = "default" }
diff --git a/nexus/src/test/kotlin/NexusApiTest.kt 
b/nexus/src/test/kotlin/NexusApiTest.kt
index 1b5caaec..d1ccad47 100644
--- a/nexus/src/test/kotlin/NexusApiTest.kt
+++ b/nexus/src/test/kotlin/NexusApiTest.kt
@@ -1,14 +1,18 @@
+import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import io.ktor.client.plugins.*
 import io.ktor.client.request.*
 import io.ktor.client.statement.*
 import io.ktor.http.*
 import io.ktor.server.testing.*
+import io.netty.handler.codec.http.HttpResponseStatus
 import kotlinx.coroutines.async
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.runBlocking
+import org.jetbrains.exposed.sql.transactions.transaction
 import org.junit.Test
+import tech.libeufin.nexus.PaymentInitiationEntity
 import tech.libeufin.nexus.server.nexusApp
 
 /**
@@ -16,6 +20,7 @@ import tech.libeufin.nexus.server.nexusApp
  * documented here: https://docs.taler.net/libeufin/api-nexus.html
  */
 class NexusApiTest {
+    private val jMapper = ObjectMapper()
     // Testing long-polling on GET /transactions
     @Test
     fun getTransactions() {
@@ -102,4 +107,59 @@ class NexusApiTest {
             }
         }
     }
+    /**
+     * Testing the idempotence of payment submissions.  That
+     * helps Sandbox not to create multiple payment initiations
+     * in case it fails at keeping track of what it submitted
+     * already.
+     */
+    @Test
+    fun paymentInitIdempotence() {
+        withTestDatabase {
+            prepNexusDb()
+            testApplication {
+                application(nexusApp)
+                // Check no pay. ini. exist.
+                transaction { PaymentInitiationEntity.all().count() == 0L }
+                // Create one.
+                fun f(futureThis: HttpRequestBuilder, subject: String = 
"idempotence pay. init. test") {
+                    futureThis.basicAuth("foo", "foo")
+                    futureThis.expectSuccess = true
+                    futureThis.contentType(ContentType.Application.Json)
+                    futureThis.setBody("""
+                        {"iban": "TESTIBAN",
+                         "bic": "SANDBOXX",
+                         "name": "TEST NAME",
+                         "amount": "TESTKUDOS:3",
+                         "subject": "$subject",
+                         "uid": "salt"
+                         }
+                    """.trimIndent())
+                }
+                val R = client.post("/bank-accounts/foo/payment-initiations") 
{ f(this) }
+                println(jMapper.readTree(R.bodyAsText()).get("uuid"))
+                // Submit again
+                client.post("/bank-accounts/foo/payment-initiations") { 
f(this) }
+                // Checking that Nexus serves it.
+                client.get("/bank-accounts/foo/payment-initiations/1") {
+                    basicAuth("foo", "foo")
+                    expectSuccess = true
+                }
+                // Checking that the database has only one, despite the double 
submission.
+                transaction {
+                    assert(PaymentInitiationEntity.all().count() == 1L)
+                }
+                /**
+                 * Causing a conflict by changing one payment detail
+                 * (the subject in this case) but not the "uid".
+                 */
+                val maybeConflict = 
client.post("/bank-accounts/foo/payment-initiations") {
+                    f(this, "different-subject")
+                    expectSuccess = false
+                }
+                assert(maybeConflict.status.value == 
HttpStatusCode.Conflict.value)
+
+            }
+        }
+    }
 }
\ No newline at end of file
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
index 1d256beb..4045d10e 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
@@ -1,7 +1,17 @@
 package tech.libeufin.sandbox
 
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import io.ktor.client.*
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.runBlocking
+import org.jetbrains.exposed.sql.and
+import org.jetbrains.exposed.sql.transactions.transaction
+import tech.libeufin.util.*
 
 /**
  * This file contains the logic for downloading/submitting incoming/outgoing
@@ -23,9 +33,18 @@ import kotlinx.coroutines.runBlocking
  *     transactions via its JSON API.
  */
 
-// Temporarily hard-coded.  According to fiat times, these values could be WAY 
higher.
-val longPollMs = 30000L // 30s long-polling.
-val loopNewReqMs = 2000L // 2s for the next request.
+/**
+ * Timeout the HTTP client waits for the server to respond,
+ * after the request is made.
+ */
+val waitTimeout = 30000L
+
+/**
+ * Time to wait before HTTP requesting again to the server.
+ * This helps to avoid tight cycles in case the server responds
+ * quickly or the client doesn't long-poll.
+ */
+val newIterationTimeout = 2000L
 
 /**
  * Executes the 'block' function every 'loopNewReqMs' milliseconds.
@@ -44,7 +63,7 @@ fun downloadLoop(block: () -> Unit) {
                  */
                 logger.error("Sandbox fiat-incoming monitor excepted: 
${e.message}")
             }
-            delay(loopNewReqMs)
+            delay(newIterationTimeout)
         }
     }
 }
@@ -64,9 +83,161 @@ fun downloadLoop(block: () -> Unit) {
  */
 // creditAdmin()
 
+// DB query helper.  The List return type (instead of SizedIterable) lets
+// the caller NOT open a transaction block to access the values -- although
+// some operations _on the values_ may be forbidden.
+private fun getUnsubmittedTransactions(bankAccountLabel: String): 
List<BankAccountTransactionEntity> {
+    return transaction {
+        val bankAccount = getBankAccountFromLabel(bankAccountLabel)
+        val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0
+        BankAccountTransactionEntity.find {
+            BankAccountTransactionsTable.id greater lowerExclusiveLimit and (
+                BankAccountTransactionsTable.direction eq "CRDT"
+            )
+        }.sortedBy { it.id }.map { it }
+        // The latest payment must occupy the highest index,
+        // to reliably update the bank account row with the last
+        // submitted cash-out.
+    }
+}
+
 /**
- * This function listens for regio-incoming events (LIBEUFIN_REGIO_INCOMING)
+ * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX)
  * and submits the related cash-out payment to Nexus.  The fiat payment will
  * then take place ENTIRELY on Nexus' responsibility.
  */
-// issueCashout()
+suspend fun cashoutMonitor(
+    httpClient: HttpClient,
+    bankAccountLabel: String = "admin",
+    demobankName: String = "default" // used to get config values.
+) {
+    // Register for a REGIO_TX event.
+    val eventChannel = buildChannelName(
+        NotificationsChannelDomains.LIBEUFIN_REGIO_TX,
+        bankAccountLabel
+    )
+    val objectMapper = jacksonObjectMapper()
+    val demobank = getDemobank(demobankName)
+    val bankAccount = getBankAccountFromLabel(bankAccountLabel)
+    val config = demobank?.config ?: throw internalServerError(
+        "Demobank '$demobankName' has no configuration."
+    )
+    val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl)
+    val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus)
+    val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus)
+    val paymentInitEndpoint = nexusBaseUrl.run {
+        var ret = this
+        if (!ret.endsWith('/'))
+            ret += '/'
+        /**
+         * WARNING: Nexus gives the possibility to have bank account names
+         * DIFFERENT from their owner's username.  Sandbox however MUST have
+         * its Nexus bank account named THE SAME as its username (until the
+         * config will allow to change).
+         */
+        ret + "bank-accounts/$usernameAtNexus/payment-initiations"
+    }
+    while (true) {
+        // delaying here avoids to delay in multiple places (errors,
+        // lack of action, success)
+        delay(2000)
+        val listenHandle = PostgresListenHandle(eventChannel)
+        // pessimistically LISTEN
+        listenHandle.postgresListen()
+        // but optimistically check for data, case some
+        // arrived _before_ the LISTEN.
+        var newTxs = getUnsubmittedTransactions(bankAccountLabel)
+        // Data found, UNLISTEN.
+        if (newTxs.isNotEmpty())
+            listenHandle.postgresUnlisten()
+        // Data not found, wait.
+        else {
+            // OK to block, because the next event is going to
+            // be _this_ one.  The caller should however execute
+            // this whole logic in a thread other than the main
+            // HTTP server.
+            val isNotificationArrived = 
listenHandle.postgresGetNotifications(waitTimeout)
+            if (isNotificationArrived && listenHandle.receivedPayload == 
"CRDT")
+                newTxs = getUnsubmittedTransactions(bankAccountLabel)
+        }
+        if (newTxs.isEmpty())
+            continue
+        newTxs.forEach {
+            val body = object {
+                /**
+                 * This field is UID of the request _as assigned by the
+                 * client_.  That helps to reconcile transactions or lets
+                 * Nexus implement idempotency.  It will NOT identify the 
created
+                 * resource at the server side.  The ID of the created 
resource is
+                 * assigned _by Nexus_ and communicated in the (successful) 
response.
+                 */
+                val uid = it.accountServicerReference
+                val iban = it.creditorIban
+                val bic = it.debtorBic
+                val amount = "${it.currency}:${it.amount}"
+                val subject = it.subject
+                val name = it.creditorName
+            }
+            val resp = try {
+                httpClient.post(paymentInitEndpoint) {
+                    expectSuccess = false // Avoid excepting on !2xx
+                    basicAuth(usernameAtNexus, passwordAtNexus)
+                    contentType(ContentType.Application.Json)
+                    setBody(objectMapper.writeValueAsString(body))
+                }
+            }
+            // Hard-error, response did not even arrive.
+            catch (e: Exception) {
+                logger.error(e.message)
+                // mark as failed and proceed to the next one.
+                transaction {
+                    CashoutSubmissionEntity.new {
+                        this.localTransaction = it.id
+                        this.hasErrors = true
+                    }
+                    bankAccount.lastFiatSubmission = it
+                }
+                return@forEach
+            }
+            // Handle the non 2xx error case.  Here we try
+            // to store the response from Nexus.
+            if (resp.status.value != HttpStatusCode.OK.value) {
+                val maybeResponseBody = resp.bodyAsText()
+                logger.error(
+                    "Fiat submission response was: $maybeResponseBody," +
+                            " status: ${resp.status.value}"
+                )
+                transaction {
+                    CashoutSubmissionEntity.new {
+                        localTransaction = it.id
+                        this.hasErrors = true
+                        if (maybeResponseBody.length > 0)
+                            this.maybeNexusResposnse = maybeResponseBody
+                    }
+                    bankAccount.lastFiatSubmission = it
+                }
+                return@forEach
+            }
+            // Successful case, mark the wire transfer as submitted,
+            // and advance the pointer to the last submitted payment.
+            val responseBody = resp.bodyAsText()
+            transaction {
+                CashoutSubmissionEntity.new {
+                    localTransaction = it.id
+                    hasErrors = false
+                    submissionTime = resp.responseTime.timestamp
+                    isSubmitted = true
+                    // Expectedly is > 0 and contains the submission
+                    // unique identifier _as assigned by Nexus_.  Not
+                    // currently used by Sandbox, but may help to resolve
+                    // disputes.
+                    if (responseBody.length > 0)
+                        maybeNexusResposnse = responseBody
+                }
+                // Advancing the 'last submitted bookmark', to avoid
+                // handling the same transaction multiple times.
+                bankAccount.lastFiatSubmission = it
+            }
+        }
+    }
+}
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
index b0654950..c8a1df18 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
@@ -508,6 +508,13 @@ object BankAccountsTable : IntIdTable() {
      * history results that start from / depend on the last transaction.
      */
     val lastTransaction = reference("lastTransaction", 
BankAccountTransactionsTable).nullable()
+
+    /**
+     * Points to the transaction that was last submitted by the conversion
+     * service to Nexus, in order to initiate a fiat payment related to a
+     * cash-out operation.
+     */
+    val lastFiatSubmission = reference("lastFiatSubmission", 
BankAccountTransactionsTable).nullable()
 }
 
 class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) {
@@ -520,6 +527,7 @@ class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) {
     var isPublic by BankAccountsTable.isPublic
     var demoBank by DemobankConfigEntity referencedOn 
BankAccountsTable.demoBank
     var lastTransaction by BankAccountTransactionEntity optionalReferencedOn 
BankAccountsTable.lastTransaction
+    var lastFiatSubmission by BankAccountTransactionEntity 
optionalReferencedOn BankAccountsTable.lastFiatSubmission
 }
 
 object BankAccountStatementsTable : IntIdTable() {
@@ -620,10 +628,36 @@ object BankAccountReportsTable : IntIdTable() {
     val bankAccount = reference("bankAccount", BankAccountsTable)
 }
 
+/**
+ * This table tracks the submissions of fiat payment instructions
+ * that Sandbox sends to Nexus.  Every fiat payment instruction is
+ * related to a confirmed cash-out operation.  The cash-out confirmation
+ * is effective once the customer sends a local wire transfer to the
+ * "admin" bank account.  Such wire transfer is tracked by the 
'localTransaction'
+ * column.
+ */
+object CashoutSubmissionsTable: LongIdTable() {
+    val localTransaction = reference("localTransaction", 
BankAccountTransactionsTable).uniqueIndex()
+    val isSubmitted = bool("isSubmitted").default(false)
+    val hasErrors = bool("hasErrors")
+    val maybeNexusResponse = text("maybeNexusResponse").nullable()
+    val submissionTime = long("submissionTime").nullable() // failed don't 
have it.
+}
+
+class CashoutSubmissionEntity(id: EntityID<Long>) : LongEntity(id) {
+    companion object : 
LongEntityClass<CashoutSubmissionEntity>(CashoutSubmissionsTable)
+    var localTransaction by CashoutSubmissionsTable.localTransaction
+    var isSubmitted by CashoutSubmissionsTable.isSubmitted
+    var hasErrors by CashoutSubmissionsTable.hasErrors
+    var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse
+    var submissionTime by CashoutSubmissionsTable.submissionTime
+}
+
 fun dbDropTables(dbConnectionString: String) {
     Database.connect(dbConnectionString)
     transaction {
         SchemaUtils.drop(
+            CashoutSubmissionsTable,
             EbicsSubscribersTable,
             EbicsHostsTable,
             EbicsDownloadTransactionsTable,
@@ -649,6 +683,7 @@ fun dbCreateTables(dbConnectionString: String) {
     TransactionManager.manager.defaultIsolationLevel = 
Connection.TRANSACTION_SERIALIZABLE
     transaction {
         SchemaUtils.create(
+            CashoutSubmissionsTable,
             DemobankConfigsTable,
             DemobankConfigPairsTable,
             EbicsSubscribersTable,
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
index a2454ff4..fdea79c2 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
@@ -31,6 +31,7 @@ import tech.libeufin.util.*
 import java.security.interfaces.RSAPublicKey
 import java.util.*
 import java.util.zip.DeflaterInputStream
+import kotlin.reflect.KProperty
 
 data class DemobankConfig(
     val allowRegistrations: Boolean,
@@ -43,9 +44,17 @@ data class DemobankConfig(
     val smsTan: String? = null, // fixme: move the config subcommand
     val emailTan: String? = null, // fixme: same as above.
     val suggestedExchangeBaseUrl: String? = null,
-    val suggestedExchangePayto: String? = null
+    val suggestedExchangePayto: String? = null,
+    val nexusBaseUrl: String? = null,
+    val usernameAtNexus: String? = null,
+    val passwordAtNexus: String? = null,
+    val enableConversionService: Boolean = false
 )
 
+fun <T>getConfigValueOrThrow(configKey: KProperty<T?>): T {
+    return configKey.getter.call() ?: throw 
nullConfigValueError(configKey.name)
+}
+
 /**
  * Helps to communicate Camt values without having
  * to parse the XML each time one is needed.
diff --git a/sandbox/src/test/kotlin/DBTest.kt 
b/sandbox/src/test/kotlin/DBTest.kt
index c63efd6f..fb2b8292 100644
--- a/sandbox/src/test/kotlin/DBTest.kt
+++ b/sandbox/src/test/kotlin/DBTest.kt
@@ -24,6 +24,7 @@ import tech.libeufin.sandbox.*
 import tech.libeufin.util.millis
 import java.io.File
 import java.time.LocalDateTime
+import kotlin.reflect.KProperty
 
 /**
  * Run a block after connecting to the test database.
diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt
index b0dcec9a..41e2a9d7 100644
--- a/util/src/main/kotlin/DB.kt
+++ b/util/src/main/kotlin/DB.kt
@@ -157,7 +157,7 @@ class PostgresListenHandle(val channelName: String) {
                 "'$channelName' for $timeoutMs millis.")
         val maybeNotifications = this.conn.getNotifications(timeoutMs.toInt())
         if (maybeNotifications == null || maybeNotifications.isEmpty()) {
-            logger.debug("DB notification channel $channelName was found 
empty.")
+            logger.debug("DB notifications not found on channel $channelName.")
             this.likelyCloseConnection()
             return false
         }
diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt
index 30a15bd9..67a0ccca 100644
--- a/util/src/main/kotlin/HTTP.kt
+++ b/util/src/main/kotlin/HTTP.kt
@@ -9,7 +9,6 @@ import io.ktor.server.util.*
 import io.ktor.util.*
 import logger
 import java.net.URLDecoder
-import kotlin.reflect.typeOf
 
 fun unauthorized(msg: String): UtilError {
     return UtilError(
@@ -62,6 +61,12 @@ fun forbidden(msg: String): UtilError {
     )
 }
 
+fun nullConfigValueError(
+    configKey: String,
+    demobankName: String = "default"
+): Throwable {
+    return internalServerError("Configuration value for '$configKey' at 
demobank '$demobankName' is null.")
+}
 fun internalServerError(
     reason: String,
     libeufinErrorCode: LibeufinErrorCode? = LibeufinErrorCode.LIBEUFIN_EC_NONE

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]