/*
 * This file is part of LibEuFin.
 * Copyright (C) 2023, 2025, 2024, 2026 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
 * <http://www.gnu.org/licenses/>
 */

package tech.libeufin.bank.db

import tech.libeufin.bank.*
import tech.libeufin.common.*
import tech.libeufin.common.crypto.*
import tech.libeufin.common.db.*
import java.time.Instant
import java.sql.SQLException
import org.postgresql.util.PSQLState

/** Data access logic for accounts */
class AccountDAO(private val db: Database) {
    /** Result status of account creation */
    sealed interface AccountCreationResult {
        data class Success(val payto: String): AccountCreationResult
        data object UsernameReuse: AccountCreationResult
        data object PayToReuse: AccountCreationResult
        data object UnknownConversionClass: AccountCreationResult
        data object BonusBalanceInsufficient: AccountCreationResult
    }

    /** Create new account */
    suspend fun create(
        username: String,
        password: String,
        name: String,
        email: String?,
        phone: String?,
        cashoutPayto: IbanPayto?,
        internalPayto: Payto,
        isPublic: Boolean,
        isTalerExchange: Boolean,
        maxDebt: TalerAmount,
        bonus: TalerAmount,
        tanChannels: Set<TanChannel>,
        // Whether to check [internalPaytoUri] for idempotency
        checkPaytoIdempotent: Boolean,
        pwCrypto: PwCrypto,
        conversionRateClassId: Long?
    ): AccountCreationResult = db.serializableTransaction { conn ->
        val timestamp = Instant.now()
        val idempotent = conn.withStatement("""
            SELECT password_hash, name=?
                AND email IS NOT DISTINCT FROM ?
                AND phone IS NOT DISTINCT FROM ?
                AND cashout_payto IS NOT DISTINCT FROM ?
                AND tan_channels = sort_uniq(?::tan_enum[])
                AND (NOT ? OR internal_payto=?)
                AND is_public=?
                AND is_taler_exchange=?
                AND max_debt=(?,?)::taler_amount
                AND conversion_rate_class_id IS NOT DISTINCT FROM ?
                ,internal_payto, name
            FROM customers 
                JOIN bank_accounts
                    ON customer_id=owning_customer_id
            WHERE username=?
        """) {
            bind(name)
            bind(email)
            bind(phone)
            bind(cashoutPayto?.simple())
            bind(tanChannels.toTypedArray())
            bind(checkPaytoIdempotent)
            bind(internalPayto.canonical)
            bind(isPublic)
            bind(isTalerExchange)
            bind(maxDebt)
            bind(conversionRateClassId)
            bind(username)
            oneOrNull { 
                Pair(
                    pwCrypto.checkpw(password, it.getString(1)).match && it.getBoolean(2),
                    it.getBankPayto("internal_payto", "name", db.ctx)
                )
            } 
        }
        
        if (idempotent != null) {
            if (idempotent.first) {
                AccountCreationResult.Success(idempotent.second)
            } else {
                AccountCreationResult.UsernameReuse
            }
        } else {
            if (internalPayto is IbanPayto)
                conn.withStatement ("""
                    INSERT INTO iban_history(
                        iban
                        ,creation_time
                    ) VALUES (?, ?)
                """) {
                    bind(internalPayto.iban.value)
                    bind(timestamp)
                    if (!executeUpdateViolation()) {
                        conn.rollback()
                        return@serializableTransaction AccountCreationResult.PayToReuse
                    }
                }

            val customerId = conn.withStatement("""
                INSERT INTO customers (
                    username
                    ,password_hash
                    ,name
                    ,email
                    ,phone
                    ,cashout_payto
                    ,tan_channels
                ) VALUES (?, ?, ?, ?, ?, ?, sort_uniq(?::tan_enum[]))
                    RETURNING customer_id
            """
            ) {
                bind(username)
                bind(pwCrypto.hashpw(password))
                bind(name)
                bind(email)
                bind(phone)
                bind(cashoutPayto?.simple())
                bind(tanChannels.toTypedArray())
                one { it.getLong("customer_id") }
            }
        
            conn.withStatement("""
                INSERT INTO bank_accounts(
                    internal_payto
                    ,owning_customer_id
                    ,is_public
                    ,is_taler_exchange
                    ,max_debt
                    ,conversion_rate_class_id
                ) VALUES (?, ?, ?, ?, (?, ?)::taler_amount, ?)
            """) {
                bind(internalPayto.canonical)
                bind(customerId)
                bind(isPublic)
                bind(isTalerExchange)
                bind(maxDebt)
                bind(conversionRateClassId)
                try {
                    executeUpdate()
                } catch (e: SQLException) {
                    logger.debug(e.message)
                    if (e.sqlState == PSQLState.UNIQUE_VIOLATION.state) {
                        conn.rollback()
                        return@serializableTransaction AccountCreationResult.PayToReuse
                    } else if (e.sqlState == PSQLState.FOREIGN_KEY_VIOLATION.state) {
                        conn.rollback()
                        return@serializableTransaction AccountCreationResult.UnknownConversionClass
                    } 
                    throw e
                }
            }
            
            if (bonus.value != 0L || bonus.frac != 0) {
                conn.withStatement("""
                    SELECT out_balance_insufficient
                    FROM bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL,NULL,NULL,NULL, NULL, NULL, NULL)
                """) {
                    bind(internalPayto.canonical)
                    bind(bonus)
                    bind(timestamp)
                    one {
                        when {
                            it.getBoolean("out_balance_insufficient") -> {
                                conn.rollback()
                                AccountCreationResult.BonusBalanceInsufficient
                            }
                            else -> AccountCreationResult.Success(internalPayto.bank(name, db.ctx))
                        }
                    }
                }
            } else {
                AccountCreationResult.Success(internalPayto.bank(name, db.ctx))
            }
        }
    }

    /** Result status of account deletion */
    enum class AccountDeletionResult {
        Success,
        UnknownAccount,
        BalanceNotZero,
        TanRequired
    }

    /** Delete account [username] */
    suspend fun delete(
        username: String, 
        is2fa: Boolean
    ): AccountDeletionResult = db.serializable(
        """
        SELECT
            out_not_found,
            out_balance_not_zero,
            out_tan_required
        FROM account_delete(?,?,?)
        """
    ) {
        bind(username)
        bind(Instant.now())
        bind(is2fa)
        one {
            when {
                it.getBoolean("out_not_found") -> AccountDeletionResult.UnknownAccount
                it.getBoolean("out_balance_not_zero") -> AccountDeletionResult.BalanceNotZero
                it.getBoolean("out_tan_required") -> AccountDeletionResult.TanRequired
                else -> AccountDeletionResult.Success
            }
        }
    }

    /** Result status of customer account patch */
    sealed interface AccountPatchResult {
        data object UnknownAccount: AccountPatchResult
        data object NonAdminName: AccountPatchResult
        data object NonAdminCashout: AccountPatchResult
        data object NonAdminDebtLimit: AccountPatchResult
        data object NonAdminConversionRateClass: AccountPatchResult
        data object UnknownConversionClass: AccountPatchResult
        data object MissingTanInfo: AccountPatchResult
        data class Challenges(val validations: Tans): AccountPatchResult
        data object Success: AccountPatchResult
    }

    /** Change account [username] information */
    suspend fun reconfig(
        username: String,
        req: AccountReconfiguration,
        isAdmin: Boolean,
        is2fa: Boolean,
        allowEditName: Boolean,
        allowEditCashout: Boolean
    ): AccountPatchResult = db.serializableTransaction { conn ->
        val name = req.name
        val cashoutPayto = req.cashout_payto_uri
        val email = req.contact_data?.email ?: Option.None
        val phone = req.contact_data?.phone ?: Option.None
        val tan_channels = req.channels
        val isPublic = req.is_public
        val debtLimit = req.debit_threshold
        val conversionRateClassId = req.conversion_rate_class_id
        val channels = req.channels.get()

        val checkName = !isAdmin && !allowEditName && name != null
        val checkCashout = !isAdmin && !allowEditCashout && cashoutPayto.isSome()
        val checkDebtLimit = !isAdmin && debtLimit != null
        val checkConversionRateClass = !isAdmin && conversionRateClassId.isSome()

        data class CurrentAccount(
            val id: Long,
            override val channels: Set<TanChannel>,
            override val email: String?,
            override val phone: String?,
            val name: String,
            val cashoutPayTo: String?,
            val debtLimit: TalerAmount,
            val conversionRateClassId: Long?
        ): TanInfo

        // Get user ID and current data
        val curr = conn.withStatement("""
            SELECT 
                customer_id, tan_channels, phone, email, name, cashout_payto
                ,(max_debt).val AS max_debt_val
                ,(max_debt).frac AS max_debt_frac
                ,conversion_rate_class_id
            FROM customers
                JOIN bank_accounts 
                ON customer_id=owning_customer_id
            WHERE username=? AND deleted_at IS NULL
        """) {
            bind(username)
            oneOrNull {
                CurrentAccount(
                    id = it.getLong("customer_id"),
                    channels = it.getEnumSet<TanChannel>("tan_channels"),
                    phone = it.getString("phone"),
                    email = it.getString("email"),
                    name = it.getString("name"),
                    cashoutPayTo = it.getOptIbanPayto("cashout_payto")?.simple(),
                    debtLimit = it.getAmount("max_debt", db.bankCurrency),
                    conversionRateClassId = it.getOptLong("conversion_rate_class_id"),
                )
            } ?: return@serializableTransaction AccountPatchResult.UnknownAccount
        }

        // Check tan info
        val validations = req.requiredValidation(curr)

        // Check performed 2fa check
        if (!isAdmin && !is2fa) {
            // Check if mfa is required
            if (curr.channels.isNotEmpty()) {
                val tans = curr.mfa;
                if (tans.size == 1) {
                    // Perform mfa and validation at the same time
                    return@serializableTransaction AccountPatchResult.Challenges(validations + tans)
                } else {
                    return@serializableTransaction AccountPatchResult.Challenges(emptyList())
                }
            }

            // Check if validation is required
            if (validations.isNotEmpty()) {
                return@serializableTransaction AccountPatchResult.Challenges(validations)
            }
        }
        
      
        // Cashout payto without a receiver-name
        val simpleCashoutPayto = cashoutPayto.get()?.simple()

        // Check reconfig rights
        if (checkName && name != curr.name) 
            return@serializableTransaction AccountPatchResult.NonAdminName
        if (checkCashout && simpleCashoutPayto != curr.cashoutPayTo) 
            return@serializableTransaction AccountPatchResult.NonAdminCashout
        if (checkDebtLimit && debtLimit != curr.debtLimit)
            return@serializableTransaction AccountPatchResult.NonAdminDebtLimit
        if (checkConversionRateClass && conversionRateClassId.get() != curr.conversionRateClassId)
            return@serializableTransaction AccountPatchResult.NonAdminConversionRateClass

        try {
            // Update bank info
            conn.dynamicUpdate(
                "bank_accounts",
                sequence {
                    if (isPublic != null) yield("is_public=?")
                    if (debtLimit != null) yield("max_debt=(?, ?)::taler_amount")
                    conversionRateClassId.some { yield("conversion_rate_class_id=?") }
                },
                "WHERE owning_customer_id = ?"
            ) {
                isPublic?.let { bind(it) }
                debtLimit?.let { bind(it) }
                conversionRateClassId?.some { bind(it) }
                bind(curr.id)
            }
        } catch (e: SQLException) {
            logger.debug(e.message)
            if (e.sqlState == PSQLState.FOREIGN_KEY_VIOLATION.state) {
                conn.rollback()
                return@serializableTransaction AccountPatchResult.UnknownConversionClass
            } 
            throw e
        }

        // Update customer info
        conn.dynamicUpdate(
            "customers",
            sequence {
                cashoutPayto.some { yield("cashout_payto=?") }
                phone.some { yield("phone=?") }
                email.some { yield("email=?") }
                tan_channels.some { yield("tan_channels=sort_uniq(?::tan_enum[])") }
                name?.let { yield("name=?") }
            },
            "WHERE customer_id = ?"
        ) {
            cashoutPayto.some { bind(simpleCashoutPayto) }
            phone.some { bind(it) }
            email.some { bind(it) }
            tan_channels.some { bind(it.toTypedArray()) }
            name?.let { bind(it) }
            bind(curr.id)
        }

        // Invalidate current challenges
        if (validations.isNotEmpty()) {
            conn.withStatement("UPDATE tan_challenges SET expiration_date=0 WHERE customer=?") {
                bind(curr.id)
                executeUpdate()
            }
        }

        AccountPatchResult.Success
    }


    /** Result status of customer account auth patch */
    enum class AccountPatchAuthResult {
        UnknownAccount,
        OldPasswordMismatch,
        TanRequired,
        Success
    }

    /** Change account [username] password to [newPw] if current match [oldPw] */
    suspend fun reconfigPassword(
        username: String, 
        newPw: Password, 
        oldPw: String?,
        is2fa: Boolean,
        pwCrypto: PwCrypto
    ): AccountPatchAuthResult = db.serializableTransaction { conn ->
        val (customerId, currentPwh, tanRequired) = conn.withStatement("""
            SELECT customer_id, password_hash, NOT ? AND cardinality(tan_channels) > 0 
            FROM customers WHERE username=? AND deleted_at IS NULL
        """) {
            bind(is2fa)
            bind(username)
            oneOrNull { 
                Triple(it.getLong(1), it.getString(2), it.getBoolean(3))
            } ?: return@serializableTransaction AccountPatchAuthResult.UnknownAccount
        }
        if (oldPw != null && !pwCrypto.checkpw(oldPw, currentPwh).match) {
            AccountPatchAuthResult.OldPasswordMismatch
        } else if (tanRequired) {
            AccountPatchAuthResult.TanRequired
        } else {
            val newPwh = pwCrypto.hashpw(newPw.pw)
            conn.withStatement("UPDATE customers SET password_hash=?, token_creation_counter=0 WHERE customer_id=?") {
                bind(newPwh)
                bind(customerId)
                executeUpdate()
            }
          
            AccountPatchAuthResult.Success
        }
    }

    /** Result status of customer account password check */
    sealed interface CheckPasswordResult {
        data object UnknownAccount: CheckPasswordResult
        data object PasswordMismatch: CheckPasswordResult
        data object Locked: CheckPasswordResult
        data class Success(val info: BankInfo): CheckPasswordResult
    }

    /** Check password of account [username] against [pw], rehashing it if outdated and returning info  */
    suspend fun checkPassword(username: String, pw: String, pwCrypto: PwCrypto): CheckPasswordResult {
        // Get user current password hash
        val res = db.serializable(
            """
            SELECT
              username,
              password_hash,
              token_creation_counter,
              bank_account_id,
              internal_payto,
              is_taler_exchange,
              name,
              tan_channels,
              email,
              phone
            FROM bank_accounts
              JOIN customers ON customer_id=owning_customer_id
            WHERE username=? AND deleted_at IS NULL
            """
        ) { 
            bind(username)
            oneOrNull {
                val info = BankInfo(
                    username = it.getString("username"),
                    payto = it.getBankPayto("internal_payto", "name", db.ctx),
                    bankAccountId = it.getLong("bank_account_id"),
                    isTalerExchange = it.getBoolean("is_taler_exchange"),
                    channels = it.getEnumSet<TanChannel>("tan_channels"),
                    phone = it.getString("phone"),
                    email = it.getString("email")
                )
                Triple(info, it.getString("password_hash"), it.getInt("token_creation_counter"))
            }
        }
        if (res == null) return CheckPasswordResult.UnknownAccount
        val (info, currentPwh, tokenCreationCounter) = res

        // Check locked
        if (tokenCreationCounter >= MAX_TOKEN_CREATION_ATTEMPTS) return CheckPasswordResult.Locked

        // Check password
        val check = pwCrypto.checkpw(pw, currentPwh)
        if (!check.match) return CheckPasswordResult.PasswordMismatch

        // Reshah if outdated
        if (check.outdated) {
            val newPwh = pwCrypto.hashpw(pw)
            db.serializable(
                "UPDATE customers SET password_hash=? where username=? AND password_hash=?"
            ) { 
                bind(newPwh)
                bind(username)
                bind(currentPwh)
                executeUpdate()
            }
        }

        return CheckPasswordResult.Success(info)
    }

    /** Get bank info of account [username] */
    suspend fun bankInfo(username: String): BankInfo? = db.serializable(
        """
        SELECT
            bank_account_id,
            internal_payto,
            name,
            is_taler_exchange,
            tan_channels,
            email,
            phone
        FROM bank_accounts
            JOIN customers ON customer_id=owning_customer_id
        WHERE username=?
        """
    ) {
        bind(username)
        oneOrNull {
            BankInfo(
                username = username,
                payto = it.getBankPayto("internal_payto", "name", db.ctx),
                bankAccountId = it.getLong("bank_account_id"),
                isTalerExchange = it.getBoolean("is_taler_exchange"),
                channels = it.getEnumSet<TanChannel>("tan_channels"),
                phone = it.getString("phone"),
                email = it.getString("email")
            )
        }
    }

    /** Check bank info of account [payto] */
    suspend fun checkInfo(payto: IbanPayto): AccountInfo? = db.serializable(
        """
        SELECT FROM bank_accounts WHERE internal_payto=?
        """
    ) {
        bind(payto.canonical)
        oneOrNull {
            AccountInfo()
        }
    }

    /** Get data of account [username] */
    suspend fun get(username: String): AccountData? = db.serializable(
        """
        SELECT
            customers.name
            ,email
            ,phone
            ,tan_channels
            ,cashout_payto
            ,internal_payto
            ,(balance).val AS balance_val
            ,(balance).frac AS balance_frac
            ,has_debt
            ,(max_debt).val AS max_debt_val
            ,(max_debt).frac AS max_debt_frac
            ,is_public
            ,is_taler_exchange
            ,CASE 
                WHEN deleted_at IS NOT NULL THEN 'deleted'
                WHEN token_creation_counter > ? THEN 'locked'
                ELSE 'active'
            END as status,
            conversion_rate_class_id,
            (cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac,
            (cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac,
            (cashin_tiny_amount).val as cashin_tiny_amount_val, (cashin_tiny_amount).frac as cashin_tiny_amount_frac,
            (cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac,
            cashin_rounding_mode,
            (cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac,
            (cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac,
            (cashout_tiny_amount).val as cashout_tiny_amount_val, (cashout_tiny_amount).frac as cashout_tiny_amount_frac,
            (cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac,
            cashout_rounding_mode
        FROM customers 
            JOIN bank_accounts ON customer_id=owning_customer_id
            CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id)
        WHERE username=?
        """
    ) {
        bind(MAX_TOKEN_CREATION_ATTEMPTS)
        bind(username)
        oneOrNull {            
            val name = it.getString("name")
            val status: AccountStatus = it.getEnum("status")
            val isTalerExchange = it.getBoolean("is_taler_exchange")
            val channels: Set<TanChannel> = it.getEnumSet("tan_channels") 
            AccountData(
                name = name,
                contact_data = ChallengeContactData(
                    email = Option.Some(it.getString("email")),
                    phone = Option.Some(it.getString("phone"))
                ),
                tan_channel = channels.firstOrNull(),
                tan_channels = channels,
                cashout_payto_uri = it.getOptIbanPayto("cashout_payto")?.full(name),
                payto_uri = it.getBankPayto("internal_payto", "name", db.ctx),
                balance = Balance(
                    amount = it.getAmount("balance", db.bankCurrency),
                    credit_debit_indicator =
                        if (it.getBoolean("has_debt")) {
                            CreditDebitInfo.debit
                        } else {
                            CreditDebitInfo.credit
                        }
                ),
                debit_threshold = it.getAmount("max_debt", db.bankCurrency),
                is_public = it.getBoolean("is_public"),
                is_taler_exchange = isTalerExchange,
                status = status,
                is_locked = status == AccountStatus.locked,
                conversion_rate = ConversionDAO.userRate(db, it, username,isTalerExchange),
                conversion_rate_class_id = it.getOptLong("conversion_rate_class_id")
            )
        }
    }

    /** Get a page of all public accounts */
    suspend fun pagePublic(params: AccountParams): List<PublicAccount> 
        = db.page(
            params.page,
            "bank_account_id",
            """
            SELECT
              (balance).val AS balance_val,
              (balance).frac AS balance_frac,
              has_debt,
              internal_payto,
              username,
              is_taler_exchange,
              name,
              bank_account_id
              FROM bank_accounts JOIN customers
                ON owning_customer_id = customer_id 
              WHERE is_public=true AND 
                ${if (params.usernameFilter != null) "name ILIKE ? AND" else ""} 
                deleted_at IS NULL AND
            """,
            {
                if (params.usernameFilter != null) {
                    bind(params.usernameFilter)
                }
            }
        ) {
            PublicAccount(
                username = it.getString("username"),
                row_id = it.getLong("bank_account_id"),
                payto_uri = it.getBankPayto("internal_payto", "name", db.ctx),
                balance = Balance(
                    amount = it.getAmount("balance", db.bankCurrency),
                    credit_debit_indicator = if (it.getBoolean("has_debt")) {
                        CreditDebitInfo.debit 
                    } else {
                        CreditDebitInfo.credit
                    }
                ),
                is_taler_exchange = it.getBoolean("is_taler_exchange")
            )
        }

    /** Get a page of accounts */
    suspend fun pageAdmin(params: AccountParams): List<AccountMinimalData>
        = db.page(
            params.page,
            "bank_account_id",
            """
            SELECT
            username
            ,name
            ,(balance).val AS balance_val
            ,(balance).frac AS balance_frac
            ,has_debt AS balance_has_debt
            ,(max_debt).val as max_debt_val
            ,(max_debt).frac as max_debt_frac
            ,is_public
            ,is_taler_exchange
            ,internal_payto
            ,bank_account_id
            ,CASE 
                WHEN deleted_at IS NOT NULL THEN 'deleted'
                WHEN token_creation_counter > ? THEN 'locked'
                ELSE 'active'
            END as status,
            conversion_rate_class_id,
            (cashin_ratio).val as cashin_ratio_val, (cashin_ratio).frac as cashin_ratio_frac,
            (cashin_fee).val as cashin_fee_val, (cashin_fee).frac as cashin_fee_frac,
            (cashin_tiny_amount).val as cashin_tiny_amount_val, (cashin_tiny_amount).frac as cashin_tiny_amount_frac,
            (cashin_min_amount).val as cashin_min_amount_val, (cashin_min_amount).frac as cashin_min_amount_frac,
            cashin_rounding_mode,
            (cashout_ratio).val as cashout_ratio_val, (cashout_ratio).frac as cashout_ratio_frac,
            (cashout_fee).val as cashout_fee_val, (cashout_fee).frac as cashout_fee_frac,
            (cashout_tiny_amount).val as cashout_tiny_amount_val, (cashout_tiny_amount).frac as cashout_tiny_amount_frac,
            (cashout_min_amount).val as cashout_min_amount_val, (cashout_min_amount).frac as cashout_min_amount_frac,
            cashout_rounding_mode
            FROM bank_accounts 
                JOIN customers ON owning_customer_id = customer_id
                CROSS JOIN LATERAL get_conversion_class_rate(conversion_rate_class_id)
            WHERE
                ${if (params.usernameFilter != null) "name ILIKE ? AND" else ""}
                ${when (params.conversionRateClassId) {
                    null -> ""
                    0L -> "conversion_rate_class_id IS NULL AND"
                    else -> "conversion_rate_class_id=? AND"
                }}
            """,
            {
                bind(MAX_TOKEN_CREATION_ATTEMPTS)
                if (params.usernameFilter != null) {
                    bind(params.usernameFilter)
                }
                if (params.conversionRateClassId != null && params.conversionRateClassId != 0L) {
                    bind(params.conversionRateClassId)
                }
            }
        ) {
            val status: AccountStatus = it.getEnum("status")
            val isTalerExchange = it.getBoolean("is_taler_exchange")
            val username = it.getString("username")
            AccountMinimalData(
                username = username,
                row_id = it.getLong("bank_account_id"),
                name = it.getString("name"),
                balance = Balance(
                    amount = it.getAmount("balance", db.bankCurrency),
                    credit_debit_indicator = if (it.getBoolean("balance_has_debt")) {
                        CreditDebitInfo.debit
                    } else {
                        CreditDebitInfo.credit
                    }
                ),
                debit_threshold = it.getAmount("max_debt", db.bankCurrency),
                is_public = it.getBoolean("is_public"),
                is_taler_exchange = isTalerExchange,
                payto_uri = it.getBankPayto("internal_payto", "name", db.ctx),
                status = status,
                is_locked = status == AccountStatus.locked,
                conversion_rate = ConversionDAO.userRate(db, it, username, isTalerExchange),
                conversion_rate_class_id = it.getOptLong("conversion_rate_class_id")
            )
        }
}

/** List all new tan channels that need to be validated */
fun AccountReconfiguration.requiredValidation(current: TanInfo): Tans {
    // Tan channels are either the new ones or the current one
    val channels = this.channels.get() ?: current.channels

    val validated = current.mfa

    return channels.mapNotNull { channel -> 
        // Info are either the new one or the current ones
        val info = when (channel) {
            TanChannel.sms -> this.contact_data?.phone?.get() ?: current.phone
            TanChannel.email -> this.contact_data?.email?.get() ?: current.email
        }
        if (info == null) {
            throw conflict(
                "missing info for tan channel $channel",
                TalerErrorCode.BANK_MISSING_TAN_INFO
            )
        }
        val tan = Pair(channel, info)
        // Check if tan is already used and therefore already validated
        if (validated.contains(tan)) null else tan
    }
}
