WithdrawalDAO.kt (16579B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2023, 2024, 2025, 2026 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 package tech.libeufin.bank.db 21 22 import kotlinx.coroutines.coroutineScope 23 import kotlinx.coroutines.flow.first 24 import kotlinx.coroutines.launch 25 import kotlinx.coroutines.withTimeoutOrNull 26 import tech.libeufin.bank.* 27 import tech.libeufin.common.EddsaPublicKey 28 import tech.libeufin.common.Payto 29 import tech.libeufin.common.TalerAmount 30 import tech.libeufin.common.db.* 31 import tech.libeufin.common.micros 32 import java.time.Instant 33 import java.util.* 34 35 /** Data access logic for withdrawal operations */ 36 class WithdrawalDAO(private val db: Database) { 37 /** Result status of withdrawal operation creation */ 38 enum class WithdrawalCreationResult { 39 Success, 40 UnknownAccount, 41 AccountIsExchange, 42 BalanceInsufficient, 43 BadAmount 44 } 45 46 /** Create a new withdrawal operation */ 47 suspend fun create( 48 username: String, 49 uuid: UUID, 50 amount: TalerAmount?, 51 suggested_amount: TalerAmount?, 52 no_amount_to_wallet: Boolean, 53 timestamp: Instant, 54 wireTransferFees: TalerAmount, 55 minAmount: TalerAmount, 56 maxAmount: TalerAmount 57 ): WithdrawalCreationResult = db.serializable( 58 """ 59 SELECT 60 out_account_not_found, 61 out_account_is_exchange, 62 out_balance_insufficient, 63 out_bad_amount 64 FROM create_taler_withdrawal( 65 ?,?, 66 ${optAmount(amount)}, 67 ${optAmount(suggested_amount)}, 68 ?, ?, (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount 69 ); 70 """ 71 ) { 72 73 bind(username) 74 bind(uuid) 75 bind(amount) 76 bind(suggested_amount) 77 bind(no_amount_to_wallet) 78 bind(timestamp.micros()) 79 bind(wireTransferFees) 80 bind(minAmount) 81 bind(maxAmount) 82 one { 83 when { 84 it.getBoolean("out_account_not_found") -> WithdrawalCreationResult.UnknownAccount 85 it.getBoolean("out_account_is_exchange") -> WithdrawalCreationResult.AccountIsExchange 86 it.getBoolean("out_balance_insufficient") -> WithdrawalCreationResult.BalanceInsufficient 87 it.getBoolean("out_bad_amount") -> WithdrawalCreationResult.BadAmount 88 else -> WithdrawalCreationResult.Success 89 } 90 } 91 } 92 93 /** Abort withdrawal operation [uuid] */ 94 suspend fun abort( 95 uuid: UUID 96 ): AbortResult = db.serializable( 97 """ 98 SELECT 99 out_no_op, 100 out_already_confirmed 101 FROM abort_taler_withdrawal(?) 102 """ 103 ) { 104 bind(uuid) 105 one { 106 when { 107 it.getBoolean("out_no_op") -> AbortResult.UnknownOperation 108 it.getBoolean("out_already_confirmed") -> AbortResult.AlreadyConfirmed 109 else -> AbortResult.Success 110 } 111 } 112 } 113 114 /** Result withdrawal operation selection */ 115 sealed interface WithdrawalSelectionResult { 116 data class Success(val status: WithdrawalStatus): WithdrawalSelectionResult 117 data object UnknownOperation: WithdrawalSelectionResult 118 data object AlreadySelected: WithdrawalSelectionResult 119 data object ReservePubReuse: WithdrawalSelectionResult 120 data object UnknownAccount: WithdrawalSelectionResult 121 data object AccountIsNotExchange: WithdrawalSelectionResult 122 data object AmountDiffers: WithdrawalSelectionResult 123 data object BalanceInsufficient: WithdrawalSelectionResult 124 data object BadAmount: WithdrawalSelectionResult 125 data object AlreadyAborted: WithdrawalSelectionResult 126 } 127 128 /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal operation [uuid] */ 129 suspend fun setDetails( 130 uuid: UUID, 131 exchangePayto: Payto, 132 reservePub: EddsaPublicKey, 133 amount: TalerAmount?, 134 wireTransferFees: TalerAmount, 135 minAmount: TalerAmount, 136 maxAmount: TalerAmount 137 ): WithdrawalSelectionResult = db.serializable( 138 """ 139 SELECT 140 out_no_op, 141 out_already_selected, 142 out_reserve_pub_reuse, 143 out_account_not_found, 144 out_account_is_not_exchange, 145 out_status, 146 out_amount_differs, 147 out_balance_insufficient, 148 out_bad_amount, 149 out_aborted 150 FROM select_taler_withdrawal( 151 ?, ?, ?, ?, 152 ${optAmount(amount)}, 153 (?,?)::taler_amount, (?,?)::taler_amount, (?,?)::taler_amount 154 ); 155 """ 156 ) { 157 bind(uuid) 158 bind(reservePub) 159 bind("Taler withdrawal $reservePub") 160 bind(exchangePayto.canonical) 161 bind(amount) 162 bind(wireTransferFees) 163 bind(minAmount) 164 bind(maxAmount) 165 one { 166 when { 167 it.getBoolean("out_aborted") -> WithdrawalSelectionResult.AlreadyAborted 168 it.getBoolean("out_balance_insufficient") -> WithdrawalSelectionResult.BalanceInsufficient 169 it.getBoolean("out_bad_amount") -> WithdrawalSelectionResult.BadAmount 170 it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation 171 it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected 172 it.getBoolean("out_amount_differs") -> WithdrawalSelectionResult.AmountDiffers 173 it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.ReservePubReuse 174 it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount 175 it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.AccountIsNotExchange 176 else -> WithdrawalSelectionResult.Success(it.getEnum("out_status")) 177 } 178 } 179 } 180 181 /** Result status of withdrawal operation confirmation */ 182 enum class WithdrawalConfirmationResult { 183 Success, 184 UnknownOperation, 185 BalanceInsufficient, 186 BadAmount, 187 NotSelected, 188 AlreadyAborted, 189 TanRequired, 190 MissingAmount, 191 AmountDiffers, 192 ReservePubReuse 193 } 194 195 /** Confirm withdrawal operation [uuid] */ 196 suspend fun confirm( 197 username: String, 198 uuid: UUID, 199 timestamp: Instant, 200 amount: TalerAmount?, 201 is2fa: Boolean, 202 wireTransferFees: TalerAmount, 203 minAmount: TalerAmount, 204 maxAmount: TalerAmount 205 ): WithdrawalConfirmationResult = db.serializable( 206 """ 207 SELECT 208 out_no_op, 209 out_balance_insufficient, 210 out_bad_amount, 211 out_not_selected, 212 out_aborted, 213 out_tan_required, 214 out_missing_amount, 215 out_amount_differs, 216 out_reserve_pub_reuse 217 FROM confirm_taler_withdrawal( 218 ?,?,?,?,(?,?)::taler_amount,(?,?)::taler_amount,(?,?)::taler_amount, ${optAmount(amount)} 219 ); 220 """ 221 ) { 222 bind(username) 223 bind(uuid) 224 bind(timestamp) 225 bind(is2fa) 226 bind(wireTransferFees) 227 bind(minAmount) 228 bind(maxAmount) 229 bind(amount) 230 one { 231 when { 232 it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation 233 it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient 234 it.getBoolean("out_bad_amount") -> WithdrawalConfirmationResult.BadAmount 235 it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected 236 it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted 237 it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired 238 it.getBoolean("out_missing_amount") -> WithdrawalConfirmationResult.MissingAmount 239 it.getBoolean("out_amount_differs") -> WithdrawalConfirmationResult.AmountDiffers 240 it.getBoolean("out_reserve_pub_reuse") -> WithdrawalConfirmationResult.ReservePubReuse 241 else -> WithdrawalConfirmationResult.Success 242 } 243 } 244 } 245 246 /** Get withdrawal operation [uuid] linked account username */ 247 suspend fun getUsername(uuid: UUID): String? = db.serializable( 248 """ 249 SELECT username 250 FROM taler_withdrawal_operations 251 JOIN bank_accounts ON wallet_bank_account=bank_account_id 252 JOIN customers ON customer_id=owning_customer_id 253 WHERE withdrawal_uuid=? 254 """ 255 ) { 256 bind(uuid) 257 oneOrNull { it.getString(1) } 258 } 259 260 private suspend fun <T> poll( 261 uuid: UUID, 262 params: StatusParams, 263 status: (T) -> WithdrawalStatus, 264 load: suspend () -> T? 265 ): T? { 266 return if (params.polling.timeout_ms > 0) { 267 db.listenWithdrawals(uuid) { flow -> 268 coroutineScope { 269 // Start buffering notification before loading transactions to not miss any 270 val polling = launch { 271 withTimeoutOrNull(params.polling.timeout_ms) { 272 flow.first { it != params.old_state } 273 } 274 } 275 // Initial loading 276 val init = load() 277 // Long polling if there is no operation or its not confirmed 278 if (init?.run { status(this) == params.old_state } != false) { 279 polling.join() 280 load() 281 } else { 282 polling.cancel() 283 init 284 } 285 } 286 } 287 } else { 288 load() 289 } 290 } 291 292 /** Pool public info of operation [uuid] */ 293 suspend fun pollInfo(uuid: UUID, params: StatusParams): WithdrawalPublicInfo? = 294 poll(uuid, params, status = { it.status }) { 295 db.serializable( 296 """ 297 SELECT 298 CASE 299 WHEN confirmation_done THEN 'confirmed' 300 WHEN aborted THEN 'aborted' 301 WHEN selection_done THEN 'selected' 302 ELSE 'pending' 303 END as status 304 ,(amount).val as amount_val 305 ,(amount).frac as amount_frac 306 ,(suggested_amount).val as suggested_amount_val 307 ,(suggested_amount).frac as suggested_amount_frac 308 ,selection_done 309 ,aborted 310 ,confirmation_done 311 ,reserve_pub 312 ,wallet_user.username 313 ,no_amount_to_wallet 314 ,exchange_account.internal_payto as exchange_payto 315 ,exchange_user.name as exchange_name 316 FROM taler_withdrawal_operations 317 JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id 318 JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id 319 LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id 320 LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id 321 WHERE withdrawal_uuid=? 322 """ 323 ) { 324 bind(uuid) 325 oneOrNull { 326 WithdrawalPublicInfo( 327 status = it.getEnum("status"), 328 amount = it.getOptAmount("amount", db.bankCurrency), 329 suggested_amount = it.getOptAmount("suggested_amount", db.bankCurrency), 330 username = it.getString("username"), 331 selected_exchange_account = it.getOptBankPayto("exchange_payto", "exchange_name", db.ctx), 332 selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), 333 no_amount_to_wallet = it.getBoolean("no_amount_to_wallet") 334 ) 335 } 336 } 337 } 338 339 /** Pool public status of operation [uuid] */ 340 suspend fun pollStatus( 341 uuid: UUID, 342 params: StatusParams, 343 wire: WireMethod, 344 maxAmount: TalerAmount 345 ): BankWithdrawalOperationStatus? = 346 poll(uuid, params, status = { it.status }) { 347 db.serializable( 348 """ 349 SELECT 350 CASE 351 WHEN confirmation_done THEN 'confirmed' 352 WHEN aborted THEN 'aborted' 353 WHEN selection_done THEN 'selected' 354 ELSE 'pending' 355 END as status 356 ,(amount).val as amount_val 357 ,(amount).frac as amount_frac 358 ,(suggested_amount).val as suggested_amount_val 359 ,(suggested_amount).frac as suggested_amount_frac 360 ,selection_done 361 ,aborted 362 ,confirmation_done 363 ,wallet_account.internal_payto 364 ,wallet_user.name 365 ,reserve_pub 366 ,exchange_account.internal_payto as exchange_payto 367 ,exchange_user.name as exchange_name 368 ,(max_amount).val as max_amount_val 369 ,(max_amount).frac as max_amount_frac 370 ,no_amount_to_wallet 371 FROM taler_withdrawal_operations 372 JOIN bank_accounts AS wallet_account ON wallet_bank_account=wallet_account.bank_account_id 373 JOIN customers AS wallet_user ON wallet_user.customer_id=wallet_account.owning_customer_id 374 LEFT JOIN bank_accounts AS exchange_account ON exchange_bank_account=exchange_account.bank_account_id 375 LEFT JOIN customers AS exchange_user ON exchange_user.customer_id=exchange_account.owning_customer_id 376 ,account_max_amount(wallet_account.bank_account_id, (?, ?)::taler_amount) AS max_amount 377 WHERE withdrawal_uuid=? 378 """ 379 ) { 380 bind(maxAmount) 381 bind(uuid) 382 oneOrNull { 383 BankWithdrawalOperationStatus( 384 status = it.getEnum("status"), 385 amount = it.getOptAmount("amount", db.bankCurrency), 386 suggested_amount = it.getOptAmount("suggested_amount", db.bankCurrency), 387 max_amount = it.getAmount("max_amount", db.bankCurrency), 388 selection_done = it.getBoolean("selection_done"), 389 transfer_done = it.getBoolean("confirmation_done"), 390 aborted = it.getBoolean("aborted"), 391 sender_wire = it.getBankPayto("internal_payto", "name", db.ctx), 392 confirm_transfer_url = null, 393 suggested_exchange = null, 394 selected_exchange_account = it.getOptBankPayto("exchange_payto", "exchange_name", db.ctx), 395 no_amount_to_wallet = it.getBoolean("no_amount_to_wallet"), 396 selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), 397 wire_types = listOf( 398 when (wire) { 399 WireMethod.IBAN -> "iban" 400 WireMethod.X_TALER_BANK -> "x-taler-bank" 401 } 402 ), 403 currency = db.bankCurrency 404 ) 405 } 406 } 407 } 408 }