gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[libeufin] branch master updated (0a69b6d2 -> ecc5e071)


From: gnunet
Subject: [libeufin] branch master updated (0a69b6d2 -> ecc5e071)
Date: Sat, 13 Jan 2024 15:41:12 +0100

This is an automated email from the git hooks/post-receive script.

antoine pushed a change to branch master
in repository libeufin.

    from 0a69b6d2 2fa
     new 14941dc6 New TAN challenge table
     new cf1456b8 Add tan_channel fields to bank accounts
     new ec327343 Create challenge when 2FA is enabled
     new 3a4c4b61 First working 2fa flow
     new a657794c Improve testing and error
     new d8709085 Add operation kind
     new dd499d21 Run protected operation when solving the challenge and 
protect account deletion
     new 741dba08 Simplify tests
     new 70ce1cf9 Check new tan parameters using a 2FA challenge when an user 
modifies them
     new 2e848710 Simplify some SQL
     new 7f1d0c90 2fa for bank transactions
     new 95a2a1df 2fa for cashout and remove obsolete cashout tan challenge 
logic
     new a8300c3e 2fa for withdrawal
     new 84683061 Bump API version
     new 9346766e 2fa for account auth reconfig
     new 01eafe1f New error codes
     new 92d10fc0 Bring back the 4 call design
     new d66b43e7 forget about pending cashout before running migration script
     new 004fc57d Allow account creation with 2FA by admin
     new 5ffae0e1 More tan error handling for account creation
     new b082dd8b Remove deprecated endpoints and fields
     new 001e6112 Check login when confirming a withdrawal operation
     new a3c3dbd5 Clean code
     new a93253a3 Clean SQL schema and add indexes for future gc feature
     new 1a3ebf8f Merge 2fa info master
     new 0233fe38 Semi automated test for postfinance ebics setup
     new e18a3099 Semi automated test for postfinance ebics-submit
     new 6182bc93 Semi automated test for postfinance ebics-fetch and, fix and 
improve fetch logic
     new f24be2da Simplify cli error handling
     new 6bac5cf1 Improve ebics cli error handling and improve logic
     new 5060b676 Semi automated test for netzbon ebics-fetch
     new 4d5ae2c6 Improve nexus logic and make bounce bank ID deterministic
     new f6e8b3e0 Deterministic bounce ID in SQL
     new 1de5b720 Uppercase bounce id
     new fe4c49c5 Bounce cashin with too small amounts
     new aa807728 Fix non transient ebics commands
     new ecc5e071 Merge nexus-integration-test into master

The 37 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .gitignore                                         |   4 +
 API_CHANGES.md                                     |  11 +
 Makefile                                           |  25 +-
 bank/conf/test.conf                                |   2 +-
 bank/conf/{test_no_tan.conf => test_tan_err.conf}  |   2 +
 .../main/kotlin/tech/libeufin/bank/Constants.kt    |   2 +-
 .../main/kotlin/tech/libeufin/bank/CoreBankApi.kt  | 382 +++++------
 bank/src/main/kotlin/tech/libeufin/bank/Error.kt   |   7 +
 bank/src/main/kotlin/tech/libeufin/bank/Main.kt    | 259 ++++----
 .../main/kotlin/tech/libeufin/bank/TalerCommon.kt  |   3 +-
 .../main/kotlin/tech/libeufin/bank/TalerMessage.kt |  41 +-
 bank/src/main/kotlin/tech/libeufin/bank/Tan.kt     |  64 +-
 .../kotlin/tech/libeufin/bank/db/AccountDAO.kt     | 182 +++--
 .../kotlin/tech/libeufin/bank/db/CashoutDAO.kt     | 165 +----
 .../main/kotlin/tech/libeufin/bank/db/Database.kt  |   1 +
 .../main/kotlin/tech/libeufin/bank/db/TanDAO.kt    | 177 +++++
 .../kotlin/tech/libeufin/bank/db/TransactionDAO.kt |   7 +-
 .../kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt  |  31 +-
 bank/src/main/kotlin/tech/libeufin/bank/helpers.kt |  26 +-
 bank/src/main/resources/logback.xml                |   3 +
 bank/src/test/kotlin/AmountTest.kt                 |   2 +
 bank/src/test/kotlin/BankIntegrationApiTest.kt     |   3 +-
 bank/src/test/kotlin/CoreBankApiTest.kt            | 740 ++++++++++++---------
 bank/src/test/kotlin/DatabaseTest.kt               |  81 +--
 bank/src/test/kotlin/helpers.kt                    | 109 ++-
 bank/src/test/kotlin/routines.kt                   |   8 +-
 database-versioning/libeufin-bank-0001.sql         | 152 ++---
 database-versioning/libeufin-bank-0002.sql         |  90 +++
 database-versioning/libeufin-bank-drop.sql         |   1 +
 database-versioning/libeufin-bank-procedures.sql   | 516 +++++++-------
 database-versioning/libeufin-conversion-setup.sql  |  11 +-
 database-versioning/libeufin-nexus-0001.sql        |   8 +-
 database-versioning/libeufin-nexus-procedures.sql  | 304 +++++----
 integration/build.gradle                           |  30 +-
 integration/conf/integration.conf                  |   1 -
 integration/conf/netzbon.conf                      |  30 +
 integration/conf/postfinance.conf                  |  29 +
 integration/src/main/kotlin/Main.kt                | 184 +++++
 integration/src/test/kotlin/IntegrationTest.kt     | 328 +++++++++
 integration/test/IntegrationTest.kt                | 187 ------
 nexus/build.gradle                                 |   5 -
 nexus/conf/test.conf                               |  13 +
 .../main/kotlin/tech/libeufin/nexus/Database.kt    | 345 ++++------
 .../src/main/kotlin/tech/libeufin/nexus/DbInit.kt  |  13 +-
 .../main/kotlin/tech/libeufin/nexus/EbicsFetch.kt  | 324 ++++-----
 .../main/kotlin/tech/libeufin/nexus/EbicsSetup.kt  | 293 +++-----
 .../main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 114 ++--
 .../main/kotlin/tech/libeufin/nexus/Iso20022.kt    | 197 +++---
 nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt  | 102 ++-
 .../kotlin/tech/libeufin/nexus/ebics/Ebics2.kt     |   6 +-
 .../kotlin/tech/libeufin/nexus/ebics/Ebics3.kt     |  20 +-
 .../tech/libeufin/nexus/ebics/EbicsCommon.kt       |  14 +-
 nexus/src/test/kotlin/CliTest.kt                   |  98 +++
 nexus/src/test/kotlin/Common.kt                    |   6 +-
 nexus/src/test/kotlin/DatabaseTest.kt              | 164 ++---
 nexus/src/test/kotlin/Keys.kt                      |  16 +-
 nexus/src/test/kotlin/PostFinance.kt               | 219 ------
 util/src/main/kotlin/Cli.kt                        |  23 +-
 util/src/main/kotlin/DB.kt                         |   2 +-
 util/src/main/kotlin/TalerErrorCode.kt             |  52 +-
 util/src/main/kotlin/ebics_h005/Ebics3Request.kt   |  22 +-
 61 files changed, 3413 insertions(+), 2843 deletions(-)
 rename bank/conf/{test_no_tan.conf => test_tan_err.conf} (88%)
 create mode 100644 bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
 create mode 100644 database-versioning/libeufin-bank-0002.sql
 create mode 100644 integration/conf/netzbon.conf
 create mode 100644 integration/conf/postfinance.conf
 create mode 100644 integration/src/main/kotlin/Main.kt
 create mode 100644 integration/src/test/kotlin/IntegrationTest.kt
 delete mode 100644 integration/test/IntegrationTest.kt
 create mode 100644 nexus/conf/test.conf
 create mode 100644 nexus/src/test/kotlin/CliTest.kt
 delete mode 100644 nexus/src/test/kotlin/PostFinance.kt

diff --git a/.gitignore b/.gitignore
index 3b7e2827..fb39b151 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@
 /sandbox/bin/
 /util/bin/
 nexus/libeufin-nexus-dev
+nexus/test
+integration/test
+integration/config.json
 sandbox/libeufin-sandbox-dev
 configure
 build/
@@ -23,6 +26,7 @@ __pycache__
 *.log
 .DS_Store
 *.mk
+*.xsd
 util/src/main/resources/version.txt
 debian/usr/share/libeufin/demobank-ui/index.js
 debian/usr/share/libeufin/demobank-ui/*.html
diff --git a/API_CHANGES.md b/API_CHANGES.md
index 30fc5509..3379c505 100644
--- a/API_CHANGES.md
+++ b/API_CHANGES.md
@@ -19,6 +19,17 @@ This files contains all the API changes for the current 
release:
 - POST /accounts/USERNAME/transactions: prohibit transaction to admin account
 - Deprecate POST /accounts/USERNAME/withdrawals/WITHDRAWAL_ID/abort
 - Add POST /taler-integration/withdrawal-operation/WITHDRAWAL_ID/abort
+- Add 2FA logic
+- Remove POST /accounts/USERNAME/cashouts/CASHOUT_ID/abort
+- Remove POST /accounts/USERNAME/cashouts/CASHOUT_ID/confirm
+- Add POST /accounts/USERNAME/challenge/CHALLENGE_ID
+- Add POST /accounts/USERNAME/challenge/CHALLENGE_ID/confirm
+- POST /accounts/USERNAME/cashouts: remove tan_channel field
+- POST /accounts/USERNAME/cashouts/CASHOUT_ID: remove confirmation_time, 
tan_channel, tan_info and status fields
+- POST /accounts/$USERNAME/cashouts: remove status field
+- POST /cashouts: remove status field
+- PATCH /accounts/USERNAME: add tan_channel
+- GET /accounts/USERNAME: add tan_channel
 
 ## bank cli
 
diff --git a/Makefile b/Makefile
index 343a6660..40c935a0 100644
--- a/Makefile
+++ b/Makefile
@@ -63,6 +63,11 @@ install-nobuild-bank-files:
        install -m 644 -D -t $(sql_dir) database-versioning/libeufin-bank*.sql
        install -m 644 -D -t $(sql_dir) 
database-versioning/libeufin-conversion*.sql
 
+.PHONY: install-nobuild-nexus-files
+install-nobuild-nexus-files:
+       install -m 644 -D -t $(config_dir) contrib/nexus.conf
+       install -m 644 -D -t $(sql_dir) database-versioning/libeufin-nexus*.sql
+
 .PHONY: install-nobuild-bank
 install-nobuild-bank: install-nobuild-common install-nobuild-bank-files
        install -d $(spa_dir)
@@ -76,9 +81,8 @@ install-nobuild-bank: install-nobuild-common 
install-nobuild-bank-files
        install -m=644 -D -t $(lib_dir) 
bank/build/install/bank-shadow/lib/bank-*.jar
 
 .PHONY: install-nobuild-nexus
-install-nobuild-nexus: install-nobuild-common
+install-nobuild-nexus: install-nobuild-common install-nobuild-nexus-files
        install -m 644 -D -t $(config_dir) contrib/nexus.conf
-       install -m 644 -D -t $(sql_dir) database-versioning/libeufin-nexus*.sql
        install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/libeufin-nexus.1
        install -m 644 -D -t $(man_dir)/man5 
doc/prebuilt/man/libeufin-nexus.conf.5
        install -D -t $(bin_dir) contrib/libeufin-nexus-dbinit
@@ -94,6 +98,11 @@ install:
 assemble:
        ./gradlew assemble
 
+.PHONY: doc
+doc:
+       ./gradlew dokkaHtmlMultiModule
+       open build/dokka/htmlMultiModule/index.html
+
 .PHONY: check
 check: install-nobuild-bank-files
        ./gradlew check
@@ -102,6 +111,18 @@ check: install-nobuild-bank-files
 test: install-nobuild-bank-files
        ./gradlew test --tests $(test) -i
 
+.PHONY: nexus-test
+nexus-test: install-nobuild-nexus-files
+       ./gradlew :nexus:test --tests $(test) -i
+
+.PHONY: integration-test
+integration-test: install-nobuild-bank-files install-nobuild-nexus-files
+       ./gradlew :integration:test --tests $(test) -i
+
+.PHONY: integration
+integration: install-nobuild-bank-files install-nobuild-nexus-files
+       ./gradlew :integration:run --console=plain --args="$(test)"
+
 .PHONY: doc
 doc:
        ./gradlew dokkaHtmlMultiModule
diff --git a/bank/conf/test.conf b/bank/conf/test.conf
index b4eb3953..4a7a4476 100644
--- a/bank/conf/test.conf
+++ b/bank/conf/test.conf
@@ -8,7 +8,7 @@ ALLOW_EDIT_CASHOUT_PAYTO_URI = yes
 allow_conversion = YES
 FIAT_CURRENCY = EUR
 tan_sms = libeufin-tan-file.sh
-tan_email = libeufin-tan-fail.sh
+tan_email = libeufin-tan-file.sh
 
 [libeufin-bankdb-postgres]
 CONFIG = postgresql:///libeufincheck
diff --git a/bank/conf/test_no_tan.conf b/bank/conf/test_tan_err.conf
similarity index 88%
rename from bank/conf/test_no_tan.conf
rename to bank/conf/test_tan_err.conf
index 52e824b2..faaf9883 100644
--- a/bank/conf/test_no_tan.conf
+++ b/bank/conf/test_tan_err.conf
@@ -6,6 +6,8 @@ FIAT_CURRENCY = EUR
 ALLOW_REGISTRATION = yes
 ALLOW_ACCOUNT_DELETION = yes
 ALLOW_EDIT_CASHOUT_PAYTO_URI = yes
+tan_sms = libeufin-tan-fail.sh
+tan_email =
 
 [libeufin-bankdb-postgres]
 CONFIG = postgresql:///libeufincheck
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
index 5567e13c..d61d767e 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
@@ -44,7 +44,7 @@ const val MIN_VERSION: Int = 14
 const val SERIALIZATION_RETRY: Int = 10;
 
 // API version
-const val COREBANK_API_VERSION: String = "3:0:3"
+const val COREBANK_API_VERSION: String = "4:0:0"
 const val CONVERSION_API_VERSION: String = "0:0:0"
 const val INTEGRATION_API_VERSION: String = "2:0:2"
 const val WIRE_GATEWAY_API_VERSION: String = "0:1:0"
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
index bc8b88b6..a7d0f2c6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -28,6 +28,7 @@ import java.time.Instant
 import java.time.temporal.ChronoUnit
 import java.util.*
 import kotlin.random.Random
+import kotlinx.serialization.json.Json
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.future.await
 import kotlinx.coroutines.withContext
@@ -35,8 +36,10 @@ import net.taler.common.errorcodes.TalerErrorCode
 import net.taler.wallet.crypto.Base32Crockford
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
+import tech.libeufin.bank.*
 import tech.libeufin.bank.auth.*
 import tech.libeufin.bank.db.*
+import tech.libeufin.bank.db.TanDAO.*
 import tech.libeufin.bank.db.AccountDAO.*
 import tech.libeufin.bank.db.CashoutDAO.*
 import tech.libeufin.bank.db.ExchangeDAO.*
@@ -73,6 +76,7 @@ fun Routing.coreBankApi(db: Database, ctx: BankConfig) {
     coreBankTransactionsApi(db, ctx)
     coreBankWithdrawalApi(db, ctx)
     coreBankCashoutApi(db, ctx)
+    coreBankTanApi(db, ctx)
 }
 
 private fun Routing.coreBankTokenApi(db: Database) {
@@ -144,29 +148,49 @@ suspend fun createAccount(db: Database, ctx: BankConfig, 
req: RegisterAccountReq
             TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
         )
 
-    if (req.debit_threshold != null && !isAdmin)
-        throw conflict(
-            "only admin account can choose the debit limit",
-            TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
-        )
+    if (!isAdmin) {
+        if (req.debit_threshold != null)
+            throw conflict(
+                "only admin account can choose the debit limit",
+                TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
+            )
+
+        if (req.tan_channel != null)
+            throw conflict(
+                "only admin account can enable 2fa on creation",
+                TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL
+            )
 
+    } else if (req.tan_channel != null) {
+        if (ctx.tanChannels.get(req.tan_channel) == null) {
+            throw unsupportedTanChannel(req.tan_channel)
+        } 
+        val missing = when (req.tan_channel) {
+            TanChannel.sms ->  req.contact_data?.phone?.get() == null
+            TanChannel.email ->  req.contact_data?.email?.get() == null
+        }
+        if (missing)
+            throw conflict(
+                "missing info for tan channel ${req.tan_channel}",
+                TalerErrorCode.BANK_MISSING_TAN_INFO
+            )
+    }
+   
     if (req.username == "exchange" && !req.is_taler_exchange)
         throw conflict(
             "'exchange' account must be a taler exchange account",
             TalerErrorCode.END
         )
 
-    val reqPayto = req.payto_uri ?: req.internal_payto_uri
-    val contactData = req.contact_data ?: req.challenge_contact_data
-    var retry = if (reqPayto == null) IBAN_ALLOCATION_RETRY_COUNTER else 0
+    var retry = if (req.payto_uri == null) IBAN_ALLOCATION_RETRY_COUNTER else 0
 
     while (true) {
-        val internalPayto = reqPayto ?: IbanPayTo(genIbanPaytoUri())
+        val internalPayto = req.payto_uri ?: IbanPayTo(genIbanPaytoUri())
         val res = db.account.create(
             login = req.username,
             name = req.name,
-            email = contactData?.email?.get(),
-            phone = contactData?.phone?.get(),
+            email = req.contact_data?.email?.get(),
+            phone = req.contact_data?.phone?.get(),
             cashoutPayto = req.cashout_payto_uri,
             password = req.password,
             internalPaytoUri = internalPayto,
@@ -175,7 +199,8 @@ suspend fun createAccount(db: Database, ctx: BankConfig, 
req: RegisterAccountReq
             maxDebt = req.debit_threshold ?: ctx.defaultDebtLimit,
             bonus = if (!req.is_taler_exchange) ctx.registrationBonus 
                     else TalerAmount(0, 0, ctx.regionalCurrency),
-            checkPaytoIdempotent = req.internal_payto_uri != null
+            tanChannel = req.tan_channel,
+            checkPaytoIdempotent = req.payto_uri != null
         )
         // Retry with new IBAN
         if (res == AccountCreationResult.PayToReuse && retry > 0) {
@@ -186,25 +211,41 @@ suspend fun createAccount(db: Database, ctx: BankConfig, 
req: RegisterAccountReq
     }
 }
 
-suspend fun patchAccount(db: Database, ctx: BankConfig, req: 
AccountReconfiguration, username: String, isAdmin: Boolean): AccountPatchResult 
{
+suspend fun patchAccount(
+    db: Database, 
+    ctx: BankConfig, 
+    req: AccountReconfiguration, 
+    username: String, 
+    isAdmin: Boolean, 
+    is2fa: Boolean, 
+    channel: TanChannel? = null, 
+    info: String? = null
+): AccountPatchResult {
     req.debit_threshold?.run { ctx.checkRegionalCurrency(this) }
-    val contactData = req.contact_data ?: req.challenge_contact_data
 
     if (username == "admin" && req.is_public == true)
         throw conflict(
             "'admin' account cannot be public",
             TalerErrorCode.END
         )
+    
+    if (req.tan_channel is Option.Some && req.tan_channel.value != null && 
ctx.tanChannels.get(req.tan_channel.value ) == null) {
+        throw unsupportedTanChannel(req.tan_channel.value)
+    }
 
     return db.account.reconfig( 
         login = username,
         name = req.name,
         cashoutPayto = req.cashout_payto_uri, 
-        email = contactData?.email ?: Option.None,
-        phone = contactData?.phone ?: Option.None,
+        email = req.contact_data?.email ?: Option.None,
+        phone = req.contact_data?.phone ?: Option.None,
+        tan_channel = req.tan_channel,
         isPublic = req.is_public,
         debtLimit = req.debit_threshold,
         isAdmin = isAdmin,
+        is2fa = is2fa,
+        faChannel = channel,
+        faInfo = info,
         allowEditName = ctx.allowEditName,
         allowEditCashout = ctx.allowEditCashout
     )
@@ -239,6 +280,8 @@ private fun Routing.coreBankAccountsApi(db: Database, ctx: 
BankConfig) {
         requireAdmin = !ctx.allowAccountDeletion
     ) {
         delete("/accounts/{USERNAME}") {
+            val challenge = call.challenge(db, Operation.account_delete)
+
             // Not deleting reserved names.
             if (RESERVED_ACCOUNTS.contains(username))
                 throw conflict(
@@ -251,21 +294,26 @@ private fun Routing.coreBankAccountsApi(db: Database, 
ctx: BankConfig) {
                     TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT
                 )
 
-            when (db.account.delete(username)) {
+            when (db.account.delete(username, isAdmin || challenge != null)) {
                 AccountDeletionResult.UnknownAccount -> throw 
unknownAccount(username)
                 AccountDeletionResult.BalanceNotZero -> throw conflict(
                     "Account balance is not zero.",
                     TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO
                 )
+                AccountDeletionResult.TanRequired -> call.respondChallenge(db, 
Operation.account_delete, Unit)
                 AccountDeletionResult.Success -> 
call.respond(HttpStatusCode.NoContent)
             }
         }
     }
     auth(db, TokenScope.readwrite, allowAdmin = true) {
         patch("/accounts/{USERNAME}") {
-            val req = call.receive<AccountReconfiguration>()
-            when (patchAccount(db, ctx, req, username, isAdmin)) {
+            val (req, challenge) = 
call.receiveChallenge<AccountReconfiguration>(db, Operation.account_reconfig)
+            val res = patchAccount(db, ctx, req, username, isAdmin, challenge 
!= null, challenge?.channel, challenge?.info)
+            when (res) {
                 AccountPatchResult.Success -> 
call.respond(HttpStatusCode.NoContent)
+                is AccountPatchResult.TanRequired -> {
+                    call.respondChallenge(db, Operation.account_reconfig, req, 
res.channel, res.info)
+                }
                 AccountPatchResult.UnknownAccount -> throw 
unknownAccount(username)
                 AccountPatchResult.NonAdminName -> throw conflict(
                     "non-admin user cannot change their legal name",
@@ -279,22 +327,24 @@ private fun Routing.coreBankAccountsApi(db: Database, 
ctx: BankConfig) {
                     "non-admin user cannot change their debt limit",
                     TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
                 )
-                AccountPatchResult.NonAdminContact -> throw conflict(
-                    "non-admin user cannot change their contact info",
-                    TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
+                AccountPatchResult.MissingTanInfo -> throw conflict(
+                    "missing info for tan channel ${req.tan_channel.get()}",
+                    TalerErrorCode.BANK_MISSING_TAN_INFO
                 )
             }
         }
         patch("/accounts/{USERNAME}/auth") {
-            val req = call.receive<AccountPasswordChange>()
+            val (req, challenge) = 
call.receiveChallenge<AccountPasswordChange>(db, 
Operation.account_auth_reconfig)
+
             if (!isAdmin && req.old_password == null) {
                 throw conflict(
                     "non-admin user cannot change password without providing 
old password",
                     TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD
                 )
             }
-            when (db.account.reconfigPassword(username, req.new_password, 
req.old_password)) {
+            when (db.account.reconfigPassword(username, req.new_password, 
req.old_password, isAdmin || challenge != null)) {
                 AccountPatchAuthResult.Success -> 
call.respond(HttpStatusCode.NoContent)
+                AccountPatchAuthResult.TanRequired -> 
call.respondChallenge(db, Operation.account_auth_reconfig, req)
                 AccountPatchAuthResult.UnknownAccount -> throw 
unknownAccount(username)
                 AccountPatchAuthResult.OldPasswordMismatch -> throw conflict(
                     "old password does not match",
@@ -356,33 +406,39 @@ private fun Routing.coreBankTransactionsApi(db: Database, 
ctx: BankConfig) {
     }
     auth(db, TokenScope.readwrite) {
         post("/accounts/{USERNAME}/transactions") {
-            val tx = call.receive<TransactionCreateRequest>()
-            val subject = tx.payto_uri.message ?: throw badRequest("Wire 
transfer lacks subject")
-            val amount =
-                    tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire 
transfer lacks amount")
+            val (req, challenge) = 
call.receiveChallenge<TransactionCreateRequest>(db, Operation.bank_transaction)
+
+            val subject = req.payto_uri.message ?: throw badRequest("Wire 
transfer lacks subject")
+            val amount = req.payto_uri.amount ?: req.amount ?: throw 
badRequest("Wire transfer lacks amount")
+
             ctx.checkRegionalCurrency(amount)
+
             val res = db.transaction.create(
-                creditAccountPayto = tx.payto_uri,
+                creditAccountPayto = req.payto_uri,
                 debitAccountUsername = username,
                 subject = subject,
                 amount = amount,
                 timestamp = Instant.now(),
+                is2fa = challenge != null
             )
             when (res) {
-                is BankTransactionResult.UnknownDebtor -> throw 
unknownAccount(username)
-                is BankTransactionResult.BothPartySame -> throw conflict(
+                BankTransactionResult.UnknownDebtor -> throw 
unknownAccount(username)
+                BankTransactionResult.TanRequired -> {
+                    call.respondChallenge(db, Operation.bank_transaction, req)
+                }
+                BankTransactionResult.BothPartySame -> throw conflict(
                     "Wire transfer attempted with credit and debit party being 
the same bank account",
                     TalerErrorCode.BANK_SAME_ACCOUNT 
                 )
-                is BankTransactionResult.UnknownCreditor -> throw conflict(
+                BankTransactionResult.UnknownCreditor -> throw conflict(
                     "Creditor account was not found",
                     TalerErrorCode.BANK_UNKNOWN_CREDITOR
                 )
-                is BankTransactionResult.AdminCreditor -> throw conflict(
+                BankTransactionResult.AdminCreditor -> throw conflict(
                     "Cannot transfer money to admin account",
                     TalerErrorCode.BANK_ADMIN_CREDITOR
                 )
-                is BankTransactionResult.BalanceInsufficient -> throw conflict(
+                BankTransactionResult.BalanceInsufficient -> throw conflict(
                     "Insufficient funds",
                     TalerErrorCode.BANK_UNALLOWED_DEBIT
                 )
@@ -398,7 +454,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, 
ctx: BankConfig) {
             val req = call.receive<BankAccountCreateWithdrawalRequest>()
             ctx.checkRegionalCurrency(req.amount)
             val opId = UUID.randomUUID()
-            when (db.withdrawal.create(username, opId, req.amount)) {
+            when (db.withdrawal.create(username, opId, req.amount, 
Instant.now())) {
                 WithdrawalCreationResult.UnknownAccount -> throw 
unknownAccount(username)
                 WithdrawalCreationResult.AccountIsExchange -> throw conflict(
                     "Exchange account cannot perform withdrawal operation",
@@ -420,25 +476,12 @@ private fun Routing.coreBankWithdrawalApi(db: Database, 
ctx: BankConfig) {
                 }
             }
         }
-        post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") {
-            val opId = call.uuidParameter("withdrawal_id")
-            when (db.withdrawal.abort(opId)) {
-                AbortResult.UnknownOperation -> throw notFound(
-                    "Withdrawal operation $opId not found",
-                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
-                )
-                AbortResult.AlreadyConfirmed -> throw conflict(
-                    "Cannot abort confirmed withdrawal", 
-                    TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
-                )
-                AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
-            }
-        }
         post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") {
-            val opId = call.uuidParameter("withdrawal_id")
-            when (db.withdrawal.confirm(opId, Instant.now())) {
+            val id = call.uuidParameter("withdrawal_id")
+            val challenge = call.challenge(db, Operation.withdrawal)
+            when (db.withdrawal.confirm(username, id, Instant.now(), challenge 
!= null)) {
                 WithdrawalConfirmationResult.UnknownOperation -> throw 
notFound(
-                    "Withdrawal operation $opId not found",
+                    "Withdrawal operation $id not found",
                     TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
                 )
                 WithdrawalConfirmationResult.AlreadyAborted -> throw conflict(
@@ -457,6 +500,9 @@ private fun Routing.coreBankWithdrawalApi(db: Database, 
ctx: BankConfig) {
                     "Exchange to withdraw from not found",
                     TalerErrorCode.BANK_UNKNOWN_CREDITOR
                 )
+                WithdrawalConfirmationResult.TanRequired -> {
+                    call.respondChallenge(db, Operation.withdrawal, 
StoredUUID(id))
+                }
                 WithdrawalConfirmationResult.Success -> 
call.respond(HttpStatusCode.NoContent)
             }
         }
@@ -470,175 +516,51 @@ private fun Routing.coreBankWithdrawalApi(db: Database, 
ctx: BankConfig) {
         )
         call.respond(op)
     }
-    post("/withdrawals/{withdrawal_id}/abort") {
-        val opId = call.uuidParameter("withdrawal_id")
-        when (db.withdrawal.abort(opId)) {
-            AbortResult.UnknownOperation -> throw notFound(
-                "Withdrawal operation $opId not found",
-                TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
-            )
-            AbortResult.AlreadyConfirmed -> throw conflict(
-                "Cannot abort confirmed withdrawal", 
-                TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
-            )
-            AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
-        }
-    }
-    post("/withdrawals/{withdrawal_id}/confirm") {
-        val opId = call.uuidParameter("withdrawal_id")
-        when (db.withdrawal.confirm(opId, Instant.now())) {
-            WithdrawalConfirmationResult.UnknownOperation -> throw notFound(
-                "Withdrawal operation $opId not found",
-                TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
-            )
-            WithdrawalConfirmationResult.AlreadyAborted -> throw conflict(
-                "Cannot confirm an aborted withdrawal",
-                TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
-            )
-            WithdrawalConfirmationResult.NotSelected -> throw conflict(
-                "Cannot confirm an unselected withdrawal",
-                TalerErrorCode.BANK_CONFIRM_INCOMPLETE
-            )
-            WithdrawalConfirmationResult.BalanceInsufficient -> throw conflict(
-                "Insufficient funds",
-                TalerErrorCode.BANK_UNALLOWED_DEBIT
-            )
-            WithdrawalConfirmationResult.UnknownExchange -> throw conflict(
-                "Exchange to withdraw from not found",
-                TalerErrorCode.BANK_UNKNOWN_CREDITOR
-            )
-            WithdrawalConfirmationResult.Success -> 
call.respond(HttpStatusCode.NoContent)
-        }
-    }
 }
 
 private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = 
conditional(ctx.allowConversion) {
     auth(db, TokenScope.readwrite) {
         post("/accounts/{USERNAME}/cashouts") {
-            val req = call.receive<CashoutRequest>()
+            val (req, challenge) = call.receiveChallenge<CashoutRequest>(db, 
Operation.cashout)
 
             ctx.checkRegionalCurrency(req.amount_debit)
             ctx.checkFiatCurrency(req.amount_credit)
-
-            val tanChannel = req.tan_channel ?: TanChannel.sms
-            val tanScript = ctx.tanChannels.get(tanChannel) ?: throw 
libeufinError( 
-                HttpStatusCode.NotImplemented,
-                "Unsupported tan channel $tanChannel",
-                TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED
-            )
-
+        
             val res = db.cashout.create(
                 login = username, 
                 requestUid = req.request_uid,
                 amountDebit = req.amount_debit, 
                 amountCredit = req.amount_credit, 
                 subject = req.subject ?: "", // TODO default subject
-                tanChannel = tanChannel, 
-                tanCode = Tan.genCode(),
-                now = Instant.now(), 
-                retryCounter = TAN_RETRY_COUNTER,
-                validityPeriod = TAN_VALIDITY_PERIOD
+                now = Instant.now(),
+                is2fa = challenge != null
             )
             when (res) {
-                is CashoutCreationResult.AccountNotFound -> throw 
unknownAccount(username)
-                is CashoutCreationResult.BadConversion -> throw conflict(
+                CashoutCreationResult.AccountNotFound -> throw 
unknownAccount(username)
+                CashoutCreationResult.BadConversion -> throw conflict(
                     "Wrong currency conversion",
                     TalerErrorCode.BANK_BAD_CONVERSION
                 )
-                is CashoutCreationResult.AccountIsExchange -> throw conflict(
+                CashoutCreationResult.AccountIsExchange -> throw conflict(
                     "Exchange account cannot perform cashout operation",
                     TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE
                 )
-                is CashoutCreationResult.BalanceInsufficient -> throw conflict(
+                CashoutCreationResult.BalanceInsufficient -> throw conflict(
                     "Insufficient funds to withdraw with Taler",
                     TalerErrorCode.BANK_UNALLOWED_DEBIT
                 )
-                is CashoutCreationResult.MissingTanInfo -> throw conflict(
-                    "Account '$username' missing info for tan channel 
${req.tan_channel}",
-                    TalerErrorCode.BANK_MISSING_TAN_INFO
-                )
-                is CashoutCreationResult.RequestUidReuse -> throw conflict(
+                CashoutCreationResult.RequestUidReuse -> throw conflict(
                     "request_uid used already",
                     TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED
                 )
-                is CashoutCreationResult.Success -> {
-                    res.tanCode?.run {
-                        val exitValue = withContext(Dispatchers.IO) {
-                            val process = ProcessBuilder(tanScript, 
res.tanInfo).start()
-                            try {
-                                process.outputWriter().use { 
it.write(res.tanCode) }
-                                process.onExit().await()
-                            } catch (e: Exception) {
-                                process.destroy()
-                            }
-                            process.exitValue()
-                        }
-                        if (exitValue != 0) {
-                            throw libeufinError(
-                                HttpStatusCode.BadGateway,
-                                "Tan channel script failure with exit value 
$exitValue",
-                                TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
-                            )
-                        }
-                        db.cashout.markSent(res.id, Instant.now(), 
TAN_RETRANSMISSION_PERIOD, tanChannel, res.tanInfo)
-                    }
-                    call.respond(CashoutPending(res.id))
-                }
-            }
-        }
-        post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") {
-            val id = call.longParameter("CASHOUT_ID")
-            when (db.cashout.abort(id, username)) {
-                AbortResult.UnknownOperation -> throw notFound(
-                    "Cashout operation $id not found",
-                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
-                )
-                AbortResult.AlreadyConfirmed -> throw conflict(
-                    "Cannot abort confirmed cashout",
-                    TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT
-                )
-                AbortResult.Success -> call.respond(HttpStatusCode.NoContent)
-            }
-        }
-        post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") {
-            val req = call.receive<CashoutConfirm>()
-            val id = call.longParameter("CASHOUT_ID")
-            when (db.cashout.confirm(
-                id = id,
-                login = username,
-                tanCode = req.tan,
-                timestamp = Instant.now()
-            )) {
-                CashoutConfirmationResult.OP_NOT_FOUND -> throw notFound(
-                    "Cashout operation $id not found",
-                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
-                )
-                CashoutConfirmationResult.ABORTED -> throw conflict(
-                    "Cannot confirm an aborted cashout",
-                    TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT
-                )
-                CashoutConfirmationResult.BAD_TAN_CODE -> throw conflict(
-                    "Incorrect TAN code",
-                    TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
-                )
-                CashoutConfirmationResult.NO_RETRY -> throw libeufinError(
-                    HttpStatusCode.TooManyRequests,
-                    "Too many failed confirmation attempt",
-                    TalerErrorCode.BANK_TAN_RATE_LIMITED
-                )
-                CashoutConfirmationResult.NO_CASHOUT_PAYTO -> throw conflict(
+                CashoutCreationResult.NoCashoutPayto -> throw conflict(
                     "Missing cashout payto uri",
                     TalerErrorCode.BANK_CONFIRM_INCOMPLETE
                 )
-                CashoutConfirmationResult.BALANCE_INSUFFICIENT -> throw 
conflict(
-                    "Insufficient funds",
-                    TalerErrorCode.BANK_UNALLOWED_DEBIT
-                )
-                CashoutConfirmationResult.BAD_CONVERSION -> throw conflict(
-                    "Wrong currency conversion",
-                    TalerErrorCode.BANK_BAD_CONVERSION
-                )
-                CashoutConfirmationResult.SUCCESS -> 
call.respond(HttpStatusCode.NoContent)
+                CashoutCreationResult.TanRequired -> {
+                    call.respondChallenge(db, Operation.cashout, req)
+                }
+                is CashoutCreationResult.Success -> 
call.respond(CashoutResponse(res.id))
             }
         }
     }
@@ -673,3 +595,83 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: 
BankConfig) = conditio
         }
     }
 }
+
+private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) {
+    auth(db, TokenScope.readwrite) {
+        post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") {
+            val id = call.longParameter("CHALLENGE_ID")
+            val res = db.tan.send(
+                id = id,
+                login = username,
+                code = Tan.genCode(),
+                now = Instant.now(), 
+                retryCounter = TAN_RETRY_COUNTER,
+                validityPeriod = TAN_VALIDITY_PERIOD
+            )
+            when (res) {
+                TanSendResult.NotFound -> throw notFound(
+                    "Challenge $id not found",
+                    TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
+                )
+                is TanSendResult.Success -> {
+                    res.tanCode?.run {
+                        val tanScript = ctx.tanChannels.get(res.tanChannel) 
+                            ?: throw unsupportedTanChannel(res.tanChannel)
+                        val exitValue = withContext(Dispatchers.IO) {
+                            val process = ProcessBuilder(tanScript, 
res.tanInfo).start()
+                            try {
+                                process.outputWriter().use { 
it.write(res.tanCode) }
+                                process.onExit().await()
+                            } catch (e: Exception) {
+                                process.destroy()
+                            }
+                            process.exitValue()
+                        }
+                        if (exitValue != 0) {
+                            throw libeufinError(
+                                HttpStatusCode.BadGateway,
+                                "Tan channel script failure with exit value 
$exitValue",
+                                TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED
+                            )
+                        }
+                        db.tan.markSent(id, Instant.now(), 
TAN_RETRANSMISSION_PERIOD)
+                    }
+                    call.respond(TanTransmission(
+                        tan_info = res.tanInfo,
+                        tan_channel = res.tanChannel
+                    ))
+                }
+            }
+        }
+        post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") {
+            val id = call.longParameter("CHALLENGE_ID")
+            val req = call.receive<ChallengeSolve>()
+            val res = db.tan.solve(
+                id = id,
+                login = username,
+                code = req.tan,
+                now = Instant.now()
+            )
+            when (res) {
+                TanSolveResult.NotFound -> throw notFound(
+                    "Challenge $id not found",
+                    TalerErrorCode.BANK_CHALLENGE_NOT_FOUND
+                )
+                TanSolveResult.BadCode -> throw conflict(
+                    "Incorrect TAN code",
+                    TalerErrorCode.BANK_TAN_CHALLENGE_FAILED
+                )
+                TanSolveResult.NoRetry -> throw libeufinError(
+                    HttpStatusCode.TooManyRequests,
+                    "Too many failed confirmation attempt",
+                    TalerErrorCode.BANK_TAN_RATE_LIMITED
+                )
+                TanSolveResult.Expired -> throw conflict(
+                    "Challenge expired",
+                    TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED
+                )
+                is TanSolveResult.Success -> 
call.respond(HttpStatusCode.NoContent)
+            }
+        }
+    }
+}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
index 68636fbb..04c3881e 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Error.kt
@@ -145,4 +145,11 @@ fun unknownAccount(id: String): LibeufinException {
         "Account '$id' not found",
         TalerErrorCode.BANK_UNKNOWN_ACCOUNT
     )
+}
+
+fun unsupportedTanChannel(channel: TanChannel): LibeufinException {
+    return conflict(
+        "Unsupported tan channel $channel",
+        TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED
+    )
 }
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index 50d47429..9f543c48 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -59,6 +59,8 @@ import tech.libeufin.bank.db.*
 import tech.libeufin.util.*
 
 private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main")
+// Dirty local variable to stop the server in test TODO remove this ugly hack
+var engine: ApplicationEngine? = null 
 
 /**
  * This plugin check for body lenght limit and inflates the requests that have 
"Content-Encoding: deflate"
@@ -144,6 +146,7 @@ fun Application.corebankWebApp(db: Database, ctx: 
BankConfig) {
             when (cause) {
                 is LibeufinException -> call.err(cause)
                 is SQLException -> {
+                    logger.debug("request failed", cause)
                     when (cause.sqlState) {
                         PSQLState.SERIALIZATION_FAILURE.state -> call.err(
                             HttpStatusCode.InternalServerError,
@@ -190,6 +193,7 @@ fun Application.corebankWebApp(db: Database, ctx: 
BankConfig) {
                     )
                 }
                 else -> {
+                    logger.debug("request failed", cause)
                     call.err(
                         HttpStatusCode.InternalServerError,
                         cause.message,
@@ -236,23 +240,24 @@ class BankDbInit : CliktCommand("Initialize the 
libeufin-bank database", name =
         val config = talerConfig(common.config)
         val cfg = config.loadDbConfig()
         val ctx = config.loadBankConfig();
-        val db = Database(cfg.dbConnStr, ctx.regionalCurrency, 
ctx.fiatCurrency)
-        runBlocking {
-            db.conn { conn ->
-                if (requestReset) {
-                    resetDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-bank")
+        Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { 
db -> 
+            runBlocking {
+                db.conn { conn ->
+                    if (requestReset) {
+                        resetDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-bank")
+                    }
+                    initializeDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-bank")
+                }
+                // Create admin account if missing
+                val res = maybeCreateAdminAccount(db, ctx) // logs provided by 
the helper
+                when (res) {
+                    AccountCreationResult.BonusBalanceInsufficient -> {}
+                    AccountCreationResult.LoginReuse -> {}
+                    AccountCreationResult.PayToReuse -> 
+                        throw Exception("Failed to create admin's account")
+                    AccountCreationResult.Success ->
+                        logger.info("Admin's account created")
                 }
-                initializeDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-bank")
-            }
-            // Create admin account if missing
-            val res = maybeCreateAdminAccount(db, ctx) // logs provided by the 
helper
-            when (res) {
-                AccountCreationResult.BonusBalanceInsufficient -> {}
-                AccountCreationResult.LoginReuse -> {}
-                AccountCreationResult.PayToReuse -> 
-                    throw Exception("Failed to create admin's account")
-                AccountCreationResult.Success ->
-                    logger.info("Admin's account created")
             }
         }
     }
@@ -266,54 +271,56 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP 
server", name = "serve")
         val ctx = cfg.loadBankConfig()
         val dbCfg = cfg.loadDbConfig()
         val serverCfg = cfg.loadServerConfig()
-        val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, 
ctx.fiatCurrency)
-        runBlocking {
-            if (ctx.allowConversion) {
-                logger.info("Ensure exchange account exists")
-                val info = db.account.bankInfo("exchange")
-                if (info == null) {
-                    throw Exception("Exchange account missing: an exchange 
account named 'exchange' is required for conversion to be enabled")
-                } else if (!info.isTalerExchange) {
-                    throw Exception("Account is not an exchange: an exchange 
account named 'exchange' is required for conversion to be enabled")
-                }
-                logger.info("Ensure conversion is enabled")
-                val sqlProcedures = 
File("${dbCfg.sqlDir}/libeufin-conversion-setup.sql")
-                if (!sqlProcedures.exists()) {
-                    throw Exception("Missing libeufin-conversion-setup.sql 
file")
-                }
-                db.conn { it.execSQLUpdate(sqlProcedures.readText()) }
-            } else {
-                logger.info("Ensure conversion is disabled")
-                val sqlProcedures = 
File("${dbCfg.sqlDir}/libeufin-conversion-drop.sql")
-                if (!sqlProcedures.exists()) {
-                    throw Exception("Missing libeufin-conversion-drop.sql 
file")
+        Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use 
{ db ->
+            runBlocking {
+                if (ctx.allowConversion) {
+                    logger.info("Ensure exchange account exists")
+                    val info = db.account.bankInfo("exchange")
+                    if (info == null) {
+                        throw Exception("Exchange account missing: an exchange 
account named 'exchange' is required for conversion to be enabled")
+                    } else if (!info.isTalerExchange) {
+                        throw Exception("Account is not an exchange: an 
exchange account named 'exchange' is required for conversion to be enabled")
+                    }
+                    logger.info("Ensure conversion is enabled")
+                    val sqlProcedures = 
File("${dbCfg.sqlDir}/libeufin-conversion-setup.sql")
+                    if (!sqlProcedures.exists()) {
+                        throw Exception("Missing libeufin-conversion-setup.sql 
file")
+                    }
+                    db.conn { it.execSQLUpdate(sqlProcedures.readText()) }
+                } else {
+                    logger.info("Ensure conversion is disabled")
+                    val sqlProcedures = 
File("${dbCfg.sqlDir}/libeufin-conversion-drop.sql")
+                    if (!sqlProcedures.exists()) {
+                        throw Exception("Missing libeufin-conversion-drop.sql 
file")
+                    }
+                    db.conn { it.execSQLUpdate(sqlProcedures.readText()) }
+                    // Remove conversion info from the database ?
                 }
-                db.conn { it.execSQLUpdate(sqlProcedures.readText()) }
-                // Remove conversion info from the database ?
             }
-        }
-        
-        val env = applicationEngineEnvironment {
-            connector {
-                when (serverCfg) {
-                    is ServerConfig.Tcp -> {
-                        port = serverCfg.port
+            
+            val env = applicationEngineEnvironment {
+                connector {
+                    when (serverCfg) {
+                        is ServerConfig.Tcp -> {
+                            port = serverCfg.port
+                        }
+                        is ServerConfig.Unix ->
+                            throw Exception("Can only serve libeufin-bank via 
TCP")
                     }
-                    is ServerConfig.Unix ->
-                        throw Exception("Can only serve libeufin-bank via TCP")
                 }
+                module { corebankWebApp(db, ctx) }
             }
-            module { corebankWebApp(db, ctx) }
-        }
-        val engine = embeddedServer(Netty, env)
-        when (serverCfg) {
-            is ServerConfig.Tcp -> {
-                logger.info("Server listening on 
http://localhost:${serverCfg.port}";)
+            val local = embeddedServer(Netty, env)
+            engine = local
+            when (serverCfg) {
+                is ServerConfig.Tcp -> {
+                    logger.info("Server listening on 
http://localhost:${serverCfg.port}";)
+                }
+                is ServerConfig.Unix ->
+                    throw Exception("Can only serve libeufin-bank via TCP")
             }
-            is ServerConfig.Unix ->
-                throw Exception("Can only serve libeufin-bank via TCP")
+            local.start(wait = true)
         }
-        engine.start(wait = true)
     }
 }
 
@@ -329,15 +336,17 @@ class ChangePw : CliktCommand("Change account password", 
name = "passwd") {
         val cfg = talerConfig(common.config)
         val ctx = cfg.loadBankConfig() 
         val dbCfg = cfg.loadDbConfig()
-        val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, 
ctx.fiatCurrency)
-        runBlocking {
-            val res = db.account.reconfigPassword(username, password, null)
-            when (res) {
-                AccountPatchAuthResult.UnknownAccount ->
-                    throw Exception("Password change for '$username' account 
failed: unknown account")
-                AccountPatchAuthResult.OldPasswordMismatch -> { /* Can never 
happen */ }
-                AccountPatchAuthResult.Success ->
-                    logger.info("Password change for '$username' account 
succeeded")
+        Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use 
{ db ->
+            runBlocking {
+                val res = db.account.reconfigPassword(username, password, 
null, true)
+                when (res) {
+                    AccountPatchAuthResult.UnknownAccount ->
+                        throw Exception("Password change for '$username' 
account failed: unknown account")
+                    AccountPatchAuthResult.OldPasswordMismatch,
+                        AccountPatchAuthResult.TanRequired -> { /* Can never 
happen */ }
+                    AccountPatchAuthResult.Success ->
+                        logger.info("Password change for '$username' account 
succeeded")
+                }
             }
         }
     }
@@ -365,6 +374,7 @@ class EditAccount : CliktCommand(
     ).boolean()
     private val email: String? by option(help = "E-Mail address used for TAN 
transmission")
     private val phone: String? by option(help = "Phone number used for TAN 
transmission")
+    private val tan_channel: String? by option(help = "which channel TAN 
challenges should be sent to")
     private val cashout_payto_uri: IbanPayTo? by option(help = "Payto URI of a 
fiant account who receive cashout amount").convert { IbanPayTo(it) }
     private val debit_threshold: TalerAmount? by option(help = "Max debit 
allowed for this account").convert { TalerAmount(it) }
  
@@ -372,31 +382,34 @@ class EditAccount : CliktCommand(
         val cfg = talerConfig(common.config)
         val ctx = cfg.loadBankConfig() 
         val dbCfg = cfg.loadDbConfig()
-        val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, 
ctx.fiatCurrency)
-        runBlocking {
-            val req = AccountReconfiguration(
-                name = name,
-                is_taler_exchange = exchange,
-                is_public = is_public,
-                contact_data = ChallengeContactData(
-                    // PATCH semantic, if not given do not change, if empty 
remove
-                    email = if (email == null) Option.None else Option.Some(if 
(email != "") email else null),
-                    phone = if (phone == null) Option.None else Option.Some(if 
(phone != "") phone else null), 
-                ),
-                cashout_payto_uri = Option.Some(cashout_payto_uri),
-                debit_threshold = debit_threshold
-            )
-            when (patchAccount(db, ctx, req, username, true)) {
-                AccountPatchResult.Success -> 
-                    logger.info("Account '$username' edited")
-                AccountPatchResult.UnknownAccount -> 
-                    throw Exception("Account '$username' not found")
-                AccountPatchResult.NonAdminName,
-                    AccountPatchResult.NonAdminCashout,
-                    AccountPatchResult.NonAdminDebtLimit,
-                    AccountPatchResult.NonAdminContact -> {
-                        // Unreachable as we edit account as admin
-                    }
+        Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use 
{ db ->
+            runBlocking {
+                val req = AccountReconfiguration(
+                    name = name,
+                    is_taler_exchange = exchange,
+                    is_public = is_public,
+                    contact_data = ChallengeContactData(
+                        // PATCH semantic, if not given do not change, if 
empty remove
+                        email = if (email == null) Option.None else 
Option.Some(if (email != "") email else null),
+                        phone = if (phone == null) Option.None else 
Option.Some(if (phone != "") phone else null), 
+                    ),
+                    cashout_payto_uri = Option.Some(cashout_payto_uri),
+                    debit_threshold = debit_threshold
+                )
+                when (patchAccount(db, ctx, req, username, true, true)) {
+                    AccountPatchResult.Success -> 
+                        logger.info("Account '$username' edited")
+                    AccountPatchResult.UnknownAccount -> 
+                        throw Exception("Account '$username' not found")
+                    AccountPatchResult.MissingTanInfo -> 
+                        throw Exception("missing info for tan channel 
${req.tan_channel.get()}")
+                    AccountPatchResult.NonAdminName,
+                        AccountPatchResult.NonAdminCashout,
+                        AccountPatchResult.NonAdminDebtLimit,
+                        is AccountPatchResult.TanRequired  -> {
+                            // Unreachable as we edit account as admin
+                        }
+                }
             }
         }
     }
@@ -444,41 +457,43 @@ class CreateAccount : CliktCommand(
     private val options by CreateAccountOption().cooccurring()
  
     override fun run() = cliCmd(logger) {
+        // TODO support setting tan
         val cfg = talerConfig(common.config)
         val ctx = cfg.loadBankConfig() 
         val dbCfg = cfg.loadDbConfig()
-        val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, 
ctx.fiatCurrency)
-        runBlocking {
-            val req = json ?: options?.run {
-                RegisterAccountRequest(
-                    username = username,
-                    password = password,
-                    name = name,
-                    is_public = is_public,
-                    is_taler_exchange = exchange,
-                    contact_data = ChallengeContactData(
-                        email = Option.Some(email),
-                        phone = Option.Some(phone), 
-                    ),
-                    cashout_payto_uri = cashout_payto_uri,
-                    internal_payto_uri = internal_payto_uri,
-                    payto_uri = payto_uri,
-                    debit_threshold = debit_threshold
-                ) 
-            }
-            req?.let {
-                val (result, internalPayto) = createAccount(db, ctx, req, 
true);
-                when (result) {
-                    AccountCreationResult.BonusBalanceInsufficient ->
-                        throw Exception("Insufficient admin funds to grant 
bonus")
-                    AccountCreationResult.LoginReuse ->
-                        throw Exception("Account username reuse 
'${req.username}'")
-                    AccountCreationResult.PayToReuse ->
-                        throw Exception("Bank internalPayToUri reuse 
'${internalPayto.canonical}'")
-                    AccountCreationResult.Success ->
-                        logger.info("Account '${req.username}' created")
+
+        Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use 
{ db ->
+            runBlocking {
+                val req = json ?: options?.run {
+                    RegisterAccountRequest(
+                        username = username,
+                        password = password,
+                        name = name,
+                        is_public = is_public,
+                        is_taler_exchange = exchange,
+                        contact_data = ChallengeContactData(
+                            email = Option.Some(email),
+                            phone = Option.Some(phone), 
+                        ),
+                        cashout_payto_uri = cashout_payto_uri,
+                        payto_uri = payto_uri,
+                        debit_threshold = debit_threshold
+                    ) 
+                }
+                req?.let {
+                    val (result, internalPayto) = createAccount(db, ctx, req, 
true);
+                    when (result) {
+                        AccountCreationResult.BonusBalanceInsufficient ->
+                            throw Exception("Insufficient admin funds to grant 
bonus")
+                        AccountCreationResult.LoginReuse ->
+                            throw Exception("Account username reuse 
'${req.username}'")
+                        AccountCreationResult.PayToReuse ->
+                            throw Exception("Bank internalPayToUri reuse 
'${internalPayto.canonical}'")
+                        AccountCreationResult.Success ->
+                            logger.info("Account '${req.username}' created")
+                    }
+                    println(internalPayto)
                 }
-                println(internalPayto)
             }
         }
     }
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
index 1322fe5f..6f42f5cb 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
@@ -28,8 +28,7 @@ import java.time.Instant
 import java.time.temporal.ChronoUnit
 import java.util.*
 import java.util.concurrent.TimeUnit
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.Serializable
+import kotlinx.serialization.*
 import kotlinx.serialization.descriptors.*
 import kotlinx.serialization.encoding.*
 import kotlinx.serialization.json.*
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
index c827d406..c94fa09e 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -72,6 +72,15 @@ enum class Timeframe {
     year
 }
 
+enum class Operation {
+    account_reconfig,
+    account_delete,
+    account_auth_reconfig,
+    bank_transaction,
+    cashout,
+    withdrawal
+}
+
 @Serializable(with = Option.Serializer::class)
 sealed class Option<out T> {
     object None : Option<Nothing>()
@@ -111,6 +120,17 @@ sealed class Option<out T> {
     }
 }
 
+@Serializable
+data class TanChallenge(
+    val challenge_id: Long
+)
+
+@Serializable
+data class TanTransmission(
+    val tan_info: String,
+    val tan_channel: TanChannel
+)
+
 /**
  * HTTP response type of successful token refresh.
  * access_token is the Crockford encoding of the 32 byte
@@ -156,9 +176,7 @@ data class RegisterAccountRequest(
     val cashout_payto_uri: IbanPayTo? = null,
     val payto_uri: IbanPayTo? = null,
     val debit_threshold: TalerAmount? = null,
-    // TODO remove
-    val internal_payto_uri: IbanPayTo? = null,
-    val challenge_contact_data: ChallengeContactData? = null,
+    val tan_channel: TanChannel? = null,
 )
 
 @Serializable
@@ -176,8 +194,7 @@ data class AccountReconfiguration(
     val name: String? = null,
     val is_public: Boolean? = null,
     val debit_threshold: TalerAmount? = null,
-    // TODO remove
-    val challenge_contact_data: ChallengeContactData? = null,
+    val tan_channel: Option<TanChannel?> = Option.None,
     val is_taler_exchange: Boolean? = null,
 )
 
@@ -335,6 +352,7 @@ data class AccountData(
     val debit_threshold: TalerAmount,
     val contact_data: ChallengeContactData? = null,
     val cashout_payto_uri: String? = null,
+    val tan_channel: TanChannel? = null,
     val is_public: Boolean,
     val is_taler_exchange: Boolean
 )
@@ -389,10 +407,6 @@ data class WithdrawalPublicInfo (
     val username: String,
     val selected_reserve_pub: EddsaPublicKey? = null,
     val selected_exchange_account: String? = null,
-    // TODO remove
-    val aborted: Boolean,
-    val confirmation_done: Boolean,
-    val selection_done: Boolean,
 )
 
 @Serializable
@@ -447,12 +461,11 @@ data class CashoutRequest(
     val request_uid: ShortHashCode,
     val subject: String?,
     val amount_debit: TalerAmount,
-    val amount_credit: TalerAmount,
-    val tan_channel: TanChannel?
+    val amount_credit: TalerAmount
 )
 
 @Serializable
-data class CashoutPending(
+data class CashoutResponse(
     val cashout_id: Long,
 )
 
@@ -493,7 +506,7 @@ data class CashoutStatusResponse(
 )
 
 @Serializable
-data class CashoutConfirm(
+data class ChallengeSolve(
     val tan: String
 )
 
@@ -628,8 +641,6 @@ data class PublicAccount(
     val payto_uri: String,
     val balance: Balance,
     val is_taler_exchange: Boolean,
-    // TODO remove
-    val account_name: String
 )
 
 /**
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt
index 8359e5e8..5dddd807 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Tan.kt
@@ -19,8 +19,70 @@
 package tech.libeufin.bank
 
 import java.security.SecureRandom
-import java.util.UUID
+import java.time.Instant
+import java.time.Duration
 import java.text.DecimalFormat
+import kotlinx.serialization.json.Json
+import io.ktor.http.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.application.*
+import tech.libeufin.bank.db.TanDAO.*
+import tech.libeufin.bank.db.*
+import tech.libeufin.bank.auth.*
+import io.ktor.util.pipeline.PipelineContext
+
+
+inline suspend fun <reified B> ApplicationCall.respondChallenge(
+    db: Database, 
+    op: Operation, 
+    body: B, 
+    channel: TanChannel? = null,
+    info: String? = null
+) {
+    val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), 
body); 
+    val code = Tan.genCode()
+    val id = db.tan.new(
+        login = username, 
+        op = op,
+        body = json,
+        code = code,
+        now = Instant.now(), 
+        retryCounter = TAN_RETRY_COUNTER,
+        validityPeriod = TAN_VALIDITY_PERIOD,
+        channel = channel,
+        info = info
+    )
+    respond(
+        status = HttpStatusCode.Accepted,
+        message = TanChallenge(id)
+    )
+}
+
+inline suspend fun <reified B> ApplicationCall.receiveChallenge(
+    db: Database,
+    op: Operation
+): Pair<B, Challenge?> {
+    val id = request.headers["X-Challenge-Id"]?.toLongOrNull()
+    return if (id != null) {
+        val challenge = db.tan.challenge(id, username, op)!!
+        Pair(Json.decodeFromString(challenge.body), challenge)
+    } else {
+        Pair(this.receive(), null)
+    }
+}
+
+suspend fun ApplicationCall.challenge(
+    db: Database,
+    op: Operation
+): Challenge? {
+    val id = request.headers["X-Challenge-Id"]?.toLongOrNull()
+    return if (id != null) {
+        db.tan.challenge(id, username, op)!!
+    } else {
+        null
+    }
+}
 
 object Tan {
     private val CODE_FORMAT = DecimalFormat("00000000");  
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
index fac65264..70b9bd1f 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -39,14 +39,15 @@ class AccountDAO(private val db: Database) {
         login: String,
         password: String,
         name: String,
-        email: String? = null,
-        phone: String? = null,
-        cashoutPayto: IbanPayTo? = null,
+        email: String?,
+        phone: String?,
+        cashoutPayto: IbanPayTo?,
         internalPaytoUri: IbanPayTo,
         isPublic: Boolean,
         isTalerExchange: Boolean,
         maxDebt: TalerAmount,
         bonus: TalerAmount,
+        tanChannel: TanChannel?,
         // Whether to check [internalPaytoUri] for idempotency
         checkPaytoIdempotent: Boolean
     ): AccountCreationResult = db.serializable { it ->
@@ -60,11 +61,13 @@ class AccountDAO(private val db: Database) {
                     AND (NOT ? OR internal_payto_uri=?)
                     AND is_public=?
                     AND is_taler_exchange=?
+                    AND tan_channel IS NOT DISTINCT FROM ?::tan_enum
                 FROM customers 
                     JOIN bank_accounts
                         ON customer_id=owning_customer_id
                 WHERE login=?
             """).run {
+                // TODO check max debt
                 setString(1, name)
                 setString(2, email)
                 setString(3, phone)
@@ -73,11 +76,13 @@ class AccountDAO(private val db: Database) {
                 setString(6, internalPaytoUri.canonical)
                 setBoolean(7, isPublic)
                 setBoolean(8, isTalerExchange)
-                setString(9, login)
+                setString(9, tanChannel?.name)
+                setString(10, login)
                 oneOrNull { 
                     CryptoUtil.checkpw(password, it.getString(1)) && 
it.getBoolean(2)
                 } 
             }
+            
             if (idempotent != null) {
                 if (idempotent) {
                     AccountCreationResult.Success
@@ -85,6 +90,20 @@ class AccountDAO(private val db: Database) {
                     AccountCreationResult.LoginReuse
                 }
             } else {
+                conn.prepareStatement("""
+                    INSERT INTO iban_history(
+                        iban
+                        ,creation_time
+                    ) VALUES (?, ?)
+                """).run {
+                    setString(1, internalPaytoUri.iban)
+                    setLong(2, now)
+                    if (!executeUpdateViolation()) {
+                        conn.rollback()
+                        return@transaction AccountCreationResult.PayToReuse
+                    }
+                }
+
                 val customerId = conn.prepareStatement("""
                     INSERT INTO customers (
                         login
@@ -93,7 +112,8 @@ class AccountDAO(private val db: Database) {
                         ,email
                         ,phone
                         ,cashout_payto
-                    ) VALUES (?, ?, ?, ?, ?, ?)
+                        ,tan_channel
+                    ) VALUES (?, ?, ?, ?, ?, ?, ?::tan_enum)
                         RETURNING customer_id
                 """
                 ).run {
@@ -103,22 +123,9 @@ class AccountDAO(private val db: Database) {
                     setString(4, email)
                     setString(5, phone)
                     setString(6, cashoutPayto?.canonical)
+                    setString(7, tanChannel?.name)
                     oneOrNull { it.getLong("customer_id") }!!
                 }
-
-                conn.prepareStatement("""
-                    INSERT INTO iban_history(
-                        iban
-                        ,creation_time
-                    ) VALUES (?, ?)
-                """).run {
-                    setString(1, internalPaytoUri.iban)
-                    setLong(2, now)
-                    if (!executeUpdateViolation()) {
-                        conn.rollback()
-                        return@transaction AccountCreationResult.PayToReuse
-                    }
-                }
             
                 conn.prepareStatement("""
                     INSERT INTO bank_accounts(
@@ -144,7 +151,7 @@ class AccountDAO(private val db: Database) {
                 if (bonus.value != 0L || bonus.frac != 0) {
                     conn.prepareStatement("""
                         SELECT out_balance_insufficient
-                        FROM 
bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?)
+                        FROM 
bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true)
                     """).run {
                         setString(1, internalPaytoUri.canonical)
                         setLong(2, bonus.value)
@@ -172,36 +179,44 @@ class AccountDAO(private val db: Database) {
     enum class AccountDeletionResult {
         Success,
         UnknownAccount,
-        BalanceNotZero
+        BalanceNotZero,
+        TanRequired
     }
 
     /** Delete account [login] */
-    suspend fun delete(login: String): AccountDeletionResult = db.serializable 
{ conn ->
+    suspend fun delete(
+        login: String, 
+        is2fa: Boolean
+    ): AccountDeletionResult = db.serializable { conn ->
         val stmt = conn.prepareStatement("""
             SELECT
-              out_nx_customer,
-              out_balance_not_zero
-              FROM customer_delete(?);
+              out_not_found,
+              out_balance_not_zero,
+              out_tan_required
+              FROM account_delete(?,?);
         """)
         stmt.setString(1, login)
+        stmt.setBoolean(2, is2fa)
         stmt.executeQuery().use {
             when {
                 !it.next() -> throw internalServerError("Deletion returned 
nothing.")
-                it.getBoolean("out_nx_customer") -> 
AccountDeletionResult.UnknownAccount
+                it.getBoolean("out_not_found") -> 
AccountDeletionResult.UnknownAccount
                 it.getBoolean("out_balance_not_zero") -> 
AccountDeletionResult.BalanceNotZero
+                it.getBoolean("out_tan_required") -> 
AccountDeletionResult.TanRequired
                 else -> AccountDeletionResult.Success
             }
         }
     }
 
     /** Result status of customer account patch */
-    enum class AccountPatchResult {
-        UnknownAccount,
-        NonAdminName,
-        NonAdminCashout,
-        NonAdminDebtLimit,
-        NonAdminContact,
-        Success
+    sealed class AccountPatchResult {
+        data object UnknownAccount: AccountPatchResult()
+        data object NonAdminName: AccountPatchResult()
+        data object NonAdminCashout: AccountPatchResult()
+        data object NonAdminDebtLimit: AccountPatchResult()
+        data object MissingTanInfo: AccountPatchResult()
+        data class TanRequired(val channel: TanChannel?, val info: String?): 
AccountPatchResult()
+        data object Success: AccountPatchResult()
     }
 
     /** Change account [login] informations */
@@ -211,27 +226,33 @@ class AccountDAO(private val db: Database) {
         cashoutPayto: Option<IbanPayTo?>,
         phone: Option<String?>,
         email: Option<String?>,
+        tan_channel: Option<TanChannel?>,
         isPublic: Boolean?,
         debtLimit: TalerAmount?,
         isAdmin: Boolean,
+        is2fa: Boolean,
+        faChannel: TanChannel?,
+        faInfo: String?,
         allowEditName: Boolean,
         allowEditCashout: Boolean,
     ): AccountPatchResult = db.serializable { it.transaction { conn ->
         val checkName = !isAdmin && !allowEditName && name != null
         val checkCashout = !isAdmin && !allowEditCashout && 
cashoutPayto.isSome()
         val checkDebtLimit = !isAdmin && debtLimit != null
-        val checkPhone = !isAdmin && phone.isSome()
-        val checkEmail = !isAdmin && email.isSome()
 
         // Get user ID and check reconfig rights
-        val customer_id = conn.prepareStatement("""
+        val (customerId, currChannel, currInfo) = conn.prepareStatement("""
             SELECT
                 customer_id
-                ,(${ if (checkName) "name != ? " else "false" }) as name_change
+                ,(${ if (checkName) "name != ?" else "false" }) as name_change
                 ,(${ if (checkCashout) "cashout_payto IS DISTINCT FROM ?" else 
"false" }) as cashout_change
                 ,(${ if (checkDebtLimit) "max_debt != (?, ?)::taler_amount" 
else "false" }) as debt_limit_change
-                ,(${ if (checkPhone) "phone IS DISTINCT FROM  ?" else "false" 
}) as phone_change
-                ,(${ if (checkEmail) "email IS DISTINCT FROM  ?" else "false" 
}) as email_change
+                ,(${ when (tan_channel.get()) {
+                    null -> "false"
+                    TanChannel.sms -> if (phone.get() != null) "false" else 
"phone IS NULL"
+                    TanChannel.email -> if (email.get() != null) "false" else 
"email IS NULL"
+                }}) as missing_tan_info
+                ,tan_channel, phone, email
             FROM customers
                 JOIN bank_accounts 
                 ON customer_id=owning_customer_id
@@ -248,12 +269,6 @@ class AccountDAO(private val db: Database) {
                 setLong(idx, debtLimit!!.value); idx++
                 setInt(idx, debtLimit.frac); idx++
             }
-            if (checkPhone) {
-                setString(idx, phone.get()); idx++
-            }
-            if (checkEmail) {
-                setString(idx, email.get()); idx++
-            }
             setString(idx, login)
             executeQuery().use {
                 when {
@@ -261,12 +276,49 @@ class AccountDAO(private val db: Database) {
                     it.getBoolean("name_change") -> return@transaction 
AccountPatchResult.NonAdminName
                     it.getBoolean("cashout_change") -> return@transaction 
AccountPatchResult.NonAdminCashout
                     it.getBoolean("debt_limit_change") -> return@transaction 
AccountPatchResult.NonAdminDebtLimit
-                    it.getBoolean("phone_change") -> return@transaction 
AccountPatchResult.NonAdminContact
-                    it.getBoolean("email_change") -> return@transaction 
AccountPatchResult.NonAdminContact
-                    else -> it.getLong("customer_id")
+                    it.getBoolean("missing_tan_info") -> return@transaction 
AccountPatchResult.MissingTanInfo
+                    else -> {
+                        val currChannel = it.getString("tan_channel")?.run { 
TanChannel.valueOf(this) }
+                        Triple(
+                            it.getLong("customer_id"),
+                            currChannel,
+                            when (tan_channel.get() ?: currChannel) {
+                                TanChannel.sms -> it.getString("phone")
+                                TanChannel.email -> it.getString("email")
+                                null -> null
+                            }
+                        )
+                    }
                 }
             }
         }
+ 
+        val newChannel = tan_channel.get();
+        val newInfo = when (newChannel ?: currChannel) {
+            TanChannel.sms -> phone.get()
+            TanChannel.email -> email.get()
+            null -> null
+        }
+
+        // Tan channel verification
+        if (!isAdmin) {
+            // Check performed 2fa check
+            if (currChannel != null && !is2fa) {
+                // Perform challenge with current settings
+                return@transaction AccountPatchResult.TanRequired(channel = 
null, info = null)
+            }
+            // If channel or info changed and the 2fa challenge is performed 
with old settings perform a new challenge with new settings
+            if ((newChannel != null && newChannel != faChannel) || (newInfo != 
null && newInfo != faInfo)) {
+                return@transaction AccountPatchResult.TanRequired(channel = 
newChannel ?: currChannel, info = newInfo ?: currInfo)
+            }
+        }
+
+        // Invalidate current challenges
+        if (newChannel != null || newInfo != null) {
+            val stmt = conn.prepareStatement("UPDATE tan_challenges SET 
expiration_date=0 WHERE customer=?")
+            stmt.setLong(1, customerId)
+            stmt.execute()
+        }
 
         // Update bank info
         conn.dynamicUpdate(
@@ -279,7 +331,7 @@ class AccountDAO(private val db: Database) {
             sequence {
                 isPublic?.let { yield(it) }
                 debtLimit?.let { yield(it.value); yield(it.frac) }
-                yield(customer_id)
+                yield(customerId)
             }
         )
 
@@ -290,6 +342,7 @@ class AccountDAO(private val db: Database) {
                 cashoutPayto.some { yield("cashout_payto=?") }
                 phone.some { yield("phone=?") }
                 email.some { yield("email=?") }
+                tan_channel.some { yield("tan_channel=?::tan_enum") }
                 name?.let { yield("name=?") }
             },
             "WHERE customer_id = ?",
@@ -297,8 +350,9 @@ class AccountDAO(private val db: Database) {
                 cashoutPayto.some { yield(it?.canonical) }
                 phone.some { yield(it) }
                 email.some { yield(it) }
+                tan_channel.some { yield(it?.name) }
                 name?.let { yield(it) }
-                yield(customer_id)
+                yield(customerId)
             }
         )
 
@@ -310,20 +364,29 @@ class AccountDAO(private val db: Database) {
     enum class AccountPatchAuthResult {
         UnknownAccount,
         OldPasswordMismatch,
+        TanRequired,
         Success
     }
 
     /** Change account [login] password to [newPw] if current match [oldPw] */
-    suspend fun reconfigPassword(login: String, newPw: String, oldPw: 
String?): AccountPatchAuthResult = db.serializable {
+    suspend fun reconfigPassword(
+        login: String, 
+        newPw: String, 
+        oldPw: String?,
+        is2fa: Boolean
+    ): AccountPatchAuthResult = db.serializable {
         it.transaction { conn ->
-            val currentPwh = conn.prepareStatement("""
-                SELECT password_hash FROM customers WHERE login=?
+            val (currentPwh, tanRequired) = conn.prepareStatement("""
+                SELECT password_hash, (NOT ? AND tan_channel IS NOT NULL) FROM 
customers WHERE login=?
             """).run {
-                setString(1, login)
-                oneOrNull { it.getString(1) }
+                setBoolean(1, is2fa)
+                setString(2, login)
+                oneOrNull { 
+                    Pair(it.getString(1), it.getBoolean(2))
+                } ?: return@transaction AccountPatchAuthResult.UnknownAccount
             }
-            if (currentPwh == null) {
-                AccountPatchAuthResult.UnknownAccount
+            if (tanRequired) {
+                AccountPatchAuthResult.TanRequired
             } else if (oldPw != null && !CryptoUtil.checkpw(oldPw, 
currentPwh)) {
                 AccountPatchAuthResult.OldPasswordMismatch
             } else {
@@ -378,6 +441,7 @@ class AccountDAO(private val db: Database) {
                 name
                 ,email
                 ,phone
+                ,tan_channel
                 ,cashout_payto
                 ,internal_payto_uri
                 ,(balance).val AS balance_val
@@ -388,7 +452,7 @@ class AccountDAO(private val db: Database) {
                 ,is_public
                 ,is_taler_exchange
             FROM customers 
-                JOIN  bank_accounts
+                JOIN bank_accounts
                     ON customer_id=owning_customer_id
             WHERE login=?
         """)
@@ -400,6 +464,7 @@ class AccountDAO(private val db: Database) {
                     email = Option.Some(it.getString("email")),
                     phone = Option.Some(it.getString("phone"))
                 ),
+                tan_channel = it.getString("tan_channel")?.run { 
TanChannel.valueOf(this) },
                 cashout_payto_uri = it.getString("cashout_payto"),
                 payto_uri = it.getString("internal_payto_uri"),
                 balance = Balance(
@@ -442,7 +507,6 @@ class AccountDAO(private val db: Database) {
         ) {
             PublicAccount(
                 username = it.getString("login"),
-                account_name = it.getString("login"),
                 payto_uri = it.getString("internal_payto_uri"),
                 balance = Balance(
                     amount = it.getAmount("balance", db.bankCurrency),
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
index bf8be4fb..a7950aa2 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt
@@ -29,14 +29,14 @@ import tech.libeufin.bank.*
 class CashoutDAO(private val db: Database) {
     /** Result of cashout operation creation */
     sealed class CashoutCreationResult {
-        /** Cashout [id] has been created or refreshed. If [tanCode] is not 
null, use [tanInfo] to send it via [tanChannel] then call [markSent] */
-        data class Success(val id: Long, val tanInfo: String, val tanCode: 
String?): CashoutCreationResult()
+        data class Success(val id: Long): CashoutCreationResult()
         object BadConversion: CashoutCreationResult()
         object AccountNotFound: CashoutCreationResult()
         object AccountIsExchange: CashoutCreationResult()
-        object MissingTanInfo: CashoutCreationResult()
         object BalanceInsufficient: CashoutCreationResult()
         object RequestUidReuse: CashoutCreationResult()
+        object NoCashoutPayto: CashoutCreationResult()
+        object TanRequired: CashoutCreationResult()
     }
 
     /** Create a new cashout operation */
@@ -46,24 +46,20 @@ class CashoutDAO(private val db: Database) {
         amountDebit: TalerAmount,
         amountCredit: TalerAmount,
         subject: String,
-        tanChannel: TanChannel,
-        tanCode: String,
         now: Instant,
-        retryCounter: Int,
-        validityPeriod: Duration
+        is2fa: Boolean
     ): CashoutCreationResult = db.serializable { conn ->
         val stmt = conn.prepareStatement("""
             SELECT
                 out_bad_conversion,
                 out_account_not_found,
                 out_account_is_exchange,
-                out_missing_tan_info,
                 out_balance_insufficient,
                 out_request_uid_reuse,
-                out_cashout_id,
-                out_tan_info,
-                out_tan_code
-            FROM cashout_create(?, ?, (?,?)::taler_amount, 
(?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?)
+                out_no_cashout_payto,
+                out_tan_required,
+                out_cashout_id
+            FROM 
cashout_create(?,?,(?,?)::taler_amount,(?,?)::taler_amount,?,?,?)
         """)
         stmt.setString(1, login)
         stmt.setBytes(2, requestUid.raw)
@@ -73,10 +69,7 @@ class CashoutDAO(private val db: Database) {
         stmt.setInt(6, amountCredit.frac)
         stmt.setString(7, subject)
         stmt.setLong(8, now.toDbMicros() ?: throw faultyTimestampByBank())
-        stmt.setString(9, tanChannel.name)
-        stmt.setString(10, tanCode)
-        stmt.setInt(11, retryCounter)
-        stmt.setLong(12, TimeUnit.MICROSECONDS.convert(validityPeriod))
+        stmt.setBoolean(9, is2fa)
         stmt.executeQuery().use {
             when {
                 !it.next() ->
@@ -84,114 +77,11 @@ class CashoutDAO(private val db: Database) {
                 it.getBoolean("out_bad_conversion") -> 
CashoutCreationResult.BadConversion
                 it.getBoolean("out_account_not_found") -> 
CashoutCreationResult.AccountNotFound
                 it.getBoolean("out_account_is_exchange") -> 
CashoutCreationResult.AccountIsExchange
-                it.getBoolean("out_missing_tan_info") -> 
CashoutCreationResult.MissingTanInfo
                 it.getBoolean("out_balance_insufficient") -> 
CashoutCreationResult.BalanceInsufficient
                 it.getBoolean("out_request_uid_reuse") -> 
CashoutCreationResult.RequestUidReuse
-                else -> CashoutCreationResult.Success(
-                    id = it.getLong("out_cashout_id"),
-                    tanInfo = it.getString("out_tan_info"),
-                    tanCode = it.getString("out_tan_code")
-                )
-            }
-        }
-    }
-
-    /** Mark cashout operation [id] challenge as having being successfully 
sent [now] and not to be retransmit until after [retransmissionPeriod] */
-    suspend fun markSent(
-        id: Long,
-        now: Instant,
-        retransmissionPeriod: Duration,
-        tanChannel: TanChannel,
-        tanInfo: String
-    ) = db.serializable {
-        it.transaction { conn ->
-            conn.prepareStatement("""
-                SELECT challenge_mark_sent(challenge, ?, ?)
-                FROM cashout_operations
-                WHERE cashout_id=?
-            """).run {
-                setLong(1, now.toDbMicros() ?: throw faultyTimestampByBank())
-                setLong(2, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
-                setLong(3, id)
-                executeQueryCheck()
-            }
-            conn.prepareStatement("""
-                UPDATE cashout_operations
-                SET tan_channel = ?, tan_info = ?
-                WHERE cashout_id=?
-            """).run {
-                setString(1, tanChannel.name)
-                setString(2, tanInfo)
-                setLong(3, id)
-                executeUpdateCheck()
-            }
-        }
-    }
-
-    /** Abort cashout operation [id] owned by [login] */
-    suspend fun abort(id: Long, login: String): AbortResult = db.serializable 
{ conn ->
-        val stmt = conn.prepareStatement("""
-            UPDATE cashout_operations
-            SET aborted = local_transaction IS NULL
-            FROM bank_accounts JOIN customers ON customer_id=owning_customer_id
-            WHERE cashout_id=? AND bank_account=bank_account_id AND login=?
-            RETURNING local_transaction IS NOT NULL
-        """)
-        stmt.setLong(1, id)
-        stmt.setString(2, login)
-        when (stmt.oneOrNull { it.getBoolean(1) }) {
-            null -> AbortResult.UnknownOperation
-            true -> AbortResult.AlreadyConfirmed
-            false -> AbortResult.Success
-        }
-    }
-
-    /** Result status of cashout operation confirmation */
-    enum class CashoutConfirmationResult {
-        SUCCESS,
-        BAD_CONVERSION,
-        OP_NOT_FOUND,
-        BAD_TAN_CODE,
-        BALANCE_INSUFFICIENT,
-        NO_RETRY,
-        NO_CASHOUT_PAYTO,
-        ABORTED
-    }
-
-    /** Confirm cashout operation [id] owned by [login] */
-    suspend fun confirm(
-        id: Long,
-        login: String,
-        tanCode: String,
-        timestamp: Instant
-    ): CashoutConfirmationResult = db.serializable { conn ->
-        val stmt = conn.prepareStatement("""
-            SELECT
-                out_no_op,
-                out_bad_conversion,
-                out_bad_code,
-                out_balance_insufficient,
-                out_aborted,
-                out_no_retry,
-                out_no_cashout_payto
-            FROM cashout_confirm(?, ?, ?, ?);
-        """)
-        stmt.setLong(1, id)
-        stmt.setString(2, login)
-        stmt.setString(3, tanCode)
-        stmt.setLong(4, timestamp.toDbMicros() ?: throw 
faultyTimestampByBank())
-        stmt.executeQuery().use {
-            when {
-                !it.next() ->
-                    throw internalServerError("No result from DB procedure 
cashout_create")
-                it.getBoolean("out_no_op") -> 
CashoutConfirmationResult.OP_NOT_FOUND
-                it.getBoolean("out_bad_code") -> 
CashoutConfirmationResult.BAD_TAN_CODE
-                it.getBoolean("out_balance_insufficient") -> 
CashoutConfirmationResult.BALANCE_INSUFFICIENT
-                it.getBoolean("out_aborted") -> 
CashoutConfirmationResult.ABORTED
-                it.getBoolean("out_no_retry") -> 
CashoutConfirmationResult.NO_RETRY
-                it.getBoolean("out_no_cashout_payto") -> 
CashoutConfirmationResult.NO_CASHOUT_PAYTO
-                it.getBoolean("out_bad_conversion") -> 
CashoutConfirmationResult.BAD_CONVERSION
-                else -> CashoutConfirmationResult.SUCCESS
+                it.getBoolean("out_no_cashout_payto") -> 
CashoutCreationResult.NoCashoutPayto
+                it.getBoolean("out_tan_required") -> 
CashoutCreationResult.TanRequired
+                else -> 
CashoutCreationResult.Success(it.getLong("out_cashout_id"))
             }
         }
     }
@@ -200,12 +90,7 @@ class CashoutDAO(private val db: Database) {
     suspend fun get(id: Long, login: String): CashoutStatusResponse? = db.conn 
{ conn ->
         val stmt = conn.prepareStatement("""
             SELECT
-                CASE 
-                    WHEN aborted THEN 'aborted'
-                    WHEN local_transaction IS NOT NULL THEN 'confirmed'
-                    ELSE 'pending'
-                END as status
-                ,(amount_debit).val as amount_debit_val
+                (amount_debit).val as amount_debit_val
                 ,(amount_debit).frac as amount_debit_frac
                 ,(amount_credit).val as amount_credit_val
                 ,(amount_credit).frac as amount_credit_frac
@@ -213,7 +98,10 @@ class CashoutDAO(private val db: Database) {
                 ,creation_time
                 ,transaction_date as confirmation_date
                 ,tan_channel
-                ,tan_info
+                ,CASE tan_channel
+                    WHEN 'sms'   THEN phone
+                    WHEN 'email' THEN email
+                END as tan_info
             FROM cashout_operations
                 JOIN bank_accounts ON bank_account=bank_account_id
                 JOIN customers ON owning_customer_id=customer_id
@@ -224,7 +112,7 @@ class CashoutDAO(private val db: Database) {
         stmt.setString(2, login)
         stmt.oneOrNull {
             CashoutStatusResponse(
-                status = CashoutStatus.valueOf(it.getString("status")),
+                status = CashoutStatus.confirmed,
                 amount_debit = it.getAmount("amount_debit", db.bankCurrency),
                 amount_credit = it.getAmount("amount_credit", 
db.fiatCurrency!!),
                 subject = it.getString("subject"),
@@ -245,11 +133,6 @@ class CashoutDAO(private val db: Database) {
             SELECT
                 cashout_id
                 ,login
-                ,CASE 
-                    WHEN aborted THEN 'aborted'
-                    WHEN local_transaction IS NOT NULL THEN 'confirmed'
-                    ELSE 'pending'
-                END as status
             FROM cashout_operations
                 JOIN bank_accounts ON bank_account=bank_account_id
                 JOIN customers ON owning_customer_id=customer_id
@@ -258,20 +141,14 @@ class CashoutDAO(private val db: Database) {
             GlobalCashoutInfo(
                 cashout_id = it.getLong("cashout_id"),
                 username = it.getString("login"),
-                status = CashoutStatus.valueOf(it.getString("status"))
+                status = CashoutStatus.confirmed
             )
         }
 
     /** Get a page of all cashout operations owned by [login] */
     suspend fun pageForUser(params: PageParams, login: String): 
List<CashoutInfo> =
         db.page(params, "cashout_id", """
-            SELECT
-                cashout_id
-                ,CASE 
-                    WHEN aborted THEN 'aborted'
-                    WHEN local_transaction IS NOT NULL THEN 'confirmed'
-                    ELSE 'pending'
-                END as status
+            SELECT cashout_id
             FROM cashout_operations
                 JOIN bank_accounts ON bank_account=bank_account_id
                 JOIN customers ON owning_customer_id=customer_id
@@ -284,7 +161,7 @@ class CashoutDAO(private val db: Database) {
         ) {
             CashoutInfo(
                 cashout_id = it.getLong("cashout_id"),
-                status = CashoutStatus.valueOf(it.getString("status"))
+                status = CashoutStatus.confirmed
             )
         }
 }
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
index 8ea2f00a..e4effe00 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt
@@ -88,6 +88,7 @@ class Database(dbConfig: String, internal val bankCurrency: 
String, internal val
     val account = AccountDAO(this)
     val transaction = TransactionDAO(this)
     val token = TokenDAO(this)
+    val tan = TanDAO(this)
 
     suspend fun monitor(
         params: MonitorParams
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
new file mode 100644
index 00000000..457d1216
--- /dev/null
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
@@ -0,0 +1,177 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023 Taler Systems S.A.
+
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.bank.db
+
+import tech.libeufin.util.*
+import tech.libeufin.bank.*
+import tech.libeufin.bank.db.*
+import java.util.concurrent.TimeUnit
+import java.time.Duration
+import java.time.Instant
+
+/** Data access logic for tan challenged */
+class TanDAO(private val db: Database) {
+    /** Create new TAN challenge */
+    suspend fun new(
+        login: String, 
+        op: Operation,
+        body: String, 
+        code: String,
+        now: Instant,
+        retryCounter: Int,
+        validityPeriod: Duration,
+        channel: TanChannel? = null,
+        info: String? = null
+    ): Long = db.serializable { conn ->
+        val stmt = conn.prepareStatement("SELECT 
tan_challenge_create(?,?::op_enum,?,?,?,?,?,?::tan_enum,?)")
+        stmt.setString(1, body)
+        stmt.setString(2, op.name)
+        stmt.setString(3, code)
+        stmt.setLong(4, now.toDbMicros() ?: throw faultyTimestampByBank())
+        stmt.setLong(5, TimeUnit.MICROSECONDS.convert(validityPeriod))
+        stmt.setInt(6, retryCounter)
+        stmt.setString(7, login)
+        stmt.setString(8, channel?.name)
+        stmt.setString(9, info)
+        stmt.oneOrNull {
+            it.getLong(1)
+        } ?: throw internalServerError("TAN challenge returned nothing.")
+    }
+
+    /** Result of TAN challenge transmission */
+    sealed class TanSendResult {
+        data class Success(val tanInfo: String, val tanChannel: TanChannel, 
val tanCode: String?): TanSendResult()
+        object NotFound: TanSendResult()
+    }
+
+    /** Request TAN challenge transmission */
+    suspend fun send(
+        id: Long, 
+        login: String, 
+        code: String,
+        now: Instant,
+        retryCounter: Int,
+        validityPeriod: Duration
+    ) = db.serializable { conn ->
+        val stmt = conn.prepareStatement("SELECT out_no_op, out_tan_code, 
out_tan_channel, out_tan_info FROM tan_challenge_send(?,?,?,?,?,?)")
+        stmt.setLong(1, id)
+        stmt.setString(2, login)
+        stmt.setString(3, code)
+        stmt.setLong(4, now.toDbMicros() ?: throw faultyTimestampByBank())
+        stmt.setLong(5, TimeUnit.MICROSECONDS.convert(validityPeriod))
+        stmt.setInt(6, retryCounter)
+        stmt.executeQuery().use {
+            when {
+                !it.next() -> throw internalServerError("TAN send returned 
nothing.")
+                it.getBoolean("out_no_op") -> TanSendResult.NotFound
+                else ->  TanSendResult.Success(
+                    tanInfo = it.getString("out_tan_info"),
+                    tanChannel = it.getString("out_tan_channel").run { 
TanChannel.valueOf(this) },
+                    tanCode = it.getString("out_tan_code")
+                )
+            }
+        }
+    }
+
+    /** Mark TAN challenge transmission */
+    suspend fun markSent(
+        id: Long,
+        now: Instant,
+        retransmissionPeriod: Duration
+    ) = db.serializable { conn ->
+        val stmt = conn.prepareStatement("SELECT 
tan_challenge_mark_sent(?,?,?)")
+        stmt.setLong(1, id)
+        stmt.setLong(2, now.toDbMicros() ?: throw faultyTimestampByBank())
+        stmt.setLong(3, TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
+        stmt.executeQuery()
+    }
+
+    /** Result of TAN challenge solution */
+    sealed class TanSolveResult {
+        data class Success(val body: String, val op: Operation, val channel: 
TanChannel?, val info: String?): TanSolveResult()
+        data object NotFound: TanSolveResult()
+        data object NoRetry: TanSolveResult()
+        data object Expired: TanSolveResult()
+        data object BadCode: TanSolveResult()
+    }
+
+    /** Solve TAN challenge */
+    suspend fun solve(
+        id: Long,
+        login: String,
+        code: String,
+        now: Instant
+    ) = db.serializable { conn ->
+        val stmt = conn.prepareStatement("""
+            SELECT 
+                out_ok, out_no_op, out_no_retry, out_expired,
+                out_body, out_op, out_channel, out_info
+            FROM tan_challenge_try(?,?,?,?)""")
+        stmt.setLong(1, id)
+        stmt.setString(2, login)
+        stmt.setString(3, code)
+        stmt.setLong(4, now.toDbMicros() ?: throw faultyTimestampByBank())
+        stmt.executeQuery().use {
+            when {
+                !it.next() -> throw internalServerError("TAN try returned 
nothing")
+                it.getBoolean("out_ok") -> TanSolveResult.Success(
+                    body = it.getString("out_body"),
+                    op = Operation.valueOf(it.getString("out_op")),
+                    channel = it.getString("out_channel")?.run { 
TanChannel.valueOf(this) },
+                    info = it.getString("out_info")
+                )
+                it.getBoolean("out_no_op") -> TanSolveResult.NotFound
+                it.getBoolean("out_no_retry") -> TanSolveResult.NoRetry
+                it.getBoolean("out_expired") -> TanSolveResult.Expired
+                else -> TanSolveResult.BadCode
+            }
+        }
+    }
+
+    data class Challenge (
+        val body: String,
+        val channel: TanChannel?,
+        val info: String?
+    )
+
+    /** Get a solved TAN challenge [id] for account [login] and [op] */
+    suspend fun challenge(
+        id: Long,
+        login: String,
+        op: Operation
+    ) = db.serializable { conn ->
+        val stmt = conn.prepareStatement("""
+            SELECT body, tan_challenges.tan_channel, tan_info
+            FROM tan_challenges
+                JOIN customers ON customer=customer_id
+            WHERE challenge_id=? AND op=?::op_enum AND login=?
+        """)
+        stmt.setLong(1, id)
+        stmt.setString(2, op.name)
+        stmt.setString(3, login)
+        stmt.oneOrNull { 
+            Challenge(
+                body = it.getString("body"),
+                channel = it.getString("tan_channel")?.run { 
TanChannel.valueOf(this) },
+                info = it.getString("tan_info")
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
index 1485ec52..a72f9743 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
@@ -38,6 +38,7 @@ class TransactionDAO(private val db: Database) {
         object UnknownDebtor: BankTransactionResult()
         object BothPartySame: BankTransactionResult()
         object BalanceInsufficient: BankTransactionResult()
+        object TanRequired: BankTransactionResult()
     }
 
     /** Create a new transaction */
@@ -47,6 +48,7 @@ class TransactionDAO(private val db: Database) {
         subject: String,
         amount: TalerAmount,
         timestamp: Instant,
+        is2fa: Boolean
     ): BankTransactionResult = db.serializable { conn ->
         val now = timestamp.toDbMicros() ?: throw faultyTimestampByBank();
         conn.transaction {
@@ -56,6 +58,7 @@ class TransactionDAO(private val db: Database) {
                     ,out_debtor_not_found
                     ,out_same_account
                     ,out_balance_insufficient
+                    ,out_tan_required
                     ,out_credit_bank_account_id
                     ,out_debit_bank_account_id
                     ,out_credit_row_id
@@ -63,7 +66,7 @@ class TransactionDAO(private val db: Database) {
                     ,out_creditor_is_exchange 
                     ,out_debtor_is_exchange
                     ,out_creditor_admin
-                FROM bank_transaction(?,?,?,(?,?)::taler_amount,?)
+                FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?)
             """
             )
             stmt.setString(1, creditAccountPayto.canonical)
@@ -72,6 +75,7 @@ class TransactionDAO(private val db: Database) {
             stmt.setLong(4, amount.value)
             stmt.setInt(5, amount.frac)
             stmt.setLong(6, now)
+            stmt.setBoolean(7, is2fa)
             stmt.executeQuery().use {
                 when {
                     !it.next() -> throw internalServerError("Bank transaction 
didn't properly return")
@@ -80,6 +84,7 @@ class TransactionDAO(private val db: Database) {
                     it.getBoolean("out_same_account") -> 
BankTransactionResult.BothPartySame
                     it.getBoolean("out_balance_insufficient") -> 
BankTransactionResult.BalanceInsufficient
                     it.getBoolean("out_creditor_admin") -> 
BankTransactionResult.AdminCreditor
+                    it.getBoolean("out_tan_required") -> 
BankTransactionResult.TanRequired
                     else -> {
                         val creditAccountId = 
it.getLong("out_credit_bank_account_id")
                         val creditRowId = it.getLong("out_credit_row_id")
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
index 4da52776..380263b4 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
@@ -42,19 +42,21 @@ class WithdrawalDAO(private val db: Database) {
     suspend fun create(
         login: String,
         uuid: UUID,
-        amount: TalerAmount
+        amount: TalerAmount,
+        now: Instant
     ): WithdrawalCreationResult = db.serializable { conn ->
         val stmt = conn.prepareStatement("""
             SELECT
                 out_account_not_found,
                 out_account_is_exchange,
                 out_balance_insufficient
-            FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount);
+            FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount, ?);
         """)
         stmt.setString(1, login)
         stmt.setObject(2, uuid)
         stmt.setLong(3, amount.value)
         stmt.setInt(4, amount.frac)
+        stmt.setLong(5, now.toDbMicros() ?: throw faultyTimestampByBank())
         stmt.executeQuery().use {
             when {
                 !it.next() ->
@@ -69,7 +71,6 @@ class WithdrawalDAO(private val db: Database) {
 
     /** Abort withdrawal operation [uuid] */
     suspend fun abort(uuid: UUID): AbortResult = db.serializable { conn ->
-        // TODO login check
         val stmt = conn.prepareStatement("""
             SELECT
                 out_no_op,
@@ -140,13 +141,16 @@ class WithdrawalDAO(private val db: Database) {
         UnknownExchange,
         BalanceInsufficient,
         NotSelected,
-        AlreadyAborted
+        AlreadyAborted,
+        TanRequired
     }
 
     /** Confirm withdrawal operation [uuid] */
     suspend fun confirm(
+        login: String,
         uuid: UUID,
-        now: Instant
+        now: Instant,
+        is2fa: Boolean
     ): WithdrawalConfirmationResult = db.serializable { conn ->
          // TODO login check
         val stmt = conn.prepareStatement("""
@@ -155,12 +159,15 @@ class WithdrawalDAO(private val db: Database) {
               out_exchange_not_found,
               out_balance_insufficient,
               out_not_selected,
-              out_aborted
-            FROM confirm_taler_withdrawal(?, ?);
+              out_aborted,
+              out_tan_required
+            FROM confirm_taler_withdrawal(?,?,?,?);
         """
         )
-        stmt.setObject(1, uuid)
-        stmt.setLong(2, now.toDbMicros() ?: throw faultyTimestampByBank())
+        stmt.setString(1, login)
+        stmt.setObject(2, uuid)
+        stmt.setLong(3, now.toDbMicros() ?: throw faultyTimestampByBank())
+        stmt.setBoolean(4, is2fa)
         stmt.executeQuery().use {
             when {
                 !it.next() ->
@@ -170,6 +177,7 @@ class WithdrawalDAO(private val db: Database) {
                 it.getBoolean("out_balance_insufficient") -> 
WithdrawalConfirmationResult.BalanceInsufficient
                 it.getBoolean("out_not_selected") -> 
WithdrawalConfirmationResult.NotSelected
                 it.getBoolean("out_aborted") -> 
WithdrawalConfirmationResult.AlreadyAborted
+                it.getBoolean("out_tan_required") -> 
WithdrawalConfirmationResult.TanRequired
                 else -> WithdrawalConfirmationResult.Success
             }
         }
@@ -252,10 +260,7 @@ class WithdrawalDAO(private val db: Database) {
                         amount = it.getAmount("amount", db.bankCurrency),
                         username = it.getString("login"),
                         selected_exchange_account = 
it.getString("selected_exchange_payto"),
-                        selected_reserve_pub = 
it.getBytes("reserve_pub")?.run(::EddsaPublicKey),
-                        selection_done = it.getBoolean("selection_done"),
-                        confirmation_done = it.getBoolean("confirmation_done"),
-                        aborted = it.getBoolean("aborted"),
+                        selected_reserve_pub = 
it.getBytes("reserve_pub")?.run(::EddsaPublicKey)
                     )
                 }
             }
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
index 85dd8ff2..84dc96a6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -29,6 +29,9 @@ import io.ktor.server.routing.RouteSelectorEvaluation
 import io.ktor.server.routing.RoutingResolveContext
 import io.ktor.server.util.*
 import io.ktor.util.pipeline.PipelineContext
+import kotlinx.serialization.*
+import kotlinx.serialization.descriptors.*
+import kotlinx.serialization.encoding.*
 import java.net.*
 import java.time.*
 import java.time.temporal.*
@@ -132,7 +135,11 @@ suspend fun maybeCreateAdminAccount(db: Database, ctx: 
BankConfig, pw: String? =
         isTalerExchange = false,
         maxDebt = ctx.defaultDebtLimit,
         bonus = TalerAmount(0, 0, ctx.regionalCurrency),
-        checkPaytoIdempotent = false
+        checkPaytoIdempotent = false,
+        email = null,
+        phone = null,
+        cashoutPayto = null,
+        tanChannel = null
     )
 }
 
@@ -157,4 +164,19 @@ fun Route.conditional(implemented: Boolean, callback: 
Route.() -> Unit): Route =
         }
     }
 
-    
\ No newline at end of file
+@Serializable(with = StoredUUID.Serializer::class)
+data class StoredUUID(val value: UUID) {
+    internal object Serializer : KSerializer<StoredUUID> {
+        override val descriptor: SerialDescriptor =
+                PrimitiveSerialDescriptor("StoredUUID", PrimitiveKind.STRING)
+
+        override fun serialize(encoder: Encoder, value: StoredUUID) {
+            encoder.encodeString(value.value.toString())
+        }
+
+        override fun deserialize(decoder: Decoder): StoredUUID {
+            val string = decoder.decodeString()
+            return StoredUUID(UUID.fromString(string))
+        }
+    }
+}
diff --git a/bank/src/main/resources/logback.xml 
b/bank/src/main/resources/logback.xml
index d04f095e..271c094c 100644
--- a/bank/src/main/resources/logback.xml
+++ b/bank/src/main/resources/logback.xml
@@ -12,6 +12,9 @@
     <logger name="tech.libeufin.util" level="ALL"  additivity="false">
         <appender-ref ref="STDERR" />
     </logger>
+    <logger name="tech.libeufin.nexus" level="ALL"  additivity="false">
+        <appender-ref ref="STDERR" />
+    </logger>
 
     <logger name="io.netty" level="INFO" />
     <logger name="ktor" level="TRACE" />
diff --git a/bank/src/test/kotlin/AmountTest.kt 
b/bank/src/test/kotlin/AmountTest.kt
index dbc920ef..43b6fbef 100644
--- a/bank/src/test/kotlin/AmountTest.kt
+++ b/bank/src/test/kotlin/AmountTest.kt
@@ -56,6 +56,7 @@ class AmountTest {
                 subject = "test",
                 amount = due,
                 timestamp = Instant.now(),
+                is2fa = false
             )
             val txBool = when (txRes) {
                 BankTransactionResult.BalanceInsufficient -> false
@@ -69,6 +70,7 @@ class AmountTest {
                 login = "merchant",
                 uuid = UUID.randomUUID(),
                 amount = due,
+                now = Instant.now()
             )
             val wBool = when (wRes) {
                 WithdrawalCreationResult.BalanceInsufficient -> false
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt 
b/bank/src/test/kotlin/BankIntegrationApiTest.kt
index dd7cce4e..ce6b46c7 100644
--- a/bank/src/test/kotlin/BankIntegrationApiTest.kt
+++ b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -143,7 +143,6 @@ class BankIntegrationApiTest {
     // POST /taler-integration/withdrawal-operation/UUID/abort
     @Test
     fun abort() = bankSetup { _ ->
-        // TODO auth routine
         // Check abort created
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:1" } 
@@ -183,7 +182,7 @@ class BankIntegrationApiTest {
         }
 
         // Check bad UUID
-        
client.postA("/taler-integration/withdrawal-operation//chocolate/abort").assertBadRequest()
+        
client.postA("/taler-integration/withdrawal-operation/chocolate/abort").assertBadRequest()
 
         // Check unknown
         
client.postA("/taler-integration/withdrawal-operation/${UUID.randomUUID()}/abort")
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt 
b/bank/src/test/kotlin/CoreBankApiTest.kt
index ca538da4..86115e78 100644
--- a/bank/src/test/kotlin/CoreBankApiTest.kt
+++ b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -183,13 +183,13 @@ class CoreBankAccountsApiTest {
             // Check idempotency with payto
             client.post("/accounts") {
                 json(req) {
-                    "internal_payto_uri" to payto
+                    "payto_uri" to payto
                 }
             }.assertOk()
             // Check payto conflict
             client.post("/accounts") {
                 json(req) {
-                    "internal_payto_uri" to genIbanPaytoUri()
+                    "payto_uri" to genIbanPaytoUri()
                 }
             }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE)
         }
@@ -201,7 +201,7 @@ class CoreBankAccountsApiTest {
             "password" to "password"
             "name" to "Jane"
             "is_public" to true
-            "internal_payto_uri" to ibanPayto
+            "payto_uri" to ibanPayto
             "is_taler_exchange" to true
         }
         // Check Ok
@@ -215,7 +215,7 @@ class CoreBankAccountsApiTest {
             json(req)
         }.assertOk()
 
-        // Check debit_threshold
+        // Check admin only debit_threshold
         obj {
             "username" to "bat"
             "password" to "password"
@@ -231,6 +231,38 @@ class CoreBankAccountsApiTest {
             }.assertOk()
         }
 
+        // Check admin only tan_channel
+        obj {
+            "username" to "bat2"
+            "password" to "password"
+            "name" to "Bat"
+            "contact_data" to obj {
+                "phone" to "+456"
+            }
+            "tan_channel" to "sms"
+        }.let { req ->
+            client.post("/accounts") {
+                json(req)
+            }.assertErr(TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL)
+            client.post("/accounts") {
+                json(req)
+                pwAuth("admin")
+            }.assertOk()
+        }
+
+        // Check tan info
+        for (channel in listOf("sms", "email")) {
+            client.post("/accounts") {
+                pwAuth("admin")
+                json { 
+                    "username" to "bat2"
+                    "password" to "password"
+                    "name" to "Bat"
+                    "tan_channel" to channel
+                }
+            }.assertErr(TalerErrorCode.BANK_MISSING_TAN_INFO)
+        }
+
         // Reserved account
         RESERVED_ACCOUNTS.forEach {
             client.post("/accounts") {
@@ -317,13 +349,24 @@ class CoreBankAccountsApiTest {
         }.assertOk()
     }
 
-    // DELETE /accounts/USERNAME
+    // Test admin-only account creation
     @Test
-    fun delete() = bankSetup { _ -> 
-        // Unknown account
-        client.delete("/accounts/unknown") {
+    fun createTanErr() = bankSetup(conf = "test_tan_err.conf") { _ -> 
+        client.post("/accounts") {
             pwAuth("admin")
-        }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+            json {
+                "username" to "baz"
+                "password" to "xyz"
+                "name" to "Mallory"
+                "tan_channel" to "email"
+            }
+        }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED)
+    }
+
+    // DELETE /accounts/USERNAME
+    @Test
+    fun delete() = bankSetup { db -> 
+        authRoutine(HttpMethod.Delete, "/accounts/merchant", allowAdmin = true)
 
         // Reserved account
         RESERVED_ACCOUNTS.forEach {
@@ -333,31 +376,42 @@ class CoreBankAccountsApiTest {
         }
         client.deleteA("/accounts/exchange")
             .assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT)
-       
-        // successful deletion
+
         client.post("/accounts") {
             json {
                 "username" to "john"
-                "password" to "password"
-                "name" to "John Smith"
+                "password" to "john-password"
+                "name" to "John"
+                "payto_uri" to genTmpPayTo()
             }
         }.assertOk()
-        client.delete("/accounts/john") {
-            pwAuth("admin")
-        }.assertNoContent()
-        // Trying again must yield 404
+        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)
+        // Sucessful deletion
+        tx("john", "KUDOS:1", "customer")
+        // TODO remove with gc
+        db.conn { conn ->
+            val id = conn.prepareStatement("SELECT bank_account_id FROM 
bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE login = 
?").run {
+                setString(1, "john")
+                oneOrNull {
+                    it.getLong(1)
+                }!!
+            }
+            conn.prepareStatement("DELETE FROM bank_account_transactions WHERE 
bank_account_id=?").run {
+                setLong(1, id)
+                execute()
+            }
+        }
+        client.deleteA("/accounts/john")
+            .assertChallenge()
+            .assertNoContent()
+        // Account no longer exists
         client.delete("/accounts/john") {
             pwAuth("admin")
         }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
-
-        
-        // fail to delete, due to a non-zero balance.
-        tx("customer", "KUDOS:1", "merchant")
-        client.deleteA("/accounts/merchant")
-            .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO)
-        tx("merchant", "KUDOS:1", "customer")
-        client.deleteA("/accounts/merchant")
-            .assertNoContent()
     }
 
     // Test admin-only account deletion
@@ -399,35 +453,39 @@ class CoreBankAccountsApiTest {
     // PATCH /accounts/USERNAME
     @Test
     fun reconfig() = bankSetup { _ -> 
-        authRoutine(HttpMethod.Patch, "/accounts/merchant", withAdmin = true)
+        authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true)
+
+        // Check tan info
+        for (channel in listOf("sms", "email")) {
+            client.patchA("/accounts/merchant") {
+                json { "tan_channel" to channel }
+            }.assertErr(TalerErrorCode.BANK_MISSING_TAN_INFO)
+        }
 
-        // Successful attempt now.
+        // Successful attempt now
         val cashout = IbanPayTo(genIbanPaytoUri())
         val req = obj {
             "cashout_payto_uri" to cashout.canonical
             "name" to "Roger"
-            "is_public" to true 
+            "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.
+        // Checking idempotence
         client.patchA("/accounts/merchant") {
             json(req)
         }.assertNoContent()
+
         
         checkAdminOnly(
             obj(req) { "debit_threshold" to "KUDOS:100" },
             TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT
         )
-        checkAdminOnly(
-            obj(req) { "contact_data" to obj { "phone" to "+99" } },
-            TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
-        )
-        checkAdminOnly(
-            obj(req) { "contact_data" to obj { "email" to "foo@example.com" } 
},
-            TalerErrorCode.BANK_NON_ADMIN_PATCH_CONTACT
-        )
         
         // Check currency
         client.patch("/accounts/merchant") {
@@ -466,6 +524,19 @@ class CoreBankAccountsApiTest {
                 "is_public" to true
             }
         }.assertConflict(TalerErrorCode.END)
+
+        // 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
@@ -492,10 +563,21 @@ class CoreBankAccountsApiTest {
         }
     }
 
+    // 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", withAdmin = 
true)
+        authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", allowAdmin = 
true)
 
         // Changing the password.
         client.patch("/accounts/customer/auth") {
@@ -537,6 +619,21 @@ class CoreBankAccountsApiTest {
         
}.assertConflict(TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD)
 
         // Check admin 
+        client.patch("/accounts/customer/auth") {
+            pwAuth("admin")
+            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.patch("/accounts/customer/auth") {
             pwAuth("admin")
             json {
@@ -578,7 +675,7 @@ class CoreBankAccountsApiTest {
             val obj = json<PublicAccountsResponse>()
             assertEquals(3, obj.public_accounts.size)
             obj.public_accounts.forEach {
-                assertEquals(0, it.account_name.toInt() % 2)
+                assertEquals(0, it.username.toInt() % 2)
             }
         }
         // All accounts
@@ -610,7 +707,7 @@ class CoreBankAccountsApiTest {
     // GET /accounts/USERNAME
     @Test
     fun get() = bankSetup { _ -> 
-        authRoutine(HttpMethod.Get, "/accounts/merchant", withAdmin = true)
+        authRoutine(HttpMethod.Get, "/accounts/merchant", allowAdmin = true)
         // Check ok
         client.getA("/accounts/merchant").assertOkJson<AccountData> {
             assertEquals("Merchant", it.name)
@@ -770,8 +867,7 @@ class CoreBankTransactionsApiTest {
         repeat(2) {
             tx("merchant", "KUDOS:3", "customer")
         }
-        client.post("/accounts/merchant/transactions") {
-            pwAuth("merchant")
+        client.postA("/accounts/merchant/transactions") {
             json {
                 "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5"
             }
@@ -789,9 +885,9 @@ class CoreBankTransactionsApiTest {
         assertBalance("exchange", "+KUDOS:0")
         tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to 
transaction
         tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed 
transaction
-        val reserve_pub = randEddsaPublicKey();
-        tx("merchant", "KUDOS:1", "exchange", 
randIncomingSubject(reserve_pub)) // Accept incoming
-        tx("merchant", "KUDOS:1", "exchange", 
randIncomingSubject(reserve_pub)) // Bounce reserve_pub reuse
+        val reservePub = randEddsaPublicKey();
+        tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) 
// Accept incoming
+        tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) 
// Bounce reserve_pub reuse
         assertBalance("merchant", "-KUDOS:1")
         assertBalance("exchange", "+KUDOS:1")
         
@@ -806,6 +902,22 @@ class CoreBankTransactionsApiTest {
         tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(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")
+        }
     }
 }
 
@@ -842,11 +954,7 @@ class CoreBankWithdrawalApiTest {
             client.get("/withdrawals/${it.withdrawal_id}") {
                 pwAuth("merchant")
             }.assertOkJson<WithdrawalPublicInfo> {
-                assert(!it.selection_done)
-                assert(!it.aborted)
-                assert(!it.confirmation_done)
                 assertEquals(amount, it.amount)
-                // TODO check all status
             }
         }
 
@@ -864,7 +972,7 @@ class CoreBankWithdrawalApiTest {
     // POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm
     @Test
     fun confirm() = bankSetup { _ -> 
-        // TODO auth routine
+        authRoutine(HttpMethod.Post, 
"/accounts/merchant/withdrawals/42/confirm")
         // Check confirm created
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:1" } 
@@ -895,7 +1003,7 @@ class CoreBankWithdrawalApiTest {
         }.assertOkJson<BankAccountCreateWithdrawalResponse> {
             val uuid = it.taler_withdraw_uri.split("/").last()
             withdrawalSelect(uuid)
-            
client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
+            
client.postA("/taler-integration/withdrawal-operation/$uuid/abort").assertNoContent()
 
             // Check error
             client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
@@ -915,7 +1023,19 @@ class CoreBankWithdrawalApiTest {
                 .assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
 
             // Check can abort because not confirmed
-            
client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent()
+            
client.postA("/taler-integration/withdrawal-operation/$uuid/abort").assertNoContent()
+        }
+
+        // Check confirm another user's operation
+        client.postA("/accounts/customer/withdrawals") {
+            json { "amount" to "KUDOS:1" } 
+        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+            val uuid = it.taler_withdraw_uri.split("/").last()
+            withdrawalSelect(uuid)
+
+            // Check error
+            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
+                .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
         }
 
         // Check bad UUID
@@ -924,6 +1044,21 @@ class CoreBankWithdrawalApiTest {
         // Check unknown
         
client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm")
             .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
+
+        // Check 2fa
+        fillTanInfo("merchant")
+        assertBalance("merchant", "-KUDOS:6")
+        client.postA("/accounts/merchant/withdrawals") {
+            json { "amount" to "KUDOS:1" } 
+        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+            val uuid = it.taler_withdraw_uri.split("/").last()
+            withdrawalSelect(uuid)
+
+            client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
+            .assertChallenge { _,_->
+                assertBalance("merchant", "-KUDOS:6")
+            }.assertNoContent()
+        }
     }
 }
 
@@ -940,40 +1075,17 @@ class CoreBankCashoutApiTest {
             "amount_credit" to convert("KUDOS:1")
         }
 
-        // Check missing TAN info
+        // Missing info
         client.postA("/accounts/customer/cashouts") {
             json(req) 
-        }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO)
-        client.patch("/accounts/customer") {
-            pwAuth("admin")
-            json {
-                "contact_data" to obj {
-                    "phone" to "+99"
-                    "email" to "foo@example.com"
-                }
-            }
-        }.assertNoContent()
+        }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)
 
-        // Check email TAN error
-        client.postA("/accounts/customer/cashouts") {
-            json(req) {
-                "tan_channel" to "email"
-            }
-        }.assertStatus(HttpStatusCode.BadGateway, 
TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)
+        fillCashoutInfo("customer")
 
         // Check OK
         client.postA("/accounts/customer/cashouts") {
             json(req) 
-        }.assertOkJson<CashoutPending> { first ->
-            smsCode("+99")
-            // Check idempotency
-            client.postA("/accounts/customer/cashouts") {
-                json(req) 
-            }.assertOkJson<CashoutPending> { second ->
-                assertEquals(first.cashout_id, second.cashout_id)
-                assertNull(smsCode("+99"))     
-            }
-        }
+        }.assertOkJson<CashoutResponse>()
 
         // Trigger conflict due to reused request_uid
         client.postA("/accounts/customer/cashouts") {
@@ -991,6 +1103,7 @@ class CoreBankCashoutApiTest {
         // Check insufficient fund
         client.postA("/accounts/customer/cashouts") {
             json(req) {
+                "request_uid" to randShortHashCode()
                 "amount_debit" to "KUDOS:75"
                 "amount_credit" to convert("KUDOS:75")
             }
@@ -1014,211 +1127,19 @@ class CoreBankCashoutApiTest {
                 "amount_credit" to "KUDOS:1"
             } 
         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
-    }
-
-    // POST /accounts/{USERNAME}/cashouts
-    @Test
-    fun createNoTan() = bankSetup("test_no_tan.conf") { _ ->
-        val req = obj {
-            "request_uid" to randShortHashCode()
-            "amount_debit" to "KUDOS:1"
-            "amount_credit" to convert("KUDOS:1")
-        }
-
-        fillCashoutInfo("customer")
-
-        // Check unsupported TAN channel
-        client.postA("/accounts/customer/cashouts") {
-            json(req) 
-        }.assertStatus(HttpStatusCode.NotImplemented, 
TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED)
-    }
-
-    // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort
-    @Test
-    fun abort() = bankSetup { _ ->
-        authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/abort")
-
-        fillCashoutInfo("customer")
-        
-        val req = obj {
-            "request_uid" to randShortHashCode()
-            "amount_debit" to "KUDOS:1"
-            "amount_credit" to convert("KUDOS:1")
-        }
-
-        // Check abort created
-        client.postA("/accounts/customer/cashouts") {
-            json(req) 
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-
-            // Check OK
-            client.postA("/accounts/customer/cashouts/$id/abort")
-                .assertNoContent()
-            // Check idempotence
-            client.postA("/accounts/customer/cashouts/$id/abort")
-                .assertNoContent()
-        }
 
-        // Check abort confirmed
+        // Check 2fa
+        fillTanInfo("customer")
+        assertBalance("customer", "-KUDOS:1")
         client.postA("/accounts/customer/cashouts") {
-            json(req) { "request_uid" to randShortHashCode() }
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-
-            client.postA("/accounts/customer/cashouts/$id/confirm") {
-                json { "tan" to smsCode("+99") } 
-            }.assertNoContent()
-
-            // Check error
-            client.postA("/accounts/customer/cashouts/$id/abort")
-                .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT)
-        }
-
-        // Check bad id
-        client.postA("/accounts/customer/cashouts/chocolate/abort") {
-            json { "tan" to "code" } 
-        }.assertBadRequest()
-
-        // Check unknown
-        client.postA("/accounts/customer/cashouts/42/abort") {
-            json { "tan" to "code" } 
-        }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
-
-        // Check abort another user's operation
-        client.postA("/accounts/customer/cashouts") {
-            json(req) { "request_uid" to randShortHashCode() }
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-
-            // Check error
-            client.postA("/accounts/merchant/cashouts/$id/abort")
-                .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
-        }
-    }
-
-    // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm
-    @Test
-    fun confirm() = bankSetup { _ -> 
-        authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/confirm")
-
-        client.patch("/accounts/customer") {
-            pwAuth("admin")
-            json {
-                "contact_data" to obj {
-                    "phone" to "+99"
-                }
-            }
-        }.assertNoContent()
-
-        val req = obj {
-            "request_uid" to randShortHashCode()
-            "amount_debit" to "KUDOS:1"
-            "amount_credit" to convert("KUDOS:1")
-        }
-
-        // Check confirm
-        client.postA("/accounts/customer/cashouts") {
-            json(req) { "request_uid" to randShortHashCode() }
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-
-            // Check missing cashout address
-            client.postA("/accounts/customer/cashouts/$id/confirm") {
-                json { "tan" to "code" }
-            }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE)
-            fillCashoutInfo("customer")
-
-            // Check bad TAN code
-            client.postA("/accounts/customer/cashouts/$id/confirm") {
-                json { "tan" to "nice-try" } 
-            }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED)
-
-            val code = smsCode("+99")
-           
-            // Check OK
-            client.postA("/accounts/customer/cashouts/$id/confirm") {
-                json { "tan" to code }
-            }.assertNoContent()
-            // Check idempotence
-            client.postA("/accounts/customer/cashouts/$id/confirm") {
-                json { "tan" to code }
-            }.assertNoContent()
-        }
-
-        // Check confirm another user's operation
-        client.postA("/accounts/customer/cashouts") {
-            json(req) { 
-                "request_uid" to randShortHashCode()
-                "amount_credit" to convert("KUDOS:1")
-            }
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-
-            // Check error
-            client.postA("/accounts/merchant/cashouts/$id/confirm") {
-                json { "tan" to "unused" }
-            }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
-        }
-
-        // Check bad conversion
-        client.postA("/accounts/customer/cashouts") {
-            json(req) { "request_uid" to randShortHashCode() }
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-            client.post("/conversion-info/conversion-rate") {
-                pwAuth("admin")
-                json {
-                    "cashin_ratio" to "1"
-                    "cashin_fee" to "KUDOS:0.1"
-                    "cashin_tiny_amount" to "KUDOS:0.0001"
-                    "cashin_rounding_mode" to "nearest"
-                    "cashin_min_amount" to "EUR:0.0001"
-                    "cashout_ratio" to "1"
-                    "cashout_fee" to "EUR:0.1"
-                    "cashout_tiny_amount" to "EUR:0.0001"
-                    "cashout_rounding_mode" to "nearest"
-                    "cashout_min_amount" to "KUDOS:0.0001"
-                }
-            }.assertNoContent()
-
-            client.postA("/accounts/customer/cashouts/$id/confirm"){
-                json { "tan" to smsCode("+99") } 
-            }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION)
-
-            // Check can abort because not confirmed
-            client.postA("/accounts/customer/cashouts/$id/abort")
-                .assertNoContent()
-        }
-
-        // Check balance insufficient
-        client.postA("/accounts/customer/cashouts") {
-            json(req) { 
+            json(req) {
                 "request_uid" to randShortHashCode()
-                "amount_credit" to convert("KUDOS:1")
             }
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-            // Send too much money
-            tx("customer", "KUDOS:9", "merchant")
-            client.postA("/accounts/customer/cashouts/$id/confirm"){
-                json { "tan" to smsCode("+99") } 
-            }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
-
-            // Check can abort because not confirmed
-            client.postA("/accounts/customer/cashouts/$id/abort")
-                .assertNoContent()
+        }.assertChallenge { _,_->
+            assertBalance("customer", "-KUDOS:1")
+        }.assertOkJson<CashoutResponse> {
+            assertBalance("customer", "-KUDOS:2")
         }
-
-        // Check bad UUID
-        client.postA("/accounts/customer/cashouts/chocolate/confirm") {
-            json { "tan" to "code" }
-        }.assertBadRequest()
-
-        // Check unknown
-        client.postA("/accounts/customer/cashouts/42/confirm") {
-            json { "tan" to "code" }
-        }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
     }
 
     // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID}
@@ -1237,41 +1158,15 @@ class CoreBankCashoutApiTest {
         // Check confirm
         client.postA("/accounts/customer/cashouts") {
             json(req) { "request_uid" to randShortHashCode() }
-        }.assertOkJson<CashoutPending> {
+        }.assertOkJson<CashoutResponse> {
             val id = it.cashout_id
             client.getA("/accounts/customer/cashouts/$id")
                 .assertOkJson<CashoutStatusResponse> {
-                assertEquals(CashoutStatus.pending, it.status)
+                assertEquals(CashoutStatus.confirmed, it.status)
                 assertEquals(amountDebit, it.amount_debit)
                 assertEquals(amountCredit, it.amount_credit)
-                assertEquals(TanChannel.sms, it.tan_channel)
-                assertEquals("+99", it.tan_info)
-            }
-
-            client.postA("/accounts/customer/cashouts/$id/confirm") {
-                json { "tan" to smsCode("+99") }
-            }.assertNoContent()
-            client.getA("/accounts/customer/cashouts/$id")
-                .assertOkJson<CashoutStatusResponse> {
-                assertEquals(CashoutStatus.confirmed, it.status)
-            }
-        }
-
-        // Check abort
-        client.postA("/accounts/customer/cashouts") {
-            json(req) { "request_uid" to randShortHashCode() }
-        }.assertOkJson<CashoutPending> {
-            val id = it.cashout_id
-            client.getA("/accounts/customer/cashouts/$id")
-                .assertOkJson<CashoutStatusResponse> {
-                assertEquals(CashoutStatus.pending, it.status)
-            }
-
-            client.postA("/accounts/customer/cashouts/$id/abort")
-                .assertNoContent()
-            client.getA("/accounts/customer/cashouts/$id")
-                .assertOkJson<CashoutStatusResponse> {
-                assertEquals(CashoutStatus.aborted, it.status)
+                assertNull(it.tan_channel)
+                assertNull(it.tan_info)
             }
         }
 
@@ -1286,7 +1181,7 @@ class CoreBankCashoutApiTest {
         // Check get another user's operation
         client.postA("/accounts/customer/cashouts") {
             json(req) { "request_uid" to randShortHashCode() }
-        }.assertOkJson<CashoutPending> {
+        }.assertOkJson<CashoutResponse> {
             val id = it.cashout_id
 
             // Check error
@@ -1325,4 +1220,227 @@ class CoreBankCashoutApiTest {
         client.get("/accounts/customer/cashouts")
             .assertNotImplemented()
     }
+}
+
+class CoreBankTanApiTest {
+    // POST /accounts/{USERNAME}/challenge/{challenge_id}
+    @Test
+    fun send() = bankSetup { _ ->
+        authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42")
+
+        suspend fun HttpResponse.expectChallenge(channel: TanChannel, info: 
String): HttpResponse {
+            return assertChallenge { tanChannel, tanInfo ->
+                assertEquals(channel, tanChannel)
+                assertEquals(info, tanInfo)
+            }
+        }
+
+        suspend fun HttpResponse.expectTransmission(channel: TanChannel, info: 
String) {
+            this.assertOkJson<TanTransmission> {
+                assertEquals(it.tan_channel, channel)
+                assertEquals(it.tan_info, info)
+            }
+        }
+
+        // Set up 2fa 
+        client.patchA("/accounts/merchant") {
+            json { 
+                "contact_data" to obj {
+                    "phone" to "+99"
+                    "email" to "email@example.com"
+                }
+                "tan_channel" to "sms"
+            }
+        }.expectChallenge(TanChannel.sms, "+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" }
+            }
+        }.expectChallenge(TanChannel.sms, "+99")
+            .expectChallenge(TanChannel.sms, "+98")
+            .assertNoContent()
+        client.patchA("/accounts/merchant") {
+            json { // Channel change
+                "tan_channel" to "email"
+            }
+        }.expectChallenge(TanChannel.sms, "+98")
+            .expectChallenge(TanChannel.email, "email@example.com")
+            .assertNoContent()
+        client.patchA("/accounts/merchant") {
+            json { // Both change
+                "contact_data" to obj { "phone" to "+97" }
+                "tan_channel" to "sms"
+            }
+        }.expectChallenge(TanChannel.email, "email@example.com")
+            .expectChallenge(TanChannel.sms, "+97")
+            .assertNoContent()
+
+        // Disable 2fa
+        client.patchA("/accounts/merchant") {
+            json { "tan_channel" to null as String? }
+        }.expectChallenge(TanChannel.sms, "+97")
+            .assertNoContent()
+
+        // Admin has no 2FA
+        client.patch("/accounts/merchant") {
+            pwAuth("admin")
+            json { 
+                "contact_data" to obj { "phone" to "+99" }
+                "tan_channel" to "sms"
+            }
+        }.assertNoContent()
+        client.patch("/accounts/merchant") {
+            pwAuth("admin")
+            json { "tan_channel" to "email" }
+        }.assertNoContent()
+        client.patch("/accounts/merchant") {
+            pwAuth("admin")
+            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<TanChallenge> { 
+            // Check ok
+            client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+                .expectTransmission(TanChannel.sms, "+88")
+            assertNotNull(tanCode("+88"))
+            // Check retry
+            client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+                .expectTransmission(TanChannel.sms, "+88")
+            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/${it.challenge_id}")
+                .expectTransmission(TanChannel.sms, "+88")
+            assertNull(tanCode("+88"))
+            // Change 2fa settings
+            client.patchA("/accounts/merchant") {
+                json { 
+                    "tan_channel" to "email"
+                }
+            }.expectChallenge(TanChannel.sms, "+88")
+                .expectChallenge(TanChannel.email, "email@example.com")
+                .assertNoContent()
+            // Check invalidated
+            client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+                .expectTransmission(TanChannel.email, "email@example.com")
+            assertNotNull(tanCode("email@example.com"))
+        }
+
+        // Unknown challenge
+        client.postA("/accounts/merchant/challenge/42")
+            .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
+    }
+
+    // POST /accounts/{USERNAME}/challenge/{challenge_id}
+    @Test
+    fun sendTanErr() = bankSetup("test_tan_err.conf") { _ ->
+        // Check fail
+        client.patch("/accounts/merchant") {
+            pwAuth("admin")
+            json { 
+                "contact_data" to obj { "phone" to "+1234" }
+                "tan_channel" to "sms"
+            }
+        }.assertNoContent()
+        client.patchA("/accounts/merchant") {
+            json { "is_public" to false }
+        }.assertAcceptedJson<TanChallenge> { 
+            client.postA("/accounts/merchant/challenge/${it.challenge_id}")
+                .assertStatus(HttpStatusCode.BadGateway, 
TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)
+        }
+    }
+
+    // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm
+    @Test
+    fun confirm() = bankSetup { _ ->
+        authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm")
+
+        fillTanInfo("merchant")
+
+        // Check simple case
+        client.patchA("/accounts/merchant") {
+            json { "is_public" to false }
+        }.assertAcceptedJson<TanChallenge> {
+            val id = it.challenge_id
+            val info = client.postA("/accounts/merchant/challenge/$id")
+                .assertOkJson<TanTransmission>().tan_info
+            val code = tanCode(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" } 
+            }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND)
+        
+            // 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/42/confirm") {
+                json { "tan" to code }
+            }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND)
+        }
+        
+        // Check invalidation
+        client.patchA("/accounts/merchant") {
+            json { "is_public" to true }
+        }.assertAcceptedJson<TanChallenge> {
+            val id = it.challenge_id
+            val info = client.postA("/accounts/merchant/challenge/$id")
+                .assertOkJson<TanTransmission>().tan_info
+             
+            // Check invalidated
+            fillTanInfo("merchant")
+            client.postA("/accounts/merchant/challenge/$id/confirm") {
+                json { "tan" to tanCode(info) }
+            }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED)
+
+            val new = client.postA("/accounts/merchant/challenge/$id")
+                .assertOkJson<TanTransmission>().tan_info
+            val code = tanCode(new)
+            // 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/$id/confirm") {
+                json { "tan" to code }
+            }.assertNoContent()
+            
+            // Solved challenge remain solved
+            fillTanInfo("merchant")
+            client.postA("/accounts/merchant/challenge/$id/confirm") {
+                json { "tan" to code }
+            }.assertNoContent()
+        }
+    }
 }
\ No newline at end of file
diff --git a/bank/src/test/kotlin/DatabaseTest.kt 
b/bank/src/test/kotlin/DatabaseTest.kt
index 81c4c813..fc19d5a8 100644
--- a/bank/src/test/kotlin/DatabaseTest.kt
+++ b/bank/src/test/kotlin/DatabaseTest.kt
@@ -69,11 +69,11 @@ class DatabaseTest {
     }
 
     @Test
-    fun challenge() = setup { db, _ -> db.conn { conn ->
-        val createStmt = conn.prepareStatement("SELECT 
challenge_create(?,?,?,?)")
-        val sendStmt = conn.prepareStatement("SELECT 
challenge_mark_sent(?,?,?)")
-        val tryStmt = conn.prepareStatement("SELECT ok, no_retry FROM 
challenge_try(?,?,?)")
-        val resendStmt = conn.prepareStatement("SELECT 
challenge_resend(?,?,?,?,?)")
+    fun tanChallenge() = bankSetup { db -> db.conn { conn ->
+        val createStmt = conn.prepareStatement("SELECT 
tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)")
+        val markSentStmt = conn.prepareStatement("SELECT 
tan_challenge_mark_sent(?,?,?)")
+        val tryStmt = conn.prepareStatement("SELECT out_ok, out_no_retry, 
out_expired FROM tan_challenge_try(?,'customer',?,?)")
+        val sendStmt = conn.prepareStatement("SELECT out_tan_code FROM 
tan_challenge_send(?,'customer',?,?,?,?)")
 
         val validityPeriod = Duration.ofHours(1)
         val retransmissionPeriod: Duration = Duration.ofMinutes(1)
@@ -87,29 +87,31 @@ class DatabaseTest {
             return createStmt.oneOrNull { it.getLong(1) }!!
         }
 
-        fun send(id: Long, now: Instant) {
-            sendStmt.setLong(1, id)
-            sendStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now))
-            sendStmt.setLong(3, 
TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
-            return sendStmt.oneOrNull { }!!
+        fun markSent(id: Long, now: Instant) {
+            markSentStmt.setLong(1, id)
+            markSentStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, 
now))
+            markSentStmt.setLong(3, 
TimeUnit.MICROSECONDS.convert(retransmissionPeriod))
+            return markSentStmt.oneOrNull { }!!
         }
 
-        fun cTry(id: Long, code: String, now: Instant): Pair<Boolean, Boolean> 
{
+        fun cTry(id: Long, code: String, now: Instant): Triple<Boolean, 
Boolean, Boolean> {
             tryStmt.setLong(1, id)
             tryStmt.setString(2, code)
             tryStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now))
             return tryStmt.oneOrNull { 
-                Pair(it.getBoolean(1), it.getBoolean(2))
+                Triple(it.getBoolean(1), it.getBoolean(2), it.getBoolean(3))
             }!!
         }
 
-        fun resend(id: Long, code: String, now: Instant): String? {
-            resendStmt.setLong(1, id)
-            resendStmt.setString(2, code)
-            resendStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, 
now))
-            resendStmt.setLong(4, 
TimeUnit.MICROSECONDS.convert(validityPeriod))
-            resendStmt.setInt(5, retryCounter)
-            return resendStmt.oneOrNull { it.getString(1) }
+        fun send(id: Long, code: String, now: Instant): String? {
+            sendStmt.setLong(1, id)
+            sendStmt.setString(2, code)
+            sendStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now))
+            sendStmt.setLong(4, TimeUnit.MICROSECONDS.convert(validityPeriod))
+            sendStmt.setInt(5, retryCounter)
+            return sendStmt.oneOrNull {
+                it.getString(1) 
+            }
         }
         
         val now = Instant.now()
@@ -119,50 +121,51 @@ class DatabaseTest {
         // Check basic
         create("good-code", now).run {
             // Bad code
-            assertEquals(Pair(false, false), cTry(this, "bad-code", now))
+            assertEquals(Triple(false, false, false), cTry(this, "bad-code", 
now))
             // Good code
-            assertEquals(Pair(true, false), cTry(this, "good-code", now))
+            assertEquals(Triple(true, false, false), cTry(this, "good-code", 
now))
             // Never resend a confirmed challenge
-            assertNull(resend(this, "new-code", expired))
+            assertNull(send(this, "new-code", expired))
             // Confirmed challenge always ok
-            assertEquals(Pair(true, false), cTry(this, "good-code", now))
+            assertEquals(Triple(true, false, false), cTry(this, "good-code", 
now))
         }
 
         // Check retry
         create("good-code", now).run {
-            send(this, now)
+            markSent(this, now)
             // Bad code
-            repeat(retryCounter) {
-                assertEquals(Pair(false, false), cTry(this, "bad-code", now))
+            repeat(retryCounter-1) {
+                assertEquals(Triple(false, false, false), cTry(this, 
"bad-code", now))
             }
+            assertEquals(Triple(false, true, false), cTry(this, "bad-code", 
now))
             // Good code fail
-            assertEquals(Pair(false, true), cTry(this, "good-code", now))
+            assertEquals(Triple(false, true, false), cTry(this, "good-code", 
now))
             // New code 
-            assertEquals("new-code", resend(this, "new-code", now))
+            assertEquals("new-code", send(this, "new-code", now))
             // Good code
-            assertEquals(Pair(true, false), cTry(this, "new-code", now))
+            assertEquals(Triple(true, false, false), cTry(this, "new-code", 
now))
         }
 
         // Check retransmission and expiration
         create("good-code", now).run {
             // Failed to send retransmit
-            assertEquals("good-code", resend(this, "new-code", now))
+            assertEquals("good-code", send(this, "new-code", now))
             // Code successfully sent and still valid
-            send(this, now)
-            assertNull(resend(this, "new-code", now))
+            markSent(this, now)
+            assertNull(send(this, "new-code", now))
             // Code is still valid but shoud be resent
-            assertEquals("good-code", resend(this, "new-code", retransmit))
+            assertEquals("good-code", send(this, "new-code", retransmit))
             // Good code fail because expired
-            assertEquals(Pair(false, false), cTry(this, "good-code", expired))
+            assertEquals(Triple(false, false, true), cTry(this, "good-code", 
expired))
             // New code because expired
-            assertEquals("new-code", resend(this, "new-code", expired))
+            assertEquals("new-code", send(this, "new-code", expired))
             // Code successfully sent and still valid
-            send(this, expired)
-            assertNull(resend(this, "another-code", expired))
+            markSent(this, expired)
+            assertNull(send(this, "another-code", expired))
             // Old code no longer workds
-            assertEquals(Pair(false, false), cTry(this, "good-code", expired))
+            assertEquals(Triple(false, false, false), cTry(this, "good-code", 
expired))
             // New code works
-            assertEquals(Pair(true, false), cTry(this, "new-code", expired))
+            assertEquals(Triple(true, false, false), cTry(this, "new-code", 
expired))
         }
     }}
 
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index c35dc721..951d24f5 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -26,6 +26,7 @@ import java.io.ByteArrayOutputStream
 import java.io.File
 import java.util.zip.DeflaterOutputStream
 import kotlin.test.*
+import kotlin.random.Random
 import kotlinx.coroutines.*
 import kotlinx.serialization.json.*
 import net.taler.common.errorcodes.TalerErrorCode
@@ -40,13 +41,19 @@ import tech.libeufin.util.*
 val merchantPayto = IbanPayTo(genIbanPaytoUri())
 val exchangePayto = IbanPayTo(genIbanPaytoUri())
 val customerPayto = IbanPayTo(genIbanPaytoUri())
-val unknownPayto = IbanPayTo(genIbanPaytoUri())
+val unknownPayto  = IbanPayTo(genIbanPaytoUri())
+var tmpPayTo      = IbanPayTo(genIbanPaytoUri())
 val paytos = mapOf(
     "merchant" to merchantPayto, 
     "exchange" to exchangePayto, 
     "customer" to customerPayto
 )
 
+fun genTmpPayTo(): IbanPayTo {
+    tmpPayTo = IbanPayTo(genIbanPaytoUri())
+    return tmpPayTo
+}
+
 fun setup(
     conf: String = "test.conf",
     lambda: suspend (Database, BankConfig) -> Unit
@@ -85,7 +92,11 @@ fun bankSetup(
             isTalerExchange = false,
             isPublic = false,
             bonus = bonus,
-            checkPaytoIdempotent = false
+            checkPaytoIdempotent = false,
+            email = null,
+            phone = null,
+            cashoutPayto = null,
+            tanChannel = null   
         ))
         assertEquals(AccountCreationResult.Success, db.account.create(
             login = "exchange",
@@ -96,7 +107,11 @@ fun bankSetup(
             isTalerExchange = true,
             isPublic = false,
             bonus = bonus,
-            checkPaytoIdempotent = false
+            checkPaytoIdempotent = false,
+            email = null,
+            phone = null,
+            cashoutPayto = null,
+            tanChannel = null   
         ))
         assertEquals(AccountCreationResult.Success, db.account.create(
             login = "customer",
@@ -107,7 +122,11 @@ fun bankSetup(
             isTalerExchange = false,
             isPublic = false,
             bonus = bonus,
-            checkPaytoIdempotent = false
+            checkPaytoIdempotent = false,
+            email = null,
+            phone = null,
+            cashoutPayto = null,
+            tanChannel = null   
         ))
         // Create admin account
         assertEquals(AccountCreationResult.Success, 
maybeCreateAdminAccount(db, ctx, "admin-password"))
@@ -163,20 +182,30 @@ suspend fun ApplicationTestBuilder.assertBalance(account: 
String, amount: String
     }
 }
 
+/** Check [account] tan channel and info */
+suspend fun ApplicationTestBuilder.tanInfo(account: String): Pair<TanChannel?, 
String?> {
+    val res = client.getA("/accounts/$account").assertOkJson<AccountData>()
+    val channel: TanChannel? = res.tan_channel
+    return Pair(channel, when (channel) {
+        TanChannel.sms -> res.contact_data!!.phone.get()
+        TanChannel.email -> res.contact_data!!.email.get()
+        null -> null
+        else -> null
+    })
+}
+
 /** Perform a bank transaction of [amount] [from] account [to] account with 
[subject} */
 suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: 
String, subject: String = "payout"): Long {
-    return client.post("/accounts/$from/transactions") {
-        basicAuth("$from", "$from-password")
+    return client.postA("/accounts/$from/transactions") {
         json {
-            "payto_uri" to 
"${paytos[to]}?message=${subject.encodeURLQueryComponent()}&amount=$amount"
+            "payto_uri" to "${paytos[to] ?: 
tmpPayTo}?message=${subject.encodeURLQueryComponent()}&amount=$amount"
         }
-    }.assertOkJson<TransactionCreateResponse>().row_id
+    }.maybeChallenge().assertOkJson<TransactionCreateResponse>().row_id
 }
 
 /** Perform a taler outgoing transaction of [amount] from exchange to merchant 
*/
 suspend fun ApplicationTestBuilder.transfer(amount: String) {
-    client.post("/accounts/exchange/taler-wire-gateway/transfer") {
-        pwAuth("exchange")
+    client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
         json {
             "request_uid" to randHashCode()
             "amount" to TalerAmount(amount)
@@ -220,11 +249,7 @@ suspend fun ApplicationTestBuilder.cashout(amount: String) 
{
         } 
     } else { 
         res
-    }.assertOkJson<CashoutPending> {
-        client.postA("/accounts/customer/cashouts/${it.cashout_id}/confirm") {
-            json { "tan" to smsCode("+99") }
-        }.assertNoContent()
-    }
+    }.assertOk()
 }
 
 /** Perform a whithrawal operation of [amount] from customer */
@@ -234,7 +259,7 @@ suspend fun ApplicationTestBuilder.withdrawal(amount: 
String) {
     }.assertOkJson<BankAccountCreateWithdrawalResponse> {
         val uuid = it.taler_withdraw_uri.split("/").last()
         withdrawalSelect(uuid)
-        client.postA("/withdrawals/${uuid}/confirm")
+        client.postA("/accounts/merchant/withdrawals/${uuid}/confirm")
             .assertNoContent()
     }
 }
@@ -251,6 +276,18 @@ suspend fun 
ApplicationTestBuilder.fillCashoutInfo(account: String) {
     }.assertNoContent()
 }
 
+suspend fun ApplicationTestBuilder.fillTanInfo(account: String) {
+    client.patch("/accounts/$account") {
+        pwAuth("admin")
+        json {
+            "contact_data" to obj {
+                "phone" to "+${Random.nextInt(0, 10000)}"
+            }
+            "tan_channel" to "sms"
+        }
+    }.assertNoContent()
+}
+
 suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String) {
     client.post("/taler-integration/withdrawal-operation/$uuid") {
         json {
@@ -265,7 +302,7 @@ suspend fun ApplicationTestBuilder.convert(amount: String): 
TalerAmount {
         .assertOkJson<ConversionResponse>().amount_credit
 }
 
-suspend fun smsCode(info: String): String? {
+suspend fun tanCode(info: String): String? {
     val file = File("/tmp/tan-$info.txt");
     if (file.exists()) {
         val code = file.readText()
@@ -280,7 +317,7 @@ suspend fun smsCode(info: String): String? {
 /* ----- Assert ----- */
 
 suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: 
TalerErrorCode?): HttpResponse {
-    assertEquals(status, this.status);
+    assertEquals(status, this.status, "$err")
     if (err != null) assertErr(err)
     return this
 }
@@ -288,6 +325,8 @@ suspend fun HttpResponse.assertOk(): HttpResponse
     = assertStatus(HttpStatusCode.OK, null)
 suspend fun HttpResponse.assertNoContent(): HttpResponse 
     = assertStatus(HttpStatusCode.NoContent, null)
+suspend fun HttpResponse.assertAccepted(): HttpResponse 
+    = assertStatus(HttpStatusCode.Accepted, null)
 suspend fun HttpResponse.assertNotFound(err: TalerErrorCode?): HttpResponse 
     = assertStatus(HttpStatusCode.NotFound, err)
 suspend fun HttpResponse.assertUnauthorized(): HttpResponse 
@@ -308,6 +347,33 @@ suspend fun HttpResponse.assertErr(code: TalerErrorCode): 
HttpResponse {
     return this
 }
 
+suspend fun HttpResponse.maybeChallenge(): HttpResponse {
+    return if (this.status == HttpStatusCode.Accepted) {
+        this.assertChallenge()
+    } else {
+        this
+    }
+}
+
+suspend fun HttpResponse.assertChallenge(
+    check: suspend (TanChannel, String) -> Unit = { _, _ -> }
+): HttpResponse {
+    val id = assertAcceptedJson<TanChallenge>().challenge_id
+    val username = call.request.url.pathSegments[2]
+    val res = 
call.client.postA("/accounts/$username/challenge/$id").assertOkJson<TanTransmission>()
+    check(res.tan_channel, res.tan_info)
+    val code = tanCode(res.tan_info)
+    assertNotNull(code)
+    call.client.postA("/accounts/$username/challenge/$id/confirm") {
+        json { "tan" to code }
+    }.assertNoContent()
+    return call.client.request(this.call.request.url) {
+        pwAuth(username)
+        method = call.request.method
+        headers["X-Challenge-Id"] = "$id"
+    }
+}
+
 suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) {
     val start = System.currentTimeMillis()
     lambda()
@@ -360,6 +426,13 @@ inline suspend fun <reified B> 
HttpResponse.assertOkJson(lambda: (B) -> Unit = {
     return body
 }
 
+inline suspend fun <reified B> HttpResponse.assertAcceptedJson(lambda: (B) -> 
Unit = {}): B {
+    assertAccepted()
+    val body = json<B>()
+    lambda(body)
+    return body
+}
+
 /* ----- Auth ----- */
 
 /** Auto auth get request */
diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt
index 6a915d58..92fdfd9c 100644
--- a/bank/src/test/kotlin/routines.kt
+++ b/bank/src/test/kotlin/routines.kt
@@ -35,7 +35,7 @@ suspend fun ApplicationTestBuilder.authRoutine(
     body: JsonObject? = null, 
     requireExchange: Boolean = false, 
     requireAdmin: Boolean = false,
-    withAdmin: Boolean = false
+    allowAdmin: Boolean = false
 ) {
     // No body when authentication must happen before parsing the body
     
@@ -63,7 +63,7 @@ suspend fun ApplicationTestBuilder.authRoutine(
             this.method = method
             pwAuth("merchant")
         }.assertUnauthorized()
-    } else if (!withAdmin) {
+    } else if (!allowAdmin) {
         // Check no admin
         client.request(path) {
             this.method = method
@@ -256,7 +256,7 @@ inline suspend fun <reified B> 
ApplicationTestBuilder.statusRoutine(
                 }
             }
             delay(100)
-            
client.post("/withdrawals/$confirmed_uuid/confirm").assertNoContent()
+            
client.postA("/accounts/customer/withdrawals/$confirmed_uuid/confirm").assertNoContent()
         }
 
         // Polling abort
@@ -274,7 +274,7 @@ inline suspend fun <reified B> 
ApplicationTestBuilder.statusRoutine(
                 }
             }
             delay(100)
-            client.post("/withdrawals/$aborted_uuid/abort").assertNoContent()
+            
client.post("/taler-integration/withdrawal-operation/$aborted_uuid/abort").assertNoContent()
         }
     }
 }
\ No newline at end of file
diff --git a/database-versioning/libeufin-bank-0001.sql 
b/database-versioning/libeufin-bank-0001.sql
index 6bf7420a..5272eeae 100644
--- a/database-versioning/libeufin-bank-0001.sql
+++ b/database-versioning/libeufin-bank-0001.sql
@@ -21,10 +21,7 @@ CREATE SCHEMA libeufin_bank;
 SET search_path TO libeufin_bank;
 
 CREATE TYPE taler_amount
-  AS
-  (val INT8
-  ,frac INT4
-  );
+  AS (val INT8 ,frac INT4);
 COMMENT ON TYPE taler_amount
   IS 'Stores an amount, fraction is in units of 1/100000000 of the base value';
 
@@ -59,7 +56,7 @@ CREATE TYPE rounding_mode
 -- start of: bank accounts
 
 CREATE TABLE IF NOT EXISTS customers
-  (customer_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+  (customer_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
   ,login TEXT NOT NULL UNIQUE
   ,password_hash TEXT NOT NULL
   ,name TEXT
@@ -72,27 +69,10 @@ COMMENT ON COLUMN customers.cashout_payto
 COMMENT ON COLUMN customers.name
   IS 'Full name of the customer.';
 
-CREATE TABLE IF NOT EXISTS bearer_tokens
-  (bearer_token_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
-  ,content BYTEA NOT NULL UNIQUE CHECK (LENGTH(content)=32)
-  ,creation_time INT8
-  ,expiration_time INT8
-  ,scope token_scope_enum
-  ,is_refreshable BOOLEAN
-  ,bank_customer BIGINT NOT NULL REFERENCES customers(customer_id) ON DELETE 
CASCADE
-);
-COMMENT ON TABLE bearer_tokens
-  IS 'Login tokens associated with one bank customer.  There is currently'
-     ' no garbage collector that deletes the expired tokens from the table';
-COMMENT ON COLUMN bearer_tokens.bank_customer
-  IS 'The customer that directly created this token, or the customer that'
-     ' created the very first token that originated all the refreshes until'
-     ' this token was created.';
-
 CREATE TABLE IF NOT EXISTS bank_accounts 
-  (bank_account_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+  (bank_account_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
   ,internal_payto_uri TEXT NOT NULL UNIQUE
-  ,owning_customer_id BIGINT NOT NULL UNIQUE -- UNIQUE enforces 1-1 map with 
customers
+  ,owning_customer_id INT8 NOT NULL UNIQUE -- UNIQUE enforces 1-1 map with 
customers
     REFERENCES customers(customer_id)
     ON DELETE CASCADE
   ,is_public BOOLEAN DEFAULT FALSE NOT NULL -- privacy by default
@@ -116,8 +96,26 @@ can be publicly shared';
 COMMENT ON COLUMN bank_accounts.owning_customer_id
   IS 'Login that owns the bank account';
 
+CREATE TABLE IF NOT EXISTS bearer_tokens
+  (bearer_token_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
+  ,content BYTEA NOT NULL UNIQUE CHECK (LENGTH(content)=32)
+  ,creation_time INT8
+  ,expiration_time INT8
+  ,scope token_scope_enum
+  ,is_refreshable BOOLEAN
+  ,bank_customer INT8 NOT NULL 
+    REFERENCES customers(customer_id)
+    ON DELETE CASCADE
+);
+COMMENT ON TABLE bearer_tokens
+  IS 'Login tokens associated with one bank customer.';
+COMMENT ON COLUMN bearer_tokens.bank_customer
+  IS 'The customer that directly created this token, or the customer that'
+     ' created the very first token that originated all the refreshes until'
+     ' this token was created.';
+
 CREATE TABLE IF NOT EXISTS iban_history 
-  (iban TEXT PRIMARY key
+  (iban TEXT PRIMARY KEY
   ,creation_time INT8 NOT NULL
   );
 COMMENT ON TABLE iban_history IS 'Track all generated iban, some might be 
unused.';
@@ -127,21 +125,19 @@ COMMENT ON TABLE iban_history IS 'Track all generated 
iban, some might be unused
 -- start of: money transactions
 
 CREATE TABLE IF NOT EXISTS bank_account_transactions 
-  (bank_transaction_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+  (bank_transaction_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
   ,creditor_payto_uri TEXT NOT NULL
   ,creditor_name TEXT NOT NULL
   ,debtor_payto_uri TEXT NOT NULL
   ,debtor_name TEXT NOT NULL
   ,subject TEXT NOT NULL
   ,amount taler_amount NOT NULL
-  ,transaction_date BIGINT NOT NULL -- is this ISO20022 terminology? document 
format (microseconds since epoch)
+  ,transaction_date INT8 NOT NULL
   ,account_servicer_reference TEXT
   ,payment_information_id TEXT
   ,end_to_end_id TEXT
   ,direction direction_enum NOT NULL
-  ,bank_account_id BIGINT NOT NULL
-    REFERENCES bank_accounts(bank_account_id)
-    ON DELETE CASCADE ON UPDATE RESTRICT
+  ,bank_account_id INT8 NOT NULL REFERENCES bank_accounts(bank_account_id)
   );
 
 COMMENT ON COLUMN bank_account_transactions.direction
@@ -157,7 +153,7 @@ COMMENT ON COLUMN bank_account_transactions.bank_account_id
 
 -- start of: TAN challenge
 CREATE TABLE IF NOT EXISTS challenges
-  (challenge_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE,
+  (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE,
    code TEXT NOT NULL,
    creation_date INT8 NOT NULL,
    expiration_date INT8 NOT NULL,
@@ -184,27 +180,23 @@ COMMENT ON COLUMN challenges.confirmation_date
 -- start of: cashout management
 
 CREATE TABLE IF NOT EXISTS cashout_operations 
-  (cashout_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE
+  (cashout_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
   ,request_uid BYTEA NOT NULL PRIMARY KEY CHECK (LENGTH(request_uid)=32)
   ,amount_debit taler_amount NOT NULL
   ,amount_credit taler_amount NOT NULL
   ,subject TEXT NOT NULL
-  ,creation_time BIGINT NOT NULL
-  ,bank_account BIGINT NOT NULL
+  ,creation_time INT8 NOT NULL
+  ,bank_account INT8 NOT NULL
     REFERENCES bank_accounts(bank_account_id)
-    ON DELETE CASCADE
-    ON UPDATE RESTRICT
-  ,challenge BIGINT NOT NULL UNIQUE
+  ,challenge INT8 NOT NULL UNIQUE
     REFERENCES challenges(challenge_id)
-    ON DELETE CASCADE
-    ON UPDATE RESTRICT
-  ,tan_channel TEXT NULL DEFAULT NULL -- TODO should be tan_enum but might be 
removed in the future
+      ON DELETE SET NULL
+  ,tan_channel TEXT NULL DEFAULT NULL
   ,tan_info TEXT NULL DEFAULT NULL
   ,aborted BOOLEAN NOT NULL DEFAULT FALSE
-  ,local_transaction BIGINT UNIQUE DEFAULT NULL-- FIXME: Comment that the 
transaction only gets created after the TAN confirmation
+  ,local_transaction INT8 UNIQUE DEFAULT NULL
     REFERENCES bank_account_transactions(bank_transaction_id)
-    ON DELETE RESTRICT
-    ON UPDATE RESTRICT
+      ON DELETE CASCADE
   );
 COMMENT ON COLUMN cashout_operations.bank_account IS 'Bank amount to debit 
during confirmation';
 COMMENT ON COLUMN cashout_operations.challenge IS 'TAN challenge used to 
confirm the operation';
@@ -216,31 +208,28 @@ COMMENT ON COLUMN cashout_operations.tan_info IS 'Info of 
the last successful tr
 
 -- start of: Taler integration
 CREATE TABLE IF NOT EXISTS taler_exchange_outgoing
-  (exchange_outgoing_id BIGINT GENERATED BY DEFAULT AS IDENTITY
+  (exchange_outgoing_id INT8 GENERATED BY DEFAULT AS IDENTITY
   ,request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=64)
   ,wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32)
   ,exchange_base_url TEXT NOT NULL
-  ,bank_transaction BIGINT UNIQUE NOT NULL
+  ,bank_transaction INT8 UNIQUE NOT NULL
     REFERENCES bank_account_transactions(bank_transaction_id)
-      ON DELETE RESTRICT
-      ON UPDATE RESTRICT
-  ,creditor_account_id BIGINT NOT NULL
+      ON DELETE CASCADE
+  ,creditor_account_id INT8 NOT NULL
     REFERENCES bank_accounts(bank_account_id)
-    ON DELETE CASCADE ON UPDATE RESTRICT
   );
 
 CREATE TABLE IF NOT EXISTS taler_exchange_incoming
-  (exchange_incoming_id BIGINT GENERATED BY DEFAULT AS IDENTITY
+  (exchange_incoming_id INT8 GENERATED BY DEFAULT AS IDENTITY
   ,reserve_pub BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_pub)=32)
-  ,bank_transaction BIGINT UNIQUE NOT NULL
+  ,bank_transaction INT8 UNIQUE NOT NULL
     REFERENCES bank_account_transactions(bank_transaction_id)
-      ON DELETE RESTRICT
-      ON UPDATE RESTRICT
+      ON DELETE CASCADE
   );
 
 CREATE TABLE IF NOT EXISTS taler_withdrawal_operations
-  (withdrawal_id BIGINT GENERATED BY DEFAULT AS IDENTITY
-  ,withdrawal_uuid uuid NOT NULL PRIMARY KEY
+  (withdrawal_id INT8 GENERATED BY DEFAULT AS IDENTITY
+  ,withdrawal_uuid uuid NOT NULL UNIQUE
   ,amount taler_amount NOT NULL
   ,selection_done BOOLEAN DEFAULT FALSE NOT NULL
   ,aborted BOOLEAN DEFAULT FALSE NOT NULL
@@ -248,10 +237,9 @@ CREATE TABLE IF NOT EXISTS taler_withdrawal_operations
   ,reserve_pub BYTEA UNIQUE CHECK (LENGTH(reserve_pub)=32)
   ,subject TEXT
   ,selected_exchange_payto TEXT
-  ,wallet_bank_account BIGINT NOT NULL
+  ,wallet_bank_account INT8 NOT NULL
     REFERENCES bank_accounts(bank_account_id)
-      ON DELETE RESTRICT
-      ON UPDATE RESTRICT
+      ON DELETE CASCADE
   );
 COMMENT ON COLUMN taler_withdrawal_operations.selection_done
   IS 'Signals whether the wallet specified the exchange and gave the reserve 
public key';
@@ -264,32 +252,44 @@ COMMENT ON COLUMN 
taler_withdrawal_operations.confirmation_done
 CREATE TABLE IF NOT EXISTS bank_stats (
   timeframe stat_timeframe_enum NOT NULL
   ,start_time timestamp NOT NULL
-  ,taler_in_count BIGINT NOT NULL DEFAULT 0
+  ,taler_in_count INT8 NOT NULL DEFAULT 0
   ,taler_in_volume taler_amount NOT NULL DEFAULT (0, 0)
-  ,taler_out_count BIGINT NOT NULL DEFAULT 0
+  ,taler_out_count INT8 NOT NULL DEFAULT 0
   ,taler_out_volume taler_amount NOT NULL DEFAULT (0, 0)
-  ,cashin_count BIGINT NOT NULL DEFAULT 0
+  ,cashin_count INT8 NOT NULL DEFAULT 0
   ,cashin_regional_volume taler_amount NOT NULL DEFAULT (0, 0)
   ,cashin_fiat_volume taler_amount NOT NULL DEFAULT (0, 0)
-  ,cashout_count BIGINT NOT NULL DEFAULT 0
+  ,cashout_count INT8 NOT NULL DEFAULT 0
   ,cashout_regional_volume taler_amount NOT NULL DEFAULT (0, 0)
   ,cashout_fiat_volume taler_amount NOT NULL DEFAULT (0, 0)
   ,PRIMARY KEY (start_time, timeframe) 
 );
--- TODO garbage collection
-COMMENT ON TABLE bank_stats IS 'Stores statistics about the bank usage.';
-COMMENT ON COLUMN bank_stats.timeframe IS 'particular timeframe that this row 
accounts for';
-COMMENT ON COLUMN bank_stats.start_time IS 'timestamp of the start of the 
timeframe that this row accounts for, truncated according to the precision of 
the timeframe';
-COMMENT ON COLUMN bank_stats.taler_out_count IS 'how many internal payments 
were made by a Taler exchange';
-COMMENT ON COLUMN bank_stats.taler_out_volume IS 'how much internal currency 
was paid by a Taler exchange';
-COMMENT ON COLUMN bank_stats.taler_in_count IS 'how many internal payments 
were made to a Taler exchange';
-COMMENT ON COLUMN bank_stats.taler_in_volume IS 'how much internal currency 
was paid to a Taler exchange';
-COMMENT ON COLUMN bank_stats.cashin_count IS 'how many cashin operations took 
place in the timeframe';
-COMMENT ON COLUMN bank_stats.cashin_regional_volume IS 'how much regional 
currency was cashed in in the timeframe';
-COMMENT ON COLUMN bank_stats.cashin_fiat_volume IS 'how much fiat currency was 
cashed in in the timeframe';
-COMMENT ON COLUMN bank_stats.cashout_count IS 'how many cashout operations 
took place in the timeframe';
-COMMENT ON COLUMN bank_stats.cashout_regional_volume IS 'how much regional 
currency was payed by the bank to customers in the timeframe';
-COMMENT ON COLUMN bank_stats.cashout_fiat_volume IS 'how much fiat currency 
was payed by the bank to customers in the timeframe';
+COMMENT ON TABLE bank_stats 
+  IS 'Stores statistics about the bank usage.';
+COMMENT ON COLUMN bank_stats.timeframe 
+  IS 'particular timeframe that this row accounts for';
+COMMENT ON COLUMN bank_stats.start_time 
+  IS 'timestamp of the start of the timeframe that this row accounts for, 
truncated according to the precision of the timeframe';
+COMMENT ON COLUMN bank_stats.taler_out_count
+  IS 'how many internal payments were made by a Taler exchange';
+COMMENT ON COLUMN bank_stats.taler_out_volume
+  IS 'how much internal currency was paid by a Taler exchange';
+COMMENT ON COLUMN bank_stats.taler_in_count
+  IS 'how many internal payments were made to a Taler exchange';
+COMMENT ON COLUMN bank_stats.taler_in_volume
+  IS 'how much internal currency was paid to a Taler exchange';
+COMMENT ON COLUMN bank_stats.cashin_count
+  IS 'how many cashin operations took place in the timeframe';
+COMMENT ON COLUMN bank_stats.cashin_regional_volume
+  IS 'how much regional currency was cashed in in the timeframe';
+COMMENT ON COLUMN bank_stats.cashin_fiat_volume
+  IS 'how much fiat currency was cashed in in the timeframe';
+COMMENT ON COLUMN bank_stats.cashout_count
+  IS 'how many cashout operations took place in the timeframe';
+COMMENT ON COLUMN bank_stats.cashout_regional_volume
+  IS 'how much regional currency was payed by the bank to customers in the 
timeframe';
+COMMENT ON COLUMN bank_stats.cashout_fiat_volume 
+  IS 'how much fiat currency was payed by the bank to customers in the 
timeframe';
 
 -- end of: Statistics
 
diff --git a/database-versioning/libeufin-bank-0002.sql 
b/database-versioning/libeufin-bank-0002.sql
new file mode 100644
index 00000000..0859d80f
--- /dev/null
+++ b/database-versioning/libeufin-bank-0002.sql
@@ -0,0 +1,90 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2023 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER 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 General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+
+BEGIN;
+
+SELECT _v.register_patch('libeufin-bank-0002', NULL, NULL);
+SET search_path TO libeufin_bank;
+
+-- Forget about all pending operations
+DELETE FROM cashout_operations WHERE local_transaction IS NULL;
+
+-- Remove challenge logic from cashout tables
+ALTER TABLE cashout_operations 
+  DROP COLUMN challenge,
+  DROP COLUMN tan_channel,
+  DROP COLUMN tan_info,
+  DROP COLUMN aborted,
+  ALTER COLUMN local_transaction SET NOT NULL;
+
+DROP TABLE challenges;
+
+ALTER TABLE customers
+  ADD tan_channel tan_enum NULL;
+
+CREATE TYPE op_enum
+  AS ENUM ('account_reconfig', 'account_auth_reconfig', 'account_delete', 
'bank_transaction', 'cashout', 'withdrawal');
+
+CREATE TABLE tan_challenges
+  (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE
+  ,body TEXT NOT NULL
+  ,op op_enum NOT NULL
+  ,code TEXT NOT NULL
+  ,creation_date INT8 NOT NULL
+  ,expiration_date INT8 NOT NULL
+  ,retransmission_date INT8 NOT NULL DEFAULT 0
+  ,confirmation_date INT8 DEFAULT NULL
+  ,retry_counter INT4 NOT NULL
+  ,customer INT8 NOT NULL
+    REFERENCES customers(customer_id)
+    ON DELETE CASCADE
+  ,tan_channel tan_enum NULL DEFAULT NULL
+  ,tan_info TEXT NULL DEFAULT NULL
+);
+COMMENT ON TABLE tan_challenges IS 'Stores 2FA challenges';
+COMMENT ON COLUMN tan_challenges.op IS 'The protected operation to run after 
the challenge';
+COMMENT ON COLUMN tan_challenges.code IS 'The pin code sent to the user and 
verified';
+COMMENT ON COLUMN tan_challenges.creation_date IS 'Creation date of the code';
+COMMENT ON COLUMN tan_challenges.retransmission_date IS 'When did we last 
transmit the challenge to the user';
+COMMENT ON COLUMN tan_challenges.expiration_date IS 'When will the code 
expire';
+COMMENT ON COLUMN tan_challenges.confirmation_date IS 'When was this challenge 
successfully verified, NULL if pending';
+COMMENT ON COLUMN tan_challenges.retry_counter IS 'How many tries are left for 
this code must be > 0';
+COMMENT ON COLUMN tan_challenges.tan_channel IS 'TAN channel to use, if null 
use customer configured one';
+COMMENT ON COLUMN tan_challenges.tan_info IS 'TAN info to use, if null use 
customer configured one';
+
+CREATE INDEX tan_challenges_expiration_index
+  ON tan_challenges (expiration_date);
+COMMENT ON INDEX tan_challenges_expiration_index
+  IS 'for garbage collection';
+
+CREATE INDEX bearer_tokens_expiration_index
+  ON bearer_tokens (expiration_time);
+COMMENT ON INDEX bearer_tokens_expiration_index
+  IS 'for garbage collection';
+
+CREATE INDEX bank_account_transactions_expiration_index
+  ON bank_account_transactions (transaction_date);
+COMMENT ON INDEX bank_account_transactions_expiration_index
+  IS 'for garbage collection';
+
+ALTER TABLE taler_withdrawal_operations
+  ADD creation_date INT8 NOT NULL;
+CREATE INDEX taler_withdrawal_operations_expiration_index
+  ON taler_withdrawal_operations (creation_date);
+COMMENT ON INDEX taler_withdrawal_operations_expiration_index
+  IS 'for garbage collection';
+
+
+COMMIT;
diff --git a/database-versioning/libeufin-bank-drop.sql 
b/database-versioning/libeufin-bank-drop.sql
index 123481a1..3746c28d 100644
--- a/database-versioning/libeufin-bank-drop.sql
+++ b/database-versioning/libeufin-bank-drop.sql
@@ -4,6 +4,7 @@ BEGIN;
 -- legacy database schema too.  That's acceptable as the
 -- legacy schema is being removed.
 SELECT _v.unregister_patch('libeufin-bank-0001');
+SELECT _v.unregister_patch('libeufin-bank-0002');
 DROP SCHEMA libeufin_bank CASCADE;
 
 COMMIT;
diff --git a/database-versioning/libeufin-bank-procedures.sql 
b/database-versioning/libeufin-bank-procedures.sql
index 6714a656..99b0bee3 100644
--- a/database-versioning/libeufin-bank-procedures.sql
+++ b/database-versioning/libeufin-bank-procedures.sql
@@ -31,7 +31,7 @@ CREATE FUNCTION amount_normalize(
 LANGUAGE plpgsql AS $$
 BEGIN
   normalized.val = amount.val + amount.frac / 100000000;
-  IF (normalized.val > 1::bigint<<52) THEN
+  IF (normalized.val > 1::INT8<<52) THEN
     RAISE EXCEPTION 'amount value overflowed';
   END IF;
   normalized.frac = amount.frac % 100000000;
@@ -85,7 +85,7 @@ COMMENT ON FUNCTION amount_left_minus_right
   IS 'Subtracts the right amount from the left and returns the difference and 
TRUE, if the left amount is larger than the right, or an invalid amount and 
FALSE otherwise.';
 
 CREATE FUNCTION account_balance_is_sufficient(
-  IN in_account_id BIGINT,
+  IN in_account_id INT8,
   IN in_amount taler_amount,
   OUT out_balance_insufficient BOOLEAN
 )
@@ -140,27 +140,28 @@ END IF;
 END $$;
 COMMENT ON FUNCTION account_balance_is_sufficient IS 'Check if an account have 
enough fund to transfer an amount.';
 
-CREATE FUNCTION customer_delete(
+CREATE FUNCTION account_delete(
   IN in_login TEXT,
-  OUT out_nx_customer BOOLEAN,
-  OUT out_balance_not_zero BOOLEAN
+  IN in_is_tan BOOLEAN,
+  OUT out_not_found BOOLEAN,
+  OUT out_balance_not_zero BOOLEAN,
+  OUT out_tan_required BOOLEAN
 )
 LANGUAGE plpgsql AS $$
 DECLARE
-my_customer_id BIGINT;
+my_customer_id INT8;
 my_balance_val INT8;
 my_balance_frac INT4;
 BEGIN
--- check if login exists
-SELECT customer_id
-  INTO my_customer_id
+-- check if login exists and if 2FA is required
+SELECT customer_id, (NOT in_is_tan AND tan_channel IS NOT NULL) 
+  INTO my_customer_id, out_tan_required
   FROM customers
   WHERE login = in_login;
 IF NOT FOUND THEN
-  out_nx_customer=TRUE;
+  out_not_found=TRUE;
   RETURN;
 END IF;
-out_nx_customer=FALSE;
 
 -- get the balance
 SELECT
@@ -174,32 +175,36 @@ SELECT
 IF NOT FOUND THEN
   RAISE EXCEPTION 'Invariant failed: customer lacks bank account';
 END IF;
+
 -- check that balance is zero.
 IF my_balance_val != 0 OR my_balance_frac != 0 THEN
   out_balance_not_zero=TRUE;
   RETURN;
 END IF;
-out_balance_not_zero=FALSE;
+
+-- check tan required
+IF out_tan_required THEN
+  RETURN;
+END IF;
 
 -- actual deletion
 DELETE FROM customers WHERE login = in_login;
 END $$;
-COMMENT ON FUNCTION customer_delete(TEXT)
-  IS 'Deletes a customer (and its bank account via cascade) if the balance is 
zero';
+COMMENT ON FUNCTION account_delete IS 'Deletes an account if the balance is 
zero';
 
 CREATE PROCEDURE register_outgoing(
   IN in_request_uid BYTEA,
   IN in_wtid BYTEA,
   IN in_exchange_base_url TEXT,
-  IN in_debtor_account_id BIGINT,
-  IN in_creditor_account_id BIGINT,
-  IN in_debit_row_id BIGINT,
-  IN in_credit_row_id BIGINT
+  IN in_debtor_account_id INT8,
+  IN in_creditor_account_id INT8,
+  IN in_debit_row_id INT8,
+  IN in_credit_row_id INT8
 )
 LANGUAGE plpgsql AS $$
 DECLARE 
   local_amount taler_amount;
-  local_bank_account_id BIGINT;
+  local_bank_account_id INT8;
 BEGIN
 -- register outgoing transaction
 INSERT
@@ -230,12 +235,12 @@ COMMENT ON PROCEDURE register_outgoing
 
 CREATE PROCEDURE register_incoming(
   IN in_reserve_pub BYTEA,
-  IN in_tx_row_id BIGINT
+  IN in_tx_row_id INT8
 )
 LANGUAGE plpgsql AS $$
 DECLARE
 local_amount taler_amount;
-local_bank_account_id BIGINT;
+local_bank_account_id INT8;
 BEGIN
 -- Register incoming transaction
 INSERT
@@ -266,7 +271,7 @@ CREATE FUNCTION taler_transfer(
   IN in_exchange_base_url TEXT,
   IN in_credit_account_payto TEXT,
   IN in_username TEXT,
-  IN in_timestamp BIGINT,
+  IN in_timestamp INT8,
   -- Error status
   OUT out_debtor_not_found BOOLEAN,
   OUT out_debtor_not_exchange BOOLEAN,
@@ -275,14 +280,14 @@ CREATE FUNCTION taler_transfer(
   OUT out_request_uid_reuse BOOLEAN,
   OUT out_exchange_balance_insufficient BOOLEAN,
   -- Success return
-  OUT out_tx_row_id BIGINT,
-  OUT out_timestamp BIGINT
+  OUT out_tx_row_id INT8,
+  OUT out_timestamp INT8
 )
 LANGUAGE plpgsql AS $$
 DECLARE
-exchange_bank_account_id BIGINT;
-receiver_bank_account_id BIGINT;
-credit_row_id BIGINT;
+exchange_bank_account_id INT8;
+receiver_bank_account_id INT8;
+credit_row_id INT8;
 BEGIN
 -- Check for idempotence and conflict
 SELECT (amount != in_amount 
@@ -356,7 +361,7 @@ CREATE FUNCTION taler_add_incoming(
   IN in_amount taler_amount,
   IN in_debit_account_payto TEXT,
   IN in_username TEXT,
-  IN in_timestamp BIGINT,
+  IN in_timestamp INT8,
   -- Error status
   OUT out_creditor_not_found BOOLEAN,
   OUT out_creditor_not_exchange BOOLEAN,
@@ -365,12 +370,12 @@ CREATE FUNCTION taler_add_incoming(
   OUT out_reserve_pub_reuse BOOLEAN,
   OUT out_debitor_balance_insufficient BOOLEAN,
   -- Success return
-  OUT out_tx_row_id BIGINT
+  OUT out_tx_row_id INT8
 )
 LANGUAGE plpgsql AS $$
 DECLARE
-exchange_bank_account_id BIGINT;
-sender_bank_account_id BIGINT;
+exchange_bank_account_id INT8;
+sender_bank_account_id INT8;
 BEGIN
 -- Check conflict
 SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub
@@ -436,18 +441,20 @@ CREATE FUNCTION bank_transaction(
   IN in_debit_account_username TEXT,
   IN in_subject TEXT,
   IN in_amount taler_amount,
-  IN in_timestamp BIGINT,
+  IN in_timestamp INT8,
+  IN in_is_tan BOOLEAN,
   -- Error status
   OUT out_creditor_not_found BOOLEAN,
   OUT out_debtor_not_found BOOLEAN,
   OUT out_same_account BOOLEAN,
   OUT out_balance_insufficient BOOLEAN,
   OUT out_creditor_admin BOOLEAN,
+  OUT out_tan_required BOOLEAN,
   -- Success return
-  OUT out_credit_bank_account_id BIGINT,
-  OUT out_debit_bank_account_id BIGINT,
-  OUT out_credit_row_id BIGINT,
-  OUT out_debit_row_id BIGINT,
+  OUT out_credit_bank_account_id INT8,
+  OUT out_debit_bank_account_id INT8,
+  OUT out_credit_row_id INT8,
+  OUT out_debit_row_id INT8,
   OUT out_creditor_is_exchange BOOLEAN,
   OUT out_debtor_is_exchange BOOLEAN
 )
@@ -466,15 +473,15 @@ ELSIF out_creditor_admin THEN
   RETURN;
 END IF;
 -- Find debit bank account id and check it's a different account
-SELECT bank_account_id, is_taler_exchange, 
out_credit_bank_account_id=bank_account_id
-  INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account
+SELECT bank_account_id, is_taler_exchange, 
out_credit_bank_account_id=bank_account_id, NOT in_is_tan AND tan_channel IS 
NOT NULL
+  INTO out_debit_bank_account_id, out_debtor_is_exchange, out_same_account, 
out_tan_required
   FROM bank_accounts 
     JOIN customers ON customer_id=owning_customer_id
   WHERE login = in_debit_account_username;
 IF NOT FOUND THEN
   out_debtor_not_found=TRUE;
   RETURN;
-ELSIF out_same_account THEN
+ELSIF out_same_account OR out_tan_required THEN
   RETURN;
 END IF;
 -- Perform bank transfer
@@ -503,6 +510,7 @@ CREATE FUNCTION create_taler_withdrawal(
   IN in_account_username TEXT,
   IN in_withdrawal_uuid UUID,
   IN in_amount taler_amount,
+  IN in_now_date INT8,
    -- Error status
   OUT out_account_not_found BOOLEAN,
   OUT out_account_is_exchange BOOLEAN,
@@ -510,9 +518,9 @@ CREATE FUNCTION create_taler_withdrawal(
 )
 LANGUAGE plpgsql AS $$ 
 DECLARE
-account_id BIGINT;
+account_id INT8;
 BEGIN
--- check account exists
+-- Check account exists
 SELECT bank_account_id, is_taler_exchange
   INTO account_id, out_account_is_exchange
   FROM bank_accounts
@@ -525,7 +533,7 @@ ELSIF out_account_is_exchange THEN
   RETURN;
 END IF;
 
--- check enough funds
+-- Check enough funds
 SELECT account_balance_is_sufficient(account_id, in_amount) INTO 
out_balance_insufficient;
 IF out_balance_insufficient THEN
   RETURN;
@@ -533,8 +541,8 @@ END IF;
 
 -- Create withdrawal operation
 INSERT INTO taler_withdrawal_operations
-    (withdrawal_uuid, wallet_bank_account, amount)
-  VALUES (in_withdrawal_uuid, account_id, in_amount);
+    (withdrawal_uuid, wallet_bank_account, amount, creation_date)
+  VALUES (in_withdrawal_uuid, account_id, in_amount, in_now_date);
 END $$;
 COMMENT ON FUNCTION create_taler_withdrawal IS 'Create a new withdrawal 
operation';
 
@@ -633,14 +641,17 @@ END $$;
 COMMENT ON FUNCTION abort_taler_withdrawal IS 'Abort a withdrawal operation.';
 
 CREATE FUNCTION confirm_taler_withdrawal(
+  IN in_login TEXT,
   IN in_withdrawal_uuid uuid,
-  IN in_confirmation_date BIGINT,
+  IN in_confirmation_date INT8,
+  IN in_is_tan BOOLEAN,
   OUT out_no_op BOOLEAN,
   OUT out_balance_insufficient BOOLEAN,
   OUT out_creditor_not_found BOOLEAN,
   OUT out_exchange_not_found BOOLEAN,
   OUT out_not_selected BOOLEAN,
-  OUT out_aborted BOOLEAN
+  OUT out_aborted BOOLEAN,
+  OUT out_tan_required BOOLEAN
 )
 LANGUAGE plpgsql AS $$
 DECLARE
@@ -648,27 +659,32 @@ DECLARE
   subject_local TEXT;
   reserve_pub_local BYTEA;
   selected_exchange_payto_local TEXT;
-  wallet_bank_account_local BIGINT;
+  wallet_bank_account_local INT8;
   amount_local taler_amount;
-  exchange_bank_account_id BIGINT;
-  tx_row_id BIGINT;
+  exchange_bank_account_id INT8;
+  tx_row_id INT8;
 BEGIN
-SELECT -- Really no-star policy and instead DECLARE almost one var per column?
+-- Check op exists
+SELECT
   confirmation_done,
   aborted, NOT selection_done,
   reserve_pub, subject,
   selected_exchange_payto,
   wallet_bank_account,
-  (amount).val, (amount).frac
+  (amount).val, (amount).frac,
+  (NOT in_is_tan AND tan_channel IS NOT NULL)
   INTO
     already_confirmed,
     out_aborted, out_not_selected,
     reserve_pub_local, subject_local,
     selected_exchange_payto_local,
     wallet_bank_account_local,
-    amount_local.val, amount_local.frac
+    amount_local.val, amount_local.frac,
+    out_tan_required
   FROM taler_withdrawal_operations
-  WHERE withdrawal_uuid=in_withdrawal_uuid;
+    JOIN bank_accounts ON wallet_bank_account=bank_account_id
+    JOIN customers ON owning_customer_id=customer_id
+  WHERE withdrawal_uuid=in_withdrawal_uuid AND login=in_login;
 IF NOT FOUND THEN
   out_no_op=TRUE;
   RETURN;
@@ -687,6 +703,11 @@ IF NOT FOUND THEN
   RETURN;
 END IF;
 
+-- Check 2FA
+IF out_tan_required THEN
+  RETURN;
+END IF;
+
 SELECT -- not checking for accounts existence, as it was done above.
   transfer.out_balance_insufficient,
   out_credit_row_id
@@ -720,19 +741,19 @@ COMMENT ON FUNCTION confirm_taler_withdrawal
   IS 'Set a withdrawal operation as confirmed and wire the funds to the 
exchange.';
 
 CREATE FUNCTION bank_wire_transfer(
-  IN in_creditor_account_id BIGINT,
-  IN in_debtor_account_id BIGINT,
+  IN in_creditor_account_id INT8,
+  IN in_debtor_account_id INT8,
   IN in_subject TEXT,
   IN in_amount taler_amount,
-  IN in_transaction_date BIGINT, -- GNUnet microseconds.
+  IN in_transaction_date INT8,
   IN in_account_servicer_reference TEXT,
   IN in_payment_information_id TEXT,
   IN in_end_to_end_id TEXT,
   -- Error status
   OUT out_balance_insufficient BOOLEAN,
   -- Success return
-  OUT out_credit_row_id BIGINT,
-  OUT out_debit_row_id BIGINT
+  OUT out_credit_row_id INT8,
+  OUT out_debit_row_id INT8
 )
 LANGUAGE plpgsql AS $$
 DECLARE
@@ -941,7 +962,7 @@ PERFORM pg_notify('bank_tx', in_debtor_account_id || ' ' || 
in_creditor_account_
 END $$;
 
 CREATE FUNCTION cashin(
-  IN in_now_date BIGINT,
+  IN in_now_date INT8,
   IN in_reserve_pub BYTEA,
   IN in_amount taler_amount,
   IN in_subject TEXT,
@@ -954,9 +975,9 @@ CREATE FUNCTION cashin(
 LANGUAGE plpgsql AS $$ 
 DECLARE
   converted_amount taler_amount;
-  admin_account_id BIGINT;
-  exchange_account_id BIGINT;
-  tx_row_id BIGINT;
+  admin_account_id INT8;
+  exchange_account_id INT8;
+  tx_row_id INT8;
 BEGIN
 -- TODO check reserve_pub reuse ?
 
@@ -1020,32 +1041,29 @@ COMMENT ON FUNCTION cashin IS 'Perform a cashin 
operation';
 
 
 CREATE FUNCTION cashout_create(
-  IN in_account_username TEXT,
+  IN in_login TEXT,
   IN in_request_uid BYTEA,
   IN in_amount_debit taler_amount,
   IN in_amount_credit taler_amount,
   IN in_subject TEXT,
   IN in_now_date INT8,
-  IN in_tan_channel tan_enum,
-  IN in_tan_code TEXT,
-  IN in_retry_counter INT4,
-  IN in_validity_period INT8,
+  IN in_is_tan BOOLEAN,
   -- Error status
   OUT out_bad_conversion BOOLEAN,
   OUT out_account_not_found BOOLEAN,
   OUT out_account_is_exchange BOOLEAN,
-  OUT out_missing_tan_info BOOLEAN,
   OUT out_balance_insufficient BOOLEAN,
   OUT out_request_uid_reuse BOOLEAN,
+  OUT out_no_cashout_payto BOOLEAN,
+  OUT out_tan_required BOOLEAN,
   -- Success return
-  OUT out_cashout_id BIGINT,
-  OUT out_tan_info TEXT,
-  OUT out_tan_code TEXT
+  OUT out_cashout_id INT8
 )
 LANGUAGE plpgsql AS $$ 
 DECLARE
-account_id BIGINT;
-challenge_id BIGINT;
+account_id INT8;
+admin_account_id INT8;
+tx_id INT8;
 BEGIN
 -- check conversion
 SELECT too_small OR no_config OR in_amount_credit!=converted INTO 
out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text);
@@ -1053,152 +1071,48 @@ IF out_bad_conversion THEN
   RETURN;
 END IF;
 
--- check account exists and has appropriate tan info
+-- Check account exists, has all info and if 2FA is required
 SELECT 
-    bank_account_id, is_taler_exchange,
-    CASE 
-      WHEN in_tan_channel = 'sms'   THEN phone
-      WHEN in_tan_channel = 'email' THEN email
-    END
-  INTO account_id, out_account_is_exchange, out_tan_info
+    bank_account_id, is_taler_exchange, cashout_payto IS NULL, (NOT in_is_tan 
AND tan_channel IS NOT NULL) 
+  INTO account_id, out_account_is_exchange, out_no_cashout_payto, 
out_tan_required
   FROM bank_accounts
   JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id
-  WHERE login=in_account_username;
+  WHERE login=in_login;
 IF NOT FOUND THEN
   out_account_not_found=TRUE;
   RETURN;
-ELSIF out_account_is_exchange THEN
-  RETURN;
-ELSIF out_tan_info IS NULL THEN
-  out_missing_tan_info=TRUE;
+ELSIF out_account_is_exchange OR out_no_cashout_payto THEN
   RETURN;
 END IF;
 
--- check enough funds
-SELECT account_balance_is_sufficient(account_id, in_amount_debit) INTO 
out_balance_insufficient;
-IF out_balance_insufficient THEN
-  RETURN;
-END IF;
+-- Retrieve admin account id
+SELECT bank_account_id
+  INTO admin_account_id
+  FROM bank_accounts
+    JOIN customers 
+      ON customer_id=owning_customer_id
+  WHERE login = 'admin';
 
 -- Check for idempotence and conflict
 SELECT (amount_debit != in_amount_debit
           OR subject != in_subject 
           OR bank_account != account_id)
-        , challenge, cashout_id
-  INTO out_request_uid_reuse, challenge_id, out_cashout_id
+        , cashout_id
+  INTO out_request_uid_reuse, out_cashout_id
   FROM cashout_operations
   WHERE request_uid = in_request_uid;
-
-IF NOT found THEN
-  -- New cashout
-  out_tan_code = in_tan_code;
-
-  -- Create challenge
-  SELECT challenge_create(in_tan_code, in_now_date, in_validity_period, 
in_retry_counter) INTO challenge_id;
-
-  -- Create cashout operation
-  INSERT INTO cashout_operations (
-    request_uid
-    ,amount_debit
-    ,amount_credit
-    ,subject
-    ,creation_time
-    ,bank_account
-    ,challenge
-  ) VALUES (
-    in_request_uid
-    ,in_amount_debit
-    ,in_amount_credit
-    ,in_subject
-    ,in_now_date
-    ,account_id
-    ,challenge_id
-  ) RETURNING cashout_id INTO out_cashout_id;
-ELSE -- Already exist, check challenge retransmission
-  SELECT challenge_resend(challenge_id, in_tan_code, in_now_date, 
in_validity_period, in_retry_counter) INTO out_tan_code;
-END IF;
-END $$;
-
-CREATE FUNCTION cashout_confirm(
-  IN in_cashout_id BIGINT,
-  IN in_login TEXT,
-  IN in_tan_code TEXT,
-  IN in_now_date BIGINT,
-  OUT out_no_op BOOLEAN,
-  OUT out_bad_conversion BOOLEAN,
-  OUT out_bad_code BOOLEAN,
-  OUT out_balance_insufficient BOOLEAN,
-  OUT out_aborted BOOLEAN,
-  OUT out_no_retry BOOLEAN,
-  OUT out_no_cashout_payto BOOLEAN
-)
-LANGUAGE plpgsql as $$
-DECLARE
-  wallet_account_id BIGINT;
-  admin_account_id BIGINT;
-  already_confirmed BOOLEAN;
-  subject_local TEXT;
-  amount_debit_local taler_amount;
-  amount_credit_local taler_amount;
-  challenge_id BIGINT;
-  tx_id BIGINT;
-BEGIN
--- Retrieve cashout operation info
-SELECT
-  local_transaction IS NOT NULL,
-  aborted, subject,
-  bank_account, challenge,
-  (amount_debit).val, (amount_debit).frac,
-  (amount_credit).val, (amount_credit).frac,
-  cashout_payto IS NULL
-  INTO
-    already_confirmed,
-    out_aborted, subject_local,
-    wallet_account_id, challenge_id,
-    amount_debit_local.val, amount_debit_local.frac,
-    amount_credit_local.val, amount_credit_local.frac,
-    out_no_cashout_payto
-  FROM cashout_operations
-    JOIN bank_accounts ON bank_account_id=bank_account
-    JOIN customers ON customer_id=owning_customer_id
-  WHERE cashout_id=in_cashout_id AND login=in_login;
-IF NOT FOUND THEN
-  out_no_op=TRUE;
-  RETURN;
-ELSIF already_confirmed OR out_aborted OR out_no_cashout_payto THEN
-  RETURN;
-END IF;
-
--- check conversion
-SELECT too_small OR no_config OR amount_credit_local!=converted INTO 
out_bad_conversion FROM conversion_to(amount_debit_local, 'cashout'::text);
-IF out_bad_conversion THEN
-  RETURN;
-END IF;
-
--- check challenge
-SELECT NOT ok, no_retry
-  INTO out_bad_code, out_no_retry
-  FROM challenge_try(challenge_id, in_tan_code, in_now_date);
-IF out_bad_code OR out_no_retry THEN
+IF found OR out_request_uid_reuse OR out_tan_required THEN
   RETURN;
 END IF;
 
--- Retrieve admin account id
-SELECT bank_account_id
-  INTO admin_account_id
-  FROM bank_accounts
-    JOIN customers 
-      ON customer_id=owning_customer_id
-  WHERE login = 'admin';
-
 -- Perform bank wire transfer
 SELECT transfer.out_balance_insufficient, out_debit_row_id
 INTO out_balance_insufficient, tx_id
 FROM bank_wire_transfer(
   admin_account_id,
-  wallet_account_id,
-  subject_local,
-  amount_debit_local,
+  account_id,
+  in_subject,
+  in_amount_debit,
   in_now_date,
   NULL,
   NULL,
@@ -1208,72 +1122,114 @@ IF out_balance_insufficient THEN
   RETURN;
 END IF;
 
--- Confirm operation
-UPDATE cashout_operations
-  SET local_transaction = tx_id
-  WHERE cashout_id=in_cashout_id;
+-- Create cashout operation
+INSERT INTO cashout_operations (
+  request_uid
+  ,amount_debit
+  ,amount_credit
+  ,creation_time
+  ,bank_account
+  ,subject
+  ,local_transaction
+) VALUES (
+  in_request_uid
+  ,in_amount_debit
+  ,in_amount_credit
+  ,in_now_date
+  ,account_id
+  ,in_subject
+  ,tx_id
+) RETURNING cashout_id INTO out_cashout_id;
 
 -- update stats
-CALL stats_register_payment('cashout', now()::TIMESTAMP, amount_debit_local, 
amount_credit_local);
+CALL stats_register_payment('cashout', now()::TIMESTAMP, in_amount_debit, 
in_amount_credit);
 END $$;
 
-CREATE FUNCTION challenge_create (
+CREATE FUNCTION tan_challenge_create (
+  IN in_body TEXT,
+  IN in_op op_enum,
   IN in_code TEXT,
   IN in_now_date INT8,
   IN in_validity_period INT8,
   IN in_retry_counter INT4,
-  OUT out_challenge_id BIGINT
+  IN in_login TEXT,
+  IN in_tan_channel tan_enum,
+  IN in_tan_info TEXT,
+  OUT out_challenge_id INT8
 )
-LANGUAGE sql AS $$
-  INSERT INTO challenges (
-    code,
-    creation_date,
-    expiration_date,
-    retry_counter
-  ) VALUES (
-    in_code,
-    in_now_date,
-    in_now_date + in_validity_period,
-    in_retry_counter
-  ) RETURNING challenge_id
-$$;
-COMMENT ON FUNCTION challenge_create IS 'Create a new challenge, return the 
generated id';
-
-CREATE FUNCTION challenge_mark_sent (
-  IN in_challenge_id BIGINT,
-  IN in_now_date INT8,
-  IN in_retransmission_period INT8
-) RETURNS void
-LANGUAGE sql AS $$
-  UPDATE challenges SET 
-    retransmission_date = in_now_date + in_retransmission_period
-  WHERE challenge_id = in_challenge_id;
-$$;
-COMMENT ON FUNCTION challenge_create IS 'Register a challenge as successfully 
sent';
+LANGUAGE plpgsql as $$
+DECLARE
+account_id INT8;
+BEGIN
+-- Retreive account id
+SELECT customer_id INTO account_id FROM customers WHERE login = in_login;
+-- Create challenge
+INSERT INTO tan_challenges (
+  body,
+  op,
+  code,
+  creation_date,
+  expiration_date,
+  retry_counter,
+  customer,
+  tan_channel,
+  tan_info
+) VALUES (
+  in_body,
+  in_op,
+  in_code,
+  in_now_date,
+  in_now_date + in_validity_period,
+  in_retry_counter,
+  account_id,
+  in_tan_channel,
+  in_tan_info
+) RETURNING challenge_id INTO out_challenge_id;
+END $$;
+COMMENT ON FUNCTION tan_challenge_create IS 'Create a new challenge, return 
the generated id';
 
-CREATE FUNCTION challenge_resend (
-  IN in_challenge_id BIGINT, 
-  IN in_code TEXT,            -- New code to use if the old code expired
+CREATE FUNCTION tan_challenge_send (
+  IN in_challenge_id INT8,
+  IN in_login TEXT,
+  IN in_code TEXT,              -- New code to use if the old code expired
   IN in_now_date INT8,        
   IN in_validity_period INT8,
   IN in_retry_counter INT4,
-  OUT out_tan_code TEXT       -- Code to send, NULL if nothing should be sent
+  -- Error status
+  OUT out_no_op BOOLEAN,
+  -- Success return
+  OUT out_tan_code TEXT,        -- TAN code to send, NULL if nothing should be 
sent
+  OUT out_tan_channel tan_enum, -- TAN channel to use, NULL if nothing should 
be sent
+  OUT out_tan_info TEXT         -- TAN info to use, NULL if nothing should be 
sent
 )
 LANGUAGE plpgsql as $$
 DECLARE
+account_id INT8;
 expired BOOLEAN;
 retransmit BOOLEAN;
 BEGIN
+-- Retreive account id
+SELECT customer_id, tan_channel, CASE tan_channel
+    WHEN 'sms'   THEN phone
+    WHEN 'email' THEN email
+  END
+INTO account_id, out_tan_channel, out_tan_info
+FROM customers WHERE login = in_login;
+
 -- Recover expiration date
 SELECT 
   (in_now_date >= expiration_date OR retry_counter <= 0) AND confirmation_date 
IS NULL
   ,in_now_date >= retransmission_date AND confirmation_date IS NULL
-  ,code
-INTO expired, retransmit, out_tan_code
-FROM challenges WHERE challenge_id = in_challenge_id;
+  ,code, COALESCE(tan_channel, out_tan_channel), COALESCE(tan_info, 
out_tan_info)
+INTO expired, retransmit, out_tan_code, out_tan_channel, out_tan_info
+FROM tan_challenges WHERE challenge_id = in_challenge_id AND customer = 
account_id;
+IF NOT FOUND THEN
+  out_no_op = true;
+  RETURN;
+END IF;
 
 IF expired THEN
-  UPDATE challenges SET
+  UPDATE tan_challenges SET
      code = in_code
     ,expiration_date = in_now_date + in_validity_period
     ,retry_counter = in_retry_counter
@@ -1283,40 +1239,82 @@ ELSIF NOT retransmit THEN
   out_tan_code = NULL;
 END IF;
 END $$;
-COMMENT ON FUNCTION challenge_resend IS 'Get the challenge code to send, 
return NULL if nothing should be sent';
+COMMENT ON FUNCTION tan_challenge_send IS 'Get the challenge to send, return 
NULL if nothing should be sent';
+
+CREATE FUNCTION tan_challenge_mark_sent (
+  IN in_challenge_id INT8,
+  IN in_now_date INT8,
+  IN in_retransmission_period INT8
+) RETURNS void
+LANGUAGE sql AS $$
+  UPDATE tan_challenges SET 
+    retransmission_date = in_now_date + in_retransmission_period
+  WHERE challenge_id = in_challenge_id;
+$$;
+COMMENT ON FUNCTION tan_challenge_mark_sent IS 'Register a challenge as 
successfully sent';
 
-CREATE FUNCTION challenge_try (
-  IN in_challenge_id BIGINT, 
+CREATE FUNCTION tan_challenge_try (
+  IN in_challenge_id INT8, 
+  IN in_login TEXT,
   IN in_code TEXT,    
-  IN in_now_date INT8,        
-  OUT ok BOOLEAN,
-  OUT no_retry BOOLEAN
+  IN in_now_date INT8,
+  -- Error status       
+  OUT out_ok BOOLEAN,
+  OUT out_no_op BOOLEAN,
+  OUT out_no_retry BOOLEAN,
+  OUT out_expired BOOLEAN,
+  -- Success return
+  OUT out_op op_enum,
+  OUT out_body TEXT,
+  OUT out_channel tan_enum,
+  OUT out_info TEXT
 )
-LANGUAGE sql as $$
-  UPDATE challenges SET 
-    confirmation_date = CASE 
-      WHEN (retry_counter > 0 AND in_now_date < expiration_date AND code = 
in_code) THEN in_now_date
-      ELSE confirmation_date
-    END,
-    retry_counter = retry_counter - 1
-  WHERE challenge_id = in_challenge_id
-  RETURNING confirmation_date IS NOT NULL, retry_counter < 0 AND 
confirmation_date IS NULL;
-$$;
-COMMENT ON FUNCTION challenge_try IS 'Try to confirm a challenge, return true 
if the challenge have been confirmed';
+LANGUAGE plpgsql as $$
+DECLARE
+account_id INT8;
+BEGIN
+-- Retreive account id
+SELECT customer_id INTO account_id FROM customers WHERE login = in_login;
+-- Check challenge
+UPDATE tan_challenges SET 
+  confirmation_date = CASE 
+    WHEN (retry_counter > 0 AND in_now_date < expiration_date AND code = 
in_code) THEN in_now_date
+    ELSE confirmation_date
+  END,
+  retry_counter = retry_counter - 1
+WHERE challenge_id = in_challenge_id AND customer = account_id
+RETURNING 
+  confirmation_date IS NOT NULL, 
+  retry_counter <= 0 AND confirmation_date IS NULL,
+  in_now_date >= expiration_date AND confirmation_date IS NULL
+INTO out_ok, out_no_retry, out_expired;
+IF NOT FOUND THEN
+  out_no_op = true;
+  RETURN;
+ELSIF NOT out_ok OR out_no_retry OR out_expired THEN
+  RETURN;
+END IF;
+
+-- Recover body and op from challenge
+SELECT body, op, tan_channel, tan_info
+  INTO out_body, out_op, out_channel, out_info
+  FROM tan_challenges WHERE challenge_id = in_challenge_id;
+END $$;
+COMMENT ON FUNCTION tan_challenge_try IS 'Try to confirm a challenge, return 
true if the challenge have been confirmed';
 
 CREATE FUNCTION stats_get_frame(
   IN now TIMESTAMP,
   IN in_timeframe stat_timeframe_enum,
   IN which INTEGER,
-  OUT cashin_count BIGINT,
+  OUT cashin_count INT8,
   OUT cashin_regional_volume taler_amount,
   OUT cashin_fiat_volume taler_amount,
-  OUT cashout_count BIGINT,
+  OUT cashout_count INT8,
   OUT cashout_regional_volume taler_amount,
   OUT cashout_fiat_volume taler_amount,
-  OUT taler_in_count BIGINT,
+  OUT taler_in_count INT8,
   OUT taler_in_volume taler_amount,
-  OUT taler_out_count BIGINT,
+  OUT taler_out_count INT8,
   OUT taler_out_volume taler_amount
 )
 LANGUAGE plpgsql AS $$
@@ -1469,7 +1467,7 @@ BEGIN
   -- Extract product parts
   result = (trunc(product_numeric / 100000000)::int8, (product_numeric % 
100000000)::int4);
 
-  IF (result.val > 1::bigint<<52) THEN
+  IF (result.val > 1::INT8<<52) THEN
     RAISE EXCEPTION 'amount value overflowed';
   END IF;
 END $$;
@@ -1506,7 +1504,7 @@ BEGIN
   -- Extract division parts
   result = (trunc(fraction_numeric / 100000000)::int8, (fraction_numeric % 
100000000)::int4);
 
-  IF (result.val > 1::bigint<<52) THEN
+  IF (result.val > 1::INT8<<52) THEN
     RAISE EXCEPTION 'amount value overflowed';
   END IF;
 END $$;
diff --git a/database-versioning/libeufin-conversion-setup.sql 
b/database-versioning/libeufin-conversion-setup.sql
index 0bf1e506..6507443c 100644
--- a/database-versioning/libeufin-conversion-setup.sql
+++ b/database-versioning/libeufin-conversion-setup.sql
@@ -5,7 +5,7 @@ CREATE OR REPLACE FUNCTION cashout_link()
 RETURNS trigger 
 LANGUAGE plpgsql AS $$
   DECLARE
-    now_date BIGINT;
+    now_date INT8;
     payto_uri TEXT;
   BEGIN
     -- TODO should send to an exchange
@@ -42,7 +42,7 @@ CREATE OR REPLACE FUNCTION cashin_link()
 RETURNS trigger 
 LANGUAGE plpgsql AS $$
   DECLARE
-    now_date BIGINT;
+    now_date INT8;
     local_amount libeufin_bank.taler_amount;
     subject TEXT;
     too_small BOOLEAN;
@@ -60,9 +60,14 @@ LANGUAGE plpgsql AS $$
       FROM libeufin_bank.cashin(now_date, NEW.reserve_public_key, 
local_amount, subject);
     SET search_path TO libeufin_nexus;
 
+    -- Bounce on soft failures
     IF too_small THEN
-      RAISE EXCEPTION 'cashin currency conversion failed: too small amount';
+      -- TODO bounce fees ?
+      PERFORM bounce_incoming(NEW.incoming_transaction_id, 
((local_amount).val, (local_amount).frac)::taler_amount, now_date);
+      RETURN NULL;
     END IF;
+
+    -- Error on hard failures
     IF no_config THEN
       RAISE EXCEPTION 'cashin currency conversion failed: missing conversion 
rates';
     END IF;
diff --git a/database-versioning/libeufin-nexus-0001.sql 
b/database-versioning/libeufin-nexus-0001.sql
index 52143aa8..6e32e0b1 100644
--- a/database-versioning/libeufin-nexus-0001.sql
+++ b/database-versioning/libeufin-nexus-0001.sql
@@ -50,13 +50,13 @@ CREATE TABLE IF NOT EXISTS incoming_transactions
   ,wire_transfer_subject TEXT NOT NULL
   ,execution_time INT8 NOT NULL
   ,debit_payto_uri TEXT NOT NULL
-  ,bank_transfer_id TEXT NOT NULL -- EBICS or Depolymerizer (generic)
+  ,bank_transfer_id TEXT NOT NULL UNIQUE -- EBICS or Depolymerizer (generic)
   );
 
 -- only active in exchange mode. Note: duplicate keys are another reason to 
bounce.
 CREATE TABLE IF NOT EXISTS talerable_incoming_transactions
   (incoming_transaction_id INT8 NOT NULL UNIQUE REFERENCES 
incoming_transactions(incoming_transaction_id) ON DELETE CASCADE
-   ,reserve_public_key BYTEA NOT NULL CHECK (LENGTH(reserve_public_key)=32) 
UNIQUE
+   ,reserve_public_key BYTEA NOT NULL UNIQUE CHECK 
(LENGTH(reserve_public_key)=32)
   );
 
 CREATE TABLE IF NOT EXISTS outgoing_transactions
@@ -65,7 +65,7 @@ CREATE TABLE IF NOT EXISTS outgoing_transactions
   ,wire_transfer_subject TEXT
   ,execution_time INT8 NOT NULL
   ,credit_payto_uri TEXT
-  ,bank_transfer_id TEXT NOT NULL
+  ,bank_transfer_id TEXT NOT NULL UNIQUE
   );
 
 CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions
@@ -76,7 +76,7 @@ CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions
   ,last_submission_time INT8
   ,submission_counter INT NOT NULL DEFAULT 0
   ,credit_payto_uri TEXT NOT NULL
-  ,outgoing_transaction_id INT8 REFERENCES outgoing_transactions 
(outgoing_transaction_id)
+  ,outgoing_transaction_id INT8 UNIQUE REFERENCES outgoing_transactions 
(outgoing_transaction_id)
   ,submitted submission_state DEFAULT 'unsubmitted'
   ,hidden BOOL DEFAULT FALSE -- FIXME: explain this.
   ,request_uid TEXT NOT NULL UNIQUE CHECK (char_length(request_uid) <= 35)
diff --git a/database-versioning/libeufin-nexus-procedures.sql 
b/database-versioning/libeufin-nexus-procedures.sql
index 918bedf3..280816c1 100644
--- a/database-versioning/libeufin-nexus-procedures.sql
+++ b/database-versioning/libeufin-nexus-procedures.sql
@@ -1,197 +1,227 @@
 BEGIN;
+SET search_path TO public;
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
 SET search_path TO libeufin_nexus;
 
-CREATE OR REPLACE FUNCTION create_incoming_and_bounce(
+-- Remove all existing functions
+DO
+$do$
+DECLARE
+  _sql text;
+BEGIN
+  SELECT INTO _sql
+        string_agg(format('DROP %s %s CASCADE;'
+                        , CASE prokind
+                            WHEN 'f' THEN 'FUNCTION'
+                            WHEN 'p' THEN 'PROCEDURE'
+                          END
+                        , oid::regprocedure)
+                  , E'\n')
+  FROM   pg_proc
+  WHERE  pronamespace = 'libeufin_nexus'::regnamespace;
+
+  IF _sql IS NOT NULL THEN
+    EXECUTE _sql;
+  END IF;
+END
+$do$;
+
+CREATE FUNCTION register_outgoing(
   IN in_amount taler_amount
   ,IN in_wire_transfer_subject TEXT
   ,IN in_execution_time BIGINT
-  ,IN in_debit_payto_uri TEXT
+  ,IN in_credit_payto_uri TEXT
   ,IN in_bank_transfer_id TEXT
-  ,IN in_timestamp BIGINT
-  ,IN in_request_uid TEXT
-  ,IN in_refund_amount taler_amount
-  ,OUT out_ok BOOLEAN
-) RETURNS BOOLEAN
+  ,OUT out_tx_id BIGINT
+  ,OUT out_found BOOLEAN
+  ,OUT out_initiated BOOLEAN
+)
 LANGUAGE plpgsql AS $$
 DECLARE
-new_tx_id INT8;
-new_init_id INT8;
+init_id BIGINT;
 BEGIN
--- creating the bounced incoming transaction.
-INSERT INTO incoming_transactions (
-  amount
-  ,wire_transfer_subject
-  ,execution_time
-  ,debit_payto_uri
-  ,bank_transfer_id
+-- Check if already registered
+SELECT outgoing_transaction_id INTO out_tx_id
+  FROM outgoing_transactions
+  WHERE bank_transfer_id = in_bank_transfer_id;
+IF FOUND THEN
+  out_found = true;
+  -- TODO Should we update the subject and credit payto if it's finally found
+  -- TODO Should we check that amount and other info match ?
+  SELECT true INTO out_initiated
+    FROM initiated_outgoing_transactions
+    WHERE outgoing_transaction_id = out_tx_id;
+ELSE
+  -- Store the transaction in the database
+  INSERT INTO outgoing_transactions (
+    amount
+    ,wire_transfer_subject
+    ,execution_time
+    ,credit_payto_uri
+    ,bank_transfer_id
   ) VALUES (
     in_amount
     ,in_wire_transfer_subject
     ,in_execution_time
-    ,in_debit_payto_uri
+    ,in_credit_payto_uri
     ,in_bank_transfer_id
-  ) RETURNING incoming_transaction_id INTO new_tx_id;
-
--- creating its reimbursement.
-INSERT INTO initiated_outgoing_transactions (
-  amount
-  ,wire_transfer_subject
-  ,credit_payto_uri
-  ,initiation_time
-  ,request_uid
-  ) VALUES (
-    in_refund_amount
-    ,'refund: ' || in_wire_transfer_subject
-    ,in_debit_payto_uri
-    ,in_timestamp
-    ,in_request_uid
-  ) RETURNING initiated_outgoing_transaction_id INTO new_init_id;
+  )
+    RETURNING outgoing_transaction_id
+      INTO out_tx_id;
 
-INSERT INTO bounced_transactions (
-  incoming_transaction_id
-  ,initiated_outgoing_transaction_id
-) VALUES (
-  new_tx_id
-  ,new_init_id
-);
-out_ok = TRUE;
+  -- Reconciles the related initiated transaction
+  UPDATE initiated_outgoing_transactions
+    SET outgoing_transaction_id = out_tx_id
+    WHERE request_uid = in_bank_transfer_id
+    RETURNING true INTO out_initiated;
+END IF;
 END $$;
+COMMENT ON FUNCTION register_outgoing
+  IS 'Register an outgoing transaction and optionally reconciles the related 
initiated transaction with it';
 
-COMMENT ON FUNCTION create_incoming_and_bounce(taler_amount, TEXT, BIGINT, 
TEXT, TEXT, BIGINT, TEXT, taler_amount)
-  IS 'creates one incoming transaction with a bounced state and initiates its 
related refund.';
-
-CREATE OR REPLACE FUNCTION create_outgoing_payment(
+CREATE FUNCTION register_incoming(
   IN in_amount taler_amount
   ,IN in_wire_transfer_subject TEXT
   ,IN in_execution_time BIGINT
-  ,IN in_credit_payto_uri TEXT
+  ,IN in_debit_payto_uri TEXT
   ,IN in_bank_transfer_id TEXT
-  ,IN in_initiated_id BIGINT
-  ,OUT out_nx_initiated BOOLEAN
+  ,OUT out_found BOOLEAN
+  ,OUT out_tx_id BIGINT
 )
 LANGUAGE plpgsql AS $$
-DECLARE
-new_outgoing_transaction_id BIGINT;
 BEGIN
-
-IF in_initiated_id IS NULL THEN
-  out_nx_initiated = FALSE;
+-- Check if already registered
+SELECT incoming_transaction_id INTO out_tx_id
+  FROM incoming_transactions
+  WHERE bank_transfer_id = in_bank_transfer_id;
+IF FOUND THEN
+  out_found = true;
+  -- TODO Should we check that amount and other info match ?
 ELSE
-  PERFORM 1
-    FROM initiated_outgoing_transactions
-    WHERE initiated_outgoing_transaction_id = in_initiated_id;
-    IF NOT FOUND THEN
-      out_nx_initiated = TRUE;
-      RETURN;
-      END IF;
-END IF;
-
-INSERT INTO outgoing_transactions (
-  amount
-  ,wire_transfer_subject
-  ,execution_time
-  ,credit_payto_uri
-  ,bank_transfer_id
-) VALUES (
-  in_amount
-  ,in_wire_transfer_subject
-  ,in_execution_time
-  ,in_credit_payto_uri
-  ,in_bank_transfer_id
-)
-  RETURNING outgoing_transaction_id
-    INTO new_outgoing_transaction_id;
-
-IF in_initiated_id IS NOT NULL
-THEN
-  UPDATE initiated_outgoing_transactions
-    SET outgoing_transaction_id = new_outgoing_transaction_id
-    WHERE initiated_outgoing_transaction_id = in_initiated_id;
+  -- Store the transaction in the database
+  INSERT INTO incoming_transactions (
+    amount
+    ,wire_transfer_subject
+    ,execution_time
+    ,debit_payto_uri
+    ,bank_transfer_id
+  ) VALUES (
+    in_amount
+    ,in_wire_transfer_subject
+    ,in_execution_time
+    ,in_debit_payto_uri
+    ,in_bank_transfer_id
+  ) RETURNING incoming_transaction_id INTO out_tx_id;
 END IF;
 END $$;
-
-COMMENT ON FUNCTION create_outgoing_payment(taler_amount, TEXT, BIGINT, TEXT, 
TEXT, BIGINT)
-  IS 'Creates a new outgoing payment and optionally reconciles the related 
initiated payment with it.  If the initiated payment to reconcile is not found, 
it inserts NOTHING.';
-
-CREATE OR REPLACE FUNCTION bounce_payment(
-  IN in_incoming_transaction_id BIGINT
-  ,IN in_initiation_time BIGINT
-  ,IN in_request_uid TEXT
-  ,OUT out_nx_incoming_payment BOOLEAN
+COMMENT ON FUNCTION register_incoming
+  IS 'Register an incoming transaction';
+
+CREATE FUNCTION bounce_incoming(
+  IN tx_id BIGINT
+  ,IN in_bounce_amount taler_amount
+  ,IN in_now_date BIGINT
+  ,OUT out_bounce_id TEXT
 )
 LANGUAGE plpgsql AS $$
+DECLARE
+bank_id TEXT;
+payto_uri TEXT;
+init_id BIGINT;
 BEGIN
-
+-- Get incoming transaction bank ID and creditor
+SELECT bank_transfer_id, debit_payto_uri 
+  INTO bank_id, payto_uri
+  FROM incoming_transactions
+  WHERE incoming_transaction_id = tx_id;
+-- Generate a bounce ID deterministically from the bank ID
+-- We hash the bank ID with SHA-256 then we encode the hash using base64
+-- As bank id can be at most 35 characters long we truncate the encoded hash
+-- We are not sure whether this field is case-insensitive in all banks as the 
standard 
+-- does not clearly specify this, so we have chosen to capitalise it
+SELECT upper(substr(encode(public.digest(bank_id, 'sha256'), 'base64'), 0, 
35)) INTO out_bounce_id;
+
+-- Initiate the bounce transaction
 INSERT INTO initiated_outgoing_transactions (
   amount
   ,wire_transfer_subject
   ,credit_payto_uri
   ,initiation_time
   ,request_uid
+  ) VALUES (
+    in_bounce_amount
+    ,'bounce: ' || bank_id
+    ,payto_uri
+    ,in_now_date
+    ,out_bounce_id
   )
-  SELECT
-    amount
-    ,'refund: ' || wire_transfer_subject
-    ,debit_payto_uri
-    ,in_initiation_time
-    ,in_request_uid
-    FROM incoming_transactions
-    WHERE incoming_transaction_id = in_incoming_transaction_id;
-
-IF NOT FOUND THEN
-  out_nx_incoming_payment=TRUE;
-  RETURN;
+  ON CONFLICT (request_uid) DO NOTHING -- idempotent
+  RETURNING initiated_outgoing_transaction_id INTO init_id;
+IF FOUND THEN
+  -- Register the bounce
+  INSERT INTO bounced_transactions (
+    incoming_transaction_id ,initiated_outgoing_transaction_id
+  ) VALUES (tx_id, init_id);
 END IF;
-out_nx_incoming_payment=FALSE;
+END$$;
+COMMENT ON FUNCTION bounce_incoming
+  IS 'Bounce an incoming transaction, initiate a bouce outgoing transaction 
with a deterministic ID';
 
--- finally setting the payment as bounced.  Not checking
--- the update outcome since the row existence was checked
--- just above.
+CREATE FUNCTION register_incoming_and_bounce(
+  IN in_amount taler_amount
+  ,IN in_wire_transfer_subject TEXT
+  ,IN in_execution_time BIGINT
+  ,IN in_debit_payto_uri TEXT
+  ,IN in_bank_transfer_id TEXT
+  ,IN in_bounce_amount taler_amount
+  ,IN in_now_date BIGINT
+  ,OUT out_found BOOLEAN
+  ,OUT out_tx_id BIGINT
+  ,OUT out_bounce_id TEXT
+)
+LANGUAGE plpgsql AS $$
+DECLARE
+init_id BIGINT;
+BEGIN
+-- Register the incoming transaction
+SELECT reg.out_found, reg.out_tx_id
+  FROM register_incoming(in_amount, in_wire_transfer_subject, 
in_execution_time, in_debit_payto_uri, in_bank_transfer_id) as reg
+  INTO out_found, out_tx_id;
 
-UPDATE incoming_transactions
-  SET bounced = true
-  WHERE incoming_transaction_id = in_incoming_transaction_id;
+-- Bounce the incoming transaction
+SELECT b.out_bounce_id INTO out_bounce_id FROM bounce_incoming(out_tx_id, 
in_bounce_amount, in_now_date) as b;
 END $$;
+COMMENT ON FUNCTION register_incoming_and_bounce
+  IS 'Register an incoming transaction and bounce it';
 
-COMMENT ON FUNCTION bounce_payment(BIGINT, BIGINT, TEXT) IS 'Marks an incoming 
payment as bounced and initiates its refunding payment';
-
-CREATE OR REPLACE FUNCTION create_incoming_talerable(
+CREATE FUNCTION register_incoming_and_talerable(
   IN in_amount taler_amount
   ,IN in_wire_transfer_subject TEXT
   ,IN in_execution_time BIGINT
   ,IN in_debit_payto_uri TEXT
   ,IN in_bank_transfer_id TEXT
   ,IN in_reserve_public_key BYTEA
-  ,OUT out_ok BOOLEAN
-) RETURNS BOOLEAN
+  ,OUT out_found BOOLEAN
+  ,OUT out_tx_id BIGINT
+)
 LANGUAGE plpgsql AS $$
-DECLARE
-new_tx_id INT8;
 BEGIN
-INSERT INTO incoming_transactions (
-  amount
-  ,wire_transfer_subject
-  ,execution_time
-  ,debit_payto_uri
-  ,bank_transfer_id
-  ) VALUES (
-    in_amount
-    ,in_wire_transfer_subject
-    ,in_execution_time
-    ,in_debit_payto_uri
-    ,in_bank_transfer_id
-  ) RETURNING incoming_transaction_id INTO new_tx_id;
+-- Register the incoming transaction
+SELECT reg.out_found, reg.out_tx_id
+  FROM register_incoming(in_amount, in_wire_transfer_subject, 
in_execution_time, in_debit_payto_uri, in_bank_transfer_id) as reg
+  INTO out_found, out_tx_id;
+
+-- Register as talerable bounce
 INSERT INTO talerable_incoming_transactions (
   incoming_transaction_id
   ,reserve_public_key
 ) VALUES (
-  new_tx_id
+  out_tx_id
   ,in_reserve_public_key
-);
-out_ok = TRUE;
+) ON CONFLICT (incoming_transaction_id) DO NOTHING;
 END $$;
-
-COMMENT ON FUNCTION create_incoming_talerable(taler_amount, TEXT, BIGINT, 
TEXT, TEXT, BYTEA) IS '
+COMMENT ON FUNCTION register_incoming_and_talerable IS '
 Creates one row in the incoming transactions table and one row
 in the talerable transactions table.  The talerable row links the
 incoming one.';
\ No newline at end of file
diff --git a/integration/build.gradle b/integration/build.gradle
index ee9f7da6..7c366e4c 100644
--- a/integration/build.gradle
+++ b/integration/build.gradle
@@ -1,5 +1,6 @@
 plugins {
     id("kotlin")
+    id("application")
 }
 
 java {
@@ -10,18 +11,29 @@ java {
 compileKotlin.kotlinOptions.jvmTarget = "17"
 compileTestKotlin.kotlinOptions.jvmTarget = "17"
 
-sourceSets.test.java.srcDirs = ["test"]
+sourceSets.main.java.srcDirs = ["src/main/kotlin"]
 
 dependencies {
-    
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version")
+    
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version")
 
-    testImplementation(project(":util"))
-    testImplementation(project(":bank"))
-    testImplementation(project(":nexus"))
+    implementation(project(":util"))
+    implementation(project(":bank"))
+    implementation(project(":nexus"))
 
-    testImplementation("com.github.ajalt.clikt:clikt:$clikt_version")
+    implementation("com.github.ajalt.clikt:clikt:$clikt_version")
 
-    testImplementation("io.ktor:ktor-server-test-host:$ktor_version")
-    testImplementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
-    testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
+    implementation("org.postgresql:postgresql:$postgres_version")
+
+    implementation("io.ktor:ktor-server-test-host:$ktor_version")
+    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
+    implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
+}
+
+application {
+    mainClass = "tech.libeufin.integration.MainKt"
+    applicationName = "libeufin-integration-test"
+}
+
+run {
+    standardInput = System.in
 }
\ No newline at end of file
diff --git a/integration/conf/integration.conf 
b/integration/conf/integration.conf
index 83537c68..39056996 100644
--- a/integration/conf/integration.conf
+++ b/integration/conf/integration.conf
@@ -6,7 +6,6 @@ allow_conversion = YES
 FIAT_CURRENCY = EUR
 tan_sms = libeufin-tan-file.sh
 tan_email = libeufin-tan-fail.sh
-PORT = 8090
 
 [libeufin-bankdb-postgres]
 CONFIG = postgresql:///libeufincheck
diff --git a/integration/conf/netzbon.conf b/integration/conf/netzbon.conf
new file mode 100644
index 00000000..1fa31c1f
--- /dev/null
+++ b/integration/conf/netzbon.conf
@@ -0,0 +1,30 @@
+[nexus-ebics]
+CURRENCY = CHF
+
+# Bank
+HOST_BASE_URL = https://ebics.postfinance.ch/ebics/ebics.aspx
+BANK_DIALECT = postfinance
+
+# EBICS IDs
+HOST_ID = PFEBICS
+USER_ID = 5183101
+PARTNER_ID = 51831
+
+
+BANK_PUBLIC_KEYS_FILE = test/netzbon/bank-keys.json
+CLIENT_PRIVATE_KEYS_FILE = test/netzbon/client-keys.json
+
+IBAN = CH4009000000160948810
+BIC = POFICHBEXXX
+NAME = Genossenschaft Netz Soziale Oekonomie
+
+[nexus-fetch]
+FREQUENCY = 5s
+STATEMENT_LOG_DIRECTORY = test/netzbon/fetch
+
+[nexus-submit]
+FREQUENCY = 5s
+SUBMISSIONS_LOG_DIRECTORY = test/netzbon/submit
+
+[nexus-postgres]
+CONFIG = postgres:///libeufincheck
diff --git a/integration/conf/postfinance.conf 
b/integration/conf/postfinance.conf
new file mode 100644
index 00000000..ece6469c
--- /dev/null
+++ b/integration/conf/postfinance.conf
@@ -0,0 +1,29 @@
+[nexus-ebics]
+currency = CHF
+
+# Bank
+HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb
+BANK_DIALECT = postfinance
+
+# EBICS IDs
+HOST_ID = PFEBICS
+USER_ID = PFC00563
+PARTNER_ID = PFC00563
+
+# Key files
+BANK_PUBLIC_KEYS_FILE = test/postfinance/bank-keys.json
+CLIENT_PRIVATE_KEYS_FILE = test/postfinance/client-keys.json
+
+#IBAN = CH2989144971918294289
+IBAN = CH7789144474425692816
+
+[nexus-fetch]
+FREQUENCY = 5s
+STATEMENT_LOG_DIRECTORY = test/postfinance/fetch
+
+[nexus-submit]
+FREQUENCY = 5s
+SUBMISSIONS_LOG_DIRECTORY = test/postfinance/submit
+
+[nexus-postgres]
+CONFIG = postgres:///libeufincheck
diff --git a/integration/src/main/kotlin/Main.kt 
b/integration/src/main/kotlin/Main.kt
new file mode 100644
index 00000000..6d823ffd
--- /dev/null
+++ b/integration/src/main/kotlin/Main.kt
@@ -0,0 +1,184 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+package tech.libeufin.integration
+
+import tech.libeufin.nexus.Database as NexusDb
+import tech.libeufin.nexus.TalerAmount as NexusAmount
+import tech.libeufin.nexus.*
+import tech.libeufin.bank.*
+import tech.libeufin.util.*
+import com.github.ajalt.clikt.core.*
+import com.github.ajalt.clikt.parameters.arguments.*
+import com.github.ajalt.clikt.parameters.types.*
+import com.github.ajalt.clikt.testing.*
+import io.ktor.client.*
+import io.ktor.client.engine.cio.*
+import kotlin.test.*
+import java.io.File
+import java.nio.file.*
+import java.time.Instant
+import kotlinx.coroutines.runBlocking
+import io.ktor.client.request.*
+import net.taler.wallet.crypto.Base32Crockford
+import kotlin.io.path.*
+
+fun randBytes(lenght: Int): ByteArray {
+    val bytes = ByteArray(lenght)
+    kotlin.random.Random.nextBytes(bytes)
+    return bytes
+}
+
+val nexusCmd = LibeufinNexusCommand()
+val client = HttpClient(CIO)
+
+fun step(name: String) {
+    println("\u001b[35m$name\u001b[0m")
+}
+
+fun ask(question: String): String? {
+    print("\u001b[;1m$question\u001b[0m")
+    System.out.flush()
+    return readlnOrNull()
+}
+
+fun CliktCommandTestResult.assertOk(msg: String? = null) {
+    assertEquals(0, statusCode, msg)
+}
+
+fun CliktCommandTestResult.assertErr(msg: String? = null) {
+    assertEquals(1, statusCode, msg)
+}
+
+enum class Kind {
+    postfinance, 
+    netzbon
+}
+
+class Cli : CliktCommand("Run integration tests on banks provider") {
+    val kind: Kind by argument().enum<Kind>()
+    override fun run() {
+        val name = kind.name
+        step("Test init $name")
+
+        runBlocking {
+            Path("test/$name").createDirectories()
+            val conf = "conf/$name.conf"
+            val cfg = loadConfig(conf)
+
+            val clientKeysPath = Path(cfg.requireString("nexus-ebics", 
"client_private_keys_file"))
+            val bankKeysPath = Path(cfg.requireString("nexus-ebics", 
"bank_public_keys_file"))
+        
+            var hasClientKeys = clientKeysPath.exists()
+            var hasBankKeys = bankKeysPath.exists()
+
+            if (ask("Reset DB ? y/n>") == "y") nexusCmd.test("dbinit -r -c 
$conf").assertOk()
+            else  nexusCmd.test("dbinit -c $conf").assertOk()
+            val nexusDb = NexusDb("postgresql:///libeufincheck")
+
+            when (kind) {
+                Kind.postfinance -> {
+                    if (hasClientKeys || hasBankKeys) {
+                        if (ask("Reset keys ? y/n>") == "y") {
+                            if (hasClientKeys) clientKeysPath.deleteIfExists()
+                            if (hasBankKeys) bankKeysPath.deleteIfExists()
+                            hasClientKeys = false
+                            hasBankKeys = false
+                        }
+                    }
+                  
+                    if (!hasClientKeys) {
+                        step("Test INI order")
+                        ask("Got to 
https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 
'Reset EBICS user'.\nPress Enter when done>")
+                        nexusCmd.test("ebics-setup -c $conf")
+                            .assertErr("ebics-setup should failed the first 
time")
+                    }
+        
+                    if (!hasBankKeys) {
+                        step("Test HIA order")
+                        ask("Got to 
https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 
'Activate EBICS user'.\nPress Enter when done>")
+                        nexusCmd.test("ebics-setup --auto-accept-keys -c 
$conf")
+                            .assertOk("ebics-setup should succeed the second 
time")
+                    }
+                   
+                    if (ask("Submit transactions ? y/n>") == "y") {
+                        val payto = 
"payto://iban/CH2989144971918294289?receiver-name=Test"
+        
+                        step("Test submit one transaction")
+                        nexusDb.initiatedPaymentCreate(InitiatedPayment(
+                            amount = NexusAmount(42L, 0, "CFH"),
+                            creditPaytoUri = payto,
+                            wireTransferSubject = "single transaction test",
+                            initiationTime = Instant.now(),
+                            requestUid = Base32Crockford.encode(randBytes(16))
+                        ))
+                        nexusCmd.test("ebics-submit --transient -c 
$conf").assertOk()
+        
+                        step("Test submit many transaction")
+                            repeat(4) {
+                                
nexusDb.initiatedPaymentCreate(InitiatedPayment(
+                                amount = NexusAmount(100L + it, 0, "CFH"),
+                                creditPaytoUri = payto,
+                                wireTransferSubject = "multi transaction test 
$it",
+                                initiationTime = Instant.now(),
+                                requestUid = 
Base32Crockford.encode(randBytes(16))
+                            ))
+                        }
+                        nexusCmd.test("ebics-submit --transient -c 
$conf").assertOk()
+                    }
+        
+                    step("Test fetch transactions")
+                    nexusCmd.test("ebics-fetch --transient -c $conf 
--pinned-start 2022-01-01").assertOk()
+                }
+                Kind.netzbon -> {
+                    if (!hasClientKeys)
+                        throw Exception("Clients keys are required to run 
netzbon tests")
+                        
+                    if (!hasBankKeys) {
+                        step("Test HIA order")
+                        nexusCmd.test("ebics-setup --auto-accept-keys -c 
$conf").assertOk("ebics-setup should succeed the second time")
+                    }
+    
+                    step("Test fetch transactions")
+                    nexusCmd.test("ebics-fetch --transient -c $conf 
--pinned-start 2022-01-01").assertOk()
+
+                    while (true) {
+                        when (ask("Run 'fetch', 'submit' or 'exit'>")) {
+                            "fetch" -> {
+                                step("Fetch new transactions")
+                                nexusCmd.test("ebics-fetch --transient -c 
$conf").assertOk()
+                            }
+                            "submit" -> {
+                                step("Submit pending transactions")
+                                nexusCmd.test("ebics-submit --transient -c 
$conf").assertOk()
+                            }
+                            "exit" -> break
+                        }
+                    }
+                }
+            }
+        }
+                
+        step("Test succeed")
+    }
+}
+
+fun main(args: Array<String>) {
+    Cli().main(args)
+}
diff --git a/integration/src/test/kotlin/IntegrationTest.kt 
b/integration/src/test/kotlin/IntegrationTest.kt
new file mode 100644
index 00000000..0b483e41
--- /dev/null
+++ b/integration/src/test/kotlin/IntegrationTest.kt
@@ -0,0 +1,328 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023 Taler Systems S.A.
+ *
+ * LibEuFin is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3, or
+ * (at your option) any later version.
+ *
+ * LibEuFin is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
+ * Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with LibEuFin; see the file COPYING.  If not, see
+ * <http://www.gnu.org/licenses/>
+ */
+
+import org.junit.Test
+import net.taler.wallet.crypto.Base32Crockford
+import tech.libeufin.bank.*
+import tech.libeufin.bank.TalerAmount as BankAmount
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.Database as NexusDb
+import tech.libeufin.nexus.TalerAmount as NexusAmount
+import tech.libeufin.bank.db.AccountDAO.*
+import tech.libeufin.util.*
+import java.io.File
+import java.time.Instant
+import java.util.Arrays
+import java.sql.SQLException
+import kotlinx.coroutines.runBlocking
+import com.github.ajalt.clikt.testing.test
+import com.github.ajalt.clikt.core.CliktCommand
+import org.postgresql.jdbc.PgConnection
+import kotlin.test.*
+import io.ktor.client.*
+import io.ktor.client.engine.cio.*
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.HttpStatusCode
+
+fun CliktCommand.run(cmd: String) {
+    val result = test(cmd)
+    if (result.statusCode != 0)
+        throw Exception(result.output)
+    println(result.output)
+}
+
+fun HttpResponse.assertNoContent() {
+    assertEquals(HttpStatusCode.NoContent, this.status)
+}
+
+fun randBytes(lenght: Int): ByteArray {
+    val bytes = ByteArray(lenght)
+    kotlin.random.Random.nextBytes(bytes)
+    return bytes
+}
+
+fun server(lambda: () -> Unit) {
+    // Start the HTTP server in another thread
+    kotlin.concurrent.thread(isDaemon = true)  {
+        lambda()
+    }
+    // Wait for the HTTP server to be up
+    runBlocking {
+        HttpClient(CIO) {
+            install(HttpRequestRetry) {
+                maxRetries = 10
+                constantDelay(200, 100)
+            }
+        }.get("http://0.0.0.0:8080/config";)
+    }
+   
+}
+
+fun setup(lambda: suspend (NexusDb) -> Unit) {
+    try {
+        runBlocking {
+            NexusDb("postgresql:///libeufincheck").use {
+                lambda(it)
+            }
+        }
+    } finally {
+        engine?.stop(0, 0) // Stop http server if started
+    }
+}
+
+inline fun assertException(msg: String, lambda: () -> Unit) {
+    try {
+        lambda()
+        throw Exception("Expected failure: $msg")
+    } catch (e: Exception) {
+        assert(e.message!!.startsWith(msg)) { "${e.message}" }
+    }
+}
+
+class IntegrationTest {
+    val nexusCmd = LibeufinNexusCommand()
+    val bankCmd = LibeufinBankCommand();
+    val client = HttpClient(CIO)
+
+    @Test
+    fun mini() {
+        bankCmd.run("dbinit -c conf/mini.conf -r")
+        bankCmd.run("passwd admin password -c conf/mini.conf")
+        bankCmd.run("dbinit -c conf/mini.conf") // Indempotent
+        
+        server {
+            bankCmd.run("serve -c conf/mini.conf")
+        }
+        
+        setup { _ ->
+            // Check bank is running
+            client.get("http://0.0.0.0:8080/public-accounts";).assertNoContent()
+        }
+    }
+
+    @Test
+    fun errors() {
+        nexusCmd.run("dbinit -c conf/integration.conf -r")
+        bankCmd.run("dbinit -c conf/integration.conf -r")
+        bankCmd.run("passwd admin password -c conf/integration.conf")
+
+        suspend fun checkCount(db: NexusDb, nbIncoming: Int, nbBounce: Int, 
nbTalerable: Int) {
+            db.runConn { conn ->
+                conn.prepareStatement("SELECT count(*) FROM 
incoming_transactions").oneOrNull {
+                    assertEquals(nbIncoming, it.getInt(1))
+                }
+                conn.prepareStatement("SELECT count(*) FROM 
bounced_transactions").oneOrNull {
+                    assertEquals(nbBounce, it.getInt(1))
+                }
+                conn.prepareStatement("SELECT count(*) FROM 
talerable_incoming_transactions").oneOrNull {
+                    assertEquals(nbTalerable, it.getInt(1))
+                }
+            }
+        }
+
+        setup { db ->
+            val userPayTo = IbanPayTo(genIbanPaytoUri())
+            val fiatPayTo = IbanPayTo(genIbanPaytoUri())
+    
+            // Load conversion setup manually as the server would refuse to 
start without an exchange account
+            val sqlProcedures = 
File("../database-versioning/libeufin-conversion-setup.sql")
+            db.runConn { 
+                it.execSQLUpdate(sqlProcedures.readText())
+                it.execSQLUpdate("SET search_path TO libeufin_nexus;")
+            }
+
+            val reservePub = randBytes(32)
+            val payment = IncomingPayment(
+                amount = NexusAmount(10, 0, "EUR"),
+                debitPaytoUri = userPayTo.canonical,
+                wireTransferSubject = "Error test 
${Base32Crockford.encode(reservePub)}",
+                executionTime = Instant.now(),
+                bankTransferId = "error"
+            )
+
+            assertException("ERROR: cashin failed: missing exchange account") {
+                ingestIncomingPayment(db, payment)
+            }
+
+            // Create exchange account
+            bankCmd.run("create-account -c conf/integration.conf -u exchange 
-p password --name 'Mr Money' --exchange")
+    
+            assertException("ERROR: cashin currency conversion failed: missing 
conversion rates") {
+                ingestIncomingPayment(db, payment)
+            }
+
+            // Start server
+            server {
+                bankCmd.run("serve -c conf/integration.conf")
+            }
+
+            // Set conversion rates
+            client.post("http://0.0.0.0:8080/conversion-info/conversion-rate";) 
{
+                basicAuth("admin", "password")
+                json {
+                    "cashin_ratio" to "0.8"
+                    "cashin_fee" to "KUDOS:0.02"
+                    "cashin_tiny_amount" to "KUDOS:0.01"
+                    "cashin_rounding_mode" to "nearest"
+                    "cashin_min_amount" to "EUR:0"
+                    "cashout_ratio" to "1.25"
+                    "cashout_fee" to "EUR:0.003"
+                    "cashout_tiny_amount" to "EUR:0.00000001"
+                    "cashout_rounding_mode" to "zero"
+                    "cashout_min_amount" to "KUDOS:0.1"
+                }
+            }.assertNoContent()
+            
+            assertException("ERROR: cashin failed: admin balance 
insufficient") {
+                db.registerTalerableIncoming(payment, reservePub)
+            }
+
+            // Allow admin debt
+            bankCmd.run("edit-account admin --debit_threshold KUDOS:100 -c 
conf/integration.conf")
+
+            // Too small amount
+            checkCount(db, 0, 0, 0)
+            ingestIncomingPayment(db, payment.copy(
+                amount = NexusAmount(0, 10, "EUR"),
+            ))
+            checkCount(db, 1, 1, 0)
+            client.get("http://0.0.0.0:8080/accounts/exchange/transactions";) {
+                basicAuth("exchange", "password")
+            }.assertNoContent()
+
+            // Check success
+            ingestIncomingPayment(db, IncomingPayment(
+                amount = NexusAmount(10, 0, "EUR"),
+                debitPaytoUri = userPayTo.canonical,
+                wireTransferSubject = "Success 
${Base32Crockford.encode(randBytes(32))}",
+                executionTime = Instant.now(),
+                bankTransferId = "success"
+            ))
+            checkCount(db, 2, 1, 1)
+            client.get("http://0.0.0.0:8080/accounts/exchange/transactions";) {
+                basicAuth("exchange", "password")
+            }.assertOkJson<BankAccountTransactionsResponse>()
+
+            // TODO check double insert cashin with different subject
+        }
+    }
+
+    @Test
+    fun conversion() {
+        nexusCmd.run("dbinit -c conf/integration.conf -r")
+        bankCmd.run("dbinit -c conf/integration.conf -r")
+        bankCmd.run("passwd admin password -c conf/integration.conf")
+        bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c 
conf/integration.conf")
+        bankCmd.run("create-account -c conf/integration.conf -u exchange -p 
password --name 'Mr Money' --exchange")
+        nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent
+        bankCmd.run("dbinit -c conf/integration.conf") // Idempotent
+
+        server {
+            bankCmd.run("serve -c conf/integration.conf")
+        }
+        
+        setup { db -> 
+            val userPayTo = IbanPayTo(genIbanPaytoUri())
+            val fiatPayTo = IbanPayTo(genIbanPaytoUri())
+
+            // Create user
+            client.post("http://0.0.0.0:8080/accounts";) {
+                basicAuth("admin", "password")
+                json {
+                    "username" to "customer"
+                    "password" to "password"
+                    "name" to "JohnSmith"
+                    "internal_payto_uri" to userPayTo
+                    "cashout_payto_uri" to fiatPayTo
+                    "debit_threshold" to "KUDOS:100"
+                    "contact_data" to obj {
+                        "phone" to "+99"
+                    }
+                }
+            }.assertOkJson<RegisterAccountResponse>()
+
+            // Set conversion rates
+            client.post("http://0.0.0.0:8080/conversion-info/conversion-rate";) 
{
+                basicAuth("admin", "password")
+                json {
+                    "cashin_ratio" to "0.8"
+                    "cashin_fee" to "KUDOS:0.02"
+                    "cashin_tiny_amount" to "KUDOS:0.01"
+                    "cashin_rounding_mode" to "nearest"
+                    "cashin_min_amount" to "EUR:0"
+                    "cashout_ratio" to "1.25"
+                    "cashout_fee" to "EUR:0.003"
+                    "cashout_tiny_amount" to "EUR:0.00000001"
+                    "cashout_rounding_mode" to "zero"
+                    "cashout_min_amount" to "KUDOS:0.1"
+                }
+            }.assertNoContent()
+
+            // Cashin
+            repeat(3) { i ->
+                val reservePub = randBytes(32);
+                val amount = NexusAmount(20L + i, 0, "EUR")
+                val subject = "cashin test $i: 
${Base32Crockford.encode(reservePub)}"
+                ingestIncomingPayment(db, 
+                    IncomingPayment(
+                        amount = amount,
+                        debitPaytoUri = userPayTo.canonical,
+                        wireTransferSubject = subject,
+                        executionTime = Instant.now(),
+                        bankTransferId = Base32Crockford.encode(reservePub)
+                    )
+                )
+                val converted = 
client.get("http://0.0.0.0:8080/conversion-info/cashin-rate?amount_debit=EUR:${20
 + i}")
+                    .assertOkJson<ConversionResponse>().amount_credit
+                
client.get("http://0.0.0.0:8080/accounts/exchange/transactions";) {
+                    basicAuth("exchange", "password")
+                }.assertOkJson<BankAccountTransactionsResponse> {
+                    val tx = it.transactions.first()
+                    assertEquals(subject, tx.subject)
+                    assertEquals(converted, tx.amount)
+                }
+                
client.get("http://0.0.0.0:8080/accounts/exchange/taler-wire-gateway/history/incoming";)
 {
+                    basicAuth("exchange", "password")
+                }.assertOkJson<IncomingHistory> {
+                    val tx = it.incoming_transactions.first()
+                    assertEquals(converted, tx.amount)
+                    assert(Arrays.equals(reservePub, tx.reserve_pub.raw))
+                }
+            }
+
+            // Cashout
+            repeat(3) { i ->  
+                val requestUid = randBytes(32);
+                val amount = BankAmount("KUDOS:${10+i}")
+                val convert = 
client.get("http://0.0.0.0:8080/conversion-info/cashout-rate?amount_debit=$amount";)
+                    .assertOkJson<ConversionResponse>().amount_credit;
+                client.post("http://0.0.0.0:8080/accounts/customer/cashouts";) {
+                    basicAuth("customer", "password")
+                    json {
+                        "request_uid" to ShortHashCode(requestUid)
+                        "amount_debit" to amount
+                        "amount_credit" to convert
+                    }
+                }.assertOkJson<CashoutResponse>()
+            }
+        }
+    }
+}
diff --git a/integration/test/IntegrationTest.kt 
b/integration/test/IntegrationTest.kt
deleted file mode 100644
index e5766a61..00000000
--- a/integration/test/IntegrationTest.kt
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- * This file is part of LibEuFin.
- * Copyright (C) 2023 Taler Systems S.A.
- *
- * LibEuFin is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3, or
- * (at your option) any later version.
- *
- * LibEuFin is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
- * Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with LibEuFin; see the file COPYING.  If not, see
- * <http://www.gnu.org/licenses/>
- */
-
-import org.junit.Test
-import tech.libeufin.bank.*
-import tech.libeufin.bank.TalerAmount as BankAmount
-import tech.libeufin.nexus.*
-import tech.libeufin.nexus.Database as NexusDb
-import tech.libeufin.nexus.TalerAmount as NexusAmount
-import tech.libeufin.bank.db.AccountDAO.*
-import tech.libeufin.util.*
-import java.io.File
-import java.time.Instant
-import java.util.Arrays
-import kotlinx.coroutines.runBlocking
-import com.github.ajalt.clikt.testing.test
-import com.github.ajalt.clikt.core.CliktCommand
-import kotlin.test.*
-import io.ktor.client.*
-import io.ktor.client.engine.cio.*
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.HttpStatusCode
-
-fun CliktCommand.run(cmd: String) {
-    val result = test(cmd)
-    if (result.statusCode != 0)
-        throw Exception(result.output)
-    println(result.output)
-}
-
-fun HttpResponse.assertNoContent() {
-    assertEquals(HttpStatusCode.NoContent, this.status)
-}
-
-fun randBytes(lenght: Int): ByteArray {
-    val bytes = ByteArray(lenght)
-    kotlin.random.Random.nextBytes(bytes)
-    return bytes
-}
-
-class IntegrationTest {
-    val nexusCmd = LibeufinNexusCommand()
-    val bankCmd = LibeufinBankCommand();
-    val client = HttpClient(CIO) {
-        install(HttpRequestRetry) {
-            maxRetries = 10
-            constantDelay(200, 100)
-        }
-    }
-
-    @Test
-    fun mini() {
-        bankCmd.run("dbinit -c conf/mini.conf -r")
-        bankCmd.run("passwd admin password -c conf/mini.conf")
-        bankCmd.run("dbinit -c conf/mini.conf") // Indempotent
-        kotlin.concurrent.thread(isDaemon = true)  {
-            bankCmd.run("serve -c conf/mini.conf")
-        }
-        
-        runBlocking {
-            // Check bank is running
-            client.get("http://0.0.0.0:8080/public-accounts";).assertNoContent()
-        }
-    }
-
-    @Test
-    fun conversion() {
-        nexusCmd.run("dbinit -c conf/integration.conf -r")
-        bankCmd.run("dbinit -c conf/integration.conf -r")
-        bankCmd.run("passwd admin password -c conf/integration.conf")
-        bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c 
conf/integration.conf")
-        bankCmd.run("create-account -c conf/integration.conf -u exchange -p 
password --name 'Mr Money' --exchange")
-        nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent
-        bankCmd.run("dbinit -c conf/integration.conf") // Idempotent
-        kotlin.concurrent.thread(isDaemon = true)  {
-            bankCmd.run("serve -c conf/integration.conf")
-        }
-        
-        runBlocking {
-            val nexusDb = NexusDb("postgresql:///libeufincheck")
-            val userPayTo = IbanPayTo(genIbanPaytoUri())
-            val fiatPayTo = IbanPayTo(genIbanPaytoUri())
-
-            // Create user
-            client.post("http://0.0.0.0:8090/accounts";) {
-                basicAuth("admin", "password")
-                json {
-                    "username" to "customer"
-                    "password" to "password"
-                    "name" to "JohnSmith"
-                    "internal_payto_uri" to userPayTo
-                    "cashout_payto_uri" to fiatPayTo
-                    "debit_threshold" to "KUDOS:100"
-                    "contact_data" to obj {
-                        "phone" to "+99"
-                    }
-                }
-            }.assertOkJson<RegisterAccountResponse>()
-
-            // Set conversion rates
-            client.post("http://0.0.0.0:8090/conversion-info/conversion-rate";) 
{
-                basicAuth("admin", "password")
-                json {
-                    "cashin_ratio" to "0.8"
-                    "cashin_fee" to "KUDOS:0.02"
-                    "cashin_tiny_amount" to "KUDOS:0.01"
-                    "cashin_rounding_mode" to "nearest"
-                    "cashin_min_amount" to "EUR:0"
-                    "cashout_ratio" to "1.25"
-                    "cashout_fee" to "EUR:0.003"
-                    "cashout_tiny_amount" to "EUR:0.00000001"
-                    "cashout_rounding_mode" to "zero"
-                    "cashout_min_amount" to "KUDOS:0.1"
-                }
-            }.assertNoContent()
-
-            // Cashin
-            repeat(3) { i ->
-                val reservePub = randBytes(32);
-                val amount = NexusAmount(20L + i, 0, "EUR")
-                nexusDb.incomingTalerablePaymentCreate(IncomingPayment(
-                    amount = amount,
-                    debitPaytoUri = userPayTo.canonical,
-                    wireTransferSubject = "cashin test $i",
-                    executionTime = Instant.now(),
-                    bankTransferId = "entropic"), 
-                reservePub)
-                val converted = 
client.get("http://0.0.0.0:8090/conversion-info/cashin-rate?amount_debit=EUR:${20
 + i}")
-                    .assertOkJson<ConversionResponse>().amount_credit
-                
client.get("http://0.0.0.0:8090/accounts/exchange/transactions";) {
-                    basicAuth("exchange", "password")
-                }.assertOkJson<BankAccountTransactionsResponse> {
-                    val tx = it.transactions.first()
-                    assertEquals("cashin test $i", tx.subject)
-                    assertEquals(converted, tx.amount)
-                }
-                
client.get("http://0.0.0.0:8090/accounts/exchange/taler-wire-gateway/history/incoming";)
 {
-                    basicAuth("exchange", "password")
-                }.assertOkJson<IncomingHistory> {
-                    val tx = it.incoming_transactions.first()
-                    assertEquals(converted, tx.amount)
-                    assert(Arrays.equals(reservePub, tx.reserve_pub.raw))
-                }
-            }
-
-            // Cashout
-            repeat(3) { i ->  
-                val requestUid = randBytes(32);
-                val amount = BankAmount("KUDOS:${10+i}")
-                val convert = 
client.get("http://0.0.0.0:8090/conversion-info/cashout-rate?amount_debit=$amount";)
-                    .assertOkJson<ConversionResponse>().amount_credit;
-                client.post("http://0.0.0.0:8090/accounts/customer/cashouts";) {
-                    basicAuth("customer", "password")
-                    json {
-                        "request_uid" to ShortHashCode(requestUid)
-                        "amount_debit" to amount
-                        "amount_credit" to convert
-                    }
-                }.assertOkJson<CashoutPending> {
-                    val code = File("/tmp/tan-+99.txt").readText()
-                    
client.post("http://0.0.0.0:8090/accounts/customer/cashouts/${it.cashout_id}/confirm";)
 {
-                        basicAuth("customer", "password")
-                        json { "tan" to code }
-                    }.assertNoContent()
-                }
-            }
-        }
-    }
-}
diff --git a/nexus/build.gradle b/nexus/build.gradle
index 224f6b60..75491bb3 100644
--- a/nexus/build.gradle
+++ b/nexus/build.gradle
@@ -57,11 +57,6 @@ dependencies {
     testImplementation("io.ktor:ktor-client-mock:$ktor_version")
 }
 
-test {
-    failFast = true
-    testLogging.showStandardStreams = false
-}
-
 application {
     mainClassName = "tech.libeufin.nexus.MainKt"
     applicationName = "libeufin-nexus"
diff --git a/nexus/conf/test.conf b/nexus/conf/test.conf
new file mode 100644
index 00000000..0235dde1
--- /dev/null
+++ b/nexus/conf/test.conf
@@ -0,0 +1,13 @@
+[nexus-ebics]
+currency = CHF
+BANK_DIALECT = postfinance
+HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb
+BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json
+CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json
+IBAN = CH7789144474425692816
+HOST_ID = PFEBICS
+USER_ID = PFC00563
+PARTNER_ID = PFC00563
+
+[nexus-postgres]
+CONFIG = postgres:///libeufincheck
\ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
index 778db5b9..1ae14cbc 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt
@@ -9,6 +9,14 @@ import tech.libeufin.util.*
 import java.sql.PreparedStatement
 import java.sql.SQLException
 import java.time.Instant
+import java.util.Date
+import java.text.SimpleDateFormat
+
+fun Instant.fmtDate(): String {
+    val formatter = SimpleDateFormat("yyyy-MM-dd")
+    return formatter.format(Date.from(this))
+}
+
 
 // Remove this once TalerAmount from the bank
 // module gets moved to the 'util' module (#7987).
@@ -16,7 +24,16 @@ data class TalerAmount(
     val value: Long,
     val fraction: Int, // has at most 8 digits.
     val currency: String
-)
+) {
+    override fun toString(): String {
+        if (fraction == 0) {
+            return "$currency:$value"
+        } else {
+            return "$currency:$value.${fraction.toString().padStart(8, '0')}"
+                .dropLastWhile { it == '0' } // Trim useless fractional 
trailing 0
+        }
+    }
+}
 
 // INCOMING PAYMENTS STRUCTS
 
@@ -29,7 +46,12 @@ data class IncomingPayment(
     val debitPaytoUri: String,
     val executionTime: Instant,
     val bankTransferId: String
-)
+)  {
+    override fun toString(): String {
+        return "IN ${executionTime.fmtDate()} '$amount $bankTransferId' 
debitor=$debitPaytoUri subject=$wireTransferSubject"
+    }
+}
+
 
 // INITIATED PAYMENTS STRUCTS
 
@@ -104,27 +126,31 @@ data class OutgoingPayment(
     val bankTransferId: String,
     val creditPaytoUri: String? = null, // not showing in camt.054
     val wireTransferSubject: String? = null // not showing in camt.054
+) {
+    override fun toString(): String {
+        return "OUT ${executionTime.fmtDate()} $amount '$bankTransferId' 
creditor=$creditPaytoUri subject=$wireTransferSubject"
+    }
+}
+
+/** Outgoing payments registration result */
+data class OutgoingRegistrationResult(
+    val id: Long,
+    val initiated: Boolean,
+    val new: Boolean
 )
 
-/**
- * Witnesses the outcome of inserting an outgoing
- * payment into the database.
- */
-enum class OutgoingPaymentOutcome {
-    /**
-     * The caller wanted to link a previously initiated payment
-     * to this outgoing one, but the row ID passed to the inserting
-     * function could not be found in the payment initiations table.
-     * Note: NO insertion takes place in this case.
-     */
-    INITIATED_COUNTERPART_NOT_FOUND,
-    /**
-     * The outgoing payment got inserted and _in case_ the caller
-     * wanted to link a previously initiated payment to this one, that
-     * succeeded too.
-     */
-    SUCCESS
-}
+/** Incoming payments registration result */
+data class IncomingRegistrationResult(
+    val id: Long,
+    val new: Boolean
+)
+
+/** Incoming payments bounce registration result */
+data class IncomingBounceRegistrationResult(
+    val id: Long,
+    val bounceId: String,
+    val new: Boolean
+)
 
 /**
  * Performs a INSERT, UPDATE, or DELETE operation.
@@ -184,28 +210,21 @@ class Database(dbConfig: String): java.io.Closeable {
     // OUTGOING PAYMENTS METHODS
 
     /**
-     * Creates one outgoing payment OPTIONALLY reconciling it with its
+     * Register an outgoing payment OPTIONALLY reconciling it with its
      * initiated payment counterpart.
      *
      * @param paymentData information about the outgoing payment.
-     * @param reconcileId optional row ID of the initiated payment
-     *        that will reference this one.  If null, then only the
-     *        outgoing payment record gets inserted.
      * @return operation outcome enum.
      */
-    suspend fun outgoingPaymentCreate(
-        paymentData: OutgoingPayment,
-        reconcileId: Long? = null
-    ): OutgoingPaymentOutcome = runConn {
+    suspend fun registerOutgoing(paymentData: OutgoingPayment): 
OutgoingRegistrationResult = runConn {        
         val stmt = it.prepareStatement("""
-            SELECT out_nx_initiated
-              FROM create_outgoing_payment(
+            SELECT out_tx_id, out_initiated, out_found
+              FROM register_outgoing(
                 (?,?)::taler_amount
                 ,?
                 ,?
                 ,?
                 ,?
-                ,?
               )"""
         )
         val executionTime = paymentData.executionTime.toDbMicros()
@@ -216,116 +235,112 @@ class Database(dbConfig: String): java.io.Closeable {
         stmt.setLong(4, executionTime)
         stmt.setString(5, paymentData.creditPaytoUri)
         stmt.setString(6, paymentData.bankTransferId)
-        if (reconcileId == null)
-            stmt.setNull(7, java.sql.Types.BIGINT)
-        else
-            stmt.setLong(7, reconcileId)
 
         stmt.executeQuery().use {
-            if (!it.next()) throw Exception("Inserting outgoing payment gave 
no outcome.")
-            if (it.getBoolean("out_nx_initiated"))
-                return@runConn 
OutgoingPaymentOutcome.INITIATED_COUNTERPART_NOT_FOUND
-        }
-        return@runConn OutgoingPaymentOutcome.SUCCESS
-    }
-
-    /**
-     * Checks if the outgoing payment was already processed by Nexus.
-     *
-     * @param bankUid unique identifier assigned by the bank to the payment.
-     *        Normally, that's the <UETR> value found in camt.05x records.  
Outgoing
-     *        payment have been observed to _lack_ the <AcctSvcrRef> element.
-     * @return true if found, false otherwise
-     */
-    suspend fun isOutgoingPaymentSeen(bankUid: String): Boolean = runConn { 
conn ->
-        val stmt = conn.prepareStatement("""
-             SELECT 1
-               FROM outgoing_transactions
-               WHERE bank_transfer_id = ?;
-        """)
-        stmt.setString(1, bankUid)
-        val res = stmt.executeQuery()
-        res.use {
-            return@runConn it.next()
+            when {
+                !it.next() -> throw Exception("Inserting outgoing payment gave 
no outcome.")
+                else -> OutgoingRegistrationResult(
+                    it.getLong("out_tx_id"),
+                    it.getBoolean("out_initiated"),
+                    !it.getBoolean("out_found")
+                )
+            }
         }
     }
 
     // INCOMING PAYMENTS METHODS
 
     /**
-     * Flags an incoming payment as bounced.  NOTE: the flag merely means
-     * that the payment had an invalid subject for a Taler withdrawal _and_
-     * it got initiated as an outgoing payments.  In NO way this flag
-     * means that the actual value was returned to the initial debtor.
+     * Register an incoming payment and bounce it
      *
-     * @param rowId row ID of the payment to flag as bounced.
-     * @param initiatedRequestUid unique identifier for the outgoing payment to
-     *                            initiate for this bouncing.
-     * @return true if the payment could be set as bounced, false otherwise.
+     * @param paymentData information about the incoming payment
+     * @param requestUid unique identifier of the bounce outgoing payment to
+     *                   initiate
+     * @param bounceAmount amount to send back to the original debtor
+     * @param bounceSubject subject of the bounce outhoing payment
+     * @return true if new
      */
-    suspend fun incomingPaymentSetAsBounced(rowId: Long, initiatedRequestUid: 
String): Boolean = runConn { conn ->
-        val timestamp = Instant.now().toDbMicros()
-            ?: throw Exception("Could not convert Instant.now() to 
microseconds, won't bounce this payment.")
-        val stmt = conn.prepareStatement("""
-             SELECT out_nx_incoming_payment
-               FROM bounce_payment(?,?,?)
-             """
+    suspend fun registerMalformedIncoming(
+        paymentData: IncomingPayment,
+        bounceAmount: TalerAmount,
+        now: Instant
+    ): IncomingBounceRegistrationResult = runConn {       
+        val stmt = it.prepareStatement("""
+            SELECT out_found, out_tx_id, out_bounce_id
+              FROM register_incoming_and_bounce(
+                (?,?)::taler_amount
+                ,?
+                ,?
+                ,?
+                ,?
+                ,(?,?)::taler_amount
+                ,?
+              )"""
         )
-        stmt.setLong(1, rowId)
-        stmt.setLong(2, timestamp)
-        stmt.setString(3, initiatedRequestUid)
-        stmt.executeQuery().use { maybeResult ->
-            if (!maybeResult.next()) throw Exception("Expected outcome from 
the SQL bounce_payment function")
-            return@runConn !maybeResult.getBoolean("out_nx_incoming_payment")
+        val refundTimestamp = now.toDbMicros()
+            ?: throw Exception("Could not convert refund execution time from 
Instant.now() to microsends.")
+        val executionTime = paymentData.executionTime.toDbMicros()
+            ?: throw Exception("Could not convert payment execution time from 
Instant to microseconds.")
+        stmt.setLong(1, paymentData.amount.value)
+        stmt.setInt(2, paymentData.amount.fraction)
+        stmt.setString(3, paymentData.wireTransferSubject)
+        stmt.setLong(4, executionTime)
+        stmt.setString(5, paymentData.debitPaytoUri)
+        stmt.setString(6, paymentData.bankTransferId)
+        stmt.setLong(7, bounceAmount.value)
+        stmt.setInt(8, bounceAmount.fraction)
+        stmt.setLong(9, refundTimestamp)
+        stmt.executeQuery().use {
+            when {
+                !it.next() -> throw Exception("Inserting malformed incoming 
payment gave no outcome")
+                else -> IncomingBounceRegistrationResult(
+                    it.getLong("out_tx_id"),
+                    it.getString("out_bounce_id"),
+                    !it.getBoolean("out_found")
+                )
+            }
         }
     }
 
     /**
-     * Creates an incoming payment as bounced _and_ initiates its
-     * reimbursement.
+     * Register an talerable incoming payment
      *
-     * @param paymentData information related to the incoming payment.
-     * @param requestUid unique identifier of the outgoing payment to
-     *                   initiate, in order to reimburse the bounced tx.
-     * @param refundAmount amount to send back to the original debtor.  If
-     *                     null, it defaults to the amount of the bounced
-     *                     incoming payment.
+     * @param paymentData incoming talerable payment.
+     * @param reservePub reserve public key.  The caller is
+     *        responsible to check it.
      */
-    suspend fun incomingPaymentCreateBounced(
+    suspend fun registerTalerableIncoming(
         paymentData: IncomingPayment,
-        requestUid: String,
-        refundAmount: TalerAmount? = null
-        ): Boolean = runConn { conn ->
-        val refundTimestamp = Instant.now().toDbMicros()
-            ?: throw Exception("Could not convert refund execution time from 
Instant.now() to microsends.")
+        reservePub: ByteArray
+    ): IncomingRegistrationResult = runConn { conn ->
+        val stmt = conn.prepareStatement("""
+            SELECT out_found, out_tx_id
+              FROM register_incoming_and_talerable(
+                (?,?)::taler_amount
+                ,?
+                ,?
+                ,?
+                ,?
+                ,?
+              )"""
+        )
         val executionTime = paymentData.executionTime.toDbMicros()
             ?: throw Exception("Could not convert payment execution time from 
Instant to microseconds.")
-        val stmt = conn.prepareStatement("""
-            SELECT out_ok FROM create_incoming_and_bounce (
-              (?,?)::taler_amount
-              ,?
-              ,?
-              ,?
-              ,?
-              ,?
-              ,?
-              ,(?,?)::taler_amount
-            )""")
         stmt.setLong(1, paymentData.amount.value)
         stmt.setInt(2, paymentData.amount.fraction)
         stmt.setString(3, paymentData.wireTransferSubject)
         stmt.setLong(4, executionTime)
         stmt.setString(5, paymentData.debitPaytoUri)
         stmt.setString(6, paymentData.bankTransferId)
-        stmt.setLong(7, refundTimestamp)
-        stmt.setString(8, requestUid)
-        val finalRefundAmount: TalerAmount = refundAmount ?: paymentData.amount
-        stmt.setLong(9, finalRefundAmount.value)
-        stmt.setInt(10, finalRefundAmount.fraction)
-        val res = stmt.executeQuery()
-        res.use {
-            if (!it.next()) return@runConn false
-            return@runConn it.getBoolean("out_ok")
+        stmt.setBytes(7, reservePub)
+        stmt.executeQuery().use {
+            when {
+                !it.next() -> throw Exception("Inserting talerable incoming 
payment gave no outcome")
+                else -> IncomingRegistrationResult(
+                    it.getLong("out_tx_id"),
+                    !it.getBoolean("out_found")
+                )
+            }
         }
     }
 
@@ -365,26 +380,6 @@ class Database(dbConfig: String): java.io.Closeable {
         }
     }
 
-    /**
-     * Checks if the incoming payment was already processed by Nexus.
-     *
-     * @param bankUid unique identifier assigned by the bank to the payment.
-     *        Normally, that's the <AcctSvcrRef> value found in camt.05x 
records.
-     * @return true if found, false otherwise
-     */
-    suspend fun isIncomingPaymentSeen(bankUid: String): Boolean = runConn { 
conn ->
-        val stmt = conn.prepareStatement("""
-             SELECT 1
-               FROM incoming_transactions
-               WHERE bank_transfer_id = ?;
-        """)
-        stmt.setString(1, bankUid)
-        val res = stmt.executeQuery()
-        res.use {
-            return@runConn it.next()
-        }
-    }
-
     /**
      * Checks if the reserve public key already exists.
      *
@@ -404,84 +399,6 @@ class Database(dbConfig: String): java.io.Closeable {
         }
     }
 
-    /**
-     * Creates an incoming transaction row and  links a new talerable
-     * row to it.
-     *
-     * @param paymentData incoming talerable payment.
-     * @param reservePub reserve public key.  The caller is
-     *        responsible to check it.
-     */
-    suspend fun incomingTalerablePaymentCreate(
-        paymentData: IncomingPayment,
-        reservePub: ByteArray
-    ): Boolean = runConn { conn ->
-        val stmt = conn.prepareStatement("""
-           SELECT out_ok FROM create_incoming_talerable(
-              (?,?)::taler_amount
-              ,?
-              ,?
-              ,?
-              ,?
-              ,?
-           )""")
-        bindIncomingPayment(paymentData, stmt)
-        stmt.setBytes(7, reservePub)
-        stmt.executeQuery().use {
-            if (!it.next()) return@runConn false
-            return@runConn it.getBoolean("out_ok")
-        }
-    }
-
-    /**
-     * Binds the values of an incoming payment to the prepared
-     * statement's placeholders.  Warn: may easily break in case
-     * the placeholders get their positions changed!
-     *
-     * @param data incoming payment to bind to the placeholders
-     * @param stmt statement to receive the values in its placeholders
-     */
-    private fun bindIncomingPayment(
-        data: IncomingPayment,
-        stmt: PreparedStatement
-    ) {
-        stmt.setLong(1, data.amount.value)
-        stmt.setInt(2, data.amount.fraction)
-        stmt.setString(3, data.wireTransferSubject)
-        val executionTime = data.executionTime.toDbMicros() ?: run {
-            throw Exception("Execution time could not be converted to 
microseconds for the database.")
-        }
-        stmt.setLong(4, executionTime)
-        stmt.setString(5, data.debitPaytoUri)
-        stmt.setString(6, data.bankTransferId)
-    }
-    /**
-     * Creates a new incoming payment record in the database.  It does NOT
-     * update the "talerable" table.
-     *
-     * @param paymentData information related to the incoming payment.
-     * @return true on success, false otherwise.
-     */
-    suspend fun incomingPaymentCreate(paymentData: IncomingPayment): Boolean = 
runConn { conn ->
-        val stmt = conn.prepareStatement("""
-            INSERT INTO incoming_transactions (
-              amount
-              ,wire_transfer_subject
-              ,execution_time
-              ,debit_payto_uri
-              ,bank_transfer_id
-            ) VALUES (
-              (?,?)::taler_amount
-              ,?
-              ,?
-              ,?
-              ,?
-            )
-        """)
-        bindIncomingPayment(paymentData, stmt)
-        return@runConn stmt.maybeUpdate()
-    }
-
     // INITIATED PAYMENTS METHODS
 
     /**
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt
index af7954cc..906943df 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt
@@ -4,7 +4,6 @@ import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.options.*
 import com.github.ajalt.clikt.parameters.groups.*
 import tech.libeufin.util.*
-import kotlin.system.exitProcess
 
 /**
  * This subcommand tries to load the SQL files that define
@@ -19,14 +18,12 @@ class DbInit : CliktCommand("Initialize the libeufin-nexus 
database", name = "db
     ).flag()
 
     override fun run() {
-        val cfg = loadConfigOrFail(common.config).extractDbConfigOrFail()
-        doOrFail {
-            pgDataSource(cfg.dbConnStr).pgConnection().use { conn ->
-                if (requestReset) {
-                    resetDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-nexus")
-                }
-                initializeDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-nexus")
+        val cfg = loadConfig(common.config).dbConfig()
+        pgDataSource(cfg.dbConnStr).pgConnection().use { conn ->
+            if (requestReset) {
+                resetDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-nexus")
             }
+            initializeDatabaseTables(conn, cfg, sqlFilePrefix = 
"libeufin-nexus")
         }
     }
 }
\ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index d355c923..0697cd67 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -4,7 +4,7 @@ import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.options.*
 import com.github.ajalt.clikt.parameters.groups.*
 import io.ktor.client.*
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.*
 import net.taler.wallet.crypto.Base32Crockford
 import net.taler.wallet.crypto.EncodingException
 import tech.libeufin.nexus.ebics.*
@@ -17,9 +17,7 @@ import java.time.Instant
 import java.time.LocalDate
 import java.time.ZoneId
 import java.util.UUID
-import kotlin.concurrent.fixedRateTimer
-import kotlin.io.path.createDirectories
-import kotlin.system.exitProcess
+import kotlin.io.path.*
 
 /**
  * Necessary data to perform a download.
@@ -75,7 +73,7 @@ data class FetchContext(
 private suspend inline fun downloadHelper(
     ctx: FetchContext,
     lastExecutionTime: Instant? = null
-): ByteArray? {
+): ByteArray {
     val initXml = if (ctx.ebicsVersion == EbicsVersion.three) {
         createEbics3DownloadInitialization(
             ctx.cfg,
@@ -112,7 +110,7 @@ private suspend inline fun downloadHelper(
          * bank side.  A client with an unreliable bank is not useful, hence
          * failing here.
          */
-        exitProcess(1)
+        throw e
     }
 }
 
@@ -141,26 +139,20 @@ fun maybeLogFile(
     val asUtcDate = LocalDate.ofInstant(now, ZoneId.of("UTC"))
     val subDir = 
"${asUtcDate.year}-${asUtcDate.monthValue}-${asUtcDate.dayOfMonth}"
     // Creating the combined dir.
-    val dirs = Path.of(maybeLogDir, subDir)
-    doOrFail { dirs.createDirectories() }
-    fun maybeWrite(f: File, xml: String) {
-        if (f.exists()) {
-            logger.error("Log file exists already at: ${f.path}")
-            exitProcess(1)
-        }
-        doOrFail { f.writeText(xml) }
-    }
+    val dirs = Path(maybeLogDir, subDir)
+    dirs.createDirectories()
     if (nonZip) {
-        val f  = File(dirs.toString(), 
"${now.toDbMicros()}_HAC_response.pain.002.xml")
-        maybeWrite(f, content.toString(Charsets.UTF_8))
-        return
-    }
-    // Write each ZIP entry in the combined dir.
-    content.unzipForEach { fileName, xmlContent ->
-        val f  = File(dirs.toString(), "${now.toDbMicros()}_$fileName")
-        // Rare: cannot download the same file twice in the same microsecond.
-        maybeWrite(f, xmlContent)
+        val f = Path(dirs.toString(), 
"${now.toDbMicros()}_HAC_response.pain.002.xml")
+        f.writeBytes(content)
+    } else {
+        // Write each ZIP entry in the combined dir.
+        content.unzipForEach { fileName, xmlContent ->
+            val f = Path(dirs.toString(), "${now.toDbMicros()}_$fileName")
+            // Rare: cannot download the same file twice in the same 
microsecond.
+            f.writeText(xmlContent)
+        }
     }
+   
 }
 
 /**
@@ -254,25 +246,17 @@ fun removeSubjectNoise(subject: String): String? {
  * Checks the two conditions that may invalidate one incoming
  * payment: subject validity and availability.
  *
- * @param db database connection.
  * @param payment incoming payment whose subject is to be checked.
  * @return [ByteArray] as the reserve public key, or null if the
  *         payment cannot lead to a Taler withdrawal.
  */
 private suspend fun getTalerReservePub(
-    db: Database,
     payment: IncomingPayment
 ): ByteArray? {
     // Removing noise around the potential reserve public key.
     val maybeReservePub = removeSubjectNoise(payment.wireTransferSubject) ?: 
return null
     // Checking validity first.
     val dec = isReservePub(maybeReservePub) ?: return null
-    // Now checking availability.
-    val maybeUnavailable = db.isReservePubFound(dec)
-    if (maybeUnavailable) {
-        logger.error("Incoming payment with subject 
'${payment.wireTransferSubject}' exists already")
-        return null
-    }
     return dec
 }
 
@@ -284,31 +268,18 @@ private suspend fun getTalerReservePub(
  * @param db database handle.
  * @param payment payment to (maybe) ingest.
  */
-private suspend fun ingestOutgoingPayment(
+suspend fun ingestOutgoingPayment(
     db: Database,
     payment: OutgoingPayment
 ) {
-    logger.debug("Ingesting outgoing payment UID ${payment.bankTransferId}, 
subject ${payment.wireTransferSubject}")
-    // Check if the payment was ingested already.
-    if (db.isOutgoingPaymentSeen(payment.bankTransferId)) {
-        logger.debug("Outgoing payment with UID '${payment.bankTransferId}' 
already seen.")
-        return
-    }
-    /**
-     * Getting the initiate payment to link to this.  A missing initiated
-     * payment could mean that a third party is downloading the bank account
-     * history (to conduct an audit, for example)
-     */
-    val initId: Long? = db.initiatedPaymentGetFromUid(payment.bankTransferId);
-    if (initId == null)
-        logger.info("Outgoing payment lacks initiated counterpart with UID 
${payment.bankTransferId}")
-    // store the payment and its (maybe null) linked init
-    val insertionResult = db.outgoingPaymentCreate(payment, initId)
-    if (insertionResult != OutgoingPaymentOutcome.SUCCESS) {
-        throw Exception("Could not store outgoing payment with UID " +
-                "'${payment.bankTransferId}' and update its related 
initiation." +
-                "  DB result: $insertionResult"
-        )
+    val result = db.registerOutgoing(payment)
+    if (result.new) {
+        if (result.initiated)
+            logger.debug("$payment")
+        else 
+            logger.debug("$payment recovered")
+    } else {
+        logger.debug("OUT '${payment.bankTransferId}' already seen")
     }
 }
 
@@ -319,29 +290,35 @@ private suspend fun ingestOutgoingPayment(
  *
  * @param db database handle.
  * @param currency fiat currency of the watched bank account.
- * @param incomingPayment payment to (maybe) ingest.
+ * @param payment payment to (maybe) ingest.
  */
-private suspend fun ingestIncomingPayment(
+suspend fun ingestIncomingPayment(
     db: Database,
-    incomingPayment: IncomingPayment
+    payment: IncomingPayment
 ) {
-    logger.debug("Ingesting incoming payment UID: 
${incomingPayment.bankTransferId}, subject: 
${incomingPayment.wireTransferSubject}")
-    if (db.isIncomingPaymentSeen(incomingPayment.bankTransferId)) {
-        logger.debug("Incoming payment with UID 
'${incomingPayment.bankTransferId}' already seen.")
-        return
-    }
-    val reservePub = getTalerReservePub(db, incomingPayment)
+    val reservePub = getTalerReservePub(payment)
     if (reservePub == null) {
-        logger.debug("Incoming payment with UID 
'${incomingPayment.bankTransferId}'" +
-                " has invalid subject: ${incomingPayment.wireTransferSubject}."
+        logger.debug("Incoming payment with UID '${payment.bankTransferId}'" +
+                " has invalid subject: ${payment.wireTransferSubject}."
         )
-        db.incomingPaymentCreateBounced(
-            incomingPayment,
-            UUID.randomUUID().toString().take(35)
+        val result = db.registerMalformedIncoming(
+            payment,
+            payment.amount, 
+            Instant.now()
         )
-        return
+        if (result.new) {
+            logger.debug("$payment bounced in '${result.bounceId}'")
+        } else {
+            logger.debug("IN '${payment.bankTransferId}' already seen and 
bounced in '${result.bounceId}'")
+        }
+    } else {
+        val result = db.registerTalerableIncoming(payment, reservePub)
+        if (result.new) {
+            logger.debug("$payment")
+        } else {
+            logger.debug("IN '${payment.bankTransferId}' already seen")
+        }
     }
-    db.incomingTalerablePaymentCreate(incomingPayment, reservePub)
 }
 
 /**
@@ -373,62 +350,34 @@ fun firstLessThanSecond(
  * @param db database connection.
  * @param content the ZIP file that contains the EBICS
  *        notification as camt.054 records.
- * @return true if the ingestion succeeded, false otherwise.
- *         False should fail the process, since it means that
- *         the notification could not be parsed.
  */
 private fun ingestNotification(
     db: Database,
     ctx: FetchContext,
     content: ByteArray
-): Boolean {
+) {
     val incomingPayments = mutableListOf<IncomingPayment>()
     val outgoingPayments = mutableListOf<OutgoingPayment>()
-    val filenamePrefixForIncoming = "camt.054_P_${ctx.cfg.myIbanAccount.iban}"
-    val filenamePrefixForOutgoing = 
"camt.054-Debit_P_${ctx.cfg.myIbanAccount.iban}"
+    
     try {
         content.unzipForEach { fileName, xmlContent ->
             if (!fileName.contains("camt.054", ignoreCase = true))
                 throw Exception("Asked for notification but did NOT get a 
camt.054")
-            /**
-             * We ignore any camt.054 that does not bring Taler-relevant 
information,
-             * like camt.054-Credit, for example.
-             */
-            if (!fileName.startsWith(filenamePrefixForIncoming) &&
-                    !fileName.startsWith(filenamePrefixForOutgoing)) {
-                logger.debug("Ignoring camt.054: $fileName")
-                return@unzipForEach
-            }
-
-            if (fileName.startsWith(filenamePrefixForIncoming))
-                incomingPayments += parseIncomingTxNotif(xmlContent, 
ctx.cfg.currency)
-            else outgoingPayments += parseOutgoingTxNotif(xmlContent, 
ctx.cfg.currency)
+            logger.debug("parse $fileName")
+            parseTxNotif(xmlContent, ctx.cfg.currency, incomingPayments, 
outgoingPayments)
         }
     } catch (e: IOException) {
-        logger.error("Could not open any ZIP archive")
-        return false
-    } catch (e: Exception) {
-        logger.error(e.message)
-        return false
+        throw Exception("Could not open any ZIP archive", e)
     }
 
-    try {
-        runBlocking {
-            incomingPayments.forEach {
-                ingestIncomingPayment(
-                    db,
-                    it
-                )
-            }
-            outgoingPayments.forEach {
-                ingestOutgoingPayment(db, it)
-            }
+    runBlocking {
+        incomingPayments.forEach {
+            ingestIncomingPayment(db, it)
+        }
+        outgoingPayments.forEach {
+            ingestOutgoingPayment(db, it)
         }
-    } catch (e: Exception) {
-        logger.error(e.message)
-        return false
     }
-    return true
 }
 
 /**
@@ -470,7 +419,7 @@ private suspend fun fetchDocuments(
     val lastExecutionTime: Instant? = ctx.pinnedStart ?: requestFrom
     logger.debug("Fetching ${ctx.whichDocument} from timestamp: 
$lastExecutionTime")
     // downloading the content
-    val maybeContent = downloadHelper(ctx, lastExecutionTime) ?: 
exitProcess(1) // client is wrong, failing.
+    val maybeContent = downloadHelper(ctx, lastExecutionTime)
     if (maybeContent.isEmpty()) return
     // logging, if the configuration wants.
     maybeLogFile(
@@ -483,9 +432,10 @@ private suspend fun fetchDocuments(
         logger.warn("Not ingesting ${ctx.whichDocument}.  Only camt.054 
notifications supported.")
         return
     }
-    if (!ingestNotification(db, ctx, maybeContent)) {
-        logger.error("Ingesting notifications failed")
-        exitProcess(1)
+    try {
+        ingestNotification(db, ctx, maybeContent)
+    } catch (e: Exception) {
+        throw Exception("Ingesting notifications failed", e)
     }
 }
 
@@ -541,13 +491,9 @@ class EbicsFetch: CliktCommand("Fetches bank records.  
Defaults to camt.054 noti
      * mode when no flags are passed to the invocation.
      * FIXME: reduce code duplication with the submit subcommand.
      */
-    override fun run() {
-        val cfg: EbicsSetupConfig = doOrFail {
-            extractEbicsConfig(common.config)
-        }
-
-        val dbCfg = cfg.config.extractDbConfigOrFail()
-        val db = Database(dbCfg.dbConnStr)
+    override fun run() = cliCmd(logger) {
+        val cfg: EbicsSetupConfig = extractEbicsConfig(common.config)
+        val dbCfg = cfg.config.dbConfig()
 
         // Deciding what to download.
         var whichDoc = SupportedDocument.CAMT_054
@@ -555,113 +501,77 @@ class EbicsFetch: CliktCommand("Fetches bank records.  
Defaults to camt.054 noti
         if (onlyReports) whichDoc = SupportedDocument.CAMT_052
         if (onlyStatements) whichDoc = SupportedDocument.CAMT_053
         if (onlyLogs) whichDoc = SupportedDocument.PAIN_002_LOGS
-        if (parse || import) {
-            logger.debug("Reading from STDIN, running in debug mode.  Not 
involving the database.")
-            val maybeStdin = generateSequence(::readLine).joinToString("\n")
-            when(whichDoc) {
-                SupportedDocument.CAMT_054 -> {
-                    try {
-                        val incomingTxs = parseIncomingTxNotif(maybeStdin, 
cfg.currency)
+
+        Database(dbCfg.dbConnStr).use { db ->
+            if (parse || import) {
+                logger.debug("Reading from STDIN, running in debug mode.  Not 
involving the database.")
+                val maybeStdin = 
generateSequence(::readLine).joinToString("\n")
+                when(whichDoc) {
+                    SupportedDocument.CAMT_054 -> {
+                        val incomingTxs = mutableListOf<IncomingPayment>()
+                        val outgoingTxs = mutableListOf<OutgoingPayment>()
+                        parseTxNotif(maybeStdin, cfg.currency, incomingTxs, 
outgoingTxs)
                         println(incomingTxs)
+                        println(outgoingTxs)
                         if (import) {
                             runBlocking {
                                 incomingTxs.forEach {
                                     ingestIncomingPayment(db, it)
                                 }
-                            }
-                        }
-                    } catch (e: WrongPaymentDirection) {
-                        logger.info("Input doesn't contain incoming payments")
-                    } catch (e: Exception) {
-                        logger.error(e.message)
-                        exitProcess(1)
-                    }
-                    try {
-                        val outgoingTxs = parseOutgoingTxNotif(maybeStdin, 
cfg.currency)
-                        println(outgoingTxs)
-                        if (import) {
-                            runBlocking {
                                 outgoingTxs.forEach {
                                     ingestOutgoingPayment(db, it)
                                 }
                             }
                         }
-                    } catch (e: WrongPaymentDirection) {
-                        logger.debug("Input doesn't contain outgoing payments")
-                    } catch (e: Exception) {
-                        logger.error(e.message)
-                        exitProcess(1)
                     }
+                    else -> throw Exception("Parsing $whichDoc not supported")
                 }
-                else -> {
-                    logger.error("Parsing $whichDoc not supported")
-                    exitProcess(1)
-                }
+                return@cliCmd
             }
-            return
-        }
-
-        // Fail now if keying is incomplete.
-        if (!isKeyingComplete(cfg)) exitProcess(1)
-        val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) ?: 
exitProcess(1)
-        if (!bankKeys.accepted && !import && !parse) {
-            logger.error("Bank keys are not accepted, yet.  Won't fetch any 
records.")
-            exitProcess(1)
-        }
-        val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-        if (clientKeys == null) {
-            logger.error("Client private keys not found at: 
${cfg.clientPrivateKeysFilename}")
-            exitProcess(1)
-        }
 
-        val ctx = FetchContext(
-            cfg,
-            HttpClient(),
-            clientKeys,
-            bankKeys,
-            whichDoc,
-            EbicsVersion.three,
-            ebicsExtraLog
-        )
-        if (transient) {
-            logger.info("Transient mode: fetching once and returning.")
-            val pinnedStartVal = pinnedStart
-            val pinnedStartArg = if (pinnedStartVal != null) {
-                logger.debug("Pinning start date to: $pinnedStartVal")
-                doOrFail {
+            val (clientKeys, bankKeys) = expectFullKeys(cfg)
+            val ctx = FetchContext(
+                cfg,
+                HttpClient(),
+                clientKeys,
+                bankKeys,
+                whichDoc,
+                EbicsVersion.three,
+                ebicsExtraLog
+            )
+            if (transient) {
+                logger.info("Transient mode: fetching once and returning.")
+                val pinnedStartVal = pinnedStart
+                val pinnedStartArg = if (pinnedStartVal != null) {
+                    logger.debug("Pinning start date to: $pinnedStartVal")
                     // Converting YYYY-MM-DD to Instant.
                     
LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant()
-                }
-            } else null
-            ctx.pinnedStart = pinnedStartArg
-            if (whichDoc == SupportedDocument.PAIN_002_LOGS)
-                ctx.ebicsVersion = EbicsVersion.two
-            runBlocking {
-                fetchDocuments(db, ctx)
-            }
-            return
-        }
-        val frequency: NexusFrequency = doOrFail {
-            val configValue = cfg.config.requireString("nexus-fetch", 
"frequency")
-            val frequencySeconds = checkFrequency(configValue)
-            return@doOrFail NexusFrequency(frequencySeconds, configValue)
-        }
-        logger.debug("Running with a frequency of ${frequency.fromConfig}")
-        if (frequency.inSeconds == 0) {
-            logger.warn("Long-polling not implemented, running therefore in 
transient mode")
-            runBlocking {
-                fetchDocuments(db, ctx)
-            }
-            return
-        }
-        fixedRateTimer(
-            name = "ebics submit period",
-            period = (frequency.inSeconds * 1000).toLong(),
-            action = {
+                } else null
+                ctx.pinnedStart = pinnedStartArg
+                if (whichDoc == SupportedDocument.PAIN_002_LOGS)
+                    ctx.ebicsVersion = EbicsVersion.two
                 runBlocking {
                     fetchDocuments(db, ctx)
                 }
+            } else {
+                val configValue = cfg.config.requireString("nexus-fetch", 
"frequency")
+                val frequencySeconds = checkFrequency(configValue)
+                val cfgFrequency: NexusFrequency = 
NexusFrequency(frequencySeconds, configValue)
+                logger.debug("Running with a frequency of 
${cfgFrequency.fromConfig}")
+                val frequency: NexusFrequency? = if (cfgFrequency.inSeconds == 
0) {
+                    logger.warn("Long-polling not implemented, running 
therefore in transient mode")
+                    null
+                } else {
+                    cfgFrequency
+                }
+                runBlocking {
+                    do {
+                        // TODO error handling
+                        fetchDocuments(db, ctx)
+                        delay(((frequency?.inSeconds ?: 0) * 1000).toLong())
+                    } while (frequency != null)
+                }
             }
-        )
+        }
     }
 }
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
index a3f025d9..40a09d7f 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt
@@ -26,7 +26,6 @@ import io.ktor.client.*
 import kotlinx.coroutines.runBlocking
 import tech.libeufin.util.ebics_h004.EbicsTypes
 import java.io.File
-import kotlin.system.exitProcess
 import TalerConfigError
 import kotlinx.serialization.encodeToString
 import tech.libeufin.nexus.ebics.*
@@ -34,32 +33,9 @@ import tech.libeufin.util.*
 import tech.libeufin.util.ebics_h004.HTDResponseOrderData
 import java.time.Instant
 import kotlin.reflect.typeOf
-
-/**
- * Checks the configuration to secure that the key exchange between
- * the bank and the subscriber took place.  Helps to fail before starting
- * to talk EBICS to the bank.
- *
- * @param cfg configuration handle.
- * @return true if the keying was made before, false otherwise.
- */
-fun isKeyingComplete(cfg: EbicsSetupConfig): Boolean {
-    val maybeClientKeys = 
loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-    if (maybeClientKeys == null ||
-        (!maybeClientKeys.submitted_ini) ||
-        (!maybeClientKeys.submitted_hia)) {
-        logger.error("Cannot operate without or with unsubmitted subscriber 
keys." +
-                "  Run 'libeufin-nexus ebics-setup' first.")
-        return false
-    }
-    val maybeBankKeys = loadBankKeys(cfg.bankPublicKeysFilename)
-    if (maybeBankKeys == null || (!maybeBankKeys.accepted)) {
-        logger.error("Cannot operate without or with unaccepted bank keys." +
-                "  Run 'libeufin-nexus ebics-setup' until accepting the bank 
keys.")
-        return false
-    }
-    return true
-}
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import kotlin.io.path.*
 
 /**
  * Writes the JSON content to disk.  Used when we create or update
@@ -67,23 +43,23 @@ fun isKeyingComplete(cfg: EbicsSetupConfig): Boolean {
  * silently what's found under the given location!
  *
  * @param obj the class representing the JSON content to store to disk.
- * @param location where to store `obj`
- * @return true in case of success, false otherwise.
+ * @param path where to store `obj`
  */
-inline fun <reified T> syncJsonToDisk(obj: T, location: String): Boolean {
-    val fileContent = try {
+inline fun <reified T> syncJsonToDisk(obj: T, path: String) {
+    val content = try {
         myJson.encodeToString(obj)
     } catch (e: Exception) {
-        logger.error("Could not encode the input '${typeOf<T>()}' to JSON, 
detail: ${e.message}")
-        return false
+        throw Exception("Could not encode the input '${typeOf<T>()}' to JSON", 
e)
     }
     try {
-        File(location).writeText(fileContent)
+        // Write to temp file then rename to enable atomicity when possible
+        val path = Path(path).absolute()
+        val tmp =  Files.createTempFile(path.parent, "tmp_", 
"_${path.fileName}")
+        tmp.writeText(content)
+        tmp.moveTo(path, StandardCopyOption.REPLACE_EXISTING);
     } catch (e: Exception) {
-        logger.error("Could not write JSON content at $location, detail: 
${e.message}")
-        return false
+        throw Exception("Could not write JSON content at $path", e)
     }
-    return true
 }
 
 /**
@@ -99,43 +75,28 @@ fun generateNewKeys(): ClientPrivateKeysFile =
         submitted_hia = false,
         submitted_ini = false
     )
-/**
- * Conditionally generates the client private keys and stores them
- * to disk, if the file does not exist already.  Does nothing if the
- * file exists.
- *
- * @param filename keys file location
- * @return true if the keys file existed already or its creation
- *         went through, false for any error.
- */
-fun maybeCreatePrivateKeysFile(filename: String): Boolean {
-    val f = File(filename)
-    // NOT overriding any file at the wanted location.
-    if (f.exists()) {
-        logger.debug("Private key file found at: $filename.")
-        return true
-    }
-    val newKeys = generateNewKeys()
-    if (!syncJsonToDisk(newKeys, filename))
-        return false
-    logger.info("New client keys created at: $filename")
-    return true
-}
 
 /**
  * Obtains the client private keys, regardless of them being
  * created for the first time, or read from an existing file
  * on disk.
  *
- * @param location path to the file that contains the keys.
- * @return true if the operation succeeds, false otherwise.
+ * @param path path to the file that contains the keys.
+ * @return current or new client keys
  */
-private fun preparePrivateKeys(location: String): ClientPrivateKeysFile? {
-    if (!maybeCreatePrivateKeysFile(location)) {
-        logger.error("Could not create client keys at $location")
-        exitProcess(1)
+private fun preparePrivateKeys(path: String): ClientPrivateKeysFile {
+    // If exists load from disk
+    val current = loadPrivateKeysFromDisk(path)
+    if (current != null) return current
+    // Else create new keys
+    try {
+        val newKeys = generateNewKeys()
+        syncJsonToDisk(newKeys, path)
+        logger.info("New client keys created at: $path")
+        return newKeys
+    } catch (e: Exception) {
+        throw Exception("Could not create client keys at $path", e)
     }
-    return loadPrivateKeysFromDisk(location) // loads what found at location.
 }
 
 /**
@@ -185,47 +146,40 @@ private fun askUserToAcceptKeys(bankKeys: 
BankPublicKeysFile): Boolean {
  *
  * @param cfg used to get the location of the bank keys file.
  * @param bankKeys bank response to the HPB message.
- * @return true if the keys were stored to disk (as "not accepted"),
- *         false if the storage failed or the content was invalid.
  */
 private fun handleHpbResponse(
     cfg: EbicsSetupConfig,
     bankKeys: EbicsKeyManagementResponseContent
-): Boolean {
+) {
     val hpbBytes = bankKeys.orderData // silences compiler.
     if (hpbBytes == null) {
-        logger.error("HPB content not found in a EBICS response with 
successful return codes.")
-        return false
+        throw Exception("HPB content not found in a EBICS response with 
successful return codes.")
     }
     val hpbObj = try {
         parseEbicsHpbOrder(hpbBytes)
-    }
-    catch (e: Exception) {
-        logger.error("HPB response content seems invalid.")
-        return false
+    } catch (e: Exception) {
+        throw Exception("HPB response content seems invalid: e")
     }
     val encPub = try {
         CryptoUtil.loadRsaPublicKey(hpbObj.encryptionPubKey.encoded)
     } catch (e: Exception) {
-        logger.error("Could not import bank encryption key from HPB response, 
detail: ${e.message}")
-        return false
+        throw Exception("Could not import bank encryption key from HPB 
response", e)
     }
     val authPub = try {
         CryptoUtil.loadRsaPublicKey(hpbObj.authenticationPubKey.encoded)
     } catch (e: Exception) {
-        logger.error("Could not import bank authentication key from HPB 
response, detail: ${e.message}")
-        return false
+        throw Exception("Could not import bank authentication key from HPB 
response", e)
     }
     val json = BankPublicKeysFile(
         bank_authentication_public_key = authPub,
         bank_encryption_public_key = encPub,
         accepted = false
     )
-    if (!syncJsonToDisk(json, cfg.bankPublicKeysFilename)) {
-        logger.error("Failed to persist the bank keys to disk at: 
${cfg.bankPublicKeysFilename}")
-        return false
+    try {
+        syncJsonToDisk(json, cfg.bankPublicKeysFilename)
+    } catch (e: Exception) {
+        throw Exception("Failed to persist the bank keys to disk", e)
     }
-    return true
 }
 
 /**
@@ -239,15 +193,13 @@ private fun handleHpbResponse(
  * @param orderType INI or HIA.
  * @param autoAcceptBankKeys only given in case of HPB.  Expresses
  *        the --auto-accept-key CLI flag.
- * @return true if the message fulfilled its purpose AND the state
- *         on disk was accordingly updated, or false otherwise.
  */
 suspend fun doKeysRequestAndUpdateState(
     cfg: EbicsSetupConfig,
     privs: ClientPrivateKeysFile,
     client: HttpClient,
     orderType: KeysOrderType
-): Boolean {
+) {
     logger.debug("Doing key request ${orderType.name}")
     val req = when(orderType) {
         KeysOrderType.INI -> generateIniMessage(cfg, privs)
@@ -256,33 +208,29 @@ suspend fun doKeysRequestAndUpdateState(
     }
     val xml = client.postToBank(cfg.hostBaseUrl, req)
     if (xml == null) {
-        logger.error("Could not POST the ${orderType.name} message to the 
bank")
-        return false
+        throw Exception("Could not POST the ${orderType.name} message to the 
bank")
     }
     val ebics = parseKeysMgmtResponse(privs.encryption_private_key, xml)
     if (ebics == null) {
-        logger.error("Could not get any EBICS from the bank ${orderType.name} 
response ($xml).")
-        return false
+        throw Exception("Could not get any EBICS from the bank 
${orderType.name} response ($xml).")
     }
     if (ebics.technicalReturnCode != EbicsReturnCode.EBICS_OK) {
-        logger.error("EBICS ${orderType.name} failed with code: 
${ebics.technicalReturnCode}")
-        return false
+        throw Exception("EBICS ${orderType.name} failed with code: 
${ebics.technicalReturnCode}")
     }
     if (ebics.bankReturnCode != EbicsReturnCode.EBICS_OK) {
-        logger.error("EBICS ${orderType.name} reached the bank, but could not 
be fulfilled, error code: ${ebics.bankReturnCode}")
-        return false
+        throw Exception("EBICS ${orderType.name} reached the bank, but could 
not be fulfilled, error code: ${ebics.bankReturnCode}")
     }
 
-    when(orderType) {
+    when (orderType) {
         KeysOrderType.INI -> privs.submitted_ini = true
         KeysOrderType.HIA -> privs.submitted_hia = true
         KeysOrderType.HPB -> return handleHpbResponse(cfg, ebics)
     }
-    if (!syncJsonToDisk(privs, cfg.clientPrivateKeysFilename)) {
-        logger.error("Could not update the ${orderType.name} state on disk")
-        return false
+    try {
+        syncJsonToDisk(privs, cfg.clientPrivateKeysFilename)
+    } catch (e: Exception) {
+        throw Exception("Could not update the ${orderType.name} state on 
disk", e)
     }
-    return true
 }
 
 /**
@@ -292,15 +240,8 @@ suspend fun doKeysRequestAndUpdateState(
  * @return internal representation of the configuration.
  */
 fun extractEbicsConfig(configFile: String?): EbicsSetupConfig {
-    val config = loadConfigOrFail(configFile)
-    // Checking the config.
-    val cfg = try {
-        EbicsSetupConfig(config)
-    } catch (e: TalerConfigError) {
-        logger.error(e.message)
-        exitProcess(1)
-    }
-    return cfg
+    val config = loadConfig(configFile)
+    return EbicsSetupConfig(config)
 }
 
 /**
@@ -314,14 +255,12 @@ private fun makePdf(privs: ClientPrivateKeysFile, cfg: 
EbicsSetupConfig) {
     val pdf = generateKeysPdf(privs, cfg)
     val pdfFile = 
File("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf")
     if (pdfFile.exists()) {
-        logger.error("PDF file exists already at: ${pdfFile.path}, not 
overriding it")
-        exitProcess(1)
+        throw Exception("PDF file exists already at: ${pdfFile.path}, not 
overriding it")
     }
     try {
         pdfFile.writeBytes(pdf)
     } catch (e: Exception) {
-        logger.error("Could not write PDF to ${pdfFile}, detail: ${e.message}")
-        exitProcess(1)
+        throw Exception("Could not write PDF to ${pdfFile}, detail: 
${e.message}")
     }
     println("PDF file with keys hex encoding created at: $pdfFile")
 }
@@ -346,104 +285,80 @@ class EbicsSetup: CliktCommand("Set up the EBICS 
subscriber") {
     /**
      * This function collects the main steps of setting up an EBICS access.
      */
-    override fun run() {
-        val cfg = doOrFail { extractEbicsConfig(common.config) }
+    override fun run() = cliCmd(logger) {
+        val cfg = extractEbicsConfig(common.config)
         if (checkFullConfig) {
-            doOrFail {
-                cfg.config.requireString("nexus-submit", "frequency").apply {
-                    if (getFrequencyInSeconds(this) == null)
-                        throw Exception("frequency value of nexus-submit 
section is not valid: $this")
-                }
-                cfg.config.requireString("nexus-fetch", "frequency").apply {
-                    if (getFrequencyInSeconds(this) == null)
-                        throw Exception("frequency value of nexus-fetch 
section is not valid: $this")
-                }
-                cfg.config.requirePath("nexus-fetch", 
"statement_log_directory")
-                cfg.config.requireNumber("nexus-httpd", "port")
-                cfg.config.requirePath("nexus-httpd", "unixpath")
-                cfg.config.requireString("nexus-httpd", "serve")
-                cfg.config.requireString("nexus-httpd-wire-gateway-facade", 
"enabled")
-                cfg.config.requireString("nexus-httpd-wire-gateway-facade", 
"auth_method")
-                cfg.config.requireString("nexus-httpd-wire-gateway-facade", 
"auth_token")
-                cfg.config.requireString("nexus-httpd-revenue-facade", 
"enabled")
-                cfg.config.requireString("nexus-httpd-revenue-facade", 
"auth_method")
-                cfg.config.requireString("nexus-httpd-revenue-facade", 
"auth_token")
+            cfg.config.requireString("nexus-submit", "frequency").apply {
+                if (getFrequencyInSeconds(this) == null)
+                    throw Exception("frequency value of nexus-submit section 
is not valid: $this")
+            }
+            cfg.config.requireString("nexus-fetch", "frequency").apply {
+                if (getFrequencyInSeconds(this) == null)
+                    throw Exception("frequency value of nexus-fetch section is 
not valid: $this")
             }
-            return
+            cfg.config.requirePath("nexus-fetch", "statement_log_directory")
+            cfg.config.requireNumber("nexus-httpd", "port")
+            cfg.config.requirePath("nexus-httpd", "unixpath")
+            cfg.config.requireString("nexus-httpd", "serve")
+            cfg.config.requireString("nexus-httpd-wire-gateway-facade", 
"enabled")
+            cfg.config.requireString("nexus-httpd-wire-gateway-facade", 
"auth_method")
+            cfg.config.requireString("nexus-httpd-wire-gateway-facade", 
"auth_token")
+            cfg.config.requireString("nexus-httpd-revenue-facade", "enabled")
+            cfg.config.requireString("nexus-httpd-revenue-facade", 
"auth_method")
+            cfg.config.requireString("nexus-httpd-revenue-facade", 
"auth_token")
+            return@cliCmd
         }
         // Config is sane.  Go (maybe) making the private keys.
-        val privsMaybe = preparePrivateKeys(cfg.clientPrivateKeysFilename)
-        if (privsMaybe == null) {
-            logger.error("Private keys preparation failed.")
-            exitProcess(1)
-        }
+        val clientKeys = preparePrivateKeys(cfg.clientPrivateKeysFilename)
         val httpClient = HttpClient()
         // Privs exist.  Upload their pubs
-        val keysNotSub = !privsMaybe.submitted_ini || !privsMaybe.submitted_hia
+        val keysNotSub = !clientKeys.submitted_ini || !clientKeys.submitted_hia
         runBlocking {
-            if ((!privsMaybe.submitted_ini) || forceKeysResubmission)
-                doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, 
KeysOrderType.INI).apply { if (!this) exitProcess(1) }
-            if ((!privsMaybe.submitted_hia) || forceKeysResubmission)
-                doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, 
KeysOrderType.HIA).apply { if (!this) exitProcess(1) }
-        }
-        // Reloading new state from disk if any upload (and therefore a disk 
write) actually took place
-        val haveSubmitted = forceKeysResubmission || keysNotSub
-        val privs = if (haveSubmitted) {
-            logger.info("Keys submitted to the bank, at ${cfg.hostBaseUrl}")
-            loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-        } else privsMaybe
-        if (privs == null) {
-            logger.error("Could not reload private keys from disk after 
submission")
-            exitProcess(1)
-        }
-        // Really both must be submitted here.
-        if ((!privs.submitted_hia) || (!privs.submitted_ini)) {
-            logger.error("Cannot continue with non-submitted client keys.")
-            exitProcess(1)
+            if ((!clientKeys.submitted_ini) || forceKeysResubmission)
+                doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, 
KeysOrderType.INI)
+            if ((!clientKeys.submitted_hia) || forceKeysResubmission)
+                doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, 
KeysOrderType.HIA)
         }
         // Eject PDF if the keys were submitted for the first time, or the 
user asked.
-        if (keysNotSub || generateRegistrationPdf) makePdf(privs, cfg)
+        if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, cfg)
         // Checking if the bank keys exist on disk.
         val bankKeysFile = File(cfg.bankPublicKeysFilename)
         if (!bankKeysFile.exists()) {
-            val areKeysOnDisk = runBlocking {
-                doKeysRequestAndUpdateState(
-                    cfg,
-                    privs,
-                    httpClient,
-                    KeysOrderType.HPB
-                )
-            }
-            if (!areKeysOnDisk) {
-                logger.error("Could not download bank keys.  Send client keys 
(and/or related PDF document with --generate-registration-pdf) to the bank.")
-                exitProcess(1)
+            runBlocking {
+                try {
+                    doKeysRequestAndUpdateState(
+                        cfg,
+                        clientKeys,
+                        httpClient,
+                        KeysOrderType.HPB
+                    )
+                } catch (e: Exception) {
+                    throw Exception("Could not download bank keys. Send client 
keys (and/or related PDF document with --generate-registration-pdf) to the 
bank", e)
+                }
             }
             logger.info("Bank keys stored at ${cfg.bankPublicKeysFilename}")
         }
         // bank keys made it to the disk, check if they're accepted.
         val bankKeysMaybe = loadBankKeys(cfg.bankPublicKeysFilename)
         if (bankKeysMaybe == null) {
-            logger.error("Although previous checks, could not load the bank 
keys file from: ${cfg.bankPublicKeysFilename}")
-            exitProcess(1)
-        }
-        val printOk = { println("setup ready") }
-
-        if (bankKeysMaybe.accepted) {
-            printOk()
-            return
+            throw Exception("Although previous checks, could not load the bank 
keys file from: ${cfg.bankPublicKeysFilename}")
         }
-        // Finishing the setup by accepting the bank keys.
-        if (autoAcceptKeys) bankKeysMaybe.accepted = true
-        else bankKeysMaybe.accepted = askUserToAcceptKeys(bankKeysMaybe)
 
         if (!bankKeysMaybe.accepted) {
-            logger.error("Cannot successfully finish the setup without 
accepting the bank keys.")
-            exitProcess(1)
-        }
-        if (!syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename)) {
-            logger.error("Could not set bank keys as accepted on disk.")
-            exitProcess(1)
+            // Finishing the setup by accepting the bank keys.
+            if (autoAcceptKeys) bankKeysMaybe.accepted = true
+            else bankKeysMaybe.accepted = askUserToAcceptKeys(bankKeysMaybe)
+
+            if (!bankKeysMaybe.accepted) {
+                throw Exception("Cannot successfully finish the setup without 
accepting the bank keys.")
+            }
+            try {
+                syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename)
+            } catch (e: Exception) {
+                throw Exception("Could not set bank keys as accepted on 
disk.", e)
+            }
         }
-        printOk()
+        
+        println("setup ready")
     }
 }
\ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
index d05df232..1e691099 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
@@ -23,7 +23,7 @@ import com.github.ajalt.clikt.core.CliktCommand
 import com.github.ajalt.clikt.parameters.options.*
 import com.github.ajalt.clikt.parameters.groups.*
 import io.ktor.client.*
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.*
 import tech.libeufin.nexus.ebics.EbicsSideError
 import tech.libeufin.nexus.ebics.EbicsSideException
 import tech.libeufin.nexus.ebics.EbicsUploadException
@@ -35,9 +35,8 @@ import java.time.Instant
 import java.time.LocalDate
 import java.time.ZoneId
 import java.util.*
-import kotlin.concurrent.fixedRateTimer
 import kotlin.io.path.createDirectories
-import kotlin.system.exitProcess
+import kotlin.io.path.*
 
 /**
  * Possible stages when an error may occur.  These stages
@@ -105,8 +104,7 @@ class NexusSubmitException(
  */
 private fun maybeLog(
     maybeLogDir: String?,
-    xml: String,
-    requestUid: String
+    xml: String
 ) {
     if (maybeLogDir == null) {
         logger.info("Logging pain.001 to files is disabled")
@@ -116,18 +114,17 @@ private fun maybeLog(
     val now = Instant.now()
     val asUtcDate = LocalDate.ofInstant(now, ZoneId.of("UTC"))
     val subDir = 
"${asUtcDate.year}-${asUtcDate.monthValue}-${asUtcDate.dayOfMonth}"
-    val dirs = Path.of(maybeLogDir, subDir)
-    doOrFail { dirs.createDirectories() }
-    val f = File(
+    val dirs = Path(maybeLogDir, subDir)
+    dirs.createDirectories()
+    val f = Path(
         dirs.toString(),
-        "${now.toDbMicros()}_requestUid_${requestUid}_pain.001.xml"
+        "${now.toDbMicros()}_pain.001.xml"
     )
     // Very rare: same pain.001 should not be submitted twice in the same 
microsecond.
     if (f.exists()) {
-        logger.error("pain.001 log file exists already at: $f")
-        exitProcess(1)
+        throw Exception("pain.001 log file exists already at: $f")
     }
-    doOrFail { f.writeText(xml) }
+    f.writeText(xml)
 }
 
 /**
@@ -163,8 +160,7 @@ private suspend fun submitInitiatedPayment(
     )
     maybeLog(
         maybeLogDir,
-        xml,
-        initiatedPayment.requestUid
+        xml
     )
     try {
         submitPain001(
@@ -270,24 +266,10 @@ class EbicsSubmit : CliktCommand("Submits any initiated 
payment found in the dat
      * or long-polls (currently not implemented) for new payments.
      * FIXME: reduce code duplication with the fetch subcommand.
      */
-    override fun run() {
-        val cfg: EbicsSetupConfig = doOrFail {
-            extractEbicsConfig(common.config)
-        }
-        // Fail now if keying is incomplete.
-        if (!isKeyingComplete(cfg)) exitProcess(1)
-        val dbCfg = cfg.config.extractDbConfigOrFail()
-        val db = Database(dbCfg.dbConnStr)
-        val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) ?: 
exitProcess(1)
-        if (!bankKeys.accepted) {
-            logger.error("Bank keys are not accepted, yet.  Won't submit any 
payment.")
-            exitProcess(1)
-        }
-        val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-        if (clientKeys == null) {
-            logger.error("Client private keys not found at: 
${cfg.clientPrivateKeysFilename}")
-            exitProcess(1)
-        }
+    override fun run() = cliCmd(logger) {
+        val cfg: EbicsSetupConfig = extractEbicsConfig(common.config)
+        val dbCfg = cfg.config.dbConfig()
+        val (clientKeys, bankKeys) = expectFullKeys(cfg)
         val ctx = SubmissionContext(
             cfg = cfg,
             bankPublicKeysFile = bankKeys,
@@ -298,42 +280,42 @@ class EbicsSubmit : CliktCommand("Submits any initiated 
payment found in the dat
         if (debug) {
             logger.info("Running in debug mode, submitting STDIN to the bank")
             val maybeStdin = generateSequence(::readLine).joinToString("\n")
-            doOrFail {
-                runBlocking {
-                    submitPain001(
-                        maybeStdin,
-                        ctx.cfg,
-                        ctx.clientPrivateKeysFile,
-                        ctx.bankPublicKeysFile,
-                        ctx.httpClient,
-                        ctx.ebicsExtraLog
-                    )
-                }
+            runBlocking {
+                submitPain001(
+                    maybeStdin,
+                    ctx.cfg,
+                    ctx.clientPrivateKeysFile,
+                    ctx.bankPublicKeysFile,
+                    ctx.httpClient,
+                    ctx.ebicsExtraLog
+                )
             }
-            return
-        }
-        if (transient) {
-            logger.info("Transient mode: submitting what found and returning.")
-            submitBatch(ctx, db)
-            return
-        }
-        val frequency: NexusFrequency = doOrFail {
-            val configValue = cfg.config.requireString("nexus-submit", 
"frequency")
-            val frequencySeconds = checkFrequency(configValue)
-            return@doOrFail NexusFrequency(frequencySeconds, configValue)
+            return@cliCmd
         }
-        logger.debug("Running with a frequency of ${frequency.fromConfig}")
-        if (frequency.inSeconds == 0) {
-            logger.warn("Long-polling not implemented, running therefore in 
transient mode")
-            submitBatch(ctx, db)
-            return
-        }
-        fixedRateTimer(
-            name = "ebics submit period",
-            period = (frequency.inSeconds * 1000).toLong(),
-            action = {
-                submitBatch(ctx, db)
+        Database(dbCfg.dbConnStr).use { db -> 
+            val frequency = if (transient) {
+                logger.info("Transient mode: submitting what found and 
returning.")
+                null
+            } else {
+                val configValue = cfg.config.requireString("nexus-submit", 
"frequency")
+                val frequencySeconds = checkFrequency(configValue)
+                val frequency: NexusFrequency =  
NexusFrequency(frequencySeconds, configValue)
+                logger.debug("Running with a frequency of 
${frequency.fromConfig}")
+                if (frequency.inSeconds == 0) {
+                    logger.warn("Long-polling not implemented, running 
therefore in transient mode")
+                    null
+                } else {
+                    frequency
+                }
             }
-        )
+            runBlocking {
+                do {
+                    // TODO error handling
+                    submitBatch(ctx, db)
+                    // TODO take submitBatch taken time in the delay
+                    delay(((frequency?.inSeconds ?: 0) * 1000).toLong())
+                } while (frequency != null)
+            }
+        }
     }
 }
\ No newline at end of file
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
index b14ecee9..ecbdbc49 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -156,78 +156,22 @@ fun createPain001(
 }
 
 /**
- * Thrown if the parser expects DBIT but the transaction
- * is CRDT, and vice-versa.
- */
-class WrongPaymentDirection(val msg: String) : Exception(msg)
-
-/**
- * Parses a camt.054 document looking for outgoing payments.
+ * Searches payments in a camt.054 (Detailavisierung) document.
  *
- * @param notifXml input document.
+ * @param notifXml camt.054 input document
  * @param acceptedCurrency currency accepted by Nexus
- * @return the list of outgoing payments.
+ * @param incoming list of incoming payments
+ * @param outgoing list of outgoing payments
  */
-fun parseOutgoingTxNotif(
+fun parseTxNotif(
     notifXml: String,
     acceptedCurrency: String,
-): List<OutgoingPayment> {
-    val ret = mutableListOf<OutgoingPayment>()
-    notificationForEachTx(notifXml) { bookDate ->
-        requireUniqueChildNamed("CdtDbtInd") {
-            if (focusElement.textContent != "DBIT")
-                throw WrongPaymentDirection("The payment is not outgoing, 
won't parse it")
-        }
-        // Obtaining the amount.
-        val amount: TalerAmount = requireUniqueChildNamed("Amt") {
-            val currency = focusElement.getAttribute("Ccy")
-            if (currency != acceptedCurrency) throw Exception("Currency 
$currency not supported")
-            getTalerAmount(focusElement.textContent, currency)
-        }
-
-        /**
-         * The MsgId extracted in the block below matches the one that
-         * was specified as the MsgId element in the pain.001 that originated
-         * this outgoing payment.  MsgId is considered unique because the
-         * bank enforces its uniqueness.  Associating MsgId to this outgoing
-         * payment is also convenient to match its initiated outgoing payment
-         * in the database for reconciliation.
-         */
-        val uidFromBank = StringBuilder()
-        requireUniqueChildNamed("Refs") {
-            requireUniqueChildNamed("MsgId") {
-                uidFromBank.append(focusElement.textContent)
-            }
-        }
-
-        ret.add(
-            OutgoingPayment(
-            amount = amount,
-            bankTransferId = uidFromBank.toString(),
-            executionTime = bookDate
-        )
-        )
-    }
-    return ret
-}
-
-/**
- * Searches incoming payments in a camt.054 (Detailavisierung) document.
- *
- * @param notifXml camt.054 input document
- * @param acceptedCurrency currency accepted by Nexus.
- * @return the list of incoming payments to ingest in the database.
- */
-fun parseIncomingTxNotif(
-    notifXml: String,
-    acceptedCurrency: String
-): List<IncomingPayment> {
-    val ret = mutableListOf<IncomingPayment>()
+    incoming: MutableList<IncomingPayment>,
+    outgoing: MutableList<OutgoingPayment>
+) {
     notificationForEachTx(notifXml) { bookDate ->
-        // Check the direction first.
-        requireUniqueChildNamed("CdtDbtInd") {
-            if (focusElement.textContent != "CRDT")
-                throw WrongPaymentDirection("The payment is not incoming, 
won't parse it")
+        val kind = requireUniqueChildNamed("CdtDbtInd") {
+            focusElement.textContent
         }
         val amount: TalerAmount = requireUniqueChildNamed("Amt") {
             val currency = focusElement.getAttribute("Ccy")
@@ -237,52 +181,85 @@ fun parseIncomingTxNotif(
             if (currency != acceptedCurrency) throw Exception("Currency 
$currency not supported")
             getTalerAmount(focusElement.textContent, currency)
         }
-        // Obtaining payment UID.
-        val uidFromBank: String = requireUniqueChildNamed("Refs") {
-            requireUniqueChildNamed("AcctSvcrRef") {
-                focusElement.textContent
-            }
-        }
-        // Obtaining payment subject.
-        val subject = StringBuilder()
-        requireUniqueChildNamed("RmtInf") {
-            this.mapEachChildNamed("Ustrd") {
-                val piece = this.focusElement.textContent
-                subject.append(piece)
-            }
-        }
+        when (kind) {
+            "CRDT" -> {
+                // Obtaining payment UID.
+                val uidFromBank: String = requireUniqueChildNamed("Refs") {
+                    requireUniqueChildNamed("AcctSvcrRef") {
+                        focusElement.textContent
+                    }
+                }
+                // Obtaining payment subject. 
+                val subject = maybeUniqueChildNamed("RmtInf") {
+                    val subject = StringBuilder()
+                    mapEachChildNamed("Ustrd") {
+                        val piece = focusElement.textContent
+                        subject.append(piece)
+                    }
+                    subject
+                }
+                if (subject == null) {
+                    logger.debug("Skip notification $uidFromBank, missing 
subject")
+                    return@notificationForEachTx
+                }
 
-        // Obtaining the payer's details
-        val debtorPayto = StringBuilder("payto://iban/")
-        requireUniqueChildNamed("RltdPties") {
-            requireUniqueChildNamed("DbtrAcct") {
-                requireUniqueChildNamed("Id") {
-                    requireUniqueChildNamed("IBAN") {
-                        debtorPayto.append(focusElement.textContent)
+                // Obtaining the payer's details
+                val debtorPayto = StringBuilder("payto://iban/")
+                requireUniqueChildNamed("RltdPties") {
+                    requireUniqueChildNamed("DbtrAcct") {
+                        requireUniqueChildNamed("Id") {
+                            requireUniqueChildNamed("IBAN") {
+                                debtorPayto.append(focusElement.textContent)
+                            }
+                        }
+                    }
+                    // warn: it might need the postal address too..
+                    requireUniqueChildNamed("Dbtr") {
+                        maybeUniqueChildNamed("Pty") {
+                            requireUniqueChildNamed("Nm") {
+                                val urlEncName = 
URLEncoder.encode(focusElement.textContent, "utf-8")
+                                
debtorPayto.append("?receiver-name=$urlEncName")
+                            }
+                        }
                     }
                 }
+                incoming.add(
+                    IncomingPayment(
+                        amount = amount,
+                        bankTransferId = uidFromBank,
+                        debitPaytoUri = debtorPayto.toString(),
+                        executionTime = bookDate,
+                        wireTransferSubject = subject.toString()
+                    )
+                )
             }
-            // warn: it might need the postal address too..
-            requireUniqueChildNamed("Dbtr") {
-                requireUniqueChildNamed("Pty") {
-                    requireUniqueChildNamed("Nm") {
-                        val urlEncName = 
URLEncoder.encode(focusElement.textContent, "utf-8")
-                        debtorPayto.append("?receiver-name=$urlEncName")
+            "DBIT" -> {
+                /**
+                 * The MsgId extracted in the block below matches the one that
+                 * was specified as the MsgId element in the pain.001 that 
originated
+                 * this outgoing payment.  MsgId is considered unique because 
the
+                 * bank enforces its uniqueness.  Associating MsgId to this 
outgoing
+                 * payment is also convenient to match its initiated outgoing 
payment
+                 * in the database for reconciliation.
+                 */
+                val uidFromBank = StringBuilder()
+                requireUniqueChildNamed("Refs") {
+                    requireUniqueChildNamed("MsgId") {
+                        uidFromBank.append(focusElement.textContent)
                     }
                 }
+
+                outgoing.add(
+                    OutgoingPayment(
+                        amount = amount,
+                        bankTransferId = uidFromBank.toString(),
+                        executionTime = bookDate
+                    )
+                )
             }
-        }
-        ret.add(
-            IncomingPayment(
-                amount = amount,
-                bankTransferId = uidFromBank,
-                debitPaytoUri = debtorPayto.toString(),
-                executionTime = bookDate,
-                wireTransferSubject = subject.toString()
-            )
-        )
+            else -> throw Exception("Unknown transaction notification kind 
'$kind'")
+        }        
     }
-    return ret
 }
 
 /**
@@ -303,11 +280,13 @@ private fun notificationForEachTx(
                 mapEachChildNamed("Ntfctn") {
                     mapEachChildNamed("Ntry") {
                         requireUniqueChildNamed("Sts") {
-                            requireUniqueChildNamed("Cd") {
-                                if (focusElement.textContent != "BOOK")
-                                    throw Exception("Found non booked 
transaction, " +
-                                            "stop parsing.  Status was: 
${focusElement.textContent}"
-                                    )
+                            if (focusElement.textContent != "BOOK") {
+                                requireUniqueChildNamed("Cd") {
+                                    if (focusElement.textContent != "BOOK")
+                                        throw Exception("Found non booked 
transaction, " +
+                                                "stop parsing.  Status was: 
${focusElement.textContent}"
+                                        )
+                                }
                             }
                         }
                         val bookDate: Instant = 
requireUniqueChildNamed("BookgDt") {
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
index 482d6c31..3a535aff 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -35,7 +35,6 @@ import kotlinx.serialization.KSerializer
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
 import java.io.File
-import kotlin.system.exitProcess
 import kotlinx.serialization.Serializable
 import kotlinx.serialization.descriptors.PrimitiveKind
 import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -49,6 +48,7 @@ import tech.libeufin.nexus.ebics.*
 import tech.libeufin.util.*
 import java.security.interfaces.RSAPrivateCrtKey
 import java.security.interfaces.RSAPublicKey
+import java.io.FileNotFoundException
 
 val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", 
"libeufin-nexus")
 val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus")
@@ -269,46 +269,51 @@ data class BankPublicKeysFile(
 )
 
 /**
- * Runs the argument and fails the process, if that throws
- * an exception.
+ * Load client and bank keys from disk.
+ * Checks that the keying process has been fully completed.
+ * 
+ * Helps to fail before starting to talk EBICS to the bank.
  *
- * @param getLambda function that might return a value.
- * @return the value from getLambda.
+ * @param cfg configuration handle.
+ * @return both client and bank keys
  */
-fun <T>doOrFail(getLambda: () -> T): T =
-    try {
-        getLambda()
-    } catch (e: Exception) {
-        logger.error(e.message)
-        exitProcess(1)
+fun expectFullKeys(
+    cfg: EbicsSetupConfig
+): Pair<ClientPrivateKeysFile, BankPublicKeysFile> {
+    val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
+    if (clientKeys == null) {
+        throw Exception("Cannot operate without client keys. Missing 
'${cfg.clientPrivateKeysFilename}' file. Run 'libeufin-nexus ebics-setup' 
first")
+    } else if (!clientKeys.submitted_ini || !clientKeys.submitted_hia) {
+        throw Exception("Cannot operate with unsubmitted client keys, run 
'libeufin-nexus ebics-setup' first")
+    }
+    val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)
+    if (bankKeys == null) {
+        throw Exception("Cannot operate without bank keys. Missing 
'${cfg.bankPublicKeysFilename}' file. run 'libeufin-nexus ebics-setup' first")
+    } else if (!bankKeys.accepted) {
+        throw Exception("Cannot operate with unaccepted bank keys, run 
'libeufin-nexus ebics-setup' until accepting the bank keys")
     }
+    return Pair(clientKeys, bankKeys)
+}
 
 /**
  * Load the bank keys file from disk.
  *
  * @param location the keys file location.
  * @return the internal JSON representation of the keys file,
- *         or null on failures.
+ *         or null if the file does not exist
  */
 fun loadBankKeys(location: String): BankPublicKeysFile? {
-    val f = File(location)
-    if (!f.exists()) {
-        logger.error("Could not find the bank keys file at: $location")
+    val content = try {
+        File(location).readText()
+    } catch (e: FileNotFoundException) {
         return null
-    }
-    val fileContent = try {
-        f.readText() // read from disk.
     } catch (e: Exception) {
-        logger.error("Could not read the bank keys file from disk, detail: 
${e.message}")
-        return null
+        throw Exception("Could not read the bank keys file from disk", e)
     }
     return try {
-        myJson.decodeFromString(fileContent) // Parse into JSON.
+        myJson.decodeFromString(content)
     } catch (e: Exception) {
-        logger.error(e.message)
-        @OptIn(InternalAPI::class) // enables message below.
-        logger.error(e.rootCause?.message) // actual useful message mentioning 
failing fields
-        return null
+        throw Exception("Could not decode bank keys", e)
     }
 }
 
@@ -317,60 +322,43 @@ fun loadBankKeys(location: String): BankPublicKeysFile? {
  *
  * @param location the keys file location.
  * @return the internal JSON representation of the keys file,
- *         or null on failures.
+ *         or null if the file does not exist
  */
 fun loadPrivateKeysFromDisk(location: String): ClientPrivateKeysFile? {
-    val f = File(location)
-    if (!f.exists()) {
-        logger.error("Could not find the private keys file at: $location")
+    val content = try {
+        File(location).readText()
+    } catch (e: FileNotFoundException) {
         return null
-    }
-    val fileContent = try {
-        f.readText() // read from disk.
     } catch (e: Exception) {
-        logger.error("Could not read private keys from disk, detail: 
${e.message}")
-        return null
+        throw Exception("Could not read private keys from disk", e)
     }
     return try {
-        myJson.decodeFromString(fileContent) // Parse into JSON.
+        myJson.decodeFromString(content)
     } catch (e: Exception) {
-        logger.error(e.message)
-        @OptIn(InternalAPI::class) // enables message below.
-        logger.error(e.rootCause?.message) // actual useful message mentioning 
failing fields
-        return null
+        throw Exception("Could not decode private keys", e)
     }
 }
 
 /**
- * Abstracts the config loading and exception handling.
+ * Abstracts the config loading
  *
  * @param configFile potentially NULL configuration file location.
  * @return the configuration handle.
  */
-fun loadConfigOrFail(configFile: String?): TalerConfig {
+fun loadConfig(configFile: String?): TalerConfig {
     val config = TalerConfig(NEXUS_CONFIG_SOURCE)
-    try {
-        config.load(configFile)
-    } catch (e: Exception) {
-        logger.error("Could not load configuration from ${configFile}, detail: 
${e.message}")
-        exitProcess(1)
-    }
+    config.load(configFile)
     return config
 }
 
 /**
  * Abstracts fetching the DB config values to set up Nexus.
  */
-fun TalerConfig.extractDbConfigOrFail(): DatabaseConfig =
-    try {
-        DatabaseConfig(
-            dbConnStr = requireString("nexus-postgres", "config"),
-            sqlDir = requirePath("libeufin-nexusdb-postgres", "sql_dir")
-        )
-    } catch (e: Exception) {
-        logger.error("Could not load config options for Nexus DB, detail: 
${e.message}.")
-        exitProcess(1)
-    }
+fun TalerConfig.dbConfig(): DatabaseConfig =
+    DatabaseConfig(
+        dbConnStr = requireString("nexus-postgres", "config"),
+        sqlDir = requirePath("libeufin-nexusdb-postgres", "sql_dir")
+    )
 
 /**
  * Main CLI class that collects all the subcommands.
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
index e8705f85..e1ef647d 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt
@@ -60,7 +60,7 @@ suspend fun doEbicsCustomDownload(
     clientKeys: ClientPrivateKeysFile,
     bankKeys: BankPublicKeysFile,
     client: HttpClient
-): ByteArray? {
+): ByteArray {
     val xmlReq = createEbics25DownloadInit(cfg, clientKeys, bankKeys, 
messageType)
     return doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, false)
 }
@@ -85,10 +85,6 @@ suspend fun fetchBankAccounts(
 ): HTDResponseOrderData? {
     val xmlReq = createEbics25DownloadInit(cfg, clientKeys, bankKeys, "HTD")
     val bytesResp = doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, 
false)
-    if (bytesResp == null) {
-        logger.error("EBICS HTD transaction failed.")
-        return null
-    }
     val xmlResp = bytesResp.toString(Charsets.UTF_8)
     return try {
         logger.debug("Fetched accounts: $bytesResp")
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
index 5c8b8c2d..192786be 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt
@@ -89,7 +89,7 @@ fun createEbics3DownloadInitialization(
     cfg: EbicsSetupConfig,
     bankkeys: BankPublicKeysFile,
     clientKeys: ClientPrivateKeysFile,
-    orderParams: Ebics3Request.OrderDetails.BTOrderParams
+    orderParams: Ebics3Request.OrderDetails.BTDOrderParams
 ): String {
     val nonce = getNonce(128)
     val req = Ebics3Request.createForDownloadInitializationPhase(
@@ -278,7 +278,7 @@ fun prepNotificationRequest3(
     startDate: Instant? = null,
     endDate: Instant? = null,
     isAppendix: Boolean
-): Ebics3Request.OrderDetails.BTOrderParams {
+): Ebics3Request.OrderDetails.BTDOrderParams {
     val service = Ebics3Request.OrderDetails.Service().apply {
         serviceName = "REP"
         scope = "CH"
@@ -292,7 +292,7 @@ fun prepNotificationRequest3(
         if (!isAppendix)
             serviceOption = "XDCI"
     }
-    return Ebics3Request.OrderDetails.BTOrderParams().apply {
+    return Ebics3Request.OrderDetails.BTDOrderParams().apply {
         this.service = service
         this.dateRange = if (startDate != null)
             getEbics3DateRange(startDate, endDate ?: Instant.now())
@@ -314,7 +314,7 @@ fun prepNotificationRequest3(
 fun prepAckRequest3(
     startDate: Instant? = null,
     endDate: Instant? = null
-): Ebics3Request.OrderDetails.BTOrderParams {
+): Ebics3Request.OrderDetails.BTDOrderParams {
     val service = Ebics3Request.OrderDetails.Service().apply {
         serviceName = "PSR"
         scope = "CH"
@@ -326,7 +326,7 @@ fun prepAckRequest3(
             version = "10"
         }
     }
-    return Ebics3Request.OrderDetails.BTOrderParams().apply {
+    return Ebics3Request.OrderDetails.BTDOrderParams().apply {
         this.service = service
         this.dateRange = if (startDate != null)
             getEbics3DateRange(startDate, endDate ?: Instant.now())
@@ -347,7 +347,7 @@ fun prepAckRequest3(
 fun prepStatementRequest3(
     startDate: Instant? = null,
     endDate: Instant? = null
-): Ebics3Request.OrderDetails.BTOrderParams {
+): Ebics3Request.OrderDetails.BTDOrderParams {
     val service = Ebics3Request.OrderDetails.Service().apply {
         serviceName = "EOP"
         scope = "CH"
@@ -359,7 +359,7 @@ fun prepStatementRequest3(
             version = "08"
         }
     }
-    return Ebics3Request.OrderDetails.BTOrderParams().apply {
+    return Ebics3Request.OrderDetails.BTDOrderParams().apply {
         this.service = service
         this.dateRange = if (startDate != null)
             getEbics3DateRange(startDate, endDate ?: Instant.now())
@@ -380,7 +380,7 @@ fun prepStatementRequest3(
 fun prepReportRequest3(
     startDate: Instant? = null,
     endDate: Instant? = null
-): Ebics3Request.OrderDetails.BTOrderParams {
+): Ebics3Request.OrderDetails.BTDOrderParams {
     val service = Ebics3Request.OrderDetails.Service().apply {
         serviceName = "STM"
         scope = "CH"
@@ -392,7 +392,7 @@ fun prepReportRequest3(
             version = "08"
         }
     }
-    return Ebics3Request.OrderDetails.BTOrderParams().apply {
+    return Ebics3Request.OrderDetails.BTDOrderParams().apply {
         this.service = service
         this.dateRange = if (startDate != null)
             getEbics3DateRange(startDate, endDate ?: Instant.now())
@@ -413,7 +413,7 @@ fun prepReportRequest3(
 fun prepEbics3Document(
     whichDoc: SupportedDocument,
     startDate: Instant? = null
-): Ebics3Request.OrderDetails.BTOrderParams =
+): Ebics3Request.OrderDetails.BTDOrderParams =
     when(whichDoc) {
         SupportedDocument.PAIN_002 -> prepAckRequest3(startDate)
         SupportedDocument.CAMT_052 -> prepReportRequest3(startDate)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
index cb0e6a63..b700abb2 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
@@ -332,20 +332,18 @@ suspend fun doEbicsDownload(
     reqXml: String,
     isEbics3: Boolean,
     tolerateEmptyResult: Boolean = false
-): ByteArray? {
+): ByteArray {
     val initResp = postEbics(client, cfg, bankKeys, reqXml, isEbics3)
     logger.debug("Download init phase done.  EBICS- and bank-technical codes 
are: ${initResp.technicalReturnCode}, ${initResp.bankReturnCode}")
     if (initResp.technicalReturnCode != EbicsReturnCode.EBICS_OK) {
-        logger.error("Download init phase has EBICS-technical error: 
${initResp.technicalReturnCode}")
-        return null
+        throw Exception("Download init phase has EBICS-technical error: 
${initResp.technicalReturnCode}")
     }
     if (initResp.bankReturnCode == 
EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE && tolerateEmptyResult) {
         logger.info("Download content is empty")
         return ByteArray(0)
     }
     if (initResp.bankReturnCode != EbicsReturnCode.EBICS_OK) {
-        logger.error("Download init phase has bank-technical error: 
${initResp.bankReturnCode}")
-        return null
+        throw Exception("Download init phase has bank-technical error: 
${initResp.bankReturnCode}")
     }
     val tId = initResp.transactionID
         ?: throw EbicsSideException(
@@ -355,8 +353,7 @@ suspend fun doEbicsDownload(
     logger.debug("EBICS download transaction passed the init phase, got ID: 
$tId")
     val howManySegments = initResp.numSegments
     if (howManySegments == null) {
-        tech.libeufin.nexus.logger.error("Init response lacks the quantity of 
segments, failing.")
-        return null
+        throw Exception("Init response lacks the quantity of segments, 
failing.")
     }
     val ebicsChunks = mutableListOf<String>()
     // Getting the chunk(s)
@@ -388,8 +385,7 @@ suspend fun doEbicsDownload(
         }
         val chunk = transResp.orderDataEncChunk
         if (chunk == null) {
-            tech.libeufin.nexus.logger.error("EBICS transfer phase lacks chunk 
#$x, failing.")
-            return null
+            throw Exception("EBICS transfer phase lacks chunk #$x, failing.")
         }
         ebicsChunks.add(chunk)
     }
diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt
new file mode 100644
index 00000000..e6386e21
--- /dev/null
+++ b/nexus/src/test/kotlin/CliTest.kt
@@ -0,0 +1,98 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2023 Stanisci and Dold.
+
+ * 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 tech.libeufin.nexus.*
+import com.github.ajalt.clikt.core.*
+import com.github.ajalt.clikt.testing.*
+import kotlin.test.*
+import java.io.*
+import java.nio.file.*
+import kotlin.io.path.*
+import tech.libeufin.util.*
+
+val nexusCmd = LibeufinNexusCommand()
+
+fun CliktCommand.testErr(cmd: String, msg: String) {
+    val prevOut = System.err
+    val tmpOut = ByteArrayOutputStream()
+    System.setErr(PrintStream(tmpOut))
+    val result = test(cmd)
+    System.setErr(prevOut)
+    val tmpStr = tmpOut.toString(Charsets.UTF_8)
+    println(tmpStr)
+    assertEquals(1, result.statusCode, "'$cmd' should have failed")
+    val line = tmpStr.substringAfterLast(" - ").trimEnd('\n')
+    println(line)
+    assertEquals(msg, line)
+}
+
+class CliTest {
+    /** Test error format related to the keying process */
+    @Test
+    fun keys() {
+        val cmds = listOf("ebics-submit", "ebics-fetch")
+        val allCmds = listOf("ebics-submit", "ebics-fetch", "ebics-setup")
+        val conf = "conf/test.conf"
+        val cfg = loadConfig(conf)
+        val clientKeysPath = Path(cfg.requireString("nexus-ebics", 
"client_private_keys_file"))
+        val bankKeysPath = Path(cfg.requireString("nexus-ebics", 
"bank_public_keys_file"))
+        clientKeysPath.parent?.createDirectories()
+        bankKeysPath.parent?.createDirectories()
+        
+        // Missing client keys
+        clientKeysPath.deleteIfExists()
+        for (cmd in cmds) {
+            nexusCmd.testErr("$cmd -c $conf", "Cannot operate without client 
keys. Missing '$clientKeysPath' file. Run 'libeufin-nexus ebics-setup' first")
+        }
+        // Bad client json
+        clientKeysPath.writeText("CORRUPTION", Charsets.UTF_8)
+        for (cmd in allCmds) {
+            nexusCmd.testErr("$cmd -c $conf", "Could not decode private keys: 
Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: 
CORRUPTION")
+        }
+        // Unfinished client
+        syncJsonToDisk(generateNewKeys(), clientKeysPath.toString())
+        for (cmd in cmds) {
+            nexusCmd.testErr("$cmd -c $conf", "Cannot operate with unsubmitted 
client keys, run 'libeufin-nexus ebics-setup' first")
+        }
+
+        // Missing bank keys
+        syncJsonToDisk(generateNewKeys().apply {
+            submitted_hia = true
+            submitted_ini = true
+        }, clientKeysPath.toString())
+        bankKeysPath.deleteIfExists()
+        for (cmd in cmds) {
+            nexusCmd.testErr("$cmd -c $conf", "Cannot operate without bank 
keys. Missing '$bankKeysPath' file. run 'libeufin-nexus ebics-setup' first")
+        }
+        // Bad bank json
+        bankKeysPath.writeText("CORRUPTION", Charsets.UTF_8)
+        for (cmd in allCmds) {
+            nexusCmd.testErr("$cmd -c $conf", "Could not decode bank keys: 
Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: 
CORRUPTION")
+        }
+        // Unfinished bank
+        syncJsonToDisk(BankPublicKeysFile(
+            bank_authentication_public_key = 
CryptoUtil.generateRsaKeyPair(2048).public,
+            bank_encryption_public_key = 
CryptoUtil.generateRsaKeyPair(2048).public,
+            accepted = false
+        ), bankKeysPath.toString())
+        for (cmd in cmds) {
+            nexusCmd.testErr("$cmd -c $conf", "Cannot operate with unaccepted 
bank keys, run 'libeufin-nexus ebics-setup' until accepting the bank keys")
+        }
+    }
+}
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt
index 82d31feb..d35315d6 100644
--- a/nexus/src/test/kotlin/Common.kt
+++ b/nexus/src/test/kotlin/Common.kt
@@ -91,7 +91,7 @@ fun genInitPay(
     )
 
 // Generates an incoming payment, given its subject.
-fun genIncPay(subject: String = "test wire transfer") =
+fun genInPay(subject: String) =
     IncomingPayment(
         amount = TalerAmount(44, 0, "KUDOS"),
         debitPaytoUri = "payto://iban/not-used",
@@ -101,11 +101,11 @@ fun genIncPay(subject: String = "test wire transfer") =
     )
 
 // Generates an outgoing payment, given its subject.
-fun genOutPay(subject: String = "outgoing payment") =
+fun genOutPay(subject: String, bankTransferId: String) =
     OutgoingPayment(
         amount = TalerAmount(44, 0, "KUDOS"),
         creditPaytoUri = "payto://iban/TEST-IBAN?receiver-name=Test",
         wireTransferSubject = subject,
         executionTime = Instant.now(),
-        bankTransferId = "entropic"
+        bankTransferId = bankTransferId
     )
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/DatabaseTest.kt 
b/nexus/src/test/kotlin/DatabaseTest.kt
index 03a709a7..a9834133 100644
--- a/nexus/src/test/kotlin/DatabaseTest.kt
+++ b/nexus/src/test/kotlin/DatabaseTest.kt
@@ -8,145 +8,113 @@ import kotlin.test.assertEquals
 
 
 class OutgoingPaymentsTest {
-
-    /**
-     * Tests the insertion of outgoing payments, including
-     * the case where we reconcile with an initiated payment.
-     */
-    @Test
-    fun outgoingPaymentCreation() {
-        val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE))
-        runBlocking {
-            // inserting without reconciling
-            assertFalse(db.isOutgoingPaymentSeen("entropic"))
-            assertEquals(
-                OutgoingPaymentOutcome.SUCCESS,
-                db.outgoingPaymentCreate(genOutPay("paid by nexus"))
-            )
-            assertTrue(db.isOutgoingPaymentSeen("entropic"))
-            // inserting trying to reconcile with a non-existing initiated 
payment.
-            assertEquals(
-                OutgoingPaymentOutcome.INITIATED_COUNTERPART_NOT_FOUND,
-                db.outgoingPaymentCreate(genOutPay(), 5)
-            )
-            // initiating a payment to reconcile later.  Takes row ID == 1
-            assertEquals(
-                PaymentInitiationOutcome.SUCCESS,
-                db.initiatedPaymentCreate(genInitPay("waiting for 
reconciliation"))
-            )
-            // Creating an outgoing payment, reconciling it with the one above.
-            assertEquals(
-                OutgoingPaymentOutcome.SUCCESS,
-                db.outgoingPaymentCreate(genOutPay(), 1)
-            )
-        }
-    }
-}
-
-// @Ignore // enable after having modified the bouncing logic in Kotlin
-class IncomingPaymentsTest {
     @Test
-    fun bounceWithCustomRefund() {
+    fun register() {
         val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE))
         runBlocking {
-            // creating and bouncing one incoming transaction.
-            assertTrue(
-                db.incomingPaymentCreateBounced(
-                    genIncPay("incoming and bounced"),
-                    "UID",
-                    TalerAmount(2, 53000000, "KUDOS")
-                )
-            )
-            db.runConn {
-                // check incoming shows up.
-                val checkIncoming = it.prepareStatement("""
-                    SELECT 
-                      (amount).val as amount_value
-                      ,(amount).frac as amount_frac
-                     FROM incoming_transactions
-                     WHERE incoming_transaction_id = 1;
-                """).executeQuery()
-                assertTrue(checkIncoming.next())
-                assertEquals(44, checkIncoming.getLong("amount_value"))
-                assertEquals(0, checkIncoming.getLong("amount_frac"))
-                // check bounced has the custom value
-                val findBounced = it.prepareStatement("""
-                    SELECT 
-                      initiated_outgoing_transaction_id
-                      FROM bounced_transactions
-                      WHERE incoming_transaction_id = 1;
-                """).executeQuery()
-                assertTrue(findBounced.next())
-                val initiatedId = 
findBounced.getLong("initiated_outgoing_transaction_id")
-                assertEquals(1, initiatedId)
-                val findInitiatedAmount = it.prepareStatement("""
-                    SELECT
-                      (amount).val as amount_value
-                      ,(amount).frac as amount_frac
-                    FROM initiated_outgoing_transactions
-                    WHERE initiated_outgoing_transaction_id = 1;
-                """).executeQuery()
-                assertTrue(findInitiatedAmount.next())
+            // With reconciling
+            genOutPay("paid by nexus", "first").run {
                 assertEquals(
-                    53000000,
-                    findInitiatedAmount.getInt("amount_frac")
-                )
-                assertEquals(
-                    2,
-                    findInitiatedAmount.getInt("amount_value")
+                    PaymentInitiationOutcome.SUCCESS,
+                    db.initiatedPaymentCreate(genInitPay("waiting for 
reconciliation", "first"))
                 )
+                db.registerOutgoing(this).run {
+                    assertTrue(new,)
+                    assertTrue(initiated)
+                }
+                db.registerOutgoing(this).run {
+                    assertFalse(new)
+                    assertTrue(initiated)
+                }
+            }
+            // Without reconciling
+            genOutPay("not paid by nexus", "second").run {
+                db.registerOutgoing(this).run {
+                    assertTrue(new)
+                    assertFalse(initiated)
+                }
+                db.registerOutgoing(this).run {
+                    assertFalse(new)
+                    assertFalse(initiated)
+                }
             }
         }
     }
+}
+
+class IncomingPaymentsTest {
     // Tests creating and bouncing incoming payments in one DB transaction.
     @Test
-    fun incomingAndBounce() {
+    fun bounce() {
         val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE))
         runBlocking {
             // creating and bouncing one incoming transaction.
-            assertTrue(db.incomingPaymentCreateBounced(
-                genIncPay("incoming and bounced"),
-                "UID"
-            ))
+            val payment = genInPay("incoming and bounced")
+            db.registerMalformedIncoming(
+                payment,
+                TalerAmount(2, 53000000, "KUDOS"),
+                Instant.now()
+            ).run {
+                assertTrue(new)
+            }
+            db.registerMalformedIncoming(
+                payment,
+                TalerAmount(2, 53000000, "KUDOS"),
+                Instant.now()
+            ).run {
+                assertFalse(new)
+            }
             db.runConn {
                 // Checking one incoming got created
                 val checkIncoming = it.prepareStatement("""
-                    SELECT 1 FROM incoming_transactions WHERE 
incoming_transaction_id = 1;
+                    SELECT (amount).val as amount_value, (amount).frac as 
amount_frac 
+                    FROM incoming_transactions WHERE incoming_transaction_id = 
1
                 """).executeQuery()
                 assertTrue(checkIncoming.next())
+                assertEquals(payment.amount.value, 
checkIncoming.getLong("amount_value"))
+                assertEquals(payment.amount.fraction, 
checkIncoming.getInt("amount_frac"))
                 // Checking the bounced table got its row.
                 val checkBounced = it.prepareStatement("""
-                    SELECT 1 FROM bounced_transactions WHERE 
incoming_transaction_id = 1;
+                    SELECT 1 FROM bounced_transactions 
+                    WHERE incoming_transaction_id = 1 AND 
initiated_outgoing_transaction_id = 1
                 """).executeQuery()
                 assertTrue(checkBounced.next())
                 // check the related initiated payment exists.
                 val checkInitiated = it.prepareStatement("""
-                    SELECT 
-                      COUNT(initiated_outgoing_transaction_id) AS how_many
-                      FROM initiated_outgoing_transactions
+                    SELECT
+                        (amount).val as amount_value
+                        ,(amount).frac as amount_frac
+                    FROM initiated_outgoing_transactions
+                    WHERE initiated_outgoing_transaction_id = 1
                 """).executeQuery()
                 assertTrue(checkInitiated.next())
-                assertEquals(1, checkInitiated.getInt("how_many"))
+                assertEquals(
+                    53000000,
+                    checkInitiated.getInt("amount_frac")
+                )
+                assertEquals(
+                    2,
+                    checkInitiated.getInt("amount_value")
+                )
             }
         }
     }
 
     // Tests the creation of a talerable incoming payment.
     @Test
-    fun incomingTalerableCreation() {
+    fun talerable() {
         val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE))
         val reservePub = ByteArray(32)
         Random.nextBytes(reservePub)
 
         runBlocking {
-            val inc = genIncPay("reserve-pub")
+            val inc = genInPay("reserve-pub")
             // Checking the reserve is not found.
             assertFalse(db.isReservePubFound(reservePub))
-            assertFalse(db.isIncomingPaymentSeen(inc.bankTransferId))
-            assertTrue(db.incomingTalerablePaymentCreate(inc, reservePub))
+            assertTrue(db.registerTalerableIncoming(inc, reservePub).new)
             // Checking the reserve is not found.
             assertTrue(db.isReservePubFound(reservePub))
-            assertTrue(db.isIncomingPaymentSeen(inc.bankTransferId))
+            assertFalse(db.registerTalerableIncoming(inc, reservePub).new)
         }
     }
 }
diff --git a/nexus/src/test/kotlin/Keys.kt b/nexus/src/test/kotlin/Keys.kt
index d2894c04..37c095fd 100644
--- a/nexus/src/test/kotlin/Keys.kt
+++ b/nexus/src/test/kotlin/Keys.kt
@@ -23,7 +23,7 @@ class PublicKeys {
             bank_encryption_public_key = 
CryptoUtil.generateRsaKeyPair(2028).public
         )
         // storing them on disk.
-        assertTrue(syncJsonToDisk(fileContent, 
"/tmp/nexus-tests-bank-keys.json"))
+        syncJsonToDisk(fileContent, "/tmp/nexus-tests-bank-keys.json")
         // loading them and check that values are the same.
         val fromDisk = loadBankKeys("/tmp/nexus-tests-bank-keys.json")
         assertNotNull(fromDisk)
@@ -50,16 +50,12 @@ class PrivateKeys {
     fun createWrongPermissions() {
         f.writeText("won't be overridden")
         f.setReadOnly()
-        assertFalse(syncJsonToDisk(clientKeys, f.path))
+        try {
+            syncJsonToDisk(clientKeys, f.path)
+            throw Exception("Should have failed")
+        } catch (e: Exception) { }
     }
 
-    // Testing keys file creation.
-    @Test
-    fun creation() {
-        assertFalse(f.exists())
-        maybeCreatePrivateKeysFile(f.path) // file doesn't exist, this must 
create.
-        j.decodeFromString<ClientPrivateKeysFile>(f.readText()) // reading and 
validating disk content.
-    }
     /**
      * Tests whether loading keys from disk yields the same
      * values that were stored to the file.
@@ -67,7 +63,7 @@ class PrivateKeys {
     @Test
     fun load() {
         assertFalse(f.exists())
-        assertTrue(syncJsonToDisk(clientKeys, f.path)) // Artificially storing 
this to the file.
+        syncJsonToDisk(clientKeys, f.path) // Artificially storing this to the 
file.
         val fromDisk = loadPrivateKeysFromDisk(f.path) // loading it via the 
tested routine.
         assertNotNull(fromDisk)
         // Checking the values from disk match the initial object.
diff --git a/nexus/src/test/kotlin/PostFinance.kt 
b/nexus/src/test/kotlin/PostFinance.kt
deleted file mode 100644
index c2ecdd55..00000000
--- a/nexus/src/test/kotlin/PostFinance.kt
+++ /dev/null
@@ -1,219 +0,0 @@
-import io.ktor.client.*
-import kotlinx.coroutines.runBlocking
-import org.junit.Ignore
-import org.junit.Test
-import tech.libeufin.nexus.*
-import tech.libeufin.nexus.ebics.*
-import tech.libeufin.util.ebics_h005.Ebics3Request
-import tech.libeufin.util.parsePayto
-import java.io.File
-import java.time.Instant
-import java.time.temporal.ChronoUnit
-import kotlin.test.assertNotNull
-import kotlin.test.assertTrue
-
-// Tests only manual, that's why they are @Ignore
-
-private fun prep(): EbicsSetupConfig {
-    val handle = TalerConfig(NEXUS_CONFIG_SOURCE)
-    val ebicsUserId = File("/tmp/pofi-ebics-user-id.txt").readText()
-    val ebicsPartnerId = File("/tmp/pofi-ebics-partner-id.txt").readText()
-    handle.loadFromString(getPofiConfig(ebicsUserId, ebicsPartnerId))
-    return EbicsSetupConfig(handle)
-}
-
-@Ignore
-class Iso20022 {
-
-    private val yesterday: Instant = Instant.now().minus(1, ChronoUnit.DAYS)
-
-    @Test // asks a pain.002, links with pain.001's MsgId
-    fun getAck() {
-        download(prepAckRequest3(startDate = yesterday)
-        )?.unzipForEach { name, content ->
-            println(name)
-            println(content)
-        }
-    }
-
-    /**
-     * With the "mit Detailavisierung" option, each entry has an
-     * AcctSvcrRef & wire transfer subject.
-     */
-    @Test
-    fun getStatement() {
-        val inflatedBytes = download(prepStatementRequest3())
-        inflatedBytes?.unzipForEach { name, content ->
-            println(name)
-            println(content)
-        }
-    }
-
-    @Test
-    fun getNotification() {
-        val inflatedBytes = download(
-            prepNotificationRequest3(
-                // startDate = yesterday,
-                isAppendix = true
-            )
-        )
-        inflatedBytes?.unzipForEach { name, content ->
-            println(name)
-            println(content)
-        }
-    }
-
-    /**
-     * Never shows the subject.
-     */
-    @Test
-    fun getReport() {
-        download(prepReportRequest3(yesterday))?.unzipForEach { name, content 
->
-            println(name)
-            println(content)
-        }
-    }
-
-    @Test
-    fun simulateIncoming() {
-        val cfg = prep()
-        val orderService: Ebics3Request.OrderDetails.Service = 
Ebics3Request.OrderDetails.Service().apply {
-            serviceName = "OTH"
-            scope = "BIL"
-            messageName = 
Ebics3Request.OrderDetails.Service.MessageName().apply {
-                value = "csv"
-            }
-            serviceOption = "CH002LMF"
-        }
-        val instruction = """
-            
Product;Channel;Account;Currency;Amount;Reference;Name;Street;Number;Postcode;City;Country;DebtorAddressLine;DebtorAddressLine;DebtorAccount;ReferenceType;UltimateDebtorName;UltimateDebtorStreet;UltimateDebtorNumber;UltimateDebtorPostcode;UltimateDebtorTownName;UltimateDebtorCountry;UltimateDebtorAddressLine;UltimateDebtorAddressLine;RemittanceInformationText
-            
QRR;PO;CH9789144829733648596;CHF;1;;D009;Musterstrasse;1;1111;Musterstadt;CH;;;;NON;D009;Musterstrasse;1;1111;Musterstadt;CH;;;Taler-Demo
-        """.trimIndent()
-
-        runBlocking {
-            try {
-                doEbicsUpload(
-                    HttpClient(),
-                    cfg,
-                    loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!,
-                    loadBankKeys(cfg.bankPublicKeysFilename)!!,
-                    orderService,
-                    instruction.toByteArray(Charsets.UTF_8)
-                )
-            }
-            catch (e: EbicsUploadException) {
-                logger.error(e.message)
-                logger.error("bank EC: ${e.bankErrorCode}, EBICS EC: 
${e.ebicsErrorCode}")
-            }
-        }
-    }
-
-    fun download(req: Ebics3Request.OrderDetails.BTOrderParams): ByteArray? {
-        val cfg = prep()
-        val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)!!
-        val myKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!
-        val initXml = createEbics3DownloadInitialization(
-            cfg,
-            bankKeys,
-            myKeys,
-            orderParams = req
-        )
-        return runBlocking {
-            doEbicsDownload(
-                HttpClient(),
-                cfg,
-                myKeys,
-                bankKeys,
-                initXml,
-                isEbics3 = true,
-                tolerateEmptyResult = true
-            )
-        }
-    }
-
-    @Test
-    fun sendPayment() {
-        val cfg = prep()
-        val xml = createPain001(
-            "random",
-            Instant.now(),
-            cfg.myIbanAccount,
-            TalerAmount(4, 0, "CHF"),
-            "Test reimbursement, part 2",
-            
parsePayto("payto://iban/CH9300762011623852957?receiver-name=NotGiven")!!
-        )
-        runBlocking {
-            // Not asserting, as it throws in case of errors.
-            submitPain001(
-                xml,
-                cfg,
-                loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!,
-                loadBankKeys(cfg.bankPublicKeysFilename)!!,
-                HttpClient()
-            )
-        }
-    }
-}
-
-@Ignore
-class PostFinance {
-    // Tests sending client keys to the PostFinance test platform.
-    @Test
-    fun postClientKeys() {
-        val cfg = prep()
-        runBlocking {
-            val httpClient = HttpClient()
-            assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, 
httpClient, KeysOrderType.INI))
-            assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, 
httpClient, KeysOrderType.HIA))
-        }
-    }
-
-    // Tests getting the PostFinance keys from their test platform.
-    @Test
-    fun getBankKeys() {
-        val cfg = prep()
-        val keys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-        assertNotNull(keys)
-        assertTrue(keys.submitted_ini)
-        assertTrue(keys.submitted_hia)
-        runBlocking {
-            assertTrue(
-                doKeysRequestAndUpdateState(
-                cfg,
-                keys,
-                HttpClient(),
-                KeysOrderType.HPB
-            ))
-        }
-    }
-
-    // Arbitrary download request for manual tests.
-    @Test
-    fun customDownload() {
-        val cfg = prep()
-        val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-        val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)
-        runBlocking {
-            val bytes = doEbicsCustomDownload(
-                messageType = "HTD",
-                cfg = cfg,
-                bankKeys = bankKeys!!,
-                clientKeys = clientKeys!!,
-                client = HttpClient()
-            )
-            println(bytes.toString())
-        }
-    }
-
-    // Tests the HTD message type.
-    @Test
-    fun fetchAccounts() {
-        val cfg = prep()
-        val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)
-        assertNotNull(clientKeys)
-        val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)
-        assertNotNull(bankKeys)
-        val htd = runBlocking { fetchBankAccounts(cfg, clientKeys, bankKeys, 
HttpClient()) }
-        println(htd)
-    }
-}
\ No newline at end of file
diff --git a/util/src/main/kotlin/Cli.kt b/util/src/main/kotlin/Cli.kt
index 6d618473..93a15a21 100644
--- a/util/src/main/kotlin/Cli.kt
+++ b/util/src/main/kotlin/Cli.kt
@@ -22,24 +22,29 @@ package tech.libeufin.util
 import ConfigSource
 import TalerConfig
 import TalerConfigError
-import com.github.ajalt.clikt.core.CliktCommand
-import com.github.ajalt.clikt.core.subcommands
+import com.github.ajalt.clikt.core.*
 import com.github.ajalt.clikt.parameters.types.*
 import com.github.ajalt.clikt.parameters.arguments.*
 import com.github.ajalt.clikt.parameters.options.*
 import com.github.ajalt.clikt.parameters.groups.*
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory
-import kotlin.system.exitProcess
 
 private val logger: Logger = 
LoggerFactory.getLogger("tech.libeufin.util.ConfigCli")
 
 fun cliCmd(logger: Logger, lambda: () -> Unit) {
     try {
         lambda()
-    } catch (e: Exception) {
-        logger.error(e.message)
-        exitProcess(1)
+    } catch (e: Throwable) {
+        var msg = StringBuilder(e.message)
+        var cause = e.cause;
+        while (cause != null) {
+            msg.append(": ")
+            msg.append(cause.message)
+            cause = cause.cause
+        }
+        logger.error(msg.toString())
+        throw ProgramResult(1)
     }
 }
 
@@ -83,15 +88,13 @@ private class CliConfigGet(private val configSource: 
ConfigSource) : CliktComman
         if (isPath) {
             val res = config.lookupPath(sectionName, optionName)
             if (res == null) {
-                logger.error("value not found in config")
-                exitProcess(2)
+                throw Exception("value not found in config")
             }
             println(res)
         } else {
             val res = config.lookupString(sectionName, optionName)
             if (res == null) {
-                logger.error("value not found in config")
-                exitProcess(2)
+                throw Exception("value not found in config")
             }
             println(res)
         }
diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt
index 13fe1b2b..0fa26c9a 100644
--- a/util/src/main/kotlin/DB.kt
+++ b/util/src/main/kotlin/DB.kt
@@ -235,7 +235,7 @@ fun initializeDatabaseTables(conn: PgConnection, cfg: 
DatabaseConfig, sqlFilePre
             val patchName = "$sqlFilePrefix-$numStr"
 
             checkStmt.setString(1, patchName)
-            val patchCount = checkStmt.oneOrNull { it.getInt(1) } ?: throw 
Error("unable to query patches");
+            val patchCount = checkStmt.oneOrNull { it.getInt(1) } ?: throw 
Exception("unable to query patches");
             if (patchCount >= 1) {
                 logger.info("patch $patchName already applied")
                 continue
diff --git a/util/src/main/kotlin/TalerErrorCode.kt 
b/util/src/main/kotlin/TalerErrorCode.kt
index 8638dfc2..ff1e6af8 100644
--- a/util/src/main/kotlin/TalerErrorCode.kt
+++ b/util/src/main/kotlin/TalerErrorCode.kt
@@ -2650,7 +2650,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The backend lacks a wire transfer method configuration option for the 
given instance. Thus, this instance is unavailable (not findable for creating 
new orders).
+   * The merchant instance has no active bank accounts configured. However, at 
least one bank account must be available to create new orders.
    * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2658,7 +2658,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The proposal had no timestamp and the backend failed to obtain the local 
time. Likely to be an internal error.
+   * The proposal had no timestamp and the merchant backend failed to obtain 
the current local time.
    * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR 
(500).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2666,7 +2666,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The order provided to the backend could not be parsed, some required 
fields were missing or ill-formed.
+   * The order provided to the backend could not be parsed; likely some 
required fields were missing or ill-formed.
    * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2674,7 +2674,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The backend encountered an error: the proposal already exists.
+   * A conflicting order (sharing the same order identifier) already exists at 
this merchant backend instance.
    * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2682,7 +2682,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The request is invalid: the wire deadline is before the refund deadline.
+   * The order creation request is invalid because the given wire deadline is 
before the refund deadline.
    * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2690,7 +2690,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The request is invalid: a delivery date was given, but it is in the past.
+   * The order creation request is invalid because the delivery date given is 
in the past.
    * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2698,7 +2698,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The request is invalid: the wire deadline for the order would be "never".
+   * The order creation request is invalid because a wire deadline of "never" 
is not allowed.
    * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2706,7 +2706,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The request is invalid: a payment deadline was given, but it is in the 
past.
+   * The order ceration request is invalid because the given payment deadline 
is in the past.
    * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2714,7 +2714,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The request is invalid: a refund deadline was given, but it is in the 
past.
+   * The order creation request is invalid because the given refund deadline 
is in the past.
    * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2722,7 +2722,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The backend does not trust any exchange that would allow funds to be 
wired to any bank account of this instance using the selected wire method. Note 
that right now, we do not support the use of exchange bank accounts with 
mandatory currency conversion.
+   * The backend does not trust any exchange that would allow funds to be 
wired to any bank account of this instance using the wire method specified with 
the order. Note that right now, we do not support the use of exchange bank 
accounts with mandatory currency conversion.
    * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2746,7 +2746,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The order provided to the backend could not be deleted, our offer is 
still valid and awaiting payment.
+   * The order provided to the backend could not be deleted, our offer is 
still valid and awaiting payment. Deletion may work later after the offer has 
expired if it remains unpaid.
    * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2762,7 +2762,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The amount to be refunded is inconsistent: either is lower than the 
previous amount being awarded, or it is too big to be paid back. In this second 
case, the fault stays on the business dept. side.
+   * The amount to be refunded is inconsistent: either is lower than the 
previous amount being awarded, or it exceeds the original price paid by the 
customer.
    * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2770,7 +2770,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The frontend gave an unpaid order id to issue the refund to.
+   * Only paid orders can be refunded, and the frontend specified an unpaid 
order to issue a refund for.
    * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2778,7 +2778,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The refund delay was set to 0 and thus no refunds are allowed for this 
order.
+   * The refund delay was set to 0 and thus no refunds are ever allowed for 
this order.
    * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -3497,6 +3497,30 @@ enum class TalerErrorCode(val code: Int) {
   BANK_ADMIN_CREDITOR(5142),
 
 
+  /**
+   * The referenced challenge was not found.
+   * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  BANK_CHALLENGE_NOT_FOUND(5143),
+
+
+  /**
+   * The referenced challenge has expired.
+   * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  BANK_TAN_CHALLENGE_EXPIRED(5144),
+
+
+  /**
+   * A non-admin user has tried to create an account with 2fa.
+   * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  BANK_NON_ADMIN_SET_TAN_CHANNEL(5145),
+
+
   /**
    * The sync service failed find the account in its database.
    * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
diff --git a/util/src/main/kotlin/ebics_h005/Ebics3Request.kt 
b/util/src/main/kotlin/ebics_h005/Ebics3Request.kt
index f539fa96..f46c38f1 100644
--- a/util/src/main/kotlin/ebics_h005/Ebics3Request.kt
+++ b/util/src/main/kotlin/ebics_h005/Ebics3Request.kt
@@ -198,7 +198,8 @@ class Ebics3Request {
         }
 
         @XmlAccessorType(XmlAccessType.NONE)
-        class BTOrderParams {
+        @XmlType(propOrder = ["service", "signatureFlag", "dateRange"])
+        class BTUOrderParams {
             @get:XmlElement(name = "Service", required = true)
             lateinit var service: Service
 
@@ -214,11 +215,21 @@ class Ebics3Request {
             var dateRange: DateRange? = null
         }
 
+        @XmlAccessorType(XmlAccessType.NONE)
+        @XmlType(propOrder = ["service", "dateRange"])
+        class BTDOrderParams {
+            @get:XmlElement(name = "Service", required = true)
+            lateinit var service: Service
+
+            @get:XmlElement(name = "DateRange", required = true)
+            var dateRange: DateRange? = null
+        }
+
         @get:XmlElement(name = "BTUOrderParams", required = true)
-        var btuOrderParams: BTOrderParams? = null
+        var btuOrderParams: BTUOrderParams? = null
 
         @get:XmlElement(name = "BTDOrderParams", required = true)
-        var btdOrderParams: BTOrderParams? = null
+        var btdOrderParams: BTDOrderParams? = null
 
         /**
          * Only present if this ebicsRequest is an upload order
@@ -359,7 +370,6 @@ class Ebics3Request {
         fun createForDownloadReceiptPhase(
             transactionId: String?,
             hostId: String
-
         ): Ebics3Request {
             return Ebics3Request().apply {
                 header = Header().apply {
@@ -393,7 +403,7 @@ class Ebics3Request {
             date: XMLGregorianCalendar,
             bankEncPub: RSAPublicKey,
             bankAuthPub: RSAPublicKey,
-            myOrderParams: OrderDetails.BTOrderParams
+            myOrderParams: OrderDetails.BTDOrderParams
         ): Ebics3Request {
             return Ebics3Request().apply {
                 version = "H005"
@@ -463,7 +473,7 @@ class Ebics3Request {
                         userID = userId
                         orderDetails = OrderDetails().apply {
                             this.adminOrderType = "BTU"
-                            this.btuOrderParams = 
OrderDetails.BTOrderParams().apply {
+                            this.btuOrderParams = 
OrderDetails.BTUOrderParams().apply {
                                 service = aOrderService
                             }
                         }

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]