gnunet-svn
[Top][All Lists]
Advanced

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

[libeufin] branch master updated (bf7d9774 -> 7f5ad7e9)


From: gnunet
Subject: [libeufin] branch master updated (bf7d9774 -> 7f5ad7e9)
Date: Fri, 21 Apr 2023 20:23:30 +0200

This is an automated email from the git hooks/post-receive script.

ms pushed a change to branch master
in repository libeufin.

    from bf7d9774 EBICS HTD server side.
     new 8611d228 Conversion service.
     new 5bca2e1e Moving CaMt-JSON mapping to util.
     new 7f5ad7e9 testing the last changes

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../main/kotlin/tech/libeufin/nexus/Anastasis.kt   |  14 +-
 nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt    |  11 +-
 .../main/kotlin/tech/libeufin/nexus/FacadeUtil.kt  |   6 +-
 nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt |  45 +--
 .../tech/libeufin/nexus/iso20022/GbicRules.kt      |   1 +
 .../tech/libeufin/nexus/iso20022/Iso20022.kt       | 299 ++----------------
 .../kotlin/tech/libeufin/nexus/server/Helpers.kt   |   7 +-
 .../main/kotlin/tech/libeufin/nexus/server/JSON.kt |  52 +---
 .../tech/libeufin/nexus/server/NexusServer.kt      |   1 -
 .../nexus/xlibeufinbank/XLibeufinBankNexus.kt      |  15 +-
 nexus/src/test/kotlin/ConversionServiceTest.kt     | 135 +++++++--
 nexus/src/test/kotlin/Iso20022Test.kt              |   2 +-
 nexus/src/test/kotlin/JsonTest.kt                  |  57 +++-
 nexus/src/test/kotlin/MakeEnv.kt                   |   7 +-
 nexus/src/test/kotlin/SubjectNormalization.kt      |   3 +-
 nexus/src/test/kotlin/XLibeufinBankTest.kt         |   1 -
 .../tech/libeufin/sandbox/ConversionService.kt     | 221 +++++++++++---
 .../src/main/kotlin/tech/libeufin/sandbox/DB.kt    |  11 +
 util/src/main/kotlin/CamtJsonMapping.kt            | 334 +++++++++++++++++++++
 util/src/main/kotlin/DB.kt                         |   2 +-
 util/src/main/kotlin/HTTP.kt                       |   6 +-
 util/src/main/kotlin/amounts.kt                    |   3 +
 util/src/main/kotlin/strings.kt                    |   9 +-
 23 files changed, 764 insertions(+), 478 deletions(-)
 create mode 100644 util/src/main/kotlin/CamtJsonMapping.kt

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt
index a7b51fd1..b75755ef 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt
@@ -1,9 +1,9 @@
 package tech.libeufin.nexus
 
+import TransactionDetails
 import io.ktor.client.*
 import io.ktor.http.*
 import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.nexus.iso20022.TransactionDetails
 import tech.libeufin.nexus.server.PermissionQuery
 import tech.libeufin.nexus.server.expectNonNull
 import tech.libeufin.nexus.server.expectUrlParameter
@@ -49,7 +49,13 @@ fun anastasisFilter(payment: NexusBankTransactionEntity, 
txDtls: TransactionDeta
         logger.warn("missing debtor agent")
         return
     }
-    if (debtorAgent.bic == null) {
+    /**
+     * This block either assigns a non-null BIC to the 'bic'
+     * variable, or causes this function (anastasisFilter())
+     * to return.  This last action ensures that the payment
+     * being processed won't show up in the Anastasis facade.
+     */
+    val bic: String = debtorAgent.bic ?: run {
         logger.warn("Not allowing transactions missing the BIC.  IBAN and 
name: ${debtorIban}, $debtorName")
         return
     }
@@ -58,7 +64,9 @@ fun anastasisFilter(payment: NexusBankTransactionEntity, 
txDtls: TransactionDeta
         subject = txDtls.unstructuredRemittanceInformation
         timestampMs = System.currentTimeMillis()
         debtorPaytoUri = buildIbanPaytoUri(
-            debtorIban, debtorAgent.bic, debtorName,
+            debtorIban,
+            bic,
+            debtorName,
         )
     }
 }
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
index 6b152858..239515e8 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/DB.kt
@@ -19,6 +19,7 @@
 
 package tech.libeufin.nexus
 
+import EntryStatus
 import com.fasterxml.jackson.databind.JsonNode
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import org.jetbrains.exposed.dao.*
@@ -31,16 +32,6 @@ import tech.libeufin.util.*
 import java.sql.Connection
 import kotlin.reflect.typeOf
 
-
-enum class EntryStatus {
-    // Booked
-    BOOK,
-    // Pending
-    PDNG,
-    // Informational
-    INFO,
-}
-
 /**
  * This table holds the values that exchange gave to issue a payment,
  * plus a reference to the prepared pain.001 version of.  Note that
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt
index 9d1e1fe4..bab3bb48 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt
@@ -1,5 +1,8 @@
 package tech.libeufin.nexus
 
+import CamtBankAccountEntry
+import EntryStatus
+import TransactionDetails
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import io.ktor.http.*
 import org.jetbrains.exposed.dao.flushCache
@@ -7,9 +10,6 @@ import org.jetbrains.exposed.sql.SortOrder
 import org.jetbrains.exposed.sql.and
 import org.jetbrains.exposed.sql.transactions.TransactionManager
 import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
-import tech.libeufin.nexus.iso20022.CreditDebitIndicator
-import tech.libeufin.nexus.iso20022.TransactionDetails
 import tech.libeufin.nexus.server.NexusFacadeType
 
 // Mainly used to resort the last processed transaction ID.
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
index 8cdbae02..131e4a76 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt
@@ -19,6 +19,8 @@
 
 package tech.libeufin.nexus
 
+import CamtBankAccountEntry
+import TransactionDetails
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import io.ktor.server.application.ApplicationCall
 import io.ktor.server.application.call
@@ -36,7 +38,6 @@ import io.ktor.server.util.*
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.async
 import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.currentCoroutineContext
 import org.jetbrains.exposed.dao.Entity
 import org.jetbrains.exposed.dao.id.IdTable
 import org.jetbrains.exposed.sql.*
@@ -44,7 +45,6 @@ import 
org.jetbrains.exposed.sql.transactions.TransactionManager
 import org.jetbrains.exposed.sql.transactions.transaction
 import tech.libeufin.nexus.bankaccount.addPaymentInitiation
 import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions
-import tech.libeufin.nexus.bankaccount.getBankAccount
 import tech.libeufin.nexus.iso20022.*
 import tech.libeufin.nexus.server.*
 import tech.libeufin.util.*
@@ -159,15 +159,6 @@ fun customConverter(body: Any): String {
     return jacksonObjectMapper().writeValueAsString(body)
 }
 
-/**
- * Tries to extract a valid reserve public key from the raw subject line
- */
-fun extractReservePubFromSubject(rawSubject: String): String? {
-    val re = "\\b[a-z0-9A-Z]{52}\\b".toRegex()
-    val result = re.find(rawSubject.replace("[\n]+".toRegex(), "")) ?: return 
null
-    return result.value.uppercase()
-}
-
 // Handle a Taler Wire Gateway /transfer request.
 private suspend fun talerTransfer(call: ApplicationCall) {
     val transferRequest = call.receive<TalerTransferRequest>()
@@ -266,13 +257,8 @@ fun talerFilter(
         logger.warn("non-iban debtor account")
         return
     }
-    val debtorAgent = txDtls.debtorAgent
-    if (debtorAgent == null) {
-        // FIXME: Report payment, we can't even send it back
-        logger.warn("missing debtor agent")
-        return
-    }
-    if (debtorAgent.bic == null) {
+    val debtorBic = txDtls.debtorAgent?.bic
+    if (debtorBic == null) {
         logger.warn("Not allowing transactions missing the BIC.  IBAN and 
name: ${debtorIban}, $debtorName")
         return
     }
@@ -314,7 +300,7 @@ fun talerFilter(
         timestampMs = System.currentTimeMillis()
         debtorPaytoUri = buildIbanPaytoUri(
             debtorIban,
-            debtorAgent.bic,
+            debtorBic,
             debtorName
         )
     }
@@ -360,7 +346,8 @@ fun maybeTalerRefunds(bankAccount: NexusBankAccountEntity, 
lastSeenId: Long) {
                 it[NexusBankTransactionsTable.transactionJson],
                 CamtBankAccountEntry::class.java
             )
-            if (paymentData.batches == null) {
+            val batches = paymentData.batches
+            if (batches == null) {
                 logger.error(
                     "Empty wire details encountered in transaction with" +
                             " AcctSvcrRef: ${paymentData.accountServicerRef}." 
+
@@ -371,23 +358,23 @@ fun maybeTalerRefunds(bankAccount: 
NexusBankAccountEntity, lastSeenId: Long) {
                     "Unexpected void payment, cannot refund"
                 )
             }
-            val debtorAccount = 
paymentData.batches[0].batchTransactions[0].details.debtorAccount
-            if (debtorAccount?.iban == null) {
+            val debtorIban = 
batches[0].batchTransactions[0].details.debtorAccount?.iban
+            if (debtorIban == null) {
                 logger.error("Could not find a IBAN to refund in transaction 
(AcctSvcrRef): ${paymentData.accountServicerRef}, aborting refund")
                 throw NexusError(HttpStatusCode.InternalServerError, "IBAN to 
refund not found")
             }
-            val debtorAgent = 
paymentData.batches[0].batchTransactions[0].details.debtorAgent
+            val debtorAgent = 
batches[0].batchTransactions[0].details.debtorAgent
             if (debtorAgent?.bic == null) {
                 logger.error("Could not find the BIC of refundable IBAN at 
transaction (AcctSvcrRef): ${paymentData.accountServicerRef}, aborting refund")
                 throw NexusError(HttpStatusCode.InternalServerError, "BIC to 
refund not found")
             }
-            val debtorPerson = 
paymentData.batches[0].batchTransactions[0].details.debtor
-            if (debtorPerson?.name == null) {
+            val debtorName = 
batches[0].batchTransactions[0].details.debtor?.name
+            if (debtorName == null) {
                 logger.error("Could not find the owner's name of refundable 
IBAN at transaction (AcctSvcrRef): ${paymentData.accountServicerRef}, aborting 
refund")
                 throw NexusError(HttpStatusCode.InternalServerError, "Name to 
refund not found")
             }
             // FIXME: investigate this amount!
-            val amount = paymentData.batches[0].batchTransactions[0].amount
+            val amount = batches[0].batchTransactions[0].amount
             NexusAssert(
                 it[NexusBankTransactionsTable.creditDebitIndicator] == "CRDT" 
&&
                         it[NexusBankTransactionsTable.bankAccount] == 
bankAccount.id,
@@ -396,10 +383,10 @@ fun maybeTalerRefunds(bankAccount: 
NexusBankAccountEntity, lastSeenId: Long) {
             // FIXME #7116
             addPaymentInitiation(
                 Pain001Data(
-                    creditorIban = debtorAccount.iban,
+                    creditorIban = debtorIban,
                     creditorBic = debtorAgent.bic,
-                    creditorName = debtorPerson.name,
-                    subject = "Taler refund of: 
${paymentData.batches[0].batchTransactions[0].details.unstructuredRemittanceInformation}",
+                    creditorName = debtorName,
+                    subject = "Taler refund of: 
${batches[0].batchTransactions[0].details.unstructuredRemittanceInformation}",
                     sum = amount.value,
                     currency = amount.currency
                 ),
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt
index df6fc4a7..2a83e847 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt
@@ -18,6 +18,7 @@
  */
 
 package tech.libeufin.nexus.iso20022
+import CreditDebitIndicator
 
 /**
  * Extra rules for German Banking Industry Committee (GBIC) for ISO 20022.
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt
index a8509323..9d7ed3ea 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt
@@ -22,6 +22,21 @@
  */
 package tech.libeufin.nexus.iso20022
 
+import AgentIdentification
+import Batch
+import BatchTransaction
+import CamtBankAccountEntry
+import CashAccount
+import CreditDebitIndicator
+import CurrencyAmount
+import CurrencyExchange
+import GenericId
+import OrganizationIdentification
+import PartyIdentification
+import PostalAddress
+import PrivateIdentification
+import ReturnInfo
+import TransactionDetails
 import com.fasterxml.jackson.annotation.JsonIgnore
 import com.fasterxml.jackson.annotation.JsonInclude
 import com.fasterxml.jackson.annotation.JsonValue
@@ -34,18 +49,13 @@ import org.w3c.dom.Document
 import tech.libeufin.nexus.*
 import tech.libeufin.nexus.bankaccount.IngestedTransactionsCount
 import tech.libeufin.nexus.bankaccount.findDuplicate
-import tech.libeufin.nexus.server.CurrencyAmount
-import tech.libeufin.nexus.server.toPlainString
 import tech.libeufin.util.*
+import toPlainString
 import java.time.Instant
 import java.time.ZoneId
 import java.time.ZonedDateTime
 import java.time.format.DateTimeFormatter
 
-enum class CreditDebitIndicator {
-    DBIT,
-    CRDT
-}
 
 enum class CashManagementResponseType(@get:JsonValue val jsonName: String) {
     Report("report"), Statement("statement"), Notification("notification")
@@ -66,22 +76,6 @@ data class CamtReport(
     val entries: List<CamtBankAccountEntry>
 )
 
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class GenericId(
-    val id: String,
-    val schemeName: String?,
-    val proprietarySchemeName: String?,
-    val issuer: String?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class CashAccount(
-    val name: String?,
-    val currency: String?,
-    val iban: String?,
-    val otherId: GenericId?
-)
-
 @JsonInclude(JsonInclude.Include.NON_NULL)
 data class Balance(
     val type: String?,
@@ -107,265 +101,6 @@ data class CamtParseResult(
     val reports: List<CamtReport>
 )
 
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class PrivateIdentification(
-    val birthDate: String?,
-    val provinceOfBirth: String?,
-    val cityOfBirth: String?,
-    val countryOfBirth: String?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class OrganizationIdentification(
-    val bic: String?,
-    val lei: String?
-)
-
-/**
- * Identification of a party, which can be a private party
- * or an organization.
- *
- * Mapping of ISO 20022 PartyIdentification135.
- */
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class PartyIdentification(
-    val name: String?,
-    val countryOfResidence: String?,
-    val privateId: PrivateIdentification?,
-    val organizationId: OrganizationIdentification?,
-    val postalAddress: PostalAddress?,
-
-    /**
-     * Identification that applies to both private parties and organizations.
-     */
-    val otherId: GenericId?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class PostalAddress(
-    val addressCode: String?,
-    val addressProprietaryId: String?,
-    val addressProprietarySchemeName: String?,
-    val addressProprietaryIssuer: String?,
-    val department: String?,
-    val subDepartment: String?,
-    val streetName: String?,
-    val buildingNumber: String?,
-    val buildingName: String?,
-    val floor: String?,
-    val postBox: String?,
-    val room: String?,
-    val postCode: String?,
-    val townName: String?,
-    val townLocationName: String?,
-    val districtName: String?,
-    val countrySubDivision: String?,
-    val country: String?,
-    val addressLines: List<String>
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class AgentIdentification(
-    val name: String?,
-
-    val bic: String?,
-
-    /**
-     * Legal entity identification.
-     */
-    val lei: String?,
-
-    val clearingSystemMemberId: String?,
-
-    val clearingSystemCode: String?,
-
-    val proprietaryClearingSystemCode: String?,
-
-    val postalAddress: PostalAddress?,
-
-    val otherId: GenericId?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class CurrencyExchange(
-    val sourceCurrency: String,
-    val targetCurrency: String,
-    val unitCurrency: String?,
-    val exchangeRate: String,
-    val contractId: String?,
-    val quotationDate: String?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class TransactionDetails(
-    val debtor: PartyIdentification?,
-    val debtorAccount: CashAccount?,
-    val debtorAgent: AgentIdentification?,
-    val creditor: PartyIdentification?,
-    val creditorAccount: CashAccount?,
-    val creditorAgent: AgentIdentification?,
-    val ultimateCreditor: PartyIdentification?,
-    val ultimateDebtor: PartyIdentification?,
-
-    val endToEndId: String? = null,
-    val paymentInformationId: String? = null,
-    val messageId: String? = null,
-
-    val purpose: String?,
-    val proprietaryPurpose: String?,
-
-    /**
-     * Currency exchange information for the transaction's amount.
-     */
-    val currencyExchange: CurrencyExchange?,
-
-    /**
-     * Amount as given in the payment initiation.
-     * Can be same or different currency as account currency.
-     */
-    val instructedAmount: CurrencyAmount?,
-
-    /**
-     * Raw amount used for currency exchange, before extra charges.
-     * Can be same or different currency as account currency.
-     */
-    val counterValueAmount: CurrencyAmount?,
-
-    /**
-     * Money that was moved between banks.
-     *
-     * For CH, we use the "TxAmt".
-     * For EPC, this amount is either blank or taken
-     * from the "IBC" proprietary amount.
-     */
-    val interBankSettlementAmount: CurrencyAmount?,
-
-    /**
-     * Unstructured remittance information (=subject line) of the transaction,
-     * or the empty string if missing.
-     */
-    val unstructuredRemittanceInformation: String,
-    val returnInfo: ReturnInfo?
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class ReturnInfo(
-    val originalBankTransactionCode: String?,
-    val originator: PartyIdentification?,
-    val reason: String?,
-    val proprietaryReason: String?,
-    val additionalInfo: String?
-)
-
-data class BatchTransaction(
-    val amount: CurrencyAmount, // Fuels Taler withdrawal amount.
-    val creditDebitIndicator: CreditDebitIndicator,
-    val details: TransactionDetails
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class Batch(
-    val messageId: String?,
-    val paymentInformationId: String?,
-    val batchTransactions: List<BatchTransaction>
-)
-
-@JsonInclude(JsonInclude.Include.NON_NULL)
-data class CamtBankAccountEntry(
-    val amount: CurrencyAmount,
-    /**
-     * Is this entry debiting or crediting the account
-     * it is reported for?
-     */
-    val creditDebitIndicator: CreditDebitIndicator,
-
-    /**
-     * Booked, pending, etc.
-     */
-    val status: EntryStatus,
-
-    /**
-     * Code that describes the type of bank transaction
-     * in more detail
-     */
-    val bankTransactionCode: String,
-
-    val valueDate: String?,
-
-    val bookingDate: String?,
-
-    val accountServicerRef: String?,
-
-    val entryRef: String?,
-
-    /**
-     * Currency exchange information for the entry's amount.
-     * Only present if currency exchange happened at the entry level.
-     */
-    val currencyExchange: CurrencyExchange?,
-
-    /**
-     * Value before/after currency exchange before charges have been applied.
-     * Only present if currency exchange happened at the entry level.
-     */
-    val counterValueAmount: CurrencyAmount?,
-
-    /**
-     * Instructed amount.
-     * Only present if currency exchange happens at the entry level.
-     */
-    val instructedAmount: CurrencyAmount?,
-
-    // list of sub-transactions participating in this money movement.
-    val batches: List<Batch>?
-) {
-    /**
-     * This function returns the subject of the unique transaction
-     * accounted in this object.  If the transaction is not unique,
-     * it throws an exception.  NOTE: the caller has the responsibility
-     * of not passing an empty report; those usually should be discarded
-     * and never participate in the application logic.
-     */
-    @JsonIgnore
-    fun getSingletonSubject(): String {
-        // Checks that the given list contains only one element and returns it.
-        fun <T>checkAndGetSingleton(maybeTxs: List<T>?): T {
-            if (maybeTxs == null || maybeTxs.size > 1) throw 
internalServerError(
-                "Only a singleton transaction is " +
-                        "allowed inside ${this.javaClass}."
-            )
-            return maybeTxs[0]
-        }
-        /**
-         * Types breakdown until the last payment information is reached.
-         *
-         * CamtBankAccountEntry contains:
-         * - Batch 0
-         * - Batch 1
-         * - Batch N
-         *
-         * Batch X contains:
-         * - BatchTransaction 0
-         * - BatchTransaction 1
-         * - BatchTransaction N
-         *
-         * BatchTransaction X contains:
-         * - TransactionDetails
-         *
-         * TransactionDetails contains the involved parties
-         * and the payment subject but MAY NOT contain the amount.
-         * In this model, the amount is held in the BatchTransaction
-         * type, that is also -- so far -- required to be a singleton
-         * inside Batch.
-         */
-        checkAndGetSingleton<Batch>(this.batches)
-        val batchTransactions = this.batches?.get(0)?.batchTransactions
-        val tx = checkAndGetSingleton<BatchTransaction>(batchTransactions)
-        val details: TransactionDetails = tx.details
-        return details.unstructuredRemittanceInformation
-    }
-}
-
 class CamtParsingError(msg: String) : Exception(msg)
 
 /**
@@ -1098,7 +833,7 @@ fun processCamtMessage(
             }
             rawEntity.flush()
             newTransactions++
-            newPaymentsLog += "\n- " + 
entry.batches[0].batchTransactions[0].details.unstructuredRemittanceInformation
+            newPaymentsLog += "\n- " + entry.getSingletonSubject()
             // This block tries to acknowledge a former outgoing payment as 
booked.
             if (singletonBatchedTransaction.creditDebitIndicator == 
CreditDebitIndicator.DBIT) {
                 val t0 = singletonBatchedTransaction.details
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt
index aecc58eb..52069270 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt
@@ -1,6 +1,8 @@
 package tech.libeufin.nexus.server
 
+import CamtBankAccountEntry
 import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.databind.node.ObjectNode
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import io.ktor.http.*
@@ -10,7 +12,6 @@ import org.jetbrains.exposed.sql.and
 import org.jetbrains.exposed.sql.transactions.transaction
 import tech.libeufin.nexus.*
 import tech.libeufin.nexus.bankaccount.getBankAccount
-import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
 import tech.libeufin.util.internalServerError
 import tech.libeufin.util.notFound
 
@@ -35,8 +36,10 @@ fun getIngestedTransactions(params: GetTransactionsParams): 
List<JsonNode> =
         }.sortedBy { it.id.value }.take(params.resultSize.toInt()) // Smallest 
index (= earliest transaction) first
         // Converting the result to the HTTP response type.
         maybeResult.map {
-            val element: ObjectNode = 
jacksonObjectMapper().readTree(it.transactionJson) as ObjectNode
+            val element: ObjectNode = jacksonObjectMapper().createObjectNode()
             element.put("index", it.id.value.toString())
+            val txObj: JsonNode = 
jacksonObjectMapper().readTree(it.transactionJson)
+            element.set<JsonNode>("camtData", txObj)
             return@map element
         }
     }
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 1db14ab9..312961c2 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt
@@ -19,21 +19,14 @@
 
 package tech.libeufin.nexus.server
 
+import CamtBankAccountEntry
+import CurrencyAmount
+import EntryStatus
 import com.fasterxml.jackson.annotation.JsonSubTypes
 import com.fasterxml.jackson.annotation.JsonTypeInfo
 import com.fasterxml.jackson.annotation.JsonTypeName
 import com.fasterxml.jackson.annotation.JsonValue
-import com.fasterxml.jackson.core.JsonGenerator
-import com.fasterxml.jackson.core.JsonParser
-import com.fasterxml.jackson.databind.DeserializationContext
 import com.fasterxml.jackson.databind.JsonNode
-import com.fasterxml.jackson.databind.SerializerProvider
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize
-import com.fasterxml.jackson.databind.annotation.JsonSerialize
-import com.fasterxml.jackson.databind.deser.std.StdDeserializer
-import com.fasterxml.jackson.databind.ser.std.StdSerializer
-import tech.libeufin.nexus.EntryStatus
-import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
 import tech.libeufin.util.*
 import java.time.Instant
 import java.time.ZoneId
@@ -92,7 +85,7 @@ class EbicsStandardOrderParamsDateJson(
     private val end: String
 ) : EbicsOrderParamsJson() {
     override fun toOrderParams(): EbicsOrderParams {
-        val dateRange: EbicsDateRange? =
+        val dateRange =
             EbicsDateRange(
                 ZonedDateTime.parse(this.start, EbicsDateFormat.fmt),
                 ZonedDateTime.parse(this.end, EbicsDateFormat.fmt)
@@ -419,43 +412,6 @@ data class ImportBankAccount(
     val nexusBankAccountId: String
 )
 
-
-class CurrencyAmountDeserializer(jc: Class<*> = CurrencyAmount::class.java) : 
StdDeserializer<CurrencyAmount>(jc) {
-    override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): 
CurrencyAmount {
-        if (p == null) {
-            throw UnsupportedOperationException();
-        }
-        val s = p.valueAsString
-        val components = s.split(":")
-        // FIXME: error handling!
-        return CurrencyAmount(components[0], components[1])
-    }
-}
-
-class CurrencyAmountSerializer(jc: Class<CurrencyAmount> = 
CurrencyAmount::class.java) : StdSerializer<CurrencyAmount>(jc) {
-    override fun serialize(value: CurrencyAmount?, gen: JsonGenerator?, 
provider: SerializerProvider?) {
-        if (gen == null) {
-            throw UnsupportedOperationException()
-        }
-        if (value == null) {
-            gen.writeNull()
-        } else {
-            gen.writeString("${value.currency}:${value.value}")
-        }
-    }
-}
-
-// FIXME: this type duplicates AmountWithCurrency.
-@JsonDeserialize(using = CurrencyAmountDeserializer::class)
-@JsonSerialize(using = CurrencyAmountSerializer::class)
-data class CurrencyAmount(
-    val currency: String,
-    val value: String
-)
-fun CurrencyAmount.toPlainString(): String {
-    return "${this.currency}:${this.value}"
-}
-
 data class InitiatedPayments(
     val initiatedPayments: MutableList<PaymentStatus> = mutableListOf()
 )
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 bca2f0a1..542f2d4f 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt
@@ -52,7 +52,6 @@ import org.slf4j.event.Level
 import tech.libeufin.nexus.*
 import tech.libeufin.nexus.bankaccount.*
 import tech.libeufin.nexus.ebics.*
-import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
 import tech.libeufin.nexus.iso20022.processCamtMessage
 import tech.libeufin.util.*
 import java.net.BindException
diff --git 
a/nexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt
index 6cec7e41..22c07e38 100644
--- 
a/nexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt
+++ 
b/nexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt
@@ -1,5 +1,14 @@
 package tech.libeufin.nexus.xlibeufinbank
 
+import AgentIdentification
+import Batch
+import BatchTransaction
+import CamtBankAccountEntry
+import CashAccount
+import CreditDebitIndicator
+import CurrencyAmount
+import PartyIdentification
+import TransactionDetails
 import com.fasterxml.jackson.databind.JsonNode
 import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -234,12 +243,6 @@ class XlibeufinBankConnectionProtocol : 
BankConnectionProtocol {
         accountId: String
     ): List<Exception>? {
         val conn = getBankConnection(bankConnectionId)
-        /**
-         * Note: fetchSpec.level is ignored because Sandbox does not
-         * differentiate between booked and non-booked transactions.
-         * Just logging if the unaware client specified non-REPORT for
-         * the level.  FIXME: docs have to mention this.
-         */
         if (fetchSpec.level == FetchLevel.REPORT || fetchSpec.level == 
FetchLevel.ALL)
             throw badRequest("level '${fetchSpec.level}' on x-libeufin-bank" +
                     "connection (${conn.connectionId}) is not supported:" +
diff --git a/nexus/src/test/kotlin/ConversionServiceTest.kt 
b/nexus/src/test/kotlin/ConversionServiceTest.kt
index f04cb0a7..654fc260 100644
--- a/nexus/src/test/kotlin/ConversionServiceTest.kt
+++ b/nexus/src/test/kotlin/ConversionServiceTest.kt
@@ -2,16 +2,120 @@ import io.ktor.client.*
 import io.ktor.client.engine.mock.*
 import io.ktor.server.testing.*
 import kotlinx.coroutines.*
+import org.jetbrains.exposed.sql.and
 import org.jetbrains.exposed.sql.transactions.transaction
+import org.junit.Ignore
 import org.junit.Test
 import tech.libeufin.nexus.server.nexusApp
 import tech.libeufin.sandbox.*
 
 class ConversionServiceTest {
+    // Without this, "launch {}" never returns.
+    val doNothingHandler = CoroutineExceptionHandler { _, _ -> }
+
+    /**
+     * Testing the buy-in monitor in the normal case: Nexus
+     * communicates a new incoming fiat transaction and the
+     * monitor wires funds to the exchange.
+     */
+    @Test
+    fun buyinTest() {
+        // First create an incoming fiat payment _at Nexus_.
+        // This payment is addressed to the Nexus user whose
+        // (Nexus) credentials will be used by Sandbox to fetch
+        // new incoming fiat payments.
+        withTestDatabase {
+            prepSandboxDb(currency = "REGIO")
+            prepNexusDb()
+            // Credits 22 TESTKUDOS to "foo".  This information comes
+            // normally from the fiat bank that Nexus is connected to.
+            val reservePub = 
"GX5H5RME193FDRCM1HZKERXXQ2K21KH7788CKQM8X6MYKYRBP8F0"
+            newNexusBankTransaction(
+                currency = "TESTKUDOS",
+                value = "22",
+                /**
+                 * If the subject does NOT have the format of a public key,
+                 * the conversion service does NOT wire any regio amount to the
+                 * exchange, just ignores it.
+                 */
+                subject = reservePub
+            )
+            // Start Nexus, to let it serve the fiat transaction.
+            testApplication {
+                application(nexusApp)
+                // Start the buy-in monitor to let it download the fiat 
transaction.
+                val job = 
CoroutineScope(Dispatchers.IO).launch(doNothingHandler) {
+                    buyinMonitor(
+                        demobankName = "default",
+                        accountToCredit = "exchange-0",
+                        client = client
+                    )
+                }
+                delay(1000L)
+                job.cancel()
+            }
+            // Checking that exchange got the converted amount.
+            transaction {
+                /**
+                 * Asserting that the exchange has only one incoming 
transaction.
+                 *
+                 * The Sandbox DB has two entries where the exchange IBAN shows
+                 * as the 'creditorIban': one DBIT related to the "admin" 
account,
+                 * and one CRDT related to the "exchange-0" account.  Thus 
filtering
+                 * the direction is also required.
+                 */
+                assert(BankAccountTransactionEntity.find {
+                    BankAccountTransactionsTable.creditorIban eq 
"AT561936082973364859" and (
+                            BankAccountTransactionsTable.direction eq "CRDT"
+                    )
+                }.count() == 1L)
+
+                // Asserting that the one incoming transactino has the wired 
reserve public key.
+                assert(BankAccountTransactionEntity.find {
+                    BankAccountTransactionsTable.creditorIban eq 
"AT561936082973364859"
+                }.first().subject == reservePub)
+            }
+        }
+    }
+
+    /**
+     * 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 #0",
+                amount = "TESTKUDOS:3"
+            )
+            testApplication {
+                application(nexusApp)
+                CoroutineScope(Dispatchers.IO).launch(doNothingHandler) {
+                    cashoutMonitor(client)
+                }
+                delay(1000L) // Lets DB persist the information.
+                transaction {
+                    assert(CashoutSubmissionEntity.all().count() == 1L)
+                    assert(CashoutSubmissionEntity.all().first().isSubmitted)
+                }
+            }
+        }
+    }
+
     /**
      * Tests whether the conversion service is able to skip
      * submissions that had problems and proceed to new ones.
+    ----------------------------------------------------------
+     * Ignoring the test because the new version just fails the
+     * process on client side errors.  Still however keeping the
+     * (ignored) test as a model to create faulty situations.
      */
+    @Ignore
     @Test
     fun testWrongSubmissionSkip() {
         withTestDatabase {
@@ -42,35 +146,4 @@ class ConversionServiceTest {
             }
         }
     }
-
-    /**
-     * 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/Iso20022Test.kt 
b/nexus/src/test/kotlin/Iso20022Test.kt
index c14b564b..18502881 100644
--- a/nexus/src/test/kotlin/Iso20022Test.kt
+++ b/nexus/src/test/kotlin/Iso20022Test.kt
@@ -1,4 +1,5 @@
 package tech.libeufin.nexus
+import CamtBankAccountEntry
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import org.junit.Ignore
 import org.junit.Test
@@ -74,7 +75,6 @@ class Iso20022Test {
         )
 
         // Third Entry
-
         // Make sure that round-tripping of entry CamtBankAccountEntry JSON 
works
         for (entry in r.reports.flatMap { it.entries }) {
             val txStr = 
jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(entry)
diff --git a/nexus/src/test/kotlin/JsonTest.kt 
b/nexus/src/test/kotlin/JsonTest.kt
index 138790cb..30e919d9 100644
--- a/nexus/src/test/kotlin/JsonTest.kt
+++ b/nexus/src/test/kotlin/JsonTest.kt
@@ -1,15 +1,15 @@
-import com.fasterxml.jackson.databind.JsonNode
-import com.fasterxml.jackson.databind.ObjectMapper
 import org.junit.Test
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import com.fasterxml.jackson.module.kotlin.readValue
-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.ktor.utils.io.jvm.javaio.*
 import org.junit.Ignore
 import tech.libeufin.nexus.server.CreateBankConnectionFromBackupRequestJson
 import tech.libeufin.nexus.server.CreateBankConnectionFromNewRequestJson
+import tech.libeufin.sandbox.NexusTransactions
 import tech.libeufin.sandbox.sandboxApp
 
 enum class EnumTest { TEST }
@@ -37,7 +37,7 @@ class JsonTest {
     fun enumTest() {
         val m = jacksonObjectMapper()
          m.readValue<EnumWrapper>("{\"enum_test\":\"TEST\"}")
-        m.readValue<EnumTest>("\"TEST\"")
+         m.readValue<EnumTest>("\"TEST\"")
     }
 
     /**
@@ -57,4 +57,53 @@ class JsonTest {
             }
         }
     }
+
+    data class CamtEntryWrapper(
+        val unusedValue: String,
+        val camtData: CamtBankAccountEntry
+    )
+
+    // Testing whether generating and parsing a CaMt JSON mapping works.
+    @Test
+    fun testCamtRoundTrip() {
+        val obj = genNexusIncomingCamt(
+            CurrencyAmount(value = "2", currency = "EUR"),
+            subject = "round trip test"
+        )
+        val str = 
jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(obj)
+        val map = jacksonObjectMapper().readValue(str, 
CamtBankAccountEntry::class.java)
+        assert(str == 
jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(map))
+    }
+
+    @Test
+    fun parseRawJson() {
+        val camtModel = """
+            {
+              "amount" : "TESTKUDOS:22",
+              "creditDebitIndicator" : "CRDT",
+              "status" : "BOOK",
+              "bankTransactionCode" : "mock",
+              "batches" : [ {
+                "batchTransactions" : [ {
+                  "amount" : "TESTKUDOS:22",
+                  "creditDebitIndicator" : "CRDT",
+                  "details" : {
+                    "debtor" : {
+                      "name" : "Mock Payer"
+                    },
+                    "debtorAccount" : {
+                      "iban" : "MOCK-IBAN"
+                    },
+                    "debtorAgent" : {
+                      "bic" : "MOCK-BIC"
+                    },
+                    "unstructuredRemittanceInformation" : "raw"
+                  }
+                } ]
+              } ]
+            }
+        """.trimIndent()
+        val obj = jacksonObjectMapper().readValue(camtModel, 
CamtBankAccountEntry::class.java)
+        assert(obj.getSingletonSubject() == "raw")
+    }
 }
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/MakeEnv.kt b/nexus/src/test/kotlin/MakeEnv.kt
index 7c19d07b..ebec64f4 100644
--- a/nexus/src/test/kotlin/MakeEnv.kt
+++ b/nexus/src/test/kotlin/MakeEnv.kt
@@ -8,7 +8,6 @@ import tech.libeufin.nexus.dbCreateTables
 import tech.libeufin.nexus.dbDropTables
 import tech.libeufin.nexus.iso20022.*
 import tech.libeufin.nexus.server.BankConnectionType
-import tech.libeufin.nexus.server.CurrencyAmount
 import tech.libeufin.nexus.server.FetchLevel
 import tech.libeufin.nexus.server.FetchSpecAllJson
 import tech.libeufin.sandbox.*
@@ -192,11 +191,11 @@ fun prepNexusDb() {
     }
 }
 
-fun prepSandboxDb(usersDebtLimit: Int = 1000) {
+fun prepSandboxDb(usersDebtLimit: Int = 1000, currency: String = "TESTKUDOS") {
     tech.libeufin.sandbox.dbCreateTables(TEST_DB_CONN)
     transaction {
         val config = DemobankConfig(
-            currency = "TESTKUDOS",
+            currency = currency,
             bankDebtLimit = 10000,
             usersDebtLimit = usersDebtLimit,
             allowRegistrations = true,
@@ -415,7 +414,7 @@ private fun genNexusIncomingXLibeufinBank(
  * values are either resorted from other sources by Nexus, or actually
  * not useful so far.
  */
-private fun genNexusIncomingCamt(
+fun genNexusIncomingCamt(
     amount: CurrencyAmount,
     subject: String,
 ): CamtBankAccountEntry =
diff --git a/nexus/src/test/kotlin/SubjectNormalization.kt 
b/nexus/src/test/kotlin/SubjectNormalization.kt
index 833edae9..a35d4958 100644
--- a/nexus/src/test/kotlin/SubjectNormalization.kt
+++ b/nexus/src/test/kotlin/SubjectNormalization.kt
@@ -1,6 +1,5 @@
 import org.junit.Test
-
-import tech.libeufin.nexus.extractReservePubFromSubject
+import tech.libeufin.util.extractReservePubFromSubject
 
 class SubjectNormalization {
 
diff --git a/nexus/src/test/kotlin/XLibeufinBankTest.kt 
b/nexus/src/test/kotlin/XLibeufinBankTest.kt
index 84fa547b..7961f9db 100644
--- a/nexus/src/test/kotlin/XLibeufinBankTest.kt
+++ b/nexus/src/test/kotlin/XLibeufinBankTest.kt
@@ -9,7 +9,6 @@ import org.junit.Test
 import tech.libeufin.nexus.*
 import tech.libeufin.nexus.bankaccount.addPaymentInitiation
 import tech.libeufin.nexus.bankaccount.ingestBankMessagesIntoAccount
-import tech.libeufin.nexus.iso20022.CamtBankAccountEntry
 import tech.libeufin.nexus.server.*
 import tech.libeufin.nexus.xlibeufinbank.XlibeufinBankConnectionProtocol
 import tech.libeufin.sandbox.BankAccountTransactionEntity
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
index c52d74dd..a4cfccbb 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/ConversionService.kt
@@ -1,16 +1,22 @@
 package tech.libeufin.sandbox
 
+import CamtBankAccountEntry
+import com.fasterxml.jackson.databind.ObjectMapper
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.jsonMapper
 import io.ktor.client.*
 import io.ktor.client.plugins.*
 import io.ktor.client.request.*
 import io.ktor.client.statement.*
 import io.ktor.http.*
+import io.ktor.utils.io.jvm.javaio.*
 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.*
+import java.math.BigDecimal
+import kotlin.system.exitProcess
 
 /**
  * This file contains the logic for downloading/submitting incoming/outgoing
@@ -27,11 +33,13 @@ import tech.libeufin.util.*
  * 2.  The time to submit a new payment is as soon as "admin" receives one
  *     incoming regional payment.
  * 3.  At this time, Nexus does NOT offer long polling when it serves the
- *     transactions via its JSON API.
+ *     transactions via its JSON API. => Fixed.
  * 4.  At this time, Nexus does NOT offer any filter when it serves the
- *     transactions via its JSON API.
+ *     transactions via its JSON API. => Can be fixed by using the TWG.
  */
 
+// DEFINITIONS AND HELPERS
+
 /**
  * Timeout the HTTP client waits for the server to respond,
  * after the request is made.
@@ -45,6 +53,17 @@ val waitTimeout = 30000L
  */
 val newIterationTimeout = 2000L
 
+/**
+ * Response format of Nexus GET /transactions.
+ */
+data class TransactionItem(
+    val index: String,
+    val camtData: CamtBankAccountEntry
+)
+data class NexusTransactions(
+    val transactions: List<TransactionItem>
+)
+
 /**
  * Executes the 'block' function every 'loopNewReqMs' milliseconds.
  * Does not exit/fail the process upon exceptions - just logs them.
@@ -67,20 +86,125 @@ fun downloadLoop(block: () -> Unit) {
     }
 }
 
+// BUY-IN SIDE.
+
 /**
- * This function downloads the incoming fiat transactions from Nexus,
- * stores them into the database and signals their arrival 
(LIBEUFIN_FIAT_INCOMING)
- * to allow crediting the "admin" account.
+ * Applies the buy-in ratio and fees to the fiat amount
+ * that came from Nexus.  The result is the regional amount
+ * that will be wired to the exchange Sandbox account.
  */
-// fetchTransactions()
-
+private fun applyBuyinRatioAndFees(
+    amount: BigDecimal,
+    ratioAndFees: RatioAndFees
+): BigDecimal =
+    ((amount * ratiosAndFees.buy_at_ratio.toBigDecimal())
+            - ratiosAndFees.buy_in_fee.toBigDecimal()).roundToTwoDigits()
 /**
- * This function listens for fiat-incoming events (LIBEUFIN_FIAT_INCOMING)
- * and credits the "admin" account as a reaction.  Lastly, the Nexus instance
- * wired to Sandbox will pick the new payment and serve it via its TWG, but
- * this is OUT of the Sandbox scope.
+ * This function downloads the incoming fiat transactions from Nexus,
+ * stores them into the database and triggers the related wire transfer
+ * to the Taler exchange (to be specified in 'accountToCredit').  In case
+ * of errors, it pauses and retries when the server fails, but _fails_ when
+ * the client does.
  */
-// creditAdmin()
+fun buyinMonitor(
+    demobankName: String, // used to get config values.
+    client: HttpClient,
+    accountToCredit: String,
+    accountToDebit: String = "admin"
+) {
+    val demobank = ensureDemobank(demobankName)
+    val nexusBaseUrl = getConfigValueOrThrow(demobank.config::nexusBaseUrl)
+    val usernameAtNexus = 
getConfigValueOrThrow(demobank.config::usernameAtNexus)
+    val passwordAtNexus = 
getConfigValueOrThrow(demobank.config::passwordAtNexus)
+    val endpoint = "bank-accounts/$usernameAtNexus/transactions"
+    val uriWithoutStart = joinUrl(nexusBaseUrl, endpoint) + 
"?long_poll_ms=$waitTimeout"
+
+    // downloadLoop does already try-catch (without failing the process).
+    downloadLoop {
+        val debitBankAccount = getBankAccountFromLabel(accountToDebit)
+        val uriWithStart = 
"$uriWithoutStart&start=${debitBankAccount.lastFiatFetch}"
+        runBlocking {
+            // Maybe get new fiat transactions.
+            logger.debug("GETting fiat transactions from: ${uriWithStart}")
+            val resp = client.get(uriWithStart) { basicAuth(usernameAtNexus, 
passwordAtNexus) }
+            // The server failed, pause and try again
+            if (resp.status.value.toString().startsWith('5')) {
+                logger.error("Buy-in monitor caught a failing to Nexus.  Pause 
and retry.")
+                logger.error("Nexus responded: ${resp.bodyAsText()}")
+                delay(2000L)
+                return@runBlocking
+            }
+            // The client failed, fail the process.
+            if (resp.status.value.toString().startsWith('4')) {
+                logger.error("Buy-in monitor failed at GETting to Nexus.  Fail 
Sandbox.")
+                logger.error("Nexus responded: ${resp.bodyAsText()}")
+                exitProcess(1)
+            }
+            // Expect 200 OK.  What if 3xx?
+            if (resp.status.value != HttpStatusCode.OK.value) {
+                logger.error("Unhandled response status ${resp.status.value}, 
failing Sandbox")
+                exitProcess(1)
+            }
+            // Nexus responded 200 OK, analyzing the result.
+            /**
+             * Wire to "admin" if the subject is a public key, or do
+             * nothing otherwise.
+             */
+            val respObj = jacksonObjectMapper().readValue(
+                resp.bodyAsText(),
+                NexusTransactions::class.java
+            ) // errors are logged by the caller (without failing).
+            respObj.transactions.forEach {
+                /**
+                 * If the payment doesn't contain a reserve public key,
+                 * continue the iteration with the new payment.
+                 */
+                if 
(extractReservePubFromSubject(it.camtData.getSingletonSubject()) == null)
+                    return@forEach
+                /**
+                 * The payment had a reserve public key in the subject, wire 
it to
+                 * the exchange.  NOTE: this ensures that all the payments 
that the
+                 * exchange gets will NOT trigger any reimbursement, because 
they have
+                 * a valid reserve public key.  Reimbursements would in fact 
introduce
+                 * significant friction, because they need to target _fiat_ 
bank accounts
+                 * (the customers'), whereas the entity that _now_ pays the 
exchange is
+                 * "admin", which lives in the regional circuit.
+                 */
+                // Extracts the amount and checks it's at most two fractional 
digits.
+                val maybeValidAmount = it.camtData.amount.value
+                if (!validatePlainAmount(maybeValidAmount)) {
+                    logger.error("Nexus gave one amount with invalid 
fractional digits: $maybeValidAmount." +
+                            "  The transaction has index ${it.index}")
+                    // Advancing the last fetched pointer, to avoid GETting
+                    // this invalid payment again.
+                    transaction {
+                        debitBankAccount.refresh()
+                        debitBankAccount.lastFiatFetch = it.index
+                    }
+                }
+                val convertedAmount = applyBuyinRatioAndFees(
+                    maybeValidAmount.toBigDecimal(),
+                    ratiosAndFees
+                )
+                transaction {
+                    wireTransfer(
+                        debitAccount = accountToDebit,
+                        creditAccount = accountToCredit,
+                        demobank = demobankName,
+                        subject = it.camtData.getSingletonSubject(),
+                        amount = "${demobank.config.currency}:$convertedAmount"
+                    )
+                    // Nexus enqueues the transactions such that the index 
increases.
+                    // If Sandbox crashes here, it'll ask again using the last 
successful
+                    // index as the start parameter.  Being this an exclusive 
bound, only
+                    // transactions later than it are expected.
+                    debitBankAccount.refresh()
+                    debitBankAccount.lastFiatFetch = it.index
+                }
+            }
+        }
+    }
+}
 
 // DB query helper.  The List return type (instead of SizedIterable) lets
 // the caller NOT open a transaction block to access the values -- although
@@ -100,6 +224,8 @@ private fun getUnsubmittedTransactions(bankAccountLabel: 
String): List<BankAccou
     }
 }
 
+// CASH-OUT SIDE.
+
 /**
  * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX)
  * on the 'watchedBankAccount' and submits the related cash-out payment
@@ -109,7 +235,8 @@ private fun getUnsubmittedTransactions(bankAccountLabel: 
String): List<BankAccou
 suspend fun cashoutMonitor(
     httpClient: HttpClient,
     watchedBankAccount: String = "admin",
-    demobankName: String = "default" // used to get config values.
+    demobankName: String = "default", // used to get config values.
+    dbEventTimeout: Long = 0 // 0 waits forever.
 ) {
     // Register for a REGIO_TX event.
     val eventChannel = buildChannelName(
@@ -132,15 +259,11 @@ suspend fun cashoutMonitor(
         /**
          * 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).
+         * its Nexus bank account named THE SAME as its username.
          */
         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()
@@ -152,11 +275,7 @@ suspend fun cashoutMonitor(
             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)
+            val isNotificationArrived = 
listenHandle.waitOnIODispatchers(dbEventTimeout)
             if (isNotificationArrived && listenHandle.receivedPayload == 
"CRDT")
                 newTxs = getUnsubmittedTransactions(watchedBankAccount)
         }
@@ -188,35 +307,41 @@ suspend fun cashoutMonitor(
             }
             // Hard-error, response did not even arrive.
             catch (e: Exception) {
+                logger.error("Cash-out monitor could not reach Nexus.  Pause 
and retry")
                 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
-                }
+                delay(2000)
                 return@forEach
             }
-            // Handle the non 2xx error case.  Here we try
-            // to store the response from Nexus.
+            // Server fault.  Pause and retry.
+            if (resp.status.value.toString().startsWith('5')) {
+                logger.error("Cash-out monitor POSTed to a failing Nexus.  
Pause and retry")
+                logger.error(resp.bodyAsText())
+                delay(2000L)
+            }
+            // Client fault, fail Sandbox.
+            if (resp.status.value.toString().startsWith('4')) {
+                logger.error("Cash-out monitor failed at POSTing to Nexus.  
Fail Sandbox")
+                logger.error("Nexus responded: ${resp.bodyAsText()}")
+                exitProcess(1)
+            }
+            // Expecting 200 OK.  What if 3xx?
             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
+                logger.error("Cash-out monitor, unhandled response status: 
${resp.status.value}.  Fail Sandbox")
+                exitProcess(1)
+
+                // Previous versions use to store the faulty transaction
+                // and continue the execution.  The block below shows how
+                // to do that.
+
+                /*transaction {
+                  CashoutSubmissionEntity.new {
+                    localTransaction = it.id
+                    this.hasErrors = true
+                    if (maybeResponseBody.isNotEmpty())
+                      this.maybeNexusResposnse = maybeResponseBody
                     }
-                    bankAccount.lastFiatSubmission = it
-                }
-                return@forEach
+                  bankAccount.lastFiatSubmission = it
+                }*/
             }
             // Successful case, mark the wire transfer as submitted,
             // and advance the pointer to the last submitted payment.
@@ -231,7 +356,7 @@ suspend fun cashoutMonitor(
                     // unique identifier _as assigned by Nexus_.  Not
                     // currently used by Sandbox, but may help to resolve
                     // disputes.
-                    if (responseBody.length > 0)
+                    if (responseBody.isNotEmpty())
                         maybeNexusResposnse = responseBody
                 }
                 // Advancing the 'last submitted bookmark', to avoid
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
index c8a1df18..ca83d31a 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
@@ -515,6 +515,16 @@ object BankAccountsTable : IntIdTable() {
      * cash-out operation.
      */
     val lastFiatSubmission = reference("lastFiatSubmission", 
BankAccountTransactionsTable).nullable()
+
+    /**
+     * Tracks the last fiat payment that was read from Nexus.  This tracker
+     * gets updated ONLY IF the exchange gets successfully paid with the 
related
+     * amount in the regional currency.  NOTE: in case of disputes, the 
customer
+     * will provide the date of a problematic withdrawal, and the regional 
currency
+     * administrator should check into the "admin" (regional) outgoing history 
by
+     * using such date as filter.
+     */
+    val lastFiatFetch = text("lastFiatFetch").default("0")
 }
 
 class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) {
@@ -528,6 +538,7 @@ class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) {
     var demoBank by DemobankConfigEntity referencedOn 
BankAccountsTable.demoBank
     var lastTransaction by BankAccountTransactionEntity optionalReferencedOn 
BankAccountsTable.lastTransaction
     var lastFiatSubmission by BankAccountTransactionEntity 
optionalReferencedOn BankAccountsTable.lastFiatSubmission
+    var lastFiatFetch by BankAccountsTable.lastFiatFetch
 }
 
 object BankAccountStatementsTable : IntIdTable() {
diff --git a/util/src/main/kotlin/CamtJsonMapping.kt 
b/util/src/main/kotlin/CamtJsonMapping.kt
new file mode 100644
index 00000000..06a042a6
--- /dev/null
+++ b/util/src/main/kotlin/CamtJsonMapping.kt
@@ -0,0 +1,334 @@
+import com.fasterxml.jackson.annotation.JsonIgnore
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.SerializerProvider
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize
+import com.fasterxml.jackson.databind.annotation.JsonSerialize
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer
+import com.fasterxml.jackson.databind.ser.std.StdSerializer
+import tech.libeufin.util.internalServerError
+
+enum class CreditDebitIndicator {
+    DBIT,
+    CRDT
+}
+
+enum class EntryStatus {
+    BOOK, // Booked
+    PDNG, // Pending
+    INFO, // Informational
+}
+
+class CurrencyAmountDeserializer(jc: Class<*> = CurrencyAmount::class.java) : 
StdDeserializer<CurrencyAmount>(jc) {
+    override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): 
CurrencyAmount {
+        if (p == null) {
+            throw UnsupportedOperationException();
+        }
+        val s = p.valueAsString
+        val components = s.split(":")
+        // FIXME: error handling!
+        return CurrencyAmount(components[0], components[1])
+    }
+}
+
+class CurrencyAmountSerializer(jc: Class<CurrencyAmount> = 
CurrencyAmount::class.java) : StdSerializer<CurrencyAmount>(jc) {
+    override fun serialize(value: CurrencyAmount?, gen: JsonGenerator?, 
provider: SerializerProvider?) {
+        if (gen == null) {
+            throw UnsupportedOperationException()
+        }
+        if (value == null) {
+            gen.writeNull()
+        } else {
+            gen.writeString("${value.currency}:${value.value}")
+        }
+    }
+}
+
+// FIXME: this type duplicates AmountWithCurrency.
+@JsonDeserialize(using = CurrencyAmountDeserializer::class)
+@JsonSerialize(using = CurrencyAmountSerializer::class)
+data class CurrencyAmount(
+    val currency: String,
+    val value: String
+)
+
+fun CurrencyAmount.toPlainString(): String {
+    return "${this.currency}:${this.value}"
+}
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class CashAccount(
+    val name: String?,
+    val currency: String?,
+    val iban: String?,
+    val otherId: GenericId?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class GenericId(
+    val id: String,
+    val schemeName: String?,
+    val proprietarySchemeName: String?,
+    val issuer: String?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class PrivateIdentification(
+    val birthDate: String?,
+    val provinceOfBirth: String?,
+    val cityOfBirth: String?,
+    val countryOfBirth: String?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class OrganizationIdentification(
+    val bic: String?,
+    val lei: String?
+)
+
+/**
+ * Identification of a party, which can be a private party
+ * or an organization.
+ *
+ * Mapping of ISO 20022 PartyIdentification135.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class PartyIdentification(
+    val name: String?,
+    val countryOfResidence: String?,
+    val privateId: PrivateIdentification?,
+    val organizationId: OrganizationIdentification?,
+    val postalAddress: PostalAddress?,
+
+    /**
+     * Identification that applies to both private parties and organizations.
+     */
+    val otherId: GenericId?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class PostalAddress(
+    val addressCode: String?,
+    val addressProprietaryId: String?,
+    val addressProprietarySchemeName: String?,
+    val addressProprietaryIssuer: String?,
+    val department: String?,
+    val subDepartment: String?,
+    val streetName: String?,
+    val buildingNumber: String?,
+    val buildingName: String?,
+    val floor: String?,
+    val postBox: String?,
+    val room: String?,
+    val postCode: String?,
+    val townName: String?,
+    val townLocationName: String?,
+    val districtName: String?,
+    val countrySubDivision: String?,
+    val country: String?,
+    val addressLines: List<String>
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class AgentIdentification(
+    val name: String?,
+
+    val bic: String?,
+
+    /**
+     * Legal entity identification.
+     */
+    val lei: String?,
+
+    val clearingSystemMemberId: String?,
+
+    val clearingSystemCode: String?,
+
+    val proprietaryClearingSystemCode: String?,
+
+    val postalAddress: PostalAddress?,
+
+    val otherId: GenericId?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class CurrencyExchange(
+    val sourceCurrency: String,
+    val targetCurrency: String,
+    val unitCurrency: String?,
+    val exchangeRate: String,
+    val contractId: String?,
+    val quotationDate: String?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class Batch(
+    val messageId: String?,
+    val paymentInformationId: String?,
+    val batchTransactions: List<BatchTransaction>
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class TransactionDetails(
+    val debtor: PartyIdentification?,
+    val debtorAccount: CashAccount?,
+    val debtorAgent: AgentIdentification?,
+    val creditor: PartyIdentification?,
+    val creditorAccount: CashAccount?,
+    val creditorAgent: AgentIdentification?,
+    val ultimateCreditor: PartyIdentification?,
+    val ultimateDebtor: PartyIdentification?,
+
+    val endToEndId: String? = null,
+    val paymentInformationId: String? = null,
+    val messageId: String? = null,
+
+    val purpose: String?,
+    val proprietaryPurpose: String?,
+
+    /**
+     * Currency exchange information for the transaction's amount.
+     */
+    val currencyExchange: CurrencyExchange?,
+
+    /**
+     * Amount as given in the payment initiation.
+     * Can be same or different currency as account currency.
+     */
+    val instructedAmount: CurrencyAmount?,
+
+    /**
+     * Raw amount used for currency exchange, before extra charges.
+     * Can be same or different currency as account currency.
+     */
+    val counterValueAmount: CurrencyAmount?,
+
+    /**
+     * Money that was moved between banks.
+     *
+     * For CH, we use the "TxAmt".
+     * For EPC, this amount is either blank or taken
+     * from the "IBC" proprietary amount.
+     */
+    val interBankSettlementAmount: CurrencyAmount?,
+
+    /**
+     * Unstructured remittance information (=subject line) of the transaction,
+     * or the empty string if missing.
+     */
+    val unstructuredRemittanceInformation: String,
+    val returnInfo: ReturnInfo?
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class ReturnInfo(
+    val originalBankTransactionCode: String?,
+    val originator: PartyIdentification?,
+    val reason: String?,
+    val proprietaryReason: String?,
+    val additionalInfo: String?
+)
+
+data class BatchTransaction(
+    val amount: CurrencyAmount, // Fuels Taler withdrawal amount.
+    val creditDebitIndicator: CreditDebitIndicator,
+    val details: TransactionDetails
+)
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+data class CamtBankAccountEntry(
+    val amount: CurrencyAmount,
+    /**
+     * Is this entry debiting or crediting the account
+     * it is reported for?
+     */
+    val creditDebitIndicator: CreditDebitIndicator,
+
+    /**
+     * Booked, pending, etc.
+     */
+    val status: EntryStatus,
+
+    /**
+     * Code that describes the type of bank transaction
+     * in more detail
+     */
+    val bankTransactionCode: String,
+
+    val valueDate: String?,
+
+    val bookingDate: String?,
+
+    val accountServicerRef: String?,
+
+    val entryRef: String?,
+
+    /**
+     * Currency exchange information for the entry's amount.
+     * Only present if currency exchange happened at the entry level.
+     */
+    val currencyExchange: CurrencyExchange?,
+
+    /**
+     * Value before/after currency exchange before charges have been applied.
+     * Only present if currency exchange happened at the entry level.
+     */
+    val counterValueAmount: CurrencyAmount?,
+
+    /**
+     * Instructed amount.
+     * Only present if currency exchange happens at the entry level.
+     */
+    val instructedAmount: CurrencyAmount?,
+
+    // list of sub-transactions participating in this money movement.
+    val batches: List<Batch>?
+) {
+    /**
+     * This function returns the subject of the unique transaction
+     * accounted in this object.  If the transaction is not unique,
+     * it throws an exception.  NOTE: the caller has the responsibility
+     * of not passing an empty report; those usually should be discarded
+     * and never participate in the application logic.
+     */
+    @JsonIgnore
+    fun getSingletonSubject(): String {
+        // Checks that the given list contains only one element and returns it.
+        fun <T>checkAndGetSingleton(maybeTxs: List<T>?): T {
+            if (maybeTxs == null || maybeTxs.size > 1) throw 
internalServerError(
+                "Only a singleton transaction is " +
+                        "allowed inside ${this.javaClass}."
+            )
+            return maybeTxs[0]
+        }
+        /**
+         * Types breakdown until the meaningful payment information is reached.
+         *
+         * CamtBankAccountEntry contains:
+         * - Batch 0
+         * - Batch 1
+         * - Batch N
+         *
+         * Batch X contains:
+         * - BatchTransaction 0
+         * - BatchTransaction 1
+         * - BatchTransaction N
+         *
+         * BatchTransaction X contains:
+         * - TransactionDetails
+         *
+         * TransactionDetails contains the involved parties
+         * and the payment subject but MAY NOT contain the amount.
+         * In this model, the amount is held in the BatchTransaction
+         * type, that is also -- so far -- required to be a singleton
+         * inside Batch.
+         */
+        val batch: Batch = checkAndGetSingleton(this.batches)
+        val batchTransactions = batch.batchTransactions
+        val tx: BatchTransaction = checkAndGetSingleton(batchTransactions)
+        val details: TransactionDetails = tx.details
+        return details.unstructuredRemittanceInformation
+    }
+}
\ No newline at end of file
diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt
index 41e2a9d7..a0bc789a 100644
--- a/util/src/main/kotlin/DB.kt
+++ b/util/src/main/kotlin/DB.kt
@@ -150,7 +150,7 @@ class PostgresListenHandle(val channelName: String) {
         keepConnectionOpen: Boolean = false
         ): Boolean {
         if (timeoutMs == 0L)
-            logger.warn("Database notification checker has timeout == 0," +
+            logger.info("Database notification checker has timeout == 0," +
                     " that waits FOREVER until a notification arrives."
             )
         logger.debug("Waiting Postgres notifications on channel " +
diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt
index 67a0ccca..c41c6aa2 100644
--- a/util/src/main/kotlin/HTTP.kt
+++ b/util/src/main/kotlin/HTTP.kt
@@ -224,4 +224,8 @@ fun ApplicationCall.maybeLong(uriParamName: String): Long? {
     catch (e: Exception) {
         throw badRequest("Could not convert '$uriParamName' to Long")
     }
-}
\ No newline at end of file
+}
+
+// Join base URL and path ensuring one (and only one) slash in between.
+fun joinUrl(baseUrl: String, path: String): String =
+    baseUrl.dropLastWhile { it == '/' } + '/' + path.dropWhile { it == '/' }
\ No newline at end of file
diff --git a/util/src/main/kotlin/amounts.kt b/util/src/main/kotlin/amounts.kt
index 00eb7b6d..043e9af3 100644
--- a/util/src/main/kotlin/amounts.kt
+++ b/util/src/main/kotlin/amounts.kt
@@ -27,6 +27,9 @@ const val plainAmountRe = "^([0-9]+(\\.[0-9][0-9]?)?)$"
 const val plainAmountReWithSign = "^-?([0-9]+(\\.[0-9][0-9]?)?)$"
 const val amountWithCurrencyRe = "^([A-Z]+):([0-9]+(\\.[0-9][0-9]?)?)$"
 
+// Ensures that the number part of one amount matches the allowed format.
+// Currently, at most two fractional digits are allowed.  It returns true
+// in the matching case, false otherwise.
 fun validatePlainAmount(plainAmount: String, withSign: Boolean = false): 
Boolean {
     if (withSign) return Regex(plainAmountReWithSign).matches(plainAmount)
     return Regex(plainAmountRe).matches(plainAmount)
diff --git a/util/src/main/kotlin/strings.kt b/util/src/main/kotlin/strings.kt
index abfa0184..dce25861 100644
--- a/util/src/main/kotlin/strings.kt
+++ b/util/src/main/kotlin/strings.kt
@@ -195,4 +195,11 @@ fun hasWopidPlaceholder(captchaUrl: String): Boolean {
     if (captchaUrl.contains("{wopid}", ignoreCase = true))
         return true
     return false
-}
\ No newline at end of file
+}
+
+// Tries to extract a valid reserve public key from the raw subject line
+fun extractReservePubFromSubject(rawSubject: String): String? {
+    val re = "\\b[a-z0-9A-Z]{52}\\b".toRegex()
+    val result = re.find(rawSubject.replace("[\n]+".toRegex(), "")) ?: return 
null
+    return result.value.uppercase()
+}

-- 
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]