/*
 * This file is part of LibEuFin.
 * Copyright (C) 2023, 2024, 2025, 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/>
 */

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.JsonElement
import org.junit.Test
import tech.libeufin.bank.*
import tech.libeufin.bank.auth.TOKEN_PREFIX
import tech.libeufin.common.*
import tech.libeufin.common.crypto.CryptoUtil
import tech.libeufin.common.db.*
import tech.libeufin.common.test.*
import java.time.Duration
import java.time.Instant
import java.util.*
import kotlin.test.*

class CoreBankSecurityTest {
    @Test
    fun passwordUpdate() = bankSetup { db ->
        suspend fun currentHash(): String {
            return db.serializable(
                "SELECT password_hash FROM customers WHERE username='customer'"
            ) {
                one {
                    it.getString(1)
                }
            }
        }

        // Set outdated hash
        val password = "customer-password"
        val pwh = CryptoUtil.hashStringSHA256(password).encodeBase64()
        val hash = "sha256\$$pwh"
        db.serializable(
            "UPDATE customers SET password_hash=? WHERE username='customer'"
        ) {
            bind(hash)
            executeUpdate()
        }
        assertEquals(hash, currentHash())

        // Check hash is updated
        client.getA("/accounts/customer").assertOk()
        val newHash = currentHash()
        assert(hash != newHash)

        // Check hash stay the same
        client.getA("/accounts/customer").assertOk()
        assertEquals(newHash, currentHash())
    }
}

class CoreBankConfigTest {
    // GET /config
    @Test
    fun config() = bankSetup { 
        client.get("/config").assertOk()
    }

    // GET /monitor
    @Test
    fun monitor() = bankSetup { 
        authRoutine(HttpMethod.Get, "/monitor", requireAdmin = true)
        // Check OK
        client.getAdmin("/monitor?timeframe=day&which=25").assertOk()
        client.getAdmin("/monitor?timeframe=day=which=25").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)
    }
}

class CoreBankTokenApiTest {
    // POST /accounts/USERNAME/token
    @Test
    fun post() = bankSetup { db -> 
        authRoutine(HttpMethod.Post, "/accounts/merchant/token")

        // Unknown account
        client.post("/accounts/merchant/token") {
            basicAuth("unknown", "password")
        }.assertUnauthorized()

        // Wrong password
        client.post("/accounts/merchant/token") {
            basicAuth("merchant", "wrong-password")
        }.assertUnauthorized()

        // Wrong account
        client.post("/accounts/merchant/token") {
            basicAuth("exchange", "merchant-password")
        }.assertUnauthorized()

        // New default token
        client.postPw("/accounts/merchant/token") {
            json { "scope" to "readonly" }
        }.assertOkJson<TokenSuccessResponse> {
            // Checking that the token lifetime defaulted to 24 hours.
            val token = db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)), Instant.now())
            val lifeTime = Duration.between(token!!.creationTime, token.expirationTime)
            assertEquals(Duration.ofDays(1), lifeTime)
        }

        // Check default duration
        client.postPw("/accounts/merchant/token") {
            json { "scope" to "readonly" }
        }.assertOkJson<TokenSuccessResponse> {
            // Checking that the token lifetime defaulted to 24 hours.
            val token = db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)), Instant.now())
            val lifeTime = Duration.between(token!!.creationTime, token.expirationTime)
            assertEquals(Duration.ofDays(1), lifeTime)
        }

        // Check valid refresh scope
        for ((fromScope, toScope) in listOf(
            "readwrite" to "readwrite",
            "readonly" to "readonly",
            "revenue" to "revenue",
            "readwrite" to "readonly",
            "readwrite" to "revenue",
            "readonly" to "revenue",
        )) {
            client.postPw("/accounts/merchant/token") {
                json { 
                    "scope" to fromScope
                    "refreshable" to true
                }
            }.assertOkJson<TokenSuccessResponse> {
                val token = it.access_token
                client.post("/accounts/merchant/token") {
                    headers[HttpHeaders.Authorization] = "Bearer $token"
                    json { "scope" to toScope }
                }.assertOk()
            }
        }

        // Check invalid refresh scope
        for ((fromScope, toScope) in listOf(
            "readonly" to "readwrite",
            "revenue" to "readonly",
            "revenue" to "readwrite"
        )) {
            client.postPw("/accounts/merchant/token") {
                json { 
                    "scope" to fromScope
                    "refreshable" to true
                }
            }.assertOkJson<TokenSuccessResponse> {
                val token = it.access_token
                client.post("/accounts/merchant/token") {
                    headers[HttpHeaders.Authorization] = "Bearer $token"
                    json { "scope" to toScope }
                }.assertForbidden(TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT)
            }
        }

        // Check no refreshable
        client.postPw("/accounts/merchant/token") {
            json { 
                "scope" to "readonly"
            }
        }.assertOkJson<TokenSuccessResponse> {
            val token = it.access_token
            client.post("/accounts/merchant/token") {
                headers[HttpHeaders.Authorization] = "Bearer $token"
                json { "scope" to "readonly" }
            }.assertForbidden(TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT)
        }
        
        // Check 'forever' case.
        client.postPw("/accounts/merchant/token") {
            json { 
                "scope" to "readonly"
                "duration" to obj {
                    "d_us" to "forever"
                }
            }
        }.assertOkJson<TokenSuccessResponse> {
            assertEquals(Instant.MAX, it.expiration.instant)
        }

        // Check too big or invalid durations
        client.postPw("/accounts/merchant/token") {
            json { 
                "scope" to "readonly"
                "duration" to obj {
                    "d_us" to "invalid"
                }
            }
        }.assertBadRequest()
        client.postPw("/accounts/merchant/token") {
            json { 
                "scope" to "readonly"
                "duration" to obj {
                    "d_us" to Long.MAX_VALUE
                }
            }
        }.assertBadRequest()
        client.postPw("/accounts/merchant/token") {
            json { 
                "scope" to "readonly"
                "duration" to obj {
                    "d_us" to -1
                }
            }
        }.assertBadRequest()
    }

    @Test
    fun post2FA() = bankSetup { db -> 
        // Setup a known phone 2FA
        client.patchA("/accounts/merchant") {
            json {
                "contact_data" to obj {
                    "phone" to "+12345"
                }
                "tan_channel" to "sms"
            }
        }.assertChallenge().assertNoContent()

        // Check creating a token requires to solve an unauthenticated challenge
        val challenge = client.postPw("/accounts/merchant/token") {
            json { "scope" to "readonly" }
        }.assertAcceptedJson<ChallengeResponse>().challenges[0]
        client.post("/accounts/merchant/challenge/${challenge.challenge_id}")
            .assertOk()
        assertEquals("REDACTED", challenge.tan_info) // Check phone number is hidden
        val code = tanCode("+12345")
        client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") {
            json { "tan" to code }
        }.assertNoContent()
        client.postPw("/accounts/merchant/token") {
            headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}"
            json { "scope" to "readonly" }
        }.assertOkJson<TokenSuccessResponse>()
    }

    @Test
    fun locked() = bankSetup { db -> 
        // Setup a known phone 2FA
        client.patchA("/accounts/merchant") {
            json {
                "contact_data" to obj {
                    "phone" to "+12345"
                }
                "tan_channel" to "sms"
            }
        }.assertChallenge().assertNoContent()

        suspend fun blockAccount() {
            var counter = MAX_TOKEN_CREATION_ATTEMPTS + 1
            while (counter > 0) {
                val challenge = client.postPw("/accounts/merchant/token") {
                    json { "scope" to "readonly" }
                }.assertAcceptedJson<ChallengeResponse>().challenges[0]
                client.post("/accounts/merchant/challenge/${challenge.challenge_id}")
                    .assertOk()
                while (counter > 0) {
                    val error = client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm"){
                        json { "tan" to "bad code" } 
                    }.json<TalerError>()
                    counter -= 1
                    when (error.code) {
                        TalerErrorCode.BANK_TAN_CHALLENGE_FAILED.code -> continue
                        TalerErrorCode.BANK_TAN_RATE_LIMITED.code, TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED.code -> break
                        else -> throw Exception("$error")
                    }
                }
            }
            client.postPw("/accounts/merchant/token") {
                json { "scope" to "readonly" }
            }.assertForbidden(TalerErrorCode.BANK_ACCOUNT_LOCKED)
        }

        blockAccount()

        // Check token still works
        client.getA("/accounts/merchant").assertOkJson<AccountData> {
            assertTrue(it.is_locked)
        }

        // Check admin can unlock
        client.patchAdmin("/accounts/merchant/auth") {
            json {
                "new_password" to "merchant-password"
            }
        }.assertNoContent()
        client.getA("/accounts/merchant").assertOkJson<AccountData> {
            assertFalse(it.is_locked)
        }
        blockAccount()

        // Check token can unlock
        client.patchA("/accounts/merchant/auth") {
            json {
                "old_password" to "merchant-password"
                "new_password" to "merchant-password"
            }
        }.assertChallenge().assertNoContent()
        client.getA("/accounts/merchant").assertOkJson<AccountData> {
            assertFalse(it.is_locked)
        }
    }

    // DELETE /accounts/USERNAME/token
    @Test
    fun delete() = bankSetup { 
        val token = client.postPw("/accounts/merchant/token") {
            json { "scope" to "readonly" }
        }.assertOkJson<TokenSuccessResponse>().access_token
        // Check OK
        client.delete("/accounts/merchant/token") {
            headers[HttpHeaders.Authorization] = "Bearer $token"
        }.assertNoContent()
        // Check token no longer work
        client.delete("/accounts/merchant/token") {
            headers[HttpHeaders.Authorization] = "Bearer $token"
        }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
    }

    // DELETE /accounts/USERNAME/tokens/TOKEN_ID
    @Test
    fun deleteById() = bankSetup {
        authRoutine(HttpMethod.Delete, "/accounts/merchant/tokens/1t", allowAdmin = true)
        
        val token = client.postPw("/accounts/merchant/token") {
            json { "scope" to "readonly" }
        }.assertOkJson<TokenSuccessResponse>().access_token
        // Check OK
        client.deleteA("/accounts/merchant/tokens/2").assertNoContent()
        client.deleteA("/accounts/merchant/tokens/2").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        // Check token no longer work
        client.delete("/accounts/merchant/token") {
            headers[HttpHeaders.Authorization] = "Bearer $token"
        }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
    }

    // GET /accounts/USERNAME/tokens
    @Test
    fun get() = bankSetup {
        // Check OK
        for (account in listOf("merchant", "customer")) {
            client.getA("/accounts/$account/tokens").assertOkJson<TokenInfos> {
                assertEquals(1, it.tokens.size)
            }
        }
        client.postPw("/accounts/merchant/token") {
            json { "scope" to "readonly" }
        }.assertOk()
        client.postPw("/accounts/merchant/token") {
            json { "scope" to "readwrite" }
        }.assertOk()
        client.postPw("/accounts/customer/token") {
            json {
                "scope" to "revenue"
                "description" to "description"
            }
        }.assertOk()
        client.getA("/accounts/merchant/tokens").assertOkJson<TokenInfos> {
            assertEquals(3, it.tokens.size)
            for (token in it.tokens) {
                assertNull(token.description)
            }
        }
        client.getA("/accounts/customer/tokens").assertOkJson<TokenInfos> {
            assertEquals(2, it.tokens.size)
            assertEquals("description", it.tokens[0].description)
        }
    }
}

class CoreBankAccountsApiTest {
    // POST /accounts
    @Test
    fun create() = bankSetup { 
        // Check generated payto
        obj {
            "username" to "john"
            "password" to "password"
            "name" to "John"
        }.let { req ->
            // Check Ok
            val payto = client.post("/accounts") {
                json(req)
            }.assertOkJson<RegisterAccountResponse>().internal_payto_uri
            // Check idempotency
            client.post("/accounts") {
                json(req)
            }.assertOkJson<RegisterAccountResponse> {
                assertEquals(payto, it.internal_payto_uri)
            }
            // Check idempotency with payto
            client.post("/accounts") {
                json(req) {
                    "payto_uri" to payto
                }
            }.assertOk()
            // Check payto conflict
            client.post("/accounts") {
                json(req) {
                    "payto_uri" to IbanPayto.rand()
                }
            }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE)
        }

        // Check given payto
        val payto = IbanPayto.rand()
        val req = obj {
            "username" to "foo"
            "password" to "password"
            "name" to "Jane"
            "is_public" to true
            "payto_uri" to payto
            "is_taler_exchange" to true
        }
        // Check Ok
        client.post("/accounts") {
            json(req)
        }.assertOkJson<RegisterAccountResponse> {
            assertEquals(payto.full("Jane"), it.internal_payto_uri)
        }
        // Testing idempotency
        client.post("/accounts") {
            json(req)
        }.assertOkJson<RegisterAccountResponse> {
            assertEquals(payto.full("Jane"), it.internal_payto_uri)
        }
        // Check admin only debit_threshold
        obj {
            "username" to "bat"
            "password" to "password"
            "name" to "Bat"
            "debit_threshold" to "KUDOS:42"
        }.let { req ->
            client.post("/accounts") {
                json(req)
            }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT)
            client.postAdmin("/accounts") {
                json(req)
            }.assertOk()
        }

        // Check admin only conversion_rate_class_id
        createConversionRateClass()
        obj {
            "username" to "bat2"
            "password" to "password"
            "name" to "Bat"
            "conversion_rate_class_id" to 1
        }.let { req ->
            client.post("/accounts") {
                json(req)
            }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS)
            client.postAdmin("/accounts") {
                json(req)
            }.assertOk()
        }

        // Check admin only tan_channel
        obj {
            "username" to "bat3"
            "password" to "password"
            "name" to "Bat"
            "contact_data" to obj {
                "phone" to "+456"
            }
            "tan_channel" to "sms"
        }.let { req ->
            client.post("/accounts") {
                json(req)
            }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL)
            client.postAdmin("/accounts") {
                json(req)
            }.assertOk()
        }

        // Check both tan channels
        client.postAdmin("/accounts") {
            json { 
                "username" to "bat2"
                "password" to "password"
                "name" to "Bat"
                "tan_channel" to "sms"
                "tan_channels" to emptyList<String>()
            }
        }.assertBadRequest()

        // Check tan info
        val channels = listOf("sms", "email")
        for (channel in channels) {
            client.postAdmin("/accounts") {
                json { 
                    "username" to "bat2"
                    "password" to "password"
                    "name" to "Bat"
                    "tan_channel" to channel
                }
            }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
            client.postAdmin("/accounts") {
                json { 
                    "username" to "bat2"
                    "password" to "password"
                    "name" to "Bat"
                    "tan_channels" to listOf(channel)
                }
            }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
        }
        client.postAdmin("/accounts") {
            json { 
                "username" to "bat2"
                "password" to "password"
                "name" to "Bat"
                "tan_channels" to channels
            }
        }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)

        // Check unknown conversion rate class
        client.postAdmin("/accounts") {
            json {
                "username" to "new_account"
                "password" to "password"
                "name" to "New Account"
                "conversion_rate_class_id" to 42
            }
        }.assertConflict(TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN)

        // Reserved account
        RESERVED_ACCOUNTS.forEach {
            client.post("/accounts") {
                json {
                    "username" to it
                    "password" to "password"
                    "name" to "John Smith"
                }
            }.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT)
        }

        // Malformed username
        listOf("bad@username", "long".repeat(50)).forEach {
            client.post("/accounts") {
                json {
                    "username" to it
                    "password" to "password"
                    "name" to "John Smith"
                }
            }.assertBadRequest()
        }

        // Non exchange account
        client.post("/accounts") {
            json {
                "username" to "exchange"
                "password" to "password"
                "name" to "Exchange"
            }
        }.assertConflict(TalerErrorCode.END)

        // Testing username conflict
        client.post("/accounts") {
            json(req) {
                "name" to "Foo"
            }
        }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE)
        // Testing payto conflict
        client.post("/accounts") {
            json(req) {
                "username" to "bar"
            }
        }.assertConflict(TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE)
        client.getAdmin("/accounts/bar").assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
        // Testing bad payto kind
        client.post("/accounts") {
            json(req) {
                "username" to "bar"
                "password" to "bar-password"
                "name" to "Mr Bar"
                "payto_uri" to "payto://x-taler-bank/bank.hostname.test/bar"
            }
        }.assertBadRequest()
        // Testing short password
        client.post("/accounts") {
            json(req) {
                "password" to "short"
            }
        }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_SHORT)
        // Testing long password
        client.post("/accounts") {
            json(req) {
                "password" to "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password"
            }
        }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_LONG)

        // Check cashout payto receiver name logic
        client.post("/accounts") {
            json {
                "username" to "cashout_guess"
                "password" to "cashout_guess-password"
                "name" to "Mr Guess My Name"
                "cashout_payto_uri" to payto
            }
        }.assertOk()
        client.getA("/accounts/cashout_guess").assertOkJson<AccountData> {
            assertEquals(payto.full("Mr Guess My Name"), it.cashout_payto_uri)
        }
        client.post("/accounts") {
            json {
                "username" to "cashout_keep"
                "password" to "cashout_keep-password"
                "name" to "Mr Keep My Name"
                "cashout_payto_uri" to payto.full("Santa Claus")
            }
        }.assertOk()
        client.getA("/accounts/cashout_keep").assertOkJson<AccountData> {
            assertEquals(payto.full("Mr Keep My Name"), it.cashout_payto_uri)
        }

        // Check input restrictions
        obj {
            "username" to "username"
            "password" to "password"
            "name" to "Name"
        }.let { req ->
            client.post("/accounts") {
                json(req) { "username" to "bad/username" }
            }.assertBadRequest()
            client.post("/accounts") {
                json(req) { "username" to " spaces " }
            }.assertBadRequest()
            client.post("/accounts") {
                json(req) {
                    "contact_data" to obj {
                        "phone" to " +456"
                    }
                }
            }.assertBadRequest()
            client.post("/accounts") {
                json(req) {
                    "contact_data" to obj {
                        "phone" to " test@mail.com"
                    }
                }
            }.assertBadRequest()
        }
    }

    // Test account created with bonus
    @Test
    fun createBonus() = bankSetup(conf = "test_bonus.conf") {
        val req = obj {
            "username" to "foo"
            "password" to "password-xyz"
            "name" to "Mallory"
        }

        setMaxDebt("admin", "KUDOS:10000")

        // Check ok
        repeat(100) {
            client.postAdmin("/accounts") {
                json(req) {
                    "username" to "foo$it"
                }
            }.assertOk()
            assertBalance("foo$it", "+KUDOS:100")
        }
        assertBalance("admin", "-KUDOS:10000")
        
        // Check insufficient fund
        client.postAdmin("/accounts") {
            json(req) {
                "username" to "bar"
            }
        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
        client.getAdmin("/accounts/bar").assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
    }

    // Test admin-only account creation
    @Test
    fun createRestricted() = bankSetup(conf = "test_restrict.conf") { 
        authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true)
        client.postAdmin("/accounts") {
            json {
                "username" to "baz"
                "password" to "password-xyz"
                "name" to "Mallory"
            }
        }.assertOk()
    }

    // Test admin-only account creation
    @Test
    fun createTanErr() = bankSetup(conf = "test_tan_err.conf") { 
        client.postAdmin("/accounts") {
            json {
                "username" to "baz"
                "password" to "xyz"
                "name" to "Mallory"
                "tan_channel" to "email"
            }
        }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED)
    }

    // POST /accounts
    @Test
    fun createNoCheck() = bankSetup("test_no_password_check.conf") {
        // Testing short password
        client.post("/accounts") {
            json {
                "username" to "short"
                "name" to "John Smith"
                "password" to "short"
            }
        }.assertOk()
        // Testing long password
        client.post("/accounts") {
            json {
                "username" to "long"
                "name" to "Jane Smith"
                "password" to "loooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password"
            }
        }.assertOk()
    }

    // DELETE /accounts/USERNAME
    @Test
    fun delete() = bankSetup { db -> 
        authRoutine(HttpMethod.Delete, "/accounts/merchant", allowAdmin = true)

        // Reserved account
        RESERVED_ACCOUNTS.forEach {
            client.deleteAdmin("/accounts/$it")
                .assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT)
        }
        client.deleteA("/accounts/exchange")
            .assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT)

        client.post("/accounts") {
            json {
                "username" to "john"
                "password" to "john-password"
                "name" to "John"
                "payto_uri" to genTmpPayTo()
            }
        }.assertOk()
        fillTanInfo("john")
        // Fail to delete, due to a non-zero balance.
        tx("customer", "KUDOS:1", "john")
        client.deleteA("/accounts/john")
            .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO)
        // Successful deletion
        tx("john", "KUDOS:1", "customer")
        client.deleteA("/accounts/john")
            .assertChallenge()
            .assertNoContent()
        // Account no longer exists
        client.deleteA("/accounts/john")
            .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
        client.deleteAdmin("/accounts/john")
            .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
    }

    @Test
    fun softDelete() = bankSetup { db -> 
        // Create all kind of operations
        val token = client.postPw("/accounts/customer/token") {
            json { "scope" to "readonly" }
        }.assertOkJson<TokenSuccessResponse>().access_token
        val tx_id = client.postA("/accounts/customer/transactions") {
            json {
                "payto_uri" to "$exchangePayto?message=payout"
                "amount" to "KUDOS:0.3"
            }
        }.assertOkJson<TransactionCreateResponse>().row_id
        val withdrawal_id = client.postA("/accounts/customer/withdrawals") {
            json { "amount" to "KUDOS:9.0" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id
        fillCashoutInfo("customer")
        val cashout_id = client.postA("/accounts/customer/cashouts") {
            json {
                "request_uid" to ShortHashCode.rand()
                "amount_debit" to "KUDOS:1"
                "amount_credit" to convert("KUDOS:1")
            }
        }.assertOkJson<CashoutResponse>().cashout_id
        fillTanInfo("customer")
        client.postA("/accounts/customer/transactions") {
            json {
                "payto_uri" to "$exchangePayto?message=payout"
                "amount" to "KUDOS:0.3"
            }
        }.assertAcceptedJson<ChallengeResponse>()

        // Delete account
        tx("merchant", "KUDOS:1", "customer")
        assertBalance("customer", "+KUDOS:0")
        client.deleteA("/accounts/customer")
            .assertChallenge()
            .assertNoContent()
        
        // Check account can no longer username
        client.delete("/accounts/customer/token") {
            headers[HttpHeaders.Authorization] = "Bearer $token"
        }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
        client.getA("/accounts/customer/transactions/$tx_id")
            .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
        client.getA("/accounts/customer/cashouts/$cashout_id")
            .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
        client.postA("/accounts/customer/withdrawals/$withdrawal_id/confirm")
            .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)

        // But admin can still see existing operations
        client.getAdmin("/accounts/customer/transactions/$tx_id")
            .assertOkJson<BankAccountTransactionInfo>()
        client.getAdmin("/accounts/customer/cashouts/$cashout_id")
            .assertOkJson<CashoutStatusResponse>()
        client.get("/withdrawals/$withdrawal_id")
            .assertOkJson<WithdrawalPublicInfo>()

        // GC
        db.gc.collect(Instant.now(), Duration.ZERO, Duration.ZERO, Duration.ZERO)
        client.getAdmin("/accounts/customer/transactions/$tx_id")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        client.getAdmin("/accounts/customer/cashouts/$cashout_id")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        client.get("/withdrawals/$withdrawal_id")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    }

    // Test admin-only account deletion
    @Test
    fun deleteRestricted() = bankSetup(conf = "test_restrict.conf") { 
        authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true)
        // Exchange is still restricted
        client.deleteAdmin("/accounts/exchange") {
        }.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT)
    }

    // Test delete exchange account
    @Test
    fun deleteNoConversion() = bankSetup(conf = "test_no_conversion.conf") { 
        // Exchange is no longer restricted
        client.deleteA("/accounts/exchange").assertNoContent()
    }

    suspend fun ApplicationTestBuilder.checkAdminOnly(
        req: JsonElement,
        error: TalerErrorCode
    ) {
        // Check restricted
        client.patchA("/accounts/merchant") {
            json(req)
        }.assertConflict(error)
        // Check admin always can
        client.patchAdmin("/accounts/merchant") {
            json(req)
        }.assertNoContent()
        // Check idempotent
        client.patchA("/accounts/merchant") {
            json(req)
        }.assertNoContent()
    }

    // PATCH /accounts/USERNAME
    @Test
    fun reconfig() = bankSetup { 
        authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true)

        // Check tan info
        val channels = listOf("sms", "email")
        for (channel in channels) {
            client.patchA("/accounts/merchant") {
                json { "tan_channel" to channel }
            }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
            client.patchA("/accounts/merchant") {
                json { "tan_channels" to listOf(channel) }
            }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
        }
        client.patchA("/accounts/merchant") {
            json { "tan_channels" to channels }
        }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)

        // Successful attempt now
        val cashout = IbanPayto.rand()
        val req = obj {
            "cashout_payto_uri" to cashout
            "name" to "Roger"
            "is_public" to true
            "contact_data" to obj {
                "phone" to "+99"
                "email" to "foo@example.com"
            }
        }
        client.patchA("/accounts/merchant") {
            json(req)
        }.assertNoContent()
        // Checking idempotence
        client.patchA("/accounts/merchant") {
            json(req)
        }.assertNoContent()

        checkAdminOnly(
            obj(req) { "debit_threshold" to "KUDOS:100" },
            TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
        )
        createConversionRateClass()
        checkAdminOnly(
            obj(req) { "conversion_rate_class_id" to 1 },
            TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS
        )

        // Check unknown conversion rate class
        client.patchAdmin("/accounts/merchant") {
            json(req) { "conversion_rate_class_id" to 42}
        }.assertConflict(TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN)
        
        // Check currency
        client.patchAdmin("/accounts/merchant") {
            json(req) { "debit_threshold" to "EUR:100" }
        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)

        // Check patch
        client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
            assertEquals("Roger", obj.name)
            assertEquals(cashout.full(obj.name), obj.cashout_payto_uri)
            assertEquals("+99", obj.contact_data?.phone?.get())
            assertEquals("foo@example.com", obj.contact_data?.email?.get())
            assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold)
            assert(obj.is_public)
            assert(!obj.is_taler_exchange)
        }

        // Check keep values when there is no changes
        client.patchA("/accounts/merchant") {
            json { }
        }.assertNoContent()
        client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
            assertEquals("Roger", obj.name)
            assertEquals(cashout.full(obj.name), obj.cashout_payto_uri)
            assertEquals("+99", obj.contact_data?.phone?.get())
            assertEquals("foo@example.com", obj.contact_data?.email?.get())
            assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold)
            assert(obj.is_public)
            assert(!obj.is_taler_exchange)
        }

        // Admin cannot be public
        client.patchA("/accounts/admin") {
            json {
                "is_public" to true
            }
        }.assertConflict(TalerErrorCode.END)

        // Exchange must be exchange
        client.patchA("/accounts/exchange") {
            json {
                "is_taler_exchange" to false
            }
        }.assertConflict(TalerErrorCode.END)

        // Check cashout payto receiver name logic
        client.post("/accounts") {
            json {
                "username" to "cashout"
                "password" to "cashout-password"
                "name" to "Mr Cashout Cashout"
            }
        }.assertOk()
        val canonical = Payto.parse(cashout.canonical).expectIban()
        for ((cashout, name, expect) in listOf(
            Triple(cashout.canonical, null, canonical.full("Mr Cashout Cashout")),
            Triple(cashout.canonical, "New name", canonical.full("New name")),
            Triple(cashout.full("Full name"), null, cashout.full("New name")),
            Triple(cashout.full("Full second name"), "Another name", cashout.full("Another name"))
        )) {
            client.patchAdmin("/accounts/cashout") {
                json {
                    "cashout_payto_uri" to cashout
                    if (name != null) "name" to name
                }
            }.assertNoContent()
            client.getA("/accounts/cashout").assertOkJson<AccountData> { obj ->
                assertEquals(expect, obj.cashout_payto_uri)
            }
        }

        // Check 2FA
        fillTanInfo("merchant")
        client.patchA("/accounts/merchant") {
            json { "is_public" to false }
        }.assertChallenge {
            client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
                assert(obj.is_public)
            }
        }.assertNoContent()
        client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
            assert(!obj.is_public)
        }
    }

    // Test admin-only account patch
    @Test
    fun patchRestricted() = bankSetup(conf = "test_restrict.conf") { 
        // Check restricted
        checkAdminOnly(
            obj { "name" to "Another Foo" },
            TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME
        )
        checkAdminOnly(
            obj { "cashout_payto_uri" to IbanPayto.rand() },
            TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT
        )
        // Check idempotent
        client.getA("/accounts/merchant").assertOkJson<AccountData> { obj ->
            client.patchA("/accounts/merchant") {
                json {
                    "name" to obj.name
                    "cashout_payto_uri" to obj.cashout_payto_uri
                    "debit_threshold" to obj.debit_threshold
                }
            }.assertNoContent()
        }
    }

    // Test TAN check account patch
    @Test
    fun patchTanErr() = bankSetup(conf = "test_tan_err.conf") { 
        // Check unsupported TAN channel
        client.patchA("/accounts/customer") {
            json {
                "tan_channel" to "email"
            }
        }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED)
    }

    // PATCH /accounts/USERNAME/auth
    @Test
    fun passwordChange() = bankSetup { 
        authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", allowAdmin = true)

        // Changing the password.
        client.patchA("/accounts/customer/auth") {
            json {
                "old_password" to "customer-password"
                "new_password" to "new-password"
            }
        }.assertNoContent()
        // Previous password should fail.
        client.post("/accounts/customer/token") {
            basicAuth("customer", "customer-password")
        }.assertUnauthorized()
        // New password should succeed.
        client.post("/accounts/customer/token") {
            basicAuth("customer", "new-password")
            json { "scope" to "readonly" }
        }.assertOk()
        client.patchA("/accounts/customer/auth") {
            json {
                "old_password" to "new-password"
                "new_password" to "customer-password"
            }
        }.assertNoContent()


        // Check require test old password
        client.patchA("/accounts/customer/auth") {
            json {
                "old_password" to "bad-password"
                "new_password" to "new-password"
            }
        }.assertConflict(TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD)

        // Check require old password for user
        client.patchA("/accounts/customer/auth") {
            json {
                "new_password" to "new-password"
            }
        }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD)
        // Testing short password
        client.patchA("/accounts/merchant/auth") {
            json {
                "old_password" to "ignored"
                "new_password" to "short"
            }
        }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_SHORT)
        // Testing long password
        client.patchA("/accounts/merchant/auth") {
            json {
                "old_password" to "ignored"
                "new_password" to "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password"
            }
        }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_LONG)

        // Check admin 
        client.patchAdmin("/accounts/customer/auth") {
            json {
                "new_password" to "customer-password"
            }
        }.assertNoContent()

        // Check 2FA
        fillTanInfo("customer")
        client.patchA("/accounts/customer/auth") {
            json {
                "old_password" to "customer-password"
                "new_password" to "it-password"
            }
        }.assertChallenge().assertNoContent()
        client.patchAdmin("/accounts/customer/auth") {
            json {
                "new_password" to "new-password"
            }
        }.assertNoContent()
   
        
        // Check 2FA after password check
        client.patchA("/accounts/customer/auth") {
            json {
                "old_password" to "password"
                "new_password" to "new-password"
            }
        }.assertConflict(TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD)
    }

    // PATCH /accounts/USERNAME/auth
    @Test
    fun passwordChangeNoCheck() = bankSetup("test_no_password_check.conf") {
        // Testing short password
        client.patchA("/accounts/merchant/auth") {
            json {
                "old_password" to "merchant-password"
                "new_password" to "short"
            }
        }.assertNoContent()
        // Testing long password
        client.patchA("/accounts/merchant/auth") {
            json {
                "old_password" to "short"
                "new_password" to "looooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password"
            }
        }.assertNoContent()
    }

    // GET /public-accounts and GET /accounts
    @Test
    fun list() = bankSetup(conf = "test_no_conversion.conf") { db -> 
        authRoutine(HttpMethod.Get, "/accounts", requireAdmin = true)
        // Remove default accounts
        val defaultAccounts = listOf("merchant", "exchange", "customer")
        defaultAccounts.forEach {
            client.deleteAdmin("/accounts/$it").assertNoContent()
        }
        client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse> {
            for (account in it.accounts) {
                assertNull(account.conversion_rate)
                if (defaultAccounts.contains(account.username)) {
                    assertEquals(AccountStatus.deleted, account.status)
                } else {
                    assertEquals(AccountStatus.active, account.status)
                }
            }
        }
        db.gc.collect(Instant.now(), Duration.ZERO, Duration.ZERO, Duration.ZERO)
        // Check error when no public accounts
        client.get("/public-accounts").assertNoContent()
        client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse>()
    }

    @Test
    fun listConversionClass() = bankSetup(conf = "test.conf") { db ->
        repeat(3) {
           createConversionRateClass()
        }
        
        // Gen some public and private accounts
        repeat(5) {
            client.postAdmin("/accounts") {
                val mod = it%3
                val rateClassId = if (mod in 1..3) mod else null
                json {
                    "username" to "$it"
                    "password" to "password"
                    "name" to "Mr 1$it"
                    "is_public" to (it%2 == 0)
                    "conversion_rate_class_id" to rateClassId
                }
            }.assertOk()
        }
        // All public
        client.get("/public-accounts").assertOkJson<PublicAccountsResponse> {
            assertEquals(3, it.public_accounts.size)
            it.public_accounts.forEach {
                assertEquals(0, (it.username.toInt() - 10) % 2)
            }
        }
        // Conversion rate
        client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse> {
            for (account in it.accounts) {
                val rate = client.getAdmin("/accounts/${account.username}/conversion-info/rate").assertOkJson<ConversionRate>()
                assertEquals(account.conversion_rate, rate)
            }
        }
        // Filtering
        suspend fun checkIds(query: String, vararg ids: String) {
            val res = client.getAdmin("/accounts?$query")
            val list = listOf(*ids)
            if (list.isEmpty()) {
                res.assertNoContent()
            } else {
                res.assertOkJson<ListBankAccountsResponse> {
                    assertEquals(list, it.accounts.map { it.username })
                }
            }
        }
        checkIds("", "4", "3", "2", "1", "0", "admin", "customer", "exchange", "merchant")
        checkIds("filter_name=1", "4", "3", "2", "1", "0")
        checkIds("filter_name=3", "3")
        checkIds("conversion_rate_class_id=1", "4", "1")
        checkIds("conversion_rate_class_id=2", "2")
        checkIds("conversion_rate_class_id=3")
        checkIds("conversion_rate_class_id=4")
        checkIds("conversion_rate_class_id=0", "3", "0", "admin", "customer", "exchange", "merchant")
        checkIds("conversion_rate_class_id=0&filter_name=1", "3", "0")
        for ((id, num) in mapOf(1 to 2, 2 to 1, 3 to 0)) {
            client.getAdmin("/conversion-rate-classes/$id").assertOkJson<ConversionRateClass> {
                assertEquals(it.num_users, num)
            }
        }
    }

    // GET /accounts/USERNAME
    @Test
    fun get() = bankSetup { 
        authRoutine(HttpMethod.Get, "/accounts/merchant", allowAdmin = true)
        // Check ok
        client.getA("/accounts/merchant").assertOkJson<AccountData> {
            assertEquals("Merchant", it.name)
        }
    }
}

class CoreBankTransactionsApiTest {
    // GET /transactions
    @Test
    fun history() = bankSetup { 
        authRoutine(HttpMethod.Get, "/accounts/merchant/transactions", allowAdmin = true)
        historyRoutine<BankAccountTransactionsResponse>(
            url = "/accounts/customer/transactions",
            ids = { it.transactions.map { it.row_id } },
            registered = listOf(
                { 
                    // Transactions from merchant to exchange
                    tx("merchant", "KUDOS:0.1", "customer")
                },
                { 
                    // Transactions from exchange to merchant
                    tx("customer", "KUDOS:0.1", "merchant")
                },
                { 
                    // Transactions from merchant to exchange
                    tx("merchant", "KUDOS:0.1", "customer")
                },
                { 
                    // Cashout from merchant
                    cashout("KUDOS:0.1")
                }
            ),
            ignored = listOf(
                {
                    // Ignore transactions of other accounts
                    tx("merchant", "KUDOS:0.1", "exchange")
                },
                {
                    // Ignore transactions of other accounts
                    tx("exchange", "KUDOS:0.1", "merchant")
                }
            )
        )
    }

    // GET /transactions/T_ID
    @Test
    fun testById() = bankSetup { 
        authRoutine(HttpMethod.Get, "/accounts/merchant/transactions/42", allowAdmin = true)

        // Create transaction
        tx("merchant", "KUDOS:0.3", "exchange", "tx")
        // Check OK
        client.getA("/accounts/merchant/transactions/1")
            .assertOkJson<BankAccountTransactionInfo> { tx ->
            assertEquals("tx", tx.subject)
            assertEquals(TalerAmount("KUDOS:0.3"), tx.amount)
        }
        // Check unknown transaction
        client.getA("/accounts/merchant/transactions/3")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        // Check another user's transaction
        client.getA("/accounts/merchant/transactions/2")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    }

    // POST /transactions
    @Test
    fun create() = bankSetup { db -> 
        authRoutine(HttpMethod.Post, "/accounts/merchant/transactions")

        val valid_req = obj {
            "payto_uri" to "$exchangePayto?message=payout"
            "amount" to "KUDOS:0.3"
        }

        // Check OK
        client.postA("/accounts/merchant/transactions") {
            json(valid_req)
        }.assertOkJson<TransactionCreateResponse> {
            client.getA("/accounts/merchant/transactions/${it.row_id}")
                .assertOkJson<BankAccountTransactionInfo> { tx ->
                assertEquals("payout", tx.subject)
                assertEquals(TalerAmount("KUDOS:0.3"), tx.amount)
            }
        }

        // Check idempotency
        ShortHashCode.rand().let { requestUid ->
            val id = client.postA("/accounts/merchant/transactions") {
                json(valid_req) {
                    "request_uid" to requestUid
                }
            }.assertOkJson<TransactionCreateResponse>().row_id
            client.postA("/accounts/merchant/transactions") {
                json(valid_req) {
                    "request_uid" to requestUid
                }
            }.assertOkJson<TransactionCreateResponse> {
                assertEquals(id, it.row_id)
            }
            client.postA("/accounts/merchant/transactions") {
                json(valid_req) {
                    "request_uid" to requestUid
                    "amount" to "KUDOS:42"
                }
            }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
        }
        
        // Check amount in payto_uri
        client.postA("/accounts/merchant/transactions") {
            json {
                "payto_uri" to "$exchangePayto?message=payout2&amount=KUDOS:1.05"
            }
        }.assertOkJson <TransactionCreateResponse> {
            client.getA("/accounts/merchant/transactions/${it.row_id}")
                .assertOkJson<BankAccountTransactionInfo> { tx ->
                assertEquals("payout2", tx.subject)
                assertEquals(TalerAmount("KUDOS:1.05"), tx.amount)
            }
        }
       
        // Check amount in payto_uri precedence
        client.postA("/accounts/merchant/transactions") {
            json {
                "payto_uri" to "$exchangePayto?message=payout3&amount=KUDOS:1.05"
                "amount" to "KUDOS:10.003"
            }
        }.assertOkJson<TransactionCreateResponse> {
            client.getA("/accounts/merchant/transactions/${it.row_id}")
                .assertOkJson<BankAccountTransactionInfo> { tx ->
                assertEquals("payout3", tx.subject)
                assertEquals(TalerAmount("KUDOS:1.05"), tx.amount)
            }
        }
        // Testing the wrong currency
        client.postA("/accounts/merchant/transactions") {
            json(valid_req) {
                "amount" to "EUR:3.3"
            }
        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
        // Surpassing the debt limit
        client.postA("/accounts/merchant/transactions") {
            json(valid_req) {
                "amount" to "KUDOS:555"
            }
        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
        // Missing message
        client.postA("/accounts/merchant/transactions") {
            json(valid_req) {
                "payto_uri" to "$exchangePayto"
            }
        }.assertBadRequest()
        // Unknown creditor
        client.postA("/accounts/merchant/transactions") {
            json(valid_req) {
                "payto_uri" to "$unknownPayto?message=payout"
            }
        }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR)
        // Transaction to self
        client.postA("/accounts/merchant/transactions") {
            json(valid_req) {
                "payto_uri" to "$merchantPayto?message=payout"
            }
        }.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT)
        // Transaction to admin
        val adminPayto = client.getA("/accounts/admin")
            .assertOkJson<AccountData>().payto_uri
        client.postA("/accounts/merchant/transactions") {
            json(valid_req) {
                "payto_uri" to "$adminPayto&message=payout"
            }
        }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR)

        // Init state
        assertBalance("merchant", "+KUDOS:0")
        assertBalance("customer", "+KUDOS:0")
        // Send 2 times 3
        repeat(2) {
            tx("merchant", "KUDOS:3", "customer")
        }
        client.postA("/accounts/merchant/transactions") {
            json {
                "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5"
            }
        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
        assertBalance("merchant", "-KUDOS:6")
        assertBalance("customer", "+KUDOS:6")
        // Send through debt
        tx("customer", "KUDOS:10", "merchant")
        assertBalance("merchant", "+KUDOS:4")
        assertBalance("customer", "-KUDOS:4")
        tx("merchant", "KUDOS:4", "customer")

        // Check bounce
        assertBalance("merchant", "+KUDOS:0")
        assertBalance("exchange", "+KUDOS:0")
        tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction
        tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction
        tx("merchant", "KUDOS:1", "exchange", "ADMIN BALANCE ADJUST") // Bounce admin balance adjust
        val reservePub = EddsaPublicKey.randEdsaKey()
        tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(IncomingType.reserve, reservePub)) // Accept incoming
        tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(IncomingType.reserve, reservePub)) // Bounce reserve_pub reuse
        assertBalance("merchant", "-KUDOS:1")
        assertBalance("exchange", "+KUDOS:1")
        
        // Check warn
        assertBalance("merchant", "-KUDOS:1")
        assertBalance("exchange", "+KUDOS:1")
        tx("exchange", "KUDOS:1", "merchant", "") // Warn common to transaction
        tx("exchange", "KUDOS:1", "merchant", "Malformed") // Warn malformed transaction
        val wtid = ShortHashCode.rand()
        val exchange = BaseURL.parse("http://exchange.example.com/")
        tx("exchange", "KUDOS:1", "merchant", fmtOutgoingSubject(wtid, exchange)) // Accept outgoing
        tx("exchange", "KUDOS:1", "merchant", fmtOutgoingSubject(wtid, exchange)) // Warn wtid reuse
        assertBalance("merchant", "+KUDOS:3")
        assertBalance("exchange", "-KUDOS:3")

        // Check 2fa
        fillTanInfo("merchant")
        assertBalance("merchant", "+KUDOS:3")
        assertBalance("customer", "+KUDOS:0")
        client.postA("/accounts/merchant/transactions") {
            json {
                "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1"
            }
        }.assertChallenge {
            assertBalance("merchant", "+KUDOS:3")
            assertBalance("customer", "+KUDOS:0")
        }.assertOkJson <TransactionCreateResponse> { 
            assertBalance("merchant", "+KUDOS:2")
            assertBalance("customer", "+KUDOS:1")
        }

        // Check 2fa idempotency
        val req = obj {
            "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1"
            "request_uid" to ShortHashCode.rand()
        }
        val id = client.postA("/accounts/merchant/transactions") {
            json(req)
        }.assertChallenge {
            assertBalance("merchant", "+KUDOS:2")
            assertBalance("customer", "+KUDOS:1")
        }.assertOkJson <TransactionCreateResponse> { 
            assertBalance("merchant", "+KUDOS:1")
            assertBalance("customer", "+KUDOS:2")
        }.row_id
        client.postA("/accounts/merchant/transactions") {
            json(req)
        }.assertOkJson<TransactionCreateResponse> {
            assertEquals(id, it.row_id)
        }
        client.postA("/accounts/merchant/transactions") {
            json(req) {
                "payto_uri" to "$customerPayto?message=tan+chec2k&amount=KUDOS:1"
            }
        }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
    }

    @Test
    fun createWithFee() = bankSetup(conf = "test_with_fees.conf") {
        // Init state
        assertBalance("merchant", "+KUDOS:0")
        assertBalance("customer", "+KUDOS:0")
        assertBalance("admin", "+KUDOS:0")

        // Check fee are sent to admin
        tx("merchant", "KUDOS:3", "customer")
        assertBalance("merchant", "-KUDOS:3.1")
        assertBalance("customer", "+KUDOS:3")
        assertBalance("admin", "+KUDOS:0.1")

        // Check amount with fee and min & max are checked
        for (amount in listOf("KUDOS:7", "KUDOS:6.9", "KUDOS:0", "KUDOS:150")) {
            client.postA("/accounts/merchant/transactions") {
                json {
                    "payto_uri" to "$customerPayto?message=payout2&amount=$amount"
                }
            }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
        }
        // Check empty account
        tx("merchant", "KUDOS:6.8", "customer")
        assertBalance("merchant", "-KUDOS:10")
        assertBalance("customer", "+KUDOS:9.8")
        assertBalance("admin", "+KUDOS:0.2")

        // Admin check no fee
        tx("admin", "KUDOS:0.35", "merchant")
        assertBalance("merchant", "-KUDOS:9.65")
        assertBalance("admin", "-KUDOS:0.15")

        // Admin recover from debt
        tx("customer", "KUDOS:1", "merchant")
        assertBalance("admin", "-KUDOS:0.05")
        tx("customer", "KUDOS:1", "merchant")
        assertBalance("merchant", "-KUDOS:7.65")
        assertBalance("customer", "+KUDOS:7.6")
        assertBalance("admin", "+KUDOS:0.05")
    }
}

class CoreBankWithdrawalApiTest {
    // POST /accounts/USERNAME/withdrawals
    @Test
    fun create() = bankSetup {
        authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals")
        
        // Check OK
        for (valid in listOf(
            obj {}, 
            obj { "amount" to "KUDOS:1.0" },
            obj { "suggested_amount" to "KUDOS:2.0" }, 
            obj {
                "amount" to "KUDOS:3.0"
                "suggested_amount" to "KUDOS:4.0"
            }
        )) {
            // Check OK
            client.postA("/accounts/merchant/withdrawals") {
                json(valid)
            }.assertOkJson<BankAccountCreateWithdrawalResponse> {
                assertEquals("taler+http://withdraw/localhost:8080/taler-integration/${it.withdrawal_id}", it.taler_withdraw_uri)
            }
        }

        // Check exchange account
        client.postA("/accounts/exchange/withdrawals") {
            json { "amount" to "KUDOS:9.0" } 
        }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE)

        // Check insufficient fund
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:90" } 
        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
        client.postA("/accounts/merchant/withdrawals") {
            json { "suggested_amount" to "KUDOS:90" } 
        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)

        // Check wrong currency
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "EUR:90" } 
        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
        client.postA("/accounts/merchant/withdrawals") {
            json { "suggested_amount" to "EUR:90" } 
        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    }
    
    @Test
    fun createWithFee() = bankSetup(conf = "test_with_fees.conf") {
        // Check insufficient fund
        for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) {
            for (name in listOf("amount", "suggested_amount")) {
                client.postA("/accounts/merchant/withdrawals") {
                    json { name to amount } 
                }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
            }
        }

        // Check OK
        for (name in listOf("amount", "suggested_amount")) {
            client.postA("/accounts/merchant/withdrawals") {
                json { name to "KUDOS:9.9" } 
            }.assertOk()
        }
    }

    // GET /withdrawals/withdrawal_id
    @Test
    fun get() = bankSetup {
        // Check OK
        for (valid in listOf(
            Pair(null, null),
            Pair("KUDOS:1.0", null),
            Pair(null, "KUDOS:2.0") ,
            Pair("KUDOS:3.0", "KUDOS:4.0")
        )) {
            val amount = valid.first?.run(::TalerAmount)
            val suggested = valid.second?.run(::TalerAmount)
            client.postA("/accounts/merchant/withdrawals") {
                json { 
                    "amount" to amount
                    "suggested_amount" to suggested
                }
            }.assertOkJson<BankAccountCreateWithdrawalResponse> {
                client.get("/withdrawals/${it.withdrawal_id}")
                    .assertOkJson<WithdrawalPublicInfo> {
                    assertEquals(amount, it.amount)
                    assertEquals(suggested, it.suggested_amount)
                }
            }
        }

        // Check polling
        statusRoutine<WithdrawalPublicInfo>("/withdrawals") { it.status }

        // Check bad UUID
        client.get("/withdrawals/chocolate").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)

        // Check unknown
        client.get("/withdrawals/${UUID.randomUUID()}")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    }

    // POST /accounts/USERNAME/withdrawals/withdrawal_id/abort
    @Test
    fun abort() = bankSetup {
        authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals/42/abort")

        // Check abort created
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:1" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id

            // Check OK
            client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
            // Check idempotence
            client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
        }

        // Check abort selected
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:1" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)

            // Check OK
            client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
            // Check idempotence
            client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
        }

        // Check abort confirmed
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:1" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent()

            // Check error
            client.postA("/accounts/merchant/withdrawals/$uuid/abort")
                .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT)
        }

        // Check bad UUID
        client.postA("/accounts/merchant/withdrawals/chocolate/abort")
            .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)

        // Check unknown
        client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/abort")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    }

    // POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm
    @Test
    fun confirm() = bankSetup { 
        authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals/42/confirm")
        // Check confirm created
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:1" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id

            // Check err
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
                .assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)
        }

        // Check confirm selected
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:1" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)

            // Check amount differs
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
                json { "amount" to "KUDOS:2" }
            }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)

            // Check OK
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent()
            // Check idempotence
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent()

            // Check amount differs
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
                json { "amount" to "KUDOS:2" }
            }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)
        }

        // Check confirm with amount
        client.postA("/accounts/merchant/withdrawals") {
            json {} 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)

            // Check missing amount
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
                .assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED)

            // Check OK
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
                json { "amount" to "KUDOS:1" } 
            }.assertNoContent()
            // Check idempotence
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
                json { "amount" to "KUDOS:1" } 
            }.assertNoContent()

            // Check amount differs
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
                json { "amount" to "KUDOS:2" }
            }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)
        }

        // Check confirm aborted
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:1" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)
            client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()

            // Check error
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
                .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT)
        }

        // Check reserve pub reuse
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:5" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            val reservePub = withdrawalSelect(uuid)

            tx("customer", "KUDOS:5", "exchange", "Taler $reservePub")
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
                .assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
        }

        // Check balance insufficient
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:5" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)

            // Send too much money
            tx("merchant", "KUDOS:5", "customer")
            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
                .assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)

            // Check can abort because not confirmed
            client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
        }

        // Check bad UUID
        client.postA("/accounts/merchant/withdrawals/chocolate/confirm")
            .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)

        // Check unknown
        client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)

        // Check 2fa without body
        fillTanInfo("merchant")
        assertBalance("merchant", "-KUDOS:7")
        client.postA("/accounts/merchant/withdrawals") {
            json { "amount" to "KUDOS:1" } 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)

            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
            .assertChallenge {
                assertBalance("merchant", "-KUDOS:7")
            }.assertNoContent()
        }

        // Check 2fa with body
        fillTanInfo("merchant")
        assertBalance("merchant", "-KUDOS:8")
        client.postA("/accounts/merchant/withdrawals") {
            json {} 
        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
            val uuid = it.withdrawal_id
            withdrawalSelect(uuid)

            client.postA("/accounts/merchant/withdrawals/$uuid/confirm") {
                json { "amount" to "KUDOS:1" }
            }
            .assertChallenge {
                assertBalance("merchant", "-KUDOS:8")
            }.assertNoContent()
        }
        assertBalance("merchant", "-KUDOS:9")
    }

    @Test
    fun confirmWithFee() = bankSetup(conf = "test_with_fees.conf") { db ->
        suspend fun run(amount: TalerAmount): HttpResponse {
            val uuid = UUID.randomUUID()
            // Create a selected withdrawal directly in the database to bypass checks
            db.serializable("""
                INSERT INTO taler_withdrawal_operations(withdrawal_uuid,amount,exchange_bank_account,selection_done,wallet_bank_account,creation_date)
                VALUES (?, (?, ?)::taler_amount, 2, true, 3, 0)
            """) {
                bind(uuid)
                bind(amount)
                executeUpdate()
            }

            return client.postA("/accounts/customer/withdrawals/$uuid/confirm")
        }

        // Check insufficient fund
        for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) {
            run(TalerAmount(amount)).assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
        }

        // Check OK
        run(TalerAmount("KUDOS:9.9"))
    }
}

class CoreBankCashoutApiTest {
    // POST /accounts/{USERNAME}/cashouts
    @Test
    fun create() = bankSetup {
        authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts")

        val req = obj {
            "request_uid" to ShortHashCode.rand()
            "amount_debit" to "KUDOS:1"
            "amount_credit" to convert("KUDOS:1")
        }

        // Missing info
        client.postA("/accounts/customer/cashouts") {
            json(req) 
        }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)

        fillCashoutInfo("customer")

        // Check OK
        val id = client.postA("/accounts/customer/cashouts") {
            json(req) 
        }.assertOkJson<CashoutResponse>().cashout_id

        // Check idempotent
        client.postA("/accounts/customer/cashouts") {
            json(req) 
        }.assertOkJson<CashoutResponse> {
            assertEquals(id, it.cashout_id)
        }

        // Trigger conflict due to reused request_uid
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "amount_debit" to "KUDOS:2"
                "amount_credit" to convert("KUDOS:2")
            }
        }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)

        // Check exchange account
        client.postA("/accounts/exchange/cashouts") {
            json(req) 
        }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE)

        // Check insufficient fund
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "request_uid" to ShortHashCode.rand()
                "amount_debit" to "KUDOS:75"
                "amount_credit" to convert("KUDOS:75")
            }
        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)

        // Check wrong conversion
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "amount_credit" to convert("KUDOS:2")
            }
        }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION)

        // Check min amount
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "amount_debit" to "KUDOS:0.09"
            }
        }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL)

        // Check custom min account
        createConversionRateClass(cashout_min_amount = TalerAmount("KUDOS:10"))
        client.patchAdmin("/accounts/customer") {
            json {
                "conversion_rate_class_id" to 1
            }
        }.assertNoContent()
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "amount_debit" to "KUDOS:5"
                "amount_credit" to convert("KUDOS:5")
            }
        }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL)
        client.patchAdmin("/accounts/customer") {
            json {
                "conversion_rate_class_id" to (null as Long?)
            }
        }.assertNoContent()

        // Check wrong currency
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "amount_debit" to "EUR:1"
            }
        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "amount_credit" to "KUDOS:1"
            } 
        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)

        // Check 2fa
        fillTanInfo("customer")
        assertBalance("customer", "-KUDOS:1")
        client.postA("/accounts/customer/cashouts") {
            json(req) {
                "request_uid" to ShortHashCode.rand()
            }
        }.assertChallenge {
            assertBalance("customer", "-KUDOS:1")
        }.assertOkJson<CashoutResponse> {
            assertBalance("customer", "-KUDOS:2")
        }
    }

    // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID}
    @Test
    fun get() = bankSetup {
        authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts/42", allowAdmin = true)
        fillCashoutInfo("customer")

        val amountDebit = TalerAmount("KUDOS:1.5")
        val amountCredit = convert("KUDOS:1.5")
        val req = obj {
            "amount_debit" to amountDebit
            "amount_credit" to amountCredit
        }

        // Check confirm
        client.postA("/accounts/customer/cashouts") {
            json(req) { "request_uid" to ShortHashCode.rand() }
        }.assertOkJson<CashoutResponse> {
            val id = it.cashout_id
            client.getA("/accounts/customer/cashouts/$id")
                .assertOkJson<CashoutStatusResponse> {
                assertEquals(amountDebit, it.amount_debit)
                assertEquals(amountCredit, it.amount_credit)
            }
        }

        // Check bad UUID
        client.getA("/accounts/customer/cashouts/chocolate")
            .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED)

        // Check unknown
        client.getA("/accounts/customer/cashouts/42")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)

        // Check get another user's operation
        client.postA("/accounts/customer/cashouts") {
            json(req) { "request_uid" to ShortHashCode.rand() }
        }.assertOkJson<CashoutResponse> {
            val id = it.cashout_id

            // Check error
            client.getA("/accounts/merchant/cashouts/$id")
                .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        }
    }

    // GET /accounts/{USERNAME}/cashouts
    @Test
    fun history() = bankSetup {
        authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts", allowAdmin = true)
        historyRoutine<Cashouts>(
            url = "/accounts/customer/cashouts",
            ids = { it.cashouts.map { it.cashout_id } },
            registered = listOf { cashout("KUDOS:0.1") },
            polling = false
        )
    }

    // GET /cashouts
    @Test
    fun globalHistory() = bankSetup {
        authRoutine(HttpMethod.Get, "/cashouts", requireAdmin = true)
        historyRoutine<GlobalCashouts>(
            url = "/cashouts",
            ids = { it.cashouts.map { it.cashout_id } },
            registered = listOf { cashout("KUDOS:0.1") },
            polling = false,
            auth = "admin"
        )
    }

    @Test
    fun notImplemented() = bankSetup("test_no_conversion.conf") {
        client.get("/accounts/customer/cashouts")
            .assertNotImplemented()
    }
}

class CoreBankTanApiTest {
    // POST /accounts/{USERNAME}/challenge/{challenge_id}
    @Test
    fun send() = bankSetup {
        suspend fun HttpResponse.expectMfa(vararg tans: Pair<TanChannel, String>): HttpResponse {
            return assertChallenge { res ->
                assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet())
                assertFalse(res.combi_and)
            }
        }
        suspend fun HttpResponse.expectValidation(vararg tans: Pair<TanChannel, String>): HttpResponse {
            return assertChallenge { res ->
                assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet())
                assertTrue(res.combi_and)
            }
        }

        // Set up 2fa 
        client.patchA("/accounts/merchant") {
            json { 
                "contact_data" to obj {
                    "phone" to "+99"
                    "email" to "email@example.com"
                }
                "tan_channel" to "sms"
            }
        }.expectValidation(TanChannel.sms to "+99")
            .assertNoContent()
        
        // Update 2fa settings - first 2FA challenge then new tan channel check
        client.patchA("/accounts/merchant") {
            json { // Info change
                "contact_data" to obj { "phone" to "+98" }
            }
        }.expectValidation(TanChannel.sms to "+99", TanChannel.sms to "+98")
            .assertNoContent()
        client.patchA("/accounts/merchant") {
            json { // Channel change
                "tan_channel" to "email"
            }
        }.expectValidation(TanChannel.sms to "+98", TanChannel.email to "email@example.com")
            .assertNoContent()
        client.patchA("/accounts/merchant") {
            json { // Both change
                "contact_data" to obj { "phone" to "+97" }
                "tan_channel" to "sms"
            }
        }.expectValidation(TanChannel.email to "email@example.com", TanChannel.sms to "+97")
            .assertNoContent()

        // Disable 2fa
        client.patchA("/accounts/merchant") {
            json { "tan_channel" to null as String? }
        }.expectValidation(TanChannel.sms to "+97")
            .assertNoContent()

        // Update mfa settings - first mfa challenge then new tan channel check
        client.patchA("/accounts/merchant") {
            json { // All channels
                "tan_channels" to setOf("sms", "email")
            }
        }.expectValidation(TanChannel.sms to "+97", TanChannel.email to "email@example.com")
            .assertNoContent()
        client.patchA("/accounts/merchant") {
            json { // All info changes
                "contact_data" to obj {
                    "phone" to "+99"
                    "email" to "email2@example.com"
                }
            }
        }.expectMfa(TanChannel.sms to "+97", TanChannel.email to "email@example.com")
            .expectValidation(TanChannel.sms to "+99", TanChannel.email to "email2@example.com")
            .assertNoContent()

        // Disable mfa
        client.patchA("/accounts/merchant") {
            json { "tan_channels" to emptySet<String>() }
        }.expectMfa(TanChannel.sms to "+99", TanChannel.email to "email2@example.com")
            .assertNoContent()
        

        // Admin has no 2FA
        client.patchAdmin("/accounts/merchant") {
            json { 
                "contact_data" to obj { "phone" to "+99" }
                "tan_channel" to "sms"
            }
        }.assertNoContent()
        client.patchAdmin("/accounts/merchant") {
            json { "tan_channel" to "email" }
        }.assertNoContent()
        client.patchAdmin("/accounts/merchant") {
            json { "tan_channel" to null as String? }
        }.assertNoContent()

        // Check retry and invalidate
        client.patchA("/accounts/merchant") {
            json { 
                "contact_data" to obj { "phone" to "+88" }
                "tan_channel" to "sms"
            }
        }.assertChallenge().assertNoContent()
        client.patchA("/accounts/merchant") {
            json { "is_public" to false }
        }.assertAcceptedJson<ChallengeResponse> {
            val challenge = it.challenges[0]
            // Check ok
            client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
                .assertOk()
            val code = tanCode("+88")
            assertNotNull(code)
            // Check retry
            client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
                .assertOk()
            assertNull(tanCode("+88"))
            // Idempotent patch does nothing
            client.patchA("/accounts/merchant") {
                json { 
                    "contact_data" to obj { "phone" to "+88" }
                    "tan_channel" to "sms"
                }
            }
            client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
                .assertOk()
            assertNull(tanCode("+88"))

            // Change 2fa settings
            client.patchA("/accounts/merchant") {
                json { 
                    "tan_channel" to "email"
                }
            }.expectValidation(TanChannel.sms to "+88", TanChannel.email to "email2@example.com")
                .assertNoContent()

            // Check invalidated
            client.postA("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") {
                json { "tan" to code }
            }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)
            client.patchA("/accounts/merchant") {
                headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}"
                json { "is_public" to false }
            }.expectValidation(TanChannel.email to "email2@example.com")
                .assertNoContent()
        }

        // Unknown challenge
        client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}")
            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    }

    @Test
    fun sendRateLimited() = bankSetup {
        fillTanInfo("merchant")

        suspend fun ApplicationTestBuilder.txChallenge() 
            = client.postA("/accounts/merchant/transactions") {
                json {
                    "payto_uri" to "$customerPayto?message=tx&amount=KUDOS:0.1"
                }
            }.assertAcceptedJson<ChallengeResponse>().challenges[0]
        suspend fun ApplicationTestBuilder.submit(challenge: Challenge)
            = client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
                .assertOkJson<ChallengeRequestResponse>()
            

        // Start a legitimate challenge and submit it
        val oldChallenge = txChallenge()
        submit(oldChallenge)
        val tanCode = tanCode(oldChallenge.tan_info)

        // Challenge creation is not rate limited
        repeat(MAX_ACTIVE_CHALLENGES*2) {
            txChallenge()
        }

        // Challenge submission is rate limited
        repeat(MAX_ACTIVE_CHALLENGES-1) {
            submit(txChallenge())
        }
        val challenge = txChallenge()
        client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
            .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED)

        // Old already submitted challenge still works
        val transmission = submit(oldChallenge)
        client.postA("/accounts/merchant/challenge/${oldChallenge.challenge_id}/confirm") {
            json { "tan" to tanCode }
        }.assertNoContent()

        // Now an active challenge slot have been freed
        submit(challenge)

        // We are rate limited again
        val newChallenge = txChallenge()
        client.postA("/accounts/merchant/challenge/${newChallenge.challenge_id}")
            .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED)
    }

    // POST /accounts/{USERNAME}/challenge/{challenge_id}
    @Test
    fun sendTanErr() = bankSetup("test_tan_err.conf") {
        // Check fail
        fillTanInfo("merchant")
        client.patchA("/accounts/merchant") {
            json { "is_public" to false }
        }.assertAcceptedJson<ChallengeResponse> {
            val challenge = it.challenges[0]
            client.postA("/accounts/merchant/challenge/${challenge.challenge_id}")
                .assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)
        }
    }

    // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm
    @Test
    fun confirm() = bankSetup {
        fillTanInfo("merchant")

        // Check simple case
        client.patchA("/accounts/merchant") {
            json { "is_public" to false }
        }.assertAcceptedJson<ChallengeResponse> {
            val challenge = it.challenges[0]
            val id = challenge.challenge_id
            client.postA("/accounts/merchant/challenge/$id")
                .assertOkJson<ChallengeRequestResponse>()
            val code = tanCode(challenge.tan_info)

            // Check bad TAN code
            client.postA("/accounts/merchant/challenge/$id/confirm") {
                json { "tan" to "nice-try" } 
            }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)

            // Check wrong account
            client.postA("/accounts/customer/challenge/$id/confirm") {
                json { "tan" to "nice-try" } 
            }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)
        
            // Check OK
            client.postA("/accounts/merchant/challenge/$id/confirm") {
                json { "tan" to code }
            }.assertNoContent()
            // Check idempotence
            client.postA("/accounts/merchant/challenge/$id/confirm") {
                json { "tan" to code }
            }.assertNoContent()

            // Unknown challenge
            client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}/confirm") {
                json { "tan" to code }
            }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND)
        }
        
        // Check invalidation
        client.patchA("/accounts/merchant") {
            json { "is_public" to true }
        }.assertAcceptedJson<ChallengeResponse> {
            val challenge = it.challenges[0]
            val id = challenge.challenge_id
            client.postA("/accounts/merchant/challenge/$id")
                .assertOkJson<ChallengeRequestResponse>()
             
            // Check invalidated
            fillTanInfo("merchant")
            client.postA("/accounts/merchant/challenge/$id/confirm") {
                json { "tan" to tanCode(challenge.tan_info) }
            }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)

            client.postA("/accounts/merchant/challenge/$id")
                .assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)
        }
    }
}

class CoreBankConversionApiTest {
    // POST /conversion-rate-classes
    // GET /conversion-rate-classes
    // GET /conversion-rate-classes/{CLASS_ID}
    @Test
    fun classes() = bankSetup() {
        authRoutine(HttpMethod.Post, "/conversion-rate-classes", requireAdmin = true)
        authRoutine(HttpMethod.Get, "/conversion-rate-classes", requireAdmin = true)
        authRoutine(HttpMethod.Get, "/conversion-rate-classes/1", requireAdmin = true)

        val fullInput = obj {
            "description" to "A nice little class"
            "cashin_ratio" to "0.1"
            "cashin_fee" to "KUDOS:0.2"
            "cashin_tiny_amount" to "KUDOS:0.3"
            "cashin_rounding_mode" to "nearest"
            "cashin_min_amount" to "EUR:0"
            "cashout_ratio" to "0.4"
            "cashout_fee" to "EUR:0.5"
            "cashout_tiny_amount" to "EUR:0.6"
            "cashout_rounding_mode" to "zero"
            "cashout_min_amount" to "KUDOS:0.7"
        }

        // Check no classes
        client.getAdmin("/conversion-rate-classes").assertNoContent()
        client.getAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        client.patchAdmin("/conversion-rate-classes/1") {
            json(fullInput) {
                "name" to "Class"
            }
        }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        client.deleteAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)

        // Create full
        val full = client.postAdmin("/conversion-rate-classes") {
            json(fullInput) {
                "name" to "Class n°1"
            }
        }.assertOkJson<ConversionRateClassResponse> { 
            assertEquals(it.conversion_rate_class_id, 1)
            val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>()
            client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") {
                json {
                    "name" to "Class n°1"
                }
            }.assertNoContent()
            it.conversion_rate_class_id
        }
        // Create empty
        val empty = client.postAdmin("/conversion-rate-classes") {
            json {
                "name" to "Class n°2"
            }
        }.assertOkJson<ConversionRateClassResponse> { 
            assertEquals(it.conversion_rate_class_id, 2)
            val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>()
            client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") {
                json(fullInput) {
                    "name" to "Class n°2"
                }
            }.assertNoContent()
            it.conversion_rate_class_id
        }

        // Bad currency
        client.postAdmin("/conversion-rate-classes") {
            json(fullInput) {
                "name" to "Bad currency"
                "cashout_fee" to "CHF:0.003"
            }
        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)

        // Name reuse currency
        client.postAdmin("/conversion-rate-classes") {
            json(fullInput) {
                "name" to "Class n°1"
            }
        }.assertConflict(TalerErrorCode.BANK_NAME_REUSE)
        client.patchAdmin("/conversion-rate-classes/2") {
            json(fullInput) {
                "name" to "Class n°1"
            }
        }.assertConflict(TalerErrorCode.BANK_NAME_REUSE)
         client.patchAdmin("/conversion-rate-classes/1") {
            json(fullInput) {
                "name" to "Class n°1"
            }
        }.assertNoContent()

        // Page
        client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> {
            assertEquals(it.classes.size, 2)
        }
        val generated = (0 until 5).map { createConversionRateClass() }
        client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> {
            assertEquals(it.classes.size, 7)
        }
        client.getAdmin("/conversion-rate-classes?filter_name=Gen").assertOkJson<ConversionRateClasses> {
            assertEquals(it.classes.size, 5)
        }

        // Delete all
        for (id in listOf(full.conversion_rate_class_id, empty.conversion_rate_class_id) + generated) {
            client.deleteAdmin("/conversion-rate-classes/$id").assertNoContent()
            client.deleteAdmin("/conversion-rate-classes/$id").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
        }
        client.getAdmin("/conversion-rate-classes").assertNoContent()
    }

    @Test
    fun notImplemented() = bankSetup("test_no_conversion.conf") {
        client.getAdmin("conversion-rate-classes/1").assertNotImplemented()
        client.getAdmin("conversion-rate-classes").assertNotImplemented()
    }
}