libeufin

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

AmountTest.kt (19126B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023-2025 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 import io.ktor.http.*
     21 import org.junit.Test
     22 import tech.libeufin.common.*
     23 import tech.libeufin.common.db.*
     24 import tech.libeufin.common.test.*
     25 import kotlin.test.*
     26 
     27 class AmountTest {
     28     // Test amount computation in db
     29     @Test
     30     fun computationTest() = bankSetup { db -> db.conn { conn ->
     31         conn.execSQLUpdate("UPDATE libeufin_bank.bank_accounts SET balance.val = 100000 WHERE internal_payto = '${customerPayto.canonical}'")
     32         val stmt = conn.talerStatement("""
     33             UPDATE libeufin_bank.bank_accounts 
     34                 SET balance = (?, ?)::taler_amount
     35                     ,has_debt = ?
     36                     ,max_debt = (?, ?)::taler_amount
     37             WHERE internal_payto = '${merchantPayto.canonical}'
     38         """)
     39         suspend fun routine(
     40             balance: TalerAmount,
     41             hasDebt: Boolean,
     42             maxDebt: TalerAmount,
     43             amount: TalerAmount
     44         ): Boolean {
     45             stmt.bind(balance)
     46             stmt.bind(hasDebt)
     47             stmt.bind(maxDebt)
     48 
     49             // Check bank transaction
     50             stmt.executeUpdate()
     51             val txRes = client.postA("/accounts/merchant/transactions") {
     52                 json {
     53                     "payto_uri" to "$customerPayto?message="
     54                     "amount" to amount
     55                 }
     56             }
     57             val txBool = when {
     58                 txRes.isStatus(HttpStatusCode.OK, null) -> true
     59                 txRes.isStatus(HttpStatusCode.Conflict, TalerErrorCode.BANK_UNALLOWED_DEBIT) -> false
     60                 else -> throw Exception("Unexpected error $txRes")
     61             }
     62 
     63             // Check whithdraw 
     64             stmt.bind(balance)
     65             stmt.bind(hasDebt)
     66             stmt.bind(maxDebt)
     67             stmt.executeUpdate()
     68             for ((amount, suggested) in listOf(Pair(amount, null), Pair(null, amount), Pair(amount, amount))) {
     69                 val wRes = client.postA("/accounts/merchant/withdrawals") {
     70                     json { 
     71                         "amount" to amount
     72                         "suggested_amount" to suggested
     73                     } 
     74                 }
     75                 val wBool = when {
     76                     wRes.isStatus(HttpStatusCode.OK, null) -> true
     77                     wRes.isStatus(HttpStatusCode.Conflict, TalerErrorCode.BANK_UNALLOWED_DEBIT) -> false
     78                     else -> throw Exception("Unexpected error $wRes")
     79                 }
     80                 // Logic must be the same
     81                 assertEquals(wBool, txBool)
     82             }
     83             
     84             return txBool
     85         }
     86 
     87         // Balance enough, assert for true
     88         assert(routine(
     89             balance = TalerAmount(10, 0, "KUDOS"),
     90             hasDebt = false,
     91             maxDebt = TalerAmount(100, 0, "KUDOS"),
     92             amount = TalerAmount(8, 0, "KUDOS"),
     93         ))
     94         // Balance still sufficient, thanks for big enough debt permission.  Assert true.
     95         assert(routine(
     96             balance = TalerAmount(10, 0, "KUDOS"),
     97             hasDebt = false,
     98             maxDebt = TalerAmount(100, 0, "KUDOS"),
     99             amount = TalerAmount(80, 0, "KUDOS"),
    100         ))
    101         // Balance not enough, max debt cannot cover, asserting for false.
    102         assert(!routine(
    103             balance = TalerAmount(10, 0, "KUDOS"),
    104             hasDebt = true,
    105             maxDebt = TalerAmount(50, 0, "KUDOS"),
    106             amount = TalerAmount(80, 0, "KUDOS"),
    107         ))
    108         // Balance becomes enough, due to a larger max debt, asserting for true.
    109         assert(routine(
    110             balance = TalerAmount(10, 0, "KUDOS"),
    111             hasDebt = false,
    112             maxDebt = TalerAmount(70, 0, "KUDOS"),
    113             amount = TalerAmount(80, 0, "KUDOS"),
    114         ))
    115         // Max debt not enough for the smallest fraction, asserting for false
    116         assert(!routine(
    117             balance = TalerAmount(0, 0, "KUDOS"),
    118             hasDebt = false,
    119             maxDebt = TalerAmount(0, 1, "KUDOS"),
    120             amount = TalerAmount(0, 2, "KUDOS"),
    121         ))
    122         // Same as above, but already in debt.
    123         assert(!routine(
    124             balance = TalerAmount(0, 1, "KUDOS"),
    125             hasDebt = true,
    126             maxDebt = TalerAmount(0, 1, "KUDOS"),
    127             amount = TalerAmount(0, 1, "KUDOS"),
    128         ))
    129     }}
    130 
    131     // Max withdrawal amount computation in db
    132     @Test
    133     fun maxComputationTest() = bankSetup { db -> db.conn { conn ->
    134         val update = conn.talerStatement("""
    135             UPDATE libeufin_bank.bank_accounts 
    136                 SET balance = (?, ?)::taler_amount
    137                     ,has_debt = ?
    138                     ,max_debt = (?, ?)::taler_amount
    139             WHERE bank_account_id = 1
    140         """)
    141         val select = conn.talerStatement("""
    142             SELECT
    143                 (max_amount).val as max_amount_val
    144                 ,(max_amount).frac as max_amount_frac
    145             FROM account_max_amount(1, (?, ?)::taler_amount) AS max_amount
    146         """)
    147         suspend fun routine(
    148             balance: TalerAmount,
    149             hasDebt: Boolean,
    150             maxDebt: TalerAmount
    151         ): TalerAmount {
    152             update.apply {
    153                 bind(balance)
    154                 bind(hasDebt)
    155                 bind(maxDebt)
    156                 executeUpdate()
    157             }
    158             select.bind(TalerAmount.max("KUDOS"))
    159             return select.one { it.getAmount("max_amount", "KUDOS") }
    160         }
    161 
    162         // Without debt
    163         assertEquals(TalerAmount(110, 3, "KUDOS"), routine(
    164             balance = TalerAmount(10, 1, "KUDOS"),
    165             hasDebt = false,
    166             maxDebt = TalerAmount(100, 2, "KUDOS"),
    167         ))
    168         // With debt
    169         assertEquals(TalerAmount(90, 1, "KUDOS"), routine(
    170             balance = TalerAmount(10, 1, "KUDOS"),
    171             hasDebt = true,
    172             maxDebt = TalerAmount(100, 2, "KUDOS"),
    173         ))
    174     }}
    175 
    176     @Test
    177     fun parseRoundTrip() {
    178         for (amount in listOf("EUR:4", "EUR:0.02", "EUR:4.12")) {
    179             assertEquals(amount, TalerAmount(amount).toString())
    180         }
    181     }
    182     
    183     @Test
    184     fun normalize() = dbSetup { db ->
    185         db.conn { conn ->
    186             val stmt = conn.talerStatement("SELECT normalized.val, normalized.frac FROM amount_normalize((?, ?)::taler_amount) as normalized")
    187             fun TalerAmount.db(): TalerAmount {
    188                 stmt.bind(value)
    189                 stmt.bind(frac)
    190                 return stmt.one {
    191                     TalerAmount(
    192                         it.getLong(1),
    193                         it.getInt(2),
    194                         "EUR"
    195                     )
    196                 }
    197             }
    198 
    199             fun assertNormalize(from: TalerAmount, to: TalerAmount) {
    200                 val normalized = from.normalize()
    201                 assertEquals(to, normalized, "Bad normalization")
    202                 val dbNormalized = from.db()
    203                 assertEquals(normalized, dbNormalized, "DB vs code behavior")
    204             }
    205 
    206             fun assertErr(from: TalerAmount, msg: String) {
    207                 assertFails { from.normalize() }
    208                 assertException(msg) { from.db() }
    209             }
    210     
    211             assertNormalize(TalerAmount(4L, 2 * TalerAmount.FRACTION_BASE, "EUR"), TalerAmount("EUR:6"))
    212             assertNormalize(TalerAmount(4L, 2 * TalerAmount.FRACTION_BASE + 1, "EUR"), TalerAmount("EUR:6.00000001"))
    213             assertNormalize(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"))
    214             assertErr(TalerAmount(Long.MAX_VALUE, TalerAmount.FRACTION_BASE, "EUR"), "ERROR: bigint out of range")
    215             assertErr(TalerAmount(TalerAmount.MAX_VALUE, TalerAmount.FRACTION_BASE , "EUR"), "ERROR: amount value overflowed")
    216 
    217             for (amount in listOf(TalerAmount.max("EUR"), TalerAmount.zero("EUR"))) {
    218                 assertNormalize(amount, amount)
    219             }
    220         }
    221     }
    222 
    223     @Test
    224     fun add() = dbSetup { db ->
    225         db.conn { conn ->
    226             val stmt = conn.talerStatement("SELECT sum.val, sum.frac FROM amount_add((?, ?)::taler_amount, (?, ?)::taler_amount) as sum")
    227             fun TalerAmount.db(increment: TalerAmount): TalerAmount {
    228                 stmt.bind(value)
    229                 stmt.bind(frac)
    230                 stmt.bind(increment)
    231                 return stmt.one {
    232                     TalerAmount(
    233                         it.getLong(1),
    234                         it.getInt(2),
    235                         "EUR"
    236                     )
    237                 }
    238             }
    239 
    240             fun assertAdd(a: TalerAmount, b: TalerAmount, sum: TalerAmount) {
    241                 val codeSum = a + b
    242                 assertEquals(sum, codeSum, "Bad sum")
    243                 val dbSum = a.db(b)
    244                 assertEquals(codeSum, dbSum, "DB vs code behavior")
    245             }
    246 
    247             fun assertErr(a: TalerAmount, b: TalerAmount, msg: String) {
    248                 assertFails { a + b }
    249                 assertException(msg) { a.db(b) }
    250             }
    251             
    252             assertAdd(TalerAmount.max("EUR"), TalerAmount.zero("EUR"), TalerAmount.max("EUR"))
    253             assertAdd(TalerAmount.zero("EUR"), TalerAmount.zero("EUR"), TalerAmount.zero("EUR"))
    254             assertAdd(TalerAmount("EUR:6.41"), TalerAmount("EUR:4.69"), TalerAmount("EUR:11.1"))
    255             assertAdd(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), TalerAmount("EUR:0.99999999"), TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"))
    256             assertErr(TalerAmount(TalerAmount.MAX_VALUE - 5, 0, "EUR"), TalerAmount(6, 0, "EUR"), "ERROR: amount value overflowed")
    257             assertErr(TalerAmount(Long.MAX_VALUE, 0, "EUR"), TalerAmount(1, 0, "EUR"), "ERROR: bigint out of range")
    258             assertErr(TalerAmount(TalerAmount.MAX_VALUE - 5, TalerAmount.FRACTION_BASE - 1, "EUR"), TalerAmount(5, 2, "EUR"), "ERROR: amount value overflowed")
    259             assertErr(TalerAmount(0, Int.MAX_VALUE, "EUR"), TalerAmount(0, 1, "EUR"), "ERROR: integer out of range")
    260         }
    261     }
    262 
    263     @Test
    264     fun conversionApply() = dbSetup { db ->
    265         db.conn { conn ->
    266             fun apply(nb: TalerAmount, times: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount {
    267                 val stmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_apply_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode)")
    268                 stmt.bind(nb)
    269                 stmt.bind(times)
    270                 stmt.bind(tiny)
    271                 stmt.bind(roundingMode)
    272                 return stmt.one {
    273                     TalerAmount(
    274                         it.getLong(1),
    275                         it.getInt(2),
    276                         nb.currency
    277                     )
    278                 }
    279             }
    280     
    281             assertEquals(TalerAmount("EUR:30.0629"), apply(TalerAmount("EUR:6.41"), DecimalNumber("4.69")))
    282             assertEquals(TalerAmount("EUR:6.41000641"), apply(TalerAmount("EUR:6.41"), DecimalNumber("1.000001")))
    283             assertEquals(TalerAmount("EUR:2.49999997"), apply(TalerAmount("EUR:0.99999999"), DecimalNumber("2.5")))
    284             assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), apply(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), DecimalNumber("1")))
    285             assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), apply(TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}"), DecimalNumber("4")))
    286             assertException("ERROR: amount value overflowed") { apply(TalerAmount(TalerAmount.MAX_VALUE/3, 0, "EUR"), DecimalNumber("3.00000001")) }
    287             assertException("ERROR: amount value overflowed") { apply(TalerAmount((TalerAmount.MAX_VALUE+2)/2, 0, "EUR"), DecimalNumber("2")) }
    288             assertException("ERROR: numeric field overflow") { apply(TalerAmount(Long.MAX_VALUE, 0, "EUR"), DecimalNumber("1")) }
    289 
    290             // Check rounding mode
    291             for ((mode, rounding) in listOf(
    292                 Pair("zero", listOf(Pair(1, listOf(10, 11, 12, 12, 14, 15, 16, 17, 18, 19)))),
    293                 Pair("up", listOf(Pair(1, listOf(10)), Pair(2, listOf(11, 12, 12, 14, 15, 16, 17, 18, 19)))),
    294                 Pair("nearest", listOf(Pair(1, listOf(10, 11, 12, 12, 14)), Pair(2, listOf(15, 16, 17, 18, 19))))
    295             )) {
    296                 for ((rounded, amounts) in rounding) {
    297                     for (amount in amounts) {
    298                         // Check euro
    299                         assertEquals(TalerAmount("EUR:0.0$rounded"), apply(TalerAmount("EUR:$amount"), DecimalNumber("0.001"), DecimalNumber("0.01"), mode))
    300                         // Check kudos
    301                         assertEquals(TalerAmount("KUDOS:0.0000000$rounded"), apply(TalerAmount("KUDOS:0.$amount"), DecimalNumber("0.0000001"), roundingMode = mode))
    302                     }
    303                 }
    304             }
    305             
    306             // Check hungarian rounding
    307             for ((mode, rounding) in listOf(
    308                 Pair("zero", listOf(Pair(10, listOf(10, 11, 12, 13, 14)), Pair(15, listOf(15, 16, 17, 18, 19)))),
    309                 Pair("up", listOf(Pair(10, listOf(10)), Pair(15, listOf(11, 12, 13, 14, 15)), Pair(20, listOf(16, 17, 18, 19)))),
    310                 Pair("nearest", listOf(Pair(10, listOf(10, 11, 12)), Pair(15, listOf(13, 14, 15, 16, 17)), Pair(20, listOf(18, 19))))
    311             )) {
    312                 for ((rounded, amounts) in rounding) {
    313                     for (amount in amounts) {
    314                         assertEquals(TalerAmount("HUF:$rounded"), apply(TalerAmount("HUF:$amount"), DecimalNumber("1"), DecimalNumber("5"), mode))
    315                     }
    316                 }
    317             }
    318             for (mode in listOf("zero", "up", "nearest")) {
    319                 assertEquals(TalerAmount("HUF:5"), apply(TalerAmount("HUF:5"), DecimalNumber("1"), DecimalNumber("1"), mode))
    320             }
    321         }
    322     }
    323 
    324     @Test
    325     fun conversionRevert() = dbSetup { db ->
    326         db.conn { conn ->
    327             val applyStmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_apply_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode)")
    328             fun TalerAmount.apply(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero"): TalerAmount {
    329                 applyStmt.bind(this)
    330                 applyStmt.bind(ratio)
    331                 applyStmt.bind(tiny)
    332                 applyStmt.bind(roundingMode)
    333                 return applyStmt.one {
    334                     TalerAmount(
    335                         it.getLong(1),
    336                         it.getInt(2),
    337                         currency
    338                     )
    339                 }
    340             }
    341 
    342             val revertStmt = conn.talerStatement("SELECT (result).val, (result).frac FROM conversion_revert_ratio((?, ?)::taler_amount, (?, ?)::taler_amount, (0, 0)::taler_amount, (?, ?)::taler_amount, ?::rounding_mode, (?, ?)::taler_amount)")
    343             fun TalerAmount.revert(ratio: DecimalNumber, tiny: DecimalNumber = DecimalNumber("0.00000001"), roundingMode: String = "zero", reverseTiny: DecimalNumber = DecimalNumber("0.00000001")): TalerAmount {
    344                 revertStmt.bind(this)
    345                 revertStmt.bind(ratio)
    346                 revertStmt.bind(tiny)
    347                 revertStmt.bind(roundingMode)
    348                 revertStmt.bind(reverseTiny)
    349                 return revertStmt.one {
    350                     TalerAmount(
    351                         it.getLong(1),
    352                         it.getInt(2),
    353                         currency
    354                     )
    355                 }
    356             }
    357     
    358             assertEquals(TalerAmount("EUR:6.41"), TalerAmount("EUR:30.0629").revert(DecimalNumber("4.69")))
    359             assertEquals(TalerAmount("EUR:6.41"), TalerAmount("EUR:6.41000641").revert(DecimalNumber("1.000001")))
    360             assertEquals(TalerAmount("EUR:1"), TalerAmount("EUR:2.49999998").revert(DecimalNumber("2.5")))
    361             assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999"), TalerAmount("EUR:${TalerAmount.MAX_VALUE}.99999999").revert(DecimalNumber("1")))
    362             assertEquals(TalerAmount("EUR:${TalerAmount.MAX_VALUE}"), TalerAmount("EUR:${TalerAmount.MAX_VALUE/4}").revert(DecimalNumber("0.25")))
    363             assertException("ERROR: amount value overflowed") { TalerAmount(TalerAmount.MAX_VALUE/4, 0, "EUR").revert(DecimalNumber("0.24999999")) }
    364             assertException("ERROR: amount value overflowed") { TalerAmount((TalerAmount.MAX_VALUE+2)/2, 0, "EUR").revert(DecimalNumber("0.5")) }
    365             assertException("ERROR: numeric field overflow") { TalerAmount(Long.MAX_VALUE, 0, "EUR").revert(DecimalNumber("1")) }
    366 
    367 
    368             for (mode in sequenceOf("zero", "up", "nearest")) {
    369                 for (tiny in sequenceOf("0.01", "0.00000001", "1", "2", "3", "5").map(::DecimalNumber)) {
    370                     for (amount in sequenceOf(10, 11, 12, 12, 14, 15, 16, 17, 18, 19).map { TalerAmount("EUR:$it") }) {
    371                         for (ratio in sequenceOf("1", "1.25", "1.26", "0.01", "0.001", "0.00000001").map(::DecimalNumber)) {
    372                             for (reverseTiny in sequenceOf("0.01", "0.00000001", "1").map(::DecimalNumber)) {
    373                                 // Apply ratio
    374                                 val rounded = amount.apply(ratio, tiny, mode)
    375                                 // Revert ratio  
    376                                 val revert = rounded.revert(ratio, tiny, mode, reverseTiny)
    377                                 // Check applying ratio again give the same result
    378                                 val check = revert.apply(ratio, tiny, mode)
    379                                 println("$amount $rounded $revert $check $ratio $tiny $mode")
    380                                 assertEquals(rounded, check)
    381                             }
    382                         }
    383                     }
    384                 }
    385             }
    386         }
    387     }
    388 
    389     @Test
    390     fun apiError() = bankSetup { 
    391         val base = obj {
    392             "payto_uri" to "$exchangePayto?message=payout"
    393         }
    394 
    395         // Check OK
    396         client.postA("/accounts/merchant/transactions") {
    397             json(base) { "amount" to "KUDOS:0.3ABC" }
    398         }.assertBadRequest(TalerErrorCode.BANK_BAD_FORMAT_AMOUNT)
    399         client.postA("/accounts/merchant/transactions") {
    400             json(base) { "amount" to "KUDOS:999999999999999999" }
    401         }.assertBadRequest(TalerErrorCode.BANK_NUMBER_TOO_BIG)
    402     }
    403 }