aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMS <ms@taler.net>2023-11-21 09:55:23 +0100
committerMS <ms@taler.net>2023-11-21 10:01:14 +0100
commit28d3c8f0608632174cb88379a75742a6c0b1d375 (patch)
treec5a1c447b5441b2d87d490be5f6b1ee53833ed38
parente0f1d57d1a46772437952a4224537b698ba324df (diff)
downloadlibeufin-28d3c8f0608632174cb88379a75742a6c0b1d375.tar.gz
libeufin-28d3c8f0608632174cb88379a75742a6c0b1d375.tar.bz2
libeufin-28d3c8f0608632174cb88379a75742a6c0b1d375.zip
nexus fetch
bouncing payments whose amounts are lower than a configurable threshold. The bouncing however ends with the creation of the related database row (as a logging mean), but they never get reimbursed.
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt20
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt88
-rw-r--r--nexus/src/test/kotlin/DatabaseTest.kt2
-rw-r--r--nexus/src/test/kotlin/Parsing.kt14
4 files changed, 103 insertions, 21 deletions
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
index d80fd665..7e4c98e5 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
@@ -11,7 +11,7 @@ import java.sql.SQLException
import java.time.Instant
// Remove this once TalerAmount from the bank
-// module gets moved to the 'util' module.
+// module gets moved to the 'util' module (#7987).
data class TalerAmount(
val value: Long,
val fraction: Int, // has at most 8 digits.
@@ -287,10 +287,14 @@ class Database(dbConfig: String): java.io.Closeable {
* @param paymentData information related to the incoming payment.
* @param requestUid unique identifier of the outgoing payment to
* initiate, in order to reimburse the bounced tx.
+ * @param refundAmount amount to send back to the original debtor. If
+ * null, it defaults to the amount of the bounced
+ * incoming payment.
*/
suspend fun incomingPaymentCreateBounced(
paymentData: IncomingPayment,
- requestUid: String
+ requestUid: String,
+ refundAmount: TalerAmount? = null
): Boolean = runConn { conn ->
val refundTimestamp = Instant.now().toDbMicros()
?: throw Exception("Could not convert refund execution time from Instant.now() to microsends.")
@@ -306,8 +310,12 @@ class Database(dbConfig: String): java.io.Closeable {
,?
,?
)""")
- stmt.setLong(1, paymentData.amount.value)
- stmt.setInt(2, paymentData.amount.fraction)
+
+ var finalAmount = paymentData.amount
+ if (refundAmount != null) finalAmount = refundAmount
+
+ stmt.setLong(1, finalAmount.value)
+ stmt.setInt(2, finalAmount.fraction)
stmt.setString(3, paymentData.wireTransferSubject)
stmt.setLong(4, executionTime)
stmt.setString(5, paymentData.debitPaytoUri)
@@ -548,8 +556,8 @@ class Database(dbConfig: String): java.io.Closeable {
,initiation_time
,request_uid
FROM initiated_outgoing_transactions
- WHERE submitted='unsubmitted'
- OR submitted='transient_failure';
+ WHERE (submitted='unsubmitted' OR submitted='transient_failure')
+ AND ((amount).val != 0 OR (amount).frac != 0);
""")
val maybeMap = mutableMapOf<Long, InitiatedPayment>()
stmt.executeQuery().use {
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index 53bf2ea7..768dde2f 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -51,16 +51,21 @@ data class FetchContext(
/**
* EBICS version. For the HAC message type, version gets switched to EBICS 2.
*/
- var ebicsVersion: EbicsVersion = EbicsVersion.three,
+ var ebicsVersion: EbicsVersion,
/**
- * Start date of the returned documents. Only
- * used in --transient mode.
+ * Logs to STDERR the init phase of an EBICS download request.
*/
- var pinnedStart: Instant? = null,
+ val ebicsExtraLog: Boolean,
/**
- * Logs to STDERR the init phase of an EBICS download request.
+ * Not triggering any Taler logic, if the incoming amount
+ * is below the following value.
+ */
+ val minimumAmount: TalerAmount?,
+ /**
+ * Start date of the returned documents. Only
+ * used in --transient mode.
*/
- val ebicsExtraLog: Boolean = false
+ var pinnedStart: Instant? = null
)
/**
@@ -191,13 +196,14 @@ private fun makeTalerFrac(bankFrac: String): Int {
*/
fun getTalerAmount(
noCurrencyAmount: String,
- currency: String
+ currency: String,
+ errorMessagePrefix: String = ""
): TalerAmount {
- if (currency.isEmpty()) throw Exception("Currency is empty")
+ if (currency.isEmpty()) throw Exception("Wrong helper invocation: 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 && split.size != 2) throw Exception("${errorMessagePrefix}invalid amount: $noCurrencyAmount")
+ val value = split[0].toLongOrNull() ?: throw Exception("${errorMessagePrefix}value part not a long")
if (split.size == 1) return TalerAmount(
value = value,
fraction = 0,
@@ -322,6 +328,7 @@ private suspend fun ingestOutgoingPayment(
*/
private suspend fun ingestIncomingPayment(
db: Database,
+ ctx: FetchContext,
incomingPayment: IncomingPayment
) {
logger.debug("Ingesting incoming payment UID: ${incomingPayment.bankTransferId}, subject: ${incomingPayment.wireTransferSubject}")
@@ -329,10 +336,30 @@ private suspend fun ingestIncomingPayment(
logger.debug("Incoming payment with UID '${incomingPayment.bankTransferId}' already seen.")
return
}
+ if (
+ ctx.minimumAmount != null &&
+ firstLessThanSecond(
+ incomingPayment.amount,
+ ctx.minimumAmount
+ )) {
+ /**
+ * Setting the refund amount to zero makes the initiated
+ * payment _never_ paid back. Inserting this row merely
+ * logs the incoming payment event, for which the policy
+ * has no reimbursement.
+ */
+ db.incomingPaymentCreateBounced(
+ incomingPayment,
+ UUID.randomUUID().toString().take(35),
+ TalerAmount(0, 0, ctx.cfg.currency)
+ )
+ return
+ }
val reservePub = getTalerReservePub(db, incomingPayment)
if (reservePub == null) {
db.incomingPaymentCreateBounced(
- incomingPayment, UUID.randomUUID().toString().take(35)
+ incomingPayment,
+ UUID.randomUUID().toString().take(35)
)
return
}
@@ -340,6 +367,25 @@ private suspend fun ingestIncomingPayment(
}
/**
+ * Compares amounts.
+ *
+ * @param a first argument
+ * @param b second argument
+ * @return true if the first argument
+ * is less than the second
+ */
+fun firstLessThanSecond(
+ a: TalerAmount,
+ b: TalerAmount
+): Boolean {
+ if (a.currency != b.currency)
+ throw Exception("different currencies: ${a.currency} vs. ${b.currency}")
+ if (a.value == b.value)
+ return a.fraction < b.fraction
+ return a.value < b.value
+}
+
+/**
* 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
@@ -391,7 +437,7 @@ private fun ingestNotification(
try {
runBlocking {
incomingPayments.forEach {
- ingestIncomingPayment(db, it)
+ ingestIncomingPayment(db, ctx, it)
}
outgoingPayments.forEach {
ingestOutgoingPayment(db, it)
@@ -562,13 +608,29 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti
}
return
}
+ val minAmountCfg: String? = cfg.config.lookupString(
+ "nexus-fetch",
+ "minimum_amount"
+ )
+ var minAmount: TalerAmount? = null
+ if (minAmountCfg != null) {
+ minAmount = doOrFail {
+ getTalerAmount(
+ cfg.currency,
+ minAmountCfg,
+ "[nexus-fetch]/minimum_amount, "
+ )
+ }
+ }
val ctx = FetchContext(
cfg,
HttpClient(),
clientKeys,
bankKeys,
whichDoc,
- ebicsExtraLog = ebicsExtraLog
+ EbicsVersion.three,
+ ebicsExtraLog,
+ minAmount
)
if (transient) {
logger.info("Transient mode: fetching once and returning.")
diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt
index 0f2cc4fa..cacec157 100644
--- a/nexus/src/test/kotlin/DatabaseTest.kt
+++ b/nexus/src/test/kotlin/DatabaseTest.kt
@@ -176,7 +176,7 @@ class PaymentInitiationsTest {
assertEquals(db.initiatedPaymentCreate(initPay), PaymentInitiationOutcome.SUCCESS)
assertEquals(db.initiatedPaymentCreate(initPay), PaymentInitiationOutcome.UNIQUE_CONSTRAINT_VIOLATION)
val haveOne = db.initiatedPaymentsSubmittableGet("KUDOS")
- assertTrue {
+ assertTrue("Size ${haveOne.size} instead of 1") {
haveOne.size == 1
&& haveOne.containsKey(1)
&& haveOne[1]?.requestUid == "unique"
diff --git a/nexus/src/test/kotlin/Parsing.kt b/nexus/src/test/kotlin/Parsing.kt
index 45737e1f..4d833d1e 100644
--- a/nexus/src/test/kotlin/Parsing.kt
+++ b/nexus/src/test/kotlin/Parsing.kt
@@ -11,6 +11,18 @@ import kotlin.test.assertTrue
class Parsing {
+ @Test // move eventually to util (#7987)
+ fun amountComparison() {
+ val one = TalerAmount(1, 0, "KUDOS")
+ val two = TalerAmount(2, 0, "KUDOS")
+ val moreFrac = TalerAmount(2, 4, "KUDOS")
+ val lessFrac = TalerAmount(2, 3, "KUDOS")
+ val zeroMoreFrac = TalerAmount(0, 4, "KUDOS")
+ val zeroLessFrac = TalerAmount(0, 3, "KUDOS")
+ assertTrue(firstLessThanSecond(one, two))
+ assertTrue(firstLessThanSecond(lessFrac, moreFrac))
+ assertTrue(firstLessThanSecond(zeroLessFrac, zeroMoreFrac))
+ }
@Test
fun gregorianTime() {
parseCamtTime("2023-11-06T20:00:00")
@@ -120,7 +132,7 @@ class Parsing {
getTalerAmount("1.", "KUDOS")
}
assertThrows<Exception> {
- getTalerAmount("0.123456789", "KUDOS")
+ getTalerAmount("0.123", "KUDOS")
}
assertThrows<Exception> {
getTalerAmount("noise", "KUDOS")