libeufin

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

commit cc35923acc45c1fe0acf4fa277ec490b43a27094
parent 537bb9822a3b6c2bd7f777cad72b4580118beb10
Author: MS <ms@taler.net>
Date:   Tue, 14 Nov 2023 17:45:27 +0100

nexus fetch, adding:

- SQL to store talerable and bounced incoming transactions
- draft of camt.054 parser
- logic to bounce or accept based on the payment subject

Diffstat:
Mdatabase-versioning/libeufin-nexus-procedures.sql | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/Database.kt | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mnexus/src/test/kotlin/DatabaseTest.kt | 76+++++++++++++++++++++++-----------------------------------------------------
Anexus/src/test/kotlin/Parsing.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mutil/src/main/kotlin/time.kt | 14+++++++++++++-
6 files changed, 507 insertions(+), 85 deletions(-)

diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -9,8 +9,12 @@ CREATE OR REPLACE FUNCTION create_incoming_and_bounce( ,IN in_bank_transfer_id TEXT ,IN in_timestamp BIGINT ,IN in_request_uid TEXT -) RETURNS void + ,OUT out_ok BOOLEAN +) RETURNS BOOLEAN LANGUAGE plpgsql AS $$ +DECLARE +new_tx_id INT8; +new_init_id INT8; BEGIN -- creating the bounced incoming transaction. INSERT INTO incoming_transactions ( @@ -19,15 +23,14 @@ INSERT INTO incoming_transactions ( ,execution_time ,debit_payto_uri ,bank_transfer_id - ,bounced ) VALUES ( in_amount ,in_wire_transfer_subject ,in_execution_time ,in_debit_payto_uri ,in_bank_transfer_id - ,true - ); + ) RETURNING incoming_transaction_id INTO new_tx_id; + -- creating its reimbursement. INSERT INTO initiated_outgoing_transactions ( amount @@ -41,7 +44,16 @@ INSERT INTO initiated_outgoing_transactions ( ,in_debit_payto_uri ,in_timestamp ,in_request_uid - ); + ) RETURNING initiated_outgoing_transaction_id INTO new_init_id; + +INSERT INTO bounced_transactions ( + incoming_transaction_id + ,initiated_outgoing_transaction_id +) VALUES ( + new_tx_id + ,new_init_id +); +out_ok = TRUE; END $$; COMMENT ON FUNCTION create_incoming_and_bounce(taler_amount, TEXT, BIGINT, TEXT, TEXT, BIGINT, TEXT) @@ -141,3 +153,44 @@ UPDATE incoming_transactions END $$; COMMENT ON FUNCTION bounce_payment(BIGINT, BIGINT, TEXT) IS 'Marks an incoming payment as bounced and initiates its refunding payment'; + +CREATE OR REPLACE FUNCTION create_incoming_talerable( + IN in_amount taler_amount + ,IN in_wire_transfer_subject TEXT + ,IN in_execution_time BIGINT + ,IN in_debit_payto_uri TEXT + ,IN in_bank_transfer_id TEXT + ,IN in_reserve_public_key BYTEA + ,OUT out_ok BOOLEAN +) RETURNS BOOLEAN +LANGUAGE plpgsql AS $$ +DECLARE +new_tx_id INT8; +BEGIN +INSERT INTO incoming_transactions ( + amount + ,wire_transfer_subject + ,execution_time + ,debit_payto_uri + ,bank_transfer_id + ) VALUES ( + in_amount + ,in_wire_transfer_subject + ,in_execution_time + ,in_debit_payto_uri + ,in_bank_transfer_id + ) RETURNING incoming_transaction_id INTO new_tx_id; +INSERT INTO talerable_incoming_transactions ( + incoming_transaction_id + ,reserve_public_key +) VALUES ( + new_tx_id + ,in_reserve_public_key +); +out_ok = TRUE; +END $$; + +COMMENT ON FUNCTION create_incoming_talerable(taler_amount, TEXT, BIGINT, TEXT, TEXT, BYTEA) IS ' +Creates one row in the incoming transactions table and one row +in the talerable transactions table. The talerable row links the +incoming one.'; +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -39,7 +39,7 @@ fun TalerAmount.stringify(): String { */ data class IncomingPayment( val amount: TalerAmount, - val wireTransferSubject: String?, + val wireTransferSubject: String, val debitPaytoUri: String, val executionTime: Instant, val bankTransferId: String @@ -281,13 +281,13 @@ class Database(dbConfig: String): java.io.Closeable { suspend fun incomingPaymentCreateBounced( paymentData: IncomingPayment, requestUid: String - ) = runConn { conn -> + ): Boolean = runConn { conn -> val refundTimestamp = Instant.now().toDbMicros() ?: throw Exception("Could not convert refund execution time from Instant.now() to microsends.") val executionTime = paymentData.executionTime.toDbMicros() ?: throw Exception("Could not convert payment execution time from Instant to microseconds.") val stmt = conn.prepareStatement(""" - SELECT create_incoming_and_bounce ( + SELECT out_ok FROM create_incoming_and_bounce ( (?,?)::taler_amount ,? ,? @@ -304,7 +304,11 @@ class Database(dbConfig: String): java.io.Closeable { stmt.setString(6, paymentData.bankTransferId) stmt.setLong(7, refundTimestamp) stmt.setString(8, requestUid) - stmt.executeQuery() + val res = stmt.executeQuery() + res.use { + if (!it.next()) return@runConn false + return@runConn it.getBoolean("out_ok") + } } /** @@ -329,7 +333,78 @@ class Database(dbConfig: String): java.io.Closeable { } /** - * Creates a new incoming payment record in the database. + * Checks if the reserve public key already exists. + * + * @param maybeReservePub reserve public key to look up + * @return true if found, false otherwise + */ + suspend fun isReservePubFound(maybeReservePub: ByteArray): Boolean = runConn { conn -> + val stmt = conn.prepareStatement(""" + SELECT 1 + FROM talerable_incoming_transactions + WHERE reserve_public_key = ?; + """) + stmt.setBytes(1, maybeReservePub) + val res = stmt.executeQuery() + res.use { + return@runConn it.next() + } + } + + /** + * Creates an incoming transaction row and links a new talerable + * row to it. + * + * @param paymentData incoming talerable payment. + * @param reservePub reserve public key. The caller is + * responsible to check it. + */ + suspend fun incomingTalerablePaymentCreate( + paymentData: IncomingPayment, + reservePub: ByteArray + ): Boolean = runConn { conn -> + val stmt = conn.prepareStatement(""" + SELECT out_ok FROM create_incoming_talerable( + (?,?)::taler_amount + ,? + ,? + ,? + ,? + ,? + )""") + bindIncomingPayment(paymentData, stmt) + stmt.setBytes(7, reservePub) + stmt.executeQuery().use { + if (!it.next()) return@runConn false + return@runConn it.getBoolean("out_ok") + } + } + + /** + * Binds the values of an incoming payment to the prepared + * statement's placeholders. Warn: may easily break in case + * the placeholders get their positions changed! + * + * @param data incoming payment to bind to the placeholders + * @param stmt statement to receive the values in its placeholders + */ + private fun bindIncomingPayment( + data: IncomingPayment, + stmt: PreparedStatement + ) { + stmt.setLong(1, data.amount.value) + stmt.setInt(2, data.amount.fraction) + stmt.setString(3, data.wireTransferSubject) + val executionTime = data.executionTime.toDbMicros() ?: run { + throw Exception("Execution time could not be converted to microseconds for the database.") + } + stmt.setLong(4, executionTime) + stmt.setString(5, data.debitPaytoUri) + stmt.setString(6, data.bankTransferId) + } + /** + * Creates a new incoming payment record in the database. It does NOT + * update the "talerable" table. * * @param paymentData information related to the incoming payment. * @return true on success, false otherwise. @@ -350,15 +425,7 @@ class Database(dbConfig: String): java.io.Closeable { ,? ) """) - stmt.setLong(1, paymentData.amount.value) - stmt.setInt(2, paymentData.amount.fraction) - stmt.setString(3, paymentData.wireTransferSubject) - val executionTime = paymentData.executionTime.toDbMicros() ?: run { - throw Exception("Execution time could not be converted to microseconds for the database.") - } - stmt.setLong(4, executionTime) - stmt.setString(5, paymentData.debitPaytoUri) - stmt.setString(6, paymentData.bankTransferId) + bindIncomingPayment(paymentData, stmt) return@runConn stmt.maybeUpdate() } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -5,21 +5,21 @@ import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import io.ktor.client.* import kotlinx.coroutines.runBlocking -import org.apache.commons.compress.archivers.zip.ZipFile -import org.apache.commons.compress.utils.SeekableInMemoryByteChannel +import net.taler.wallet.crypto.Base32Crockford +import net.taler.wallet.crypto.EncodingException import tech.libeufin.nexus.ebics.* -import tech.libeufin.util.EbicsOrderParams +import tech.libeufin.util.* import tech.libeufin.util.ebics_h005.Ebics3Request -import tech.libeufin.util.getXmlDate -import tech.libeufin.util.toDbMicros import java.io.File +import java.io.IOException +import java.lang.StringBuilder import java.nio.file.Path import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.util.UUID import kotlin.concurrent.fixedRateTimer import kotlin.io.path.createDirectories -import kotlin.reflect.typeOf import kotlin.system.exitProcess /** @@ -159,6 +159,239 @@ fun maybeLogFile( } /** + * Converts the given fractional value to the sub-cent 8 digits + * fraction used in Taler. Note: this value has very likely a <2 + * length, but the function is general, for each fraction with at + * most 8 digits. + * + * @param bankFrac fractional value + * @return the Taler fractional value with at most 8 digits. + */ +fun makeTalerFrac(bankFrac: String): Int { + if (bankFrac.length > 8) throw Exception("Fractional value has more than 8 digits") + var buf = bankFrac.toIntOrNull() ?: throw Exception("Fractional value not an Int: $bankFrac") + repeat(8 - bankFrac.length) { + buf *= 10 + } + return buf +} + +/** + * Gets Taler amount from a currency-agnostic value. + * + * @param noCurrencyAmount currency-agnostic value coming from the bank. + * @param currency currency to set to the result. + * @return [TalerAmount] + */ +fun getTalerAmount( + noCurrencyAmount: String, + currency: String +): TalerAmount { + if (currency.isEmpty()) throw Exception("Currency is empty") + val split = noCurrencyAmount.split(".") + // only 1 (no fraction) or 2 (with fraction) sizes allowed. + if (split.size != 1 && split.size != 2) throw Exception("Invalid amount: ${noCurrencyAmount}") + val value = split[0].toLongOrNull() ?: throw Exception("value part not a long") + if (split.size == 1) return TalerAmount( + value = value, + fraction = 0, + currency = currency + ) + return TalerAmount( + value = value, + fraction = makeTalerFrac(split[1]), + currency = currency + ) +} + +/** + * Searches for incoming transactions in a camt.054 document, that + * was downloaded via EBICS notification. + * + * @param notifXml the input document. + * @return any incoming payment as a list of [IncomingPayment] + */ +fun findIncomingTxInNotification( + notifXml: String, + acceptedCurrency: String +): List<IncomingPayment> { + val notifDoc = XMLUtil.parseStringIntoDom(notifXml) + val ret = mutableListOf<IncomingPayment>() + destructXml(notifDoc) { + requireRootElement("Document") { + requireUniqueChildNamed("BkToCstmrDbtCdtNtfctn") { + mapEachChildNamed("Ntfctn") { + mapEachChildNamed("Ntry") { + mapEachChildNamed("NtryDtls") { + mapEachChildNamed("TxDtls") maybeDbit@{ + + // currently, only incoming payments are considered. + if (requireUniqueChildNamed("CdtDbtInd") { + focusElement.textContent == "DBIT" + }) return@maybeDbit + + // Obtaining the amount. + val amount: TalerAmount = requireUniqueChildNamed("Amt") { + val currency = focusElement.getAttribute("Ccy") + if (currency != acceptedCurrency) throw Exception("Currency $currency not supported") + getTalerAmount(focusElement.textContent, currency) + } + // Obtaining payment UID. + val uidFromBank: String = requireUniqueChildNamed("Refs") { + requireUniqueChildNamed("AcctSvcrRef") { + focusElement.textContent + } + } + // Obtaining payment subject. + val subject = StringBuilder() + requireUniqueChildNamed("RmtInf") { + this.mapEachChildNamed("Ustrd") { + val piece = this.focusElement.textContent + subject.append(piece) + } + } + // Obtaining the execution time. + val executionTime: Instant = requireUniqueChildNamed("RltdDts") { + requireUniqueChildNamed("AccptncDtTm") { + parseGregorianTime(focusElement.textContent) + } + } + // Obtaining the payer's details + val debtorPayto = StringBuilder("payto://iban/") + requireUniqueChildNamed("RltdPties") { + requireUniqueChildNamed("DbtrAcct") { + requireUniqueChildNamed("Id") { + requireUniqueChildNamed("IBAN") { + debtorPayto.append(focusElement.textContent) + } + } + } + // warn: it might need the postal address too.. + requireUniqueChildNamed("Dbtr") { + requireUniqueChildNamed("Nm") { + debtorPayto.append("?receiver-name=${focusElement.textContent}") + } + } + } + val incomingPayment = IncomingPayment( + amount = amount, + bankTransferId = uidFromBank, + debitPaytoUri = debtorPayto.toString(), + executionTime = executionTime, + wireTransferSubject = subject.toString() + ) + ret.add(incomingPayment) + } + } + } + } + } + } + } + return ret +} + +/** + * Converts valid reserve pubs to its binary representation. + * + * @param maybeReservePub input. + * @return [ByteArray] or null if not valid. + */ +fun isReservePub(maybeReservePub: String): ByteArray? { + val dec = try { + Base32Crockford.decode(maybeReservePub) + } catch (e: EncodingException) { + logger.error("Not a reserve pub: $maybeReservePub") + return null + } + logger.debug("Reserve how many bytes: ${dec.size}") + if (dec.size != 32) { + logger.error("Not a reserve pub, wrong length: ${dec.size}") + return null + } + return dec +} +/** + * Checks the two conditions that may invalidate one incoming + * payment: subject validity and availability. + * + * @param db database connection. + * @param payment incoming payment whose subject is to be checked. + * @return [ByteArray] as the reserve public key, or null if the + * payment cannot lead to a Taler withdrawal. + */ +suspend fun isTalerable( + db: Database, + payment: IncomingPayment +): ByteArray? { + // Checking validity first. + val dec = isReservePub(payment.wireTransferSubject) ?: return null + // Now checking availability. + val maybeUnavailable = db.isReservePubFound(dec) + if (maybeUnavailable) { + logger.error("Incoming payment with subject '${payment.wireTransferSubject}' exists already") + return null + } + return dec +} + +/** + * Parses the response of an EBICS notification looking for + * incoming payments. As a result, it either creates a Taler + * withdrawal or bounces the incoming payment. In detail, this + * function extracts the camt.054 from the ZIP archive, invokes + * the lower-level camt.054 parser and updates the database. + * + * @param db database connection. + * @param content the ZIP file that contains the EBICS + * notification as camt.054 records. + * @return true if the ingestion succeeded, false otherwise. + * False should fail the process, since it means that + * the notification could not be parsed. + */ +fun ingestNotification( + db: Database, + ctx: FetchContext, + content: ByteArray +): Boolean { + val incomingPayments = mutableListOf<IncomingPayment>() + try { + content.unzipForEach { fileName, xmlContent -> + // discarding plain "avisierung", since they don't bring any payment subject. + if (!fileName.startsWith("camt.054_P_")) return@unzipForEach + val found = findIncomingTxInNotification(xmlContent, ctx.cfg.currency) + incomingPayments += found + } + } catch (e: IOException) { + logger.error("Could not open any ZIP archive") + return false + } catch (e: Exception) { + logger.error(e.message) + return false + } + // Distinguishing now valid and invalid payments. + // Any error at this point is only due to Nexus. + try { + incomingPayments.forEach { + runBlocking { + val reservePub = isTalerable(db, it) + if (reservePub == null) { + db.incomingPaymentCreateBounced( + it, UUID.randomUUID().toString().take(35) + ) + return@runBlocking + } + db.incomingTalerablePaymentCreate(it, reservePub) + } + } + } catch (e: Exception) { + logger.error(e.message) + return false + } + return true +} + +/** * Fetches the banking records via EBICS notifications requests. * * It first checks the last execution_time (db column) among the @@ -184,14 +417,25 @@ private suspend fun fetchDocuments( ) { // maybe get last execution_date. val lastExecutionTime: Instant? = ctx.pinnedStart ?: db.incomingPaymentLastExecTime() - logger.debug("Fetching documents from timestamp: $lastExecutionTime") + logger.debug("Fetching ${ctx.whichDocument} from timestamp: $lastExecutionTime") + // downloading the content val maybeContent = downloadHelper(ctx, lastExecutionTime) ?: exitProcess(1) // client is wrong, failing. if (maybeContent.isEmpty()) return + // logging, if the configuration wants. maybeLogFile( ctx.cfg, maybeContent, nonZip = ctx.whichDocument == SupportedDocument.PAIN_002_LOGS ) + // Parsing the XML: only camt.054 (Detailavisierung) supported currently. + if (ctx.whichDocument != SupportedDocument.CAMT_054) { + logger.warn("Not parsing ${ctx.whichDocument}. Only camt.054 notifications supported.") + return + } + if (!ingestNotification(db, ctx, maybeContent)) { + logger.error("Ingesting notifications failed") + exitProcess(1) + } } class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 notifications") { @@ -218,13 +462,15 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti ).flag(default = false) private val onlyLogs by option( - help = "Downloads only EBICS activity logs via pain.002, only available to --transient mode. Config needs log directory" + help = "Downloads only EBICS activity logs via pain.002," + + " only available to --transient mode. Config needs" + + " log directory" ).flag(default = false) private val pinnedStart by option( - help = "constant YYYY-MM-DD date for the earliest document to download " + - "(only consumed in --transient mode). The latest document is always" + - " until the current time." + help = "constant YYYY-MM-DD date for the earliest document" + + " to download (only consumed in --transient mode). The" + + " latest document is always until the current time." ) /** @@ -252,6 +498,7 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti logger.error("Client private keys not found at: ${cfg.clientPrivateKeysFilename}") exitProcess(1) } + // Deciding what to download. var whichDoc = SupportedDocument.CAMT_054 if (onlyAck) whichDoc = SupportedDocument.PAIN_002 @@ -266,7 +513,6 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti bankKeys, whichDoc ) - if (transient) { logger.info("Transient mode: fetching once and returning.") val pinnedStartVal = pinnedStart diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -1,11 +1,10 @@ import kotlinx.coroutines.runBlocking -import org.junit.Ignore import org.junit.Test import tech.libeufin.nexus.* import java.time.Instant +import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue @@ -43,7 +42,7 @@ class OutgoingPaymentsTest { } } -@Ignore // enable after having modified the bouncing logic in Kotlin +// @Ignore // enable after having modified the bouncing logic in Kotlin class IncomingPaymentsTest { // Tests creating and bouncing incoming payments in one DB transaction. @Test @@ -51,17 +50,21 @@ class IncomingPaymentsTest { val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) runBlocking { // creating and bouncing one incoming transaction. - db.incomingPaymentCreateBounced( + assertTrue(db.incomingPaymentCreateBounced( genIncPay("incoming and bounced"), "UID" - ) + )) db.runConn { - // check the bounced flaag is true + // Checking one incoming got created + val checkIncoming = it.prepareStatement(""" + SELECT 1 FROM incoming_transactions WHERE incoming_transaction_id = 1; + """).executeQuery() + assertTrue(checkIncoming.next()) + // Checking the bounced table got its row. val checkBounced = it.prepareStatement(""" - SELECT bounced FROM incoming_transactions WHERE incoming_transaction_id = 1; + SELECT 1 FROM bounced_transactions WHERE incoming_transaction_id = 1; """).executeQuery() assertTrue(checkBounced.next()) - assertTrue(checkBounced.getBoolean("bounced")) // check the related initiated payment exists. val checkInitiated = it.prepareStatement(""" SELECT @@ -74,55 +77,22 @@ class IncomingPaymentsTest { } } - // Tests the function that flags incoming payments as bounced. + // Tests the creation of a talerable incoming payment. @Test - fun incomingPaymentBounce() { + fun incomingTalerableCreation() { val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) - runBlocking { - // creating one incoming payment. - assertTrue(db.incomingPaymentCreate(genIncPay("to be bounced"))) // row ID == 1. - db.runConn { - val bouncedSql = """ - SELECT bounced - FROM incoming_transactions - WHERE incoming_transaction_id = 1""" - // asserting is NOT bounced. - val expectNotBounced = it.execSQLQuery(bouncedSql) - assertTrue(expectNotBounced.next()) - assertFalse(expectNotBounced.getBoolean("bounced")) - // now bouncing it. - assertTrue(db.incomingPaymentSetAsBounced(1, "unique 0")) - // asserting it got flagged as bounced. - val expectBounced = it.execSQLQuery(bouncedSql) - assertTrue(expectBounced.next()) - assertTrue(expectBounced.getBoolean("bounced")) - // Trying to bounce a non-existing payment. - assertFalse(db.incomingPaymentSetAsBounced(5, "unique 1")) - } - } - } + val reservePub = ByteArray(32) + Random.nextBytes(reservePub) - // Tests the creation of an incoming payment. - @Test - fun incomingPaymentCreation() { - val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) - val countRows = "SELECT count(*) AS how_many FROM incoming_transactions" runBlocking { - // Asserting the table is empty. - db.runConn { - val res = it.execSQLQuery(countRows) - assertTrue(res.next()) - assertEquals(0, res.getInt("how_many")) - } - assertTrue(db.incomingPaymentCreate(genIncPay("singleton"))) - // Asserting the table has one. - db.runConn { - val res = it.execSQLQuery(countRows) - assertTrue(res.next()) - assertEquals(1, res.getInt("how_many")) - } - // Checking insertion of null (allowed) subjects. - assertTrue(db.incomingPaymentCreate(genIncPay())) + // Checking the reserve is not found. + assertFalse(db.isReservePubFound(reservePub)) + assertTrue(db.incomingTalerablePaymentCreate( + genIncPay("reserve-pub"), + reservePub + )) + // Checking the reserve is not found. + assertTrue(db.isReservePubFound(reservePub)) } } } diff --git a/nexus/src/test/kotlin/Parsing.kt b/nexus/src/test/kotlin/Parsing.kt @@ -0,0 +1,72 @@ +import org.junit.Test +import org.junit.jupiter.api.assertThrows +import tech.libeufin.nexus.getTalerAmount +import tech.libeufin.nexus.isReservePub +import java.lang.StringBuilder +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Parsing { + @Test // parses amounts as found in the camt.05x documents. + fun parseCurrencyAgnosticAmount() { + assertTrue { + getTalerAmount("1.00", "KUDOS").run { + this.value == 1L && this.fraction == 0 && this.currency == "KUDOS" + } + } + assertTrue { + getTalerAmount("1", "KUDOS").run { + this.value == 1L && this.fraction == 0 && this.currency == "KUDOS" + } + } + assertTrue { + getTalerAmount("0.99", "KUDOS").run { + this.value == 0L && this.fraction == 99000000 && this.currency == "KUDOS" + } + } + assertTrue { + getTalerAmount("0.01", "KUDOS").run { + this.value == 0L && this.fraction == 1000000 && this.currency == "KUDOS" + } + } + assertThrows<Exception> { + getTalerAmount("", "") + } + assertThrows<Exception> { + getTalerAmount(".1", "KUDOS") + } + assertThrows<Exception> { + getTalerAmount("1.", "KUDOS") + } + assertThrows<Exception> { + getTalerAmount("0.123456789", "KUDOS") + } + assertThrows<Exception> { + getTalerAmount("noise", "KUDOS") + } + assertThrows<Exception> { + getTalerAmount("1.noise", "KUDOS") + } + assertThrows<Exception> { + getTalerAmount("5", "") + } + } + + // Checks that the input decodes to a 32-bytes value. + @Test + fun validateReservePub() { + val valid = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0" + val validBytes = isReservePub(valid) + assertNotNull(validBytes) + assertEquals(32, validBytes.size) + assertNull(isReservePub("noise")) + val trimmedInput = valid.dropLast(10) + assertNull(isReservePub(trimmedInput)) + val invalidChar = StringBuilder(valid) + invalidChar.setCharAt(10, '*') + assertNull(isReservePub(invalidChar.toString())) + // assertNull(isReservePub(valid.dropLast(1))) // FIXME: this fails now because the decoder is buggy. + } +} +\ No newline at end of file diff --git a/util/src/main/kotlin/time.kt b/util/src/main/kotlin/time.kt @@ -20,8 +20,8 @@ package tech.libeufin.util import java.time.* +import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit -import java.util.concurrent.TimeUnit /** * Converts the 'this' Instant to the number of nanoseconds @@ -79,4 +79,16 @@ fun Long.microsToJavaInstant(): Instant? { logger.error(e.message) return null } +} + +/** + * Parses one timestamp from the ISO 8601 format. + * + * @param timeFromXml input time string from the XML + * @return [Instant] in the UTC timezone + */ +fun parseGregorianTime(timeFromXml: String): Instant { + val formatter = DateTimeFormatter.ISO_DATE_TIME.parse(timeFromXml) + return Instant.from(formatter) + } \ No newline at end of file