libeufin

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

commit 5bca2e1eedeb85d6b206413e7166018dce748f44
parent 8611d22879eb802bc69077f77543004adc4970f7
Author: MS <ms@taler.net>
Date:   Fri, 21 Apr 2023 20:17:15 +0200

Moving CaMt-JSON mapping to util.

In the context of the buy-in monitor, that lets
Sandbox use such mapping to process Nexus transactions.

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/Anastasis.kt | 14+++++++++++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/DB.kt | 11+----------
Mnexus/src/main/kotlin/tech/libeufin/nexus/FacadeUtil.kt | 6+++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/Taler.kt | 45++++++++++++++++-----------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/GbicRules.kt | 1+
Mnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Iso20022.kt | 299+++++--------------------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/server/Helpers.kt | 7+++++--
Mnexus/src/main/kotlin/tech/libeufin/nexus/server/JSON.kt | 52++++------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/server/NexusServer.kt | 1-
Mnexus/src/main/kotlin/tech/libeufin/nexus/xlibeufinbank/XLibeufinBankNexus.kt | 15+++++++++------
Autil/src/main/kotlin/CamtJsonMapping.kt | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mutil/src/main/kotlin/DB.kt | 2+-
Mutil/src/main/kotlin/HTTP.kt | 8++++++--
Mutil/src/main/kotlin/amounts.kt | 3+++
Mutil/src/main/kotlin/strings.kt | 10++++++++--
15 files changed, 420 insertions(+), 389 deletions(-)

diff --git 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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") @@ -67,22 +77,6 @@ data class CamtReport( ) @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?, val subtype: 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 @@ -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 @@ -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 @@ -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 @@ -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/util/src/main/kotlin/CamtJsonMapping.kt 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 @@ -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 @@ -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 @@ -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 @@ -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() +}