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 }