/* * This file is part of LibEuFin. * Copyright (C) 2023 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation; either version 3, or * (at your option) any later version. * LibEuFin is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General * Public License for more details. * You should have received a copy of the GNU Affero General Public * License along with LibEuFin; see the file COPYING. If not, see * */ package tech.libeufin.bank.db import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import tech.libeufin.bank.* import tech.libeufin.common.* import java.time.Instant import java.util.* /** Data access logic for withdrawal operations */ class WithdrawalDAO(private val db: Database) { /** Result status of withdrawal operation creation */ enum class WithdrawalCreationResult { Success, UnknownAccount, AccountIsExchange, BalanceInsufficient } /** Create a new withdrawal operation */ suspend fun create( login: String, uuid: UUID, amount: TalerAmount, now: Instant ): WithdrawalCreationResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT out_account_not_found, out_account_is_exchange, out_balance_insufficient FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount, ?); """) stmt.setString(1, login) stmt.setObject(2, uuid) stmt.setLong(3, amount.value) stmt.setInt(4, amount.frac) stmt.setLong(5, now.toDbMicros() ?: throw faultyTimestampByBank()) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("No result from DB procedure create_taler_withdrawal") it.getBoolean("out_account_not_found") -> WithdrawalCreationResult.UnknownAccount it.getBoolean("out_account_is_exchange") -> WithdrawalCreationResult.AccountIsExchange it.getBoolean("out_balance_insufficient") -> WithdrawalCreationResult.BalanceInsufficient else -> WithdrawalCreationResult.Success } } } /** Abort withdrawal operation [uuid] */ suspend fun abort(uuid: UUID): AbortResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT out_no_op, out_already_confirmed FROM abort_taler_withdrawal(?) """) stmt.setObject(1, uuid) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("No result from DB procedure abort_taler_withdrawal") it.getBoolean("out_no_op") -> AbortResult.UnknownOperation it.getBoolean("out_already_confirmed") -> AbortResult.AlreadyConfirmed else -> AbortResult.Success } } } /** Result withdrawal operation selection */ sealed interface WithdrawalSelectionResult { data class Success(val status: WithdrawalStatus): WithdrawalSelectionResult data object UnknownOperation: WithdrawalSelectionResult data object AlreadySelected: WithdrawalSelectionResult data object RequestPubReuse: WithdrawalSelectionResult data object UnknownAccount: WithdrawalSelectionResult data object AccountIsNotExchange: WithdrawalSelectionResult } /** Set details ([exchangePayto] & [reservePub]) for withdrawal operation [uuid] */ suspend fun setDetails( uuid: UUID, exchangePayto: Payto, reservePub: EddsaPublicKey ): WithdrawalSelectionResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT out_no_op, out_already_selected, out_reserve_pub_reuse, out_account_not_found, out_account_is_not_exchange, out_status FROM select_taler_withdrawal(?, ?, ?, ?); """ ) stmt.setObject(1, uuid) stmt.setBytes(2, reservePub.raw) stmt.setString(3, "Taler withdrawal $reservePub") stmt.setString(4, exchangePayto.canonical) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("No result from DB procedure select_taler_withdrawal") it.getBoolean("out_no_op") -> WithdrawalSelectionResult.UnknownOperation it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.AlreadySelected it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RequestPubReuse it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.UnknownAccount it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.AccountIsNotExchange else -> WithdrawalSelectionResult.Success(WithdrawalStatus.valueOf(it.getString("out_status"))) } } } /** Result status of withdrawal operation confirmation */ enum class WithdrawalConfirmationResult { Success, UnknownOperation, UnknownExchange, BalanceInsufficient, NotSelected, AlreadyAborted, TanRequired } /** Confirm withdrawal operation [uuid] */ suspend fun confirm( login: String, uuid: UUID, now: Instant, is2fa: Boolean ): WithdrawalConfirmationResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT out_no_op, out_exchange_not_found, out_balance_insufficient, out_not_selected, out_aborted, out_tan_required FROM confirm_taler_withdrawal(?,?,?,?); """ ) stmt.setString(1, login) stmt.setObject(2, uuid) stmt.setLong(3, now.toDbMicros() ?: throw faultyTimestampByBank()) stmt.setBoolean(4, is2fa) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("No result from DB procedure confirm_taler_withdrawal") it.getBoolean("out_no_op") -> WithdrawalConfirmationResult.UnknownOperation it.getBoolean("out_exchange_not_found") -> WithdrawalConfirmationResult.UnknownExchange it.getBoolean("out_balance_insufficient") -> WithdrawalConfirmationResult.BalanceInsufficient it.getBoolean("out_not_selected") -> WithdrawalConfirmationResult.NotSelected it.getBoolean("out_aborted") -> WithdrawalConfirmationResult.AlreadyAborted it.getBoolean("out_tan_required") -> WithdrawalConfirmationResult.TanRequired else -> WithdrawalConfirmationResult.Success } } } /** Get withdrawal operation [uuid] linked account username */ suspend fun getUsername(uuid: UUID): String? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT login FROM taler_withdrawal_operations JOIN bank_accounts ON wallet_bank_account=bank_account_id JOIN customers ON customer_id=owning_customer_id WHERE withdrawal_uuid=? """) stmt.setObject(1, uuid) stmt.oneOrNull { it.getString(1) } } private suspend fun poll( uuid: UUID, params: StatusParams, status: (T) -> WithdrawalStatus, load: suspend () -> T? ): T? { return if (params.polling.poll_ms > 0) { db.notifWatcher.listenWithdrawals(uuid) { flow -> coroutineScope { // Start buffering notification before loading transactions to not miss any val polling = launch { withTimeoutOrNull(params.polling.poll_ms) { flow.first { it != params.old_state } } } // Initial loading val init = load() // Long polling if there is no operation or its not confirmed if (init?.run { status(this) == params.old_state } != false) { polling.join() load() } else { polling.cancel() init } } } } else { load() } } /** Pool public info of operation [uuid] */ suspend fun pollInfo(uuid: UUID, params: StatusParams): WithdrawalPublicInfo? = poll(uuid, params, status = { it.status }) { db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT CASE WHEN confirmation_done THEN 'confirmed' WHEN aborted THEN 'aborted' WHEN selection_done THEN 'selected' ELSE 'pending' END as status ,(amount).val as amount_val ,(amount).frac as amount_frac ,selection_done ,aborted ,confirmation_done ,reserve_pub ,selected_exchange_payto ,login FROM taler_withdrawal_operations JOIN bank_accounts ON wallet_bank_account=bank_account_id JOIN customers ON customer_id=owning_customer_id WHERE withdrawal_uuid=? """) stmt.setObject(1, uuid) stmt.oneOrNull { WithdrawalPublicInfo( status = WithdrawalStatus.valueOf(it.getString("status")), amount = it.getAmount("amount", db.bankCurrency), username = it.getString("login"), selected_exchange_account = it.getString("selected_exchange_payto"), selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey) ) } } } /** Pool public status of operation [uuid] */ suspend fun pollStatus(uuid: UUID, params: StatusParams, wire: WireMethod): BankWithdrawalOperationStatus? = poll(uuid, params, status = { it.status }) { db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT CASE WHEN confirmation_done THEN 'confirmed' WHEN aborted THEN 'aborted' WHEN selection_done THEN 'selected' ELSE 'pending' END as status ,(amount).val as amount_val ,(amount).frac as amount_frac ,selection_done ,aborted ,confirmation_done ,internal_payto_uri ,reserve_pub ,selected_exchange_payto FROM taler_withdrawal_operations JOIN bank_accounts ON (wallet_bank_account=bank_account_id) WHERE withdrawal_uuid=? """) stmt.setObject(1, uuid) stmt.oneOrNull { BankWithdrawalOperationStatus( status = WithdrawalStatus.valueOf(it.getString("status")), amount = it.getAmount("amount", db.bankCurrency), selection_done = it.getBoolean("selection_done"), transfer_done = it.getBoolean("confirmation_done"), aborted = it.getBoolean("aborted"), sender_wire = it.getString("internal_payto_uri"), confirm_transfer_url = null, suggested_exchange = null, selected_exchange_account = it.getString("selected_exchange_payto"), selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), wire_types = listOf( when (wire) { WireMethod.IBAN -> "iban" WireMethod.X_TALER_BANK -> "x-taler-bank" } ) ) } } } }