[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.
[Prev in Thread] |
Current Thread |
[Next in Thread] |
- [libeufin] branch master updated: Conversion service.,
gnunet <=