gnunet-svn
[Top][All Lists]
Advanced

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

[libeufin] branch master updated (93a56d04 -> 8e063c16)


From: gnunet
Subject: [libeufin] branch master updated (93a56d04 -> 8e063c16)
Date: Wed, 12 Jun 2024 13:42:45 +0200

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

sebasjm pushed a change to branch master
in repository libeufin.

    from 93a56d04 -missing files
     new 5de415d9 support creating a withdrawal operation without amount being 
known a priori
     new ce31d171 nexus: support for incoming transactions without bank ID and 
fix instant transactions support from GLS
     new ce67cdbc Use another database for testbench
     new 62dfdddb Update kotlin and dependencies
     new 41b0837c Update gradle
     new 47c4d337 More subject parsing test
     new 5490a93b Prepare for wire transfer fees
     new 66fe3087 bank: support wire transfer fees
     new 7008f055 cleaning
     new f82af382 bank: improve fee transaction subject
     new 0a41f246 bank: fix documentation
     new 77327820 bank: improve support for suggested_amount
     new a3e1b26a Merge remote-tracking branch 'origin/master' into v12-dev
     new c9516d41 bank: new token api and revenue token scope
     new 9c244375 bank: token api fix
     new db975b2f bank: token api fix
     new 8e063c16 latest

The 17 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                                         |   1 +
 API_CHANGES.md                                     |   5 +
 bank/conf/{test.conf => test_with_fees.conf}       |   1 +
 bank/src/main/kotlin/tech/libeufin/bank/Config.kt  |   2 +
 .../main/kotlin/tech/libeufin/bank/Constants.kt    |   4 +-
 .../main/kotlin/tech/libeufin/bank/TalerMessage.kt |  37 +++-
 .../tech/libeufin/bank/api/BankIntegrationApi.kt   |  25 ++-
 .../kotlin/tech/libeufin/bank/api/CoreBankApi.kt   |  37 +++-
 .../kotlin/tech/libeufin/bank/api/RevenueApi.kt    |   2 +-
 .../main/kotlin/tech/libeufin/bank/auth/auth.kt    |  13 +-
 .../kotlin/tech/libeufin/bank/db/AccountDAO.kt     |   2 +-
 .../main/kotlin/tech/libeufin/bank/db/TokenDAO.kt  |  67 ++++--
 .../kotlin/tech/libeufin/bank/db/TransactionDAO.kt |   8 +-
 .../kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt  |  67 ++++--
 bank/src/test/kotlin/AmountTest.kt                 |  34 +--
 bank/src/test/kotlin/BankIntegrationApiTest.kt     | 100 +++++++--
 bank/src/test/kotlin/CommonApiTest.kt              |   2 +-
 bank/src/test/kotlin/ConversionApiTest.kt          |   8 +-
 bank/src/test/kotlin/CoreBankApiTest.kt            | 237 ++++++++++++++++----
 bank/src/test/kotlin/GcTest.kt                     |  12 +-
 bank/src/test/kotlin/PaytoTest.kt                  |   2 +-
 bank/src/test/kotlin/RevenueApiTest.kt             |   2 +-
 bank/src/test/kotlin/SecurityTest.kt               |   2 +-
 bank/src/test/kotlin/WireGatewayApiTest.kt         |   6 +-
 build.gradle                                       |  12 +-
 common/build.gradle                                |   6 +-
 common/src/main/kotlin/TalerErrorCode.kt           |  84 +++++++-
 contrib/bank.conf                                  |   3 +
 contrib/wallet-core                                |   2 +-
 ...beufin-bank-0003.sql => libeufin-bank-0005.sql} |  24 +--
 database-versioning/libeufin-bank-procedures.sql   | 240 ++++++++++++++-------
 ...eufin-bank-0004.sql => libeufin-nexus-0004.sql} |   9 +-
 database-versioning/libeufin-nexus-procedures.sql  |  29 ++-
 gradle/wrapper/gradle-wrapper.jar                  | Bin 43462 -> 43453 bytes
 gradle/wrapper/gradle-wrapper.properties           |   2 +-
 gradlew                                            |   2 +-
 nexus/build.gradle                                 |   2 +-
 .../main/kotlin/tech/libeufin/nexus/EbicsFetch.kt  |   4 +
 .../main/kotlin/tech/libeufin/nexus/Iso20022.kt    | 203 ++++++++++-------
 nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt  |   2 +-
 .../kotlin/tech/libeufin/nexus/db/PaymentDAO.kt    |   2 +-
 nexus/src/test/kotlin/CliTest.kt                   |   3 +
 nexus/src/test/kotlin/DatabaseTest.kt              |  59 +++++
 nexus/src/test/kotlin/Iso20022Test.kt              |   2 +-
 nexus/src/test/kotlin/Parsing.kt                   |  41 +---
 nexus/src/test/kotlin/WireGatewayApiTest.kt        |   8 +-
 nexus/src/test/kotlin/helpers.kt                   |  10 +-
 testbench/src/main/kotlin/Main.kt                  |   2 +-
 testbench/src/test/kotlin/IntegrationTest.kt       |   2 +-
 49 files changed, 1030 insertions(+), 399 deletions(-)
 copy bank/conf/{test.conf => test_with_fees.conf} (93%)
 copy database-versioning/{libeufin-bank-0003.sql => libeufin-bank-0005.sql} 
(54%)
 copy database-versioning/{libeufin-bank-0004.sql => libeufin-nexus-0004.sql} 
(74%)

diff --git a/.gitignore b/.gitignore
index b2a403fc..c883c22c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ testbench/config.json
 configure
 build/
 .gradle
+.kotlin
 out
 *.sqlite3
 *.swp
diff --git a/API_CHANGES.md b/API_CHANGES.md
index 54faa0d3..7dbe233a 100644
--- a/API_CHANGES.md
+++ b/API_CHANGES.md
@@ -56,6 +56,11 @@ This files contains all the API changes for the current 
release:
 - PATCH /accounts/USERNAME: add min_cashout field for the custom minimum 
cashout amount
 - GET /accounts: add min_cashout field for the custom minimum cashout amount
 - GET /accounts/USERNAME: add min_cashout field for the custom minimum cashout 
amount
+- GET /config: new wire_transfer_fees field for transaction fees
+- POST /accounts/USERNAME/withdrawals: drop card_fees field
+- GET /withdrawals/WITHDRAWAL_ID: make amount optional and add suggested_amount
+- POST /accounts/USERNAME/token: add optional description field
+- Add GET /accounts/USERNAME/tokens
 
 ## bank cli
 
diff --git a/bank/conf/test.conf b/bank/conf/test_with_fees.conf
similarity index 93%
copy from bank/conf/test.conf
copy to bank/conf/test_with_fees.conf
index d689fcb6..ee716251 100644
--- a/bank/conf/test.conf
+++ b/bank/conf/test_with_fees.conf
@@ -12,6 +12,7 @@ allow_conversion = YES
 FIAT_CURRENCY = EUR
 tan_sms = libeufin-tan-file.sh
 tan_email = libeufin-tan-file.sh
+wire_transfer_fees = KUDOS:0.1
 
 [libeufin-bankdb-postgres]
 CONFIG = postgresql:///libeufincheck
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt
index 17fef641..24ece5a7 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt
@@ -35,6 +35,7 @@ data class BankConfig(
     val baseUrl: String?,
     val regionalCurrency: String,
     val regionalCurrencySpec: CurrencySpecification,
+    val wireTransferFees: TalerAmount,
     val allowRegistration: Boolean,
     val allowAccountDeletion: Boolean,
     val allowEditName: Boolean,
@@ -126,6 +127,7 @@ fun TalerConfig.loadBankConfig(): BankConfig {
         allowConversion = allowConversion,
         defaultDebtLimit = amount("libeufin-bank", "default_debt_limit", 
regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency),
         registrationBonus = amount("libeufin-bank", "registration_bonus", 
regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency),
+        wireTransferFees = amount("libeufin-bank", "wire_transfer_fees", 
regionalCurrency) ?: TalerAmount(0, 0, regionalCurrency),
         suggestedWithdrawalExchange = lookupString("libeufin-bank", 
"suggested_withdrawal_exchange"),
         spaPath = lookupPath("libeufin-bank", "spa"),
         baseUrl = lookupString("libeufin-bank", "base_url"),
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
index 209ea700..46af29ea 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt
@@ -37,6 +37,6 @@ val RESERVED_ACCOUNTS = setOf("admin", "bank")
 const val IBAN_ALLOCATION_RETRY_COUNTER: Int = 5
 
 // API version  
-const val COREBANK_API_VERSION: String = "4:8:0"
+const val COREBANK_API_VERSION: String = "4:11:1"
 const val CONVERSION_API_VERSION: String = "0:1:0"
-const val INTEGRATION_API_VERSION: String = "2:0:2"
+const val INTEGRATION_API_VERSION: String = "2:0:3"
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
index cca56e57..a9314ddb 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -223,6 +223,7 @@ data class AccountReconfiguration(
 data class TokenRequest(
     val scope: TokenScope,
     val duration: RelativeTime? = null,
+    val description: String? = null,
     val refreshable: Boolean = false
 )
 
@@ -278,6 +279,7 @@ enum class TanChannel {
 enum class TokenScope {
     readonly,
     readwrite,
+    revenue,
     refreshable // Not spec'd as a scope!
 }
 
@@ -289,6 +291,22 @@ data class BearerToken(
     val login: String
 )
 
+@Serializable
+data class TokenInfo(
+    val creation_time: TalerProtocolTimestamp,
+    val expiration: TalerProtocolTimestamp,
+    val scope: TokenScope,
+    val isRefreshable: Boolean,
+    val description: String? = null,
+    val last_access: TalerProtocolTimestamp
+)
+
+
+@Serializable
+data class TokenInfos (
+    val tokens: List<TokenInfo>
+)
+
 @Serializable
 data class Config(
     val currency: String,
@@ -302,7 +320,8 @@ data class Config(
     val allow_edit_cashout_payto_uri: Boolean,
     val default_debit_threshold: TalerAmount,
     val supported_tan_channels: Set<TanChannel>,
-    val wire_type: WireMethod
+    val wire_type: WireMethod,
+    val wire_transfer_fees: TalerAmount
 ) {
     val name: String = "libeufin-bank"
     val version: String = COREBANK_API_VERSION
@@ -416,7 +435,8 @@ data class BankAccountTransactionsResponse(
 // Taler withdrawal request.
 @Serializable
 data class BankAccountCreateWithdrawalRequest(
-    val amount: TalerAmount
+    val amount: TalerAmount? = null,
+    val suggested_amount: TalerAmount? = null
 )
 
 // Taler withdrawal response.
@@ -429,7 +449,8 @@ data class BankAccountCreateWithdrawalResponse(
 @Serializable
 data class WithdrawalPublicInfo (
     val status: WithdrawalStatus,
-    val amount: TalerAmount,
+    val amount: TalerAmount? = null,
+    val suggested_amount: TalerAmount? = null,
     val username: String,
     val selected_reserve_pub: EddsaPublicKey? = null,
     val selected_exchange_account: String? = null,
@@ -448,13 +469,18 @@ data class CurrencySpecification(
 @Serializable
 data class BankWithdrawalOperationStatus(
     val status: WithdrawalStatus,
-    val amount: TalerAmount,
+    val amount: TalerAmount? = null,
+    val suggested_amount: TalerAmount? = null,
+    val max_amount: TalerAmount? = null,
+    val card_fees: TalerAmount? = null,
     val sender_wire: String? = null,
     val suggested_exchange: String? = null,
+    val required_exchange: String? = null,
     val confirm_transfer_url: String? = null,
+    val wire_types: List<String>,
     val selected_reserve_pub: EddsaPublicKey? = null,
     val selected_exchange_account: String? = null,
-    val wire_types: List<String>,
+    val currency: String? = null,
     // TODO remove
     val aborted: Boolean,
     val selection_done: Boolean,
@@ -468,6 +494,7 @@ data class BankWithdrawalOperationStatus(
 data class BankWithdrawalOperationPostRequest(
     val reserve_pub: EddsaPublicKey,
     val selected_exchange: Payto,
+    val amount: TalerAmount? = null
 )
 
 /**
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
index b88d8fa1..35dcfaf6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt
@@ -44,7 +44,7 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) 
{
     get("/taler-integration/withdrawal-operation/{wopid}") {
         val uuid = call.uuidPath("wopid")
         val params = StatusParams.extract(call.request.queryParameters)
-        val op = db.withdrawal.pollStatus(uuid, params, ctx.wireMethod) ?: 
throw notFound(
+        val op = db.withdrawal.pollStatus(uuid, params, 
ctx.wireMethod)?.copy(card_fees = ctx.wireTransferFees) ?: throw notFound(
             "Withdrawal operation '$uuid' not found", 
             TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
         )
@@ -56,31 +56,40 @@ fun Routing.bankIntegrationApi(db: Database, ctx: 
BankConfig) {
     post("/taler-integration/withdrawal-operation/{wopid}") {
         val uuid = call.uuidPath("wopid")
         val req = call.receive<BankWithdrawalOperationPostRequest>()
-
+        req.amount?.run(ctx::checkRegionalCurrency)
         val res = db.withdrawal.setDetails(
-            uuid, req.selected_exchange, req.reserve_pub
+            uuid, req.selected_exchange, req.reserve_pub, req.amount
         )
+        // TODO check amount
         when (res) {
-            is WithdrawalSelectionResult.UnknownOperation -> throw notFound(
+            WithdrawalSelectionResult.UnknownOperation -> throw notFound(
                 "Withdrawal operation '$uuid' not found", 
                 TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
             )
-            is WithdrawalSelectionResult.AlreadySelected -> throw conflict(
+            WithdrawalSelectionResult.AlreadySelected -> throw conflict(
                 "Cannot select different exchange and reserve pub. under the 
same withdrawal operation",
                 
TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT
             )
-            is WithdrawalSelectionResult.RequestPubReuse -> throw conflict(
+            WithdrawalSelectionResult.RequestPubReuse -> throw conflict(
                 "Reserve pub. already used", 
                 TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT
             )
-            is WithdrawalSelectionResult.UnknownAccount -> throw conflict(
+            WithdrawalSelectionResult.UnknownAccount -> throw conflict(
                 "Account ${req.selected_exchange} not found",
                 TalerErrorCode.BANK_UNKNOWN_ACCOUNT
             )
-            is WithdrawalSelectionResult.AccountIsNotExchange -> throw 
conflict(
+            WithdrawalSelectionResult.AccountIsNotExchange -> throw conflict(
                 "Account ${req.selected_exchange} is not an exchange",
                 TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE
             )
+            WithdrawalSelectionResult.MissingAmount -> throw conflict(
+                "An amount is required",
+                TalerErrorCode.BANK_AMOUNT_REQUIRED
+            )
+            WithdrawalSelectionResult.AmountDiffers -> throw conflict(
+                "Given amount is different from the current",
+                TalerErrorCode.BANK_AMOUNT_DIFFERS
+            )
             is WithdrawalSelectionResult.Success -> {
                 call.respond(BankWithdrawalOperationPostResponse(
                     transfer_done = res.status == WithdrawalStatus.confirmed, 
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
index d57c504d..0aaf1a02 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -62,7 +62,8 @@ fun Routing.coreBankApi(db: Database, ctx: BankConfig) {
                 supported_tan_channels = ctx.tanChannels.keys,
                 allow_edit_name = ctx.allowEditName,
                 allow_edit_cashout_payto_uri = ctx.allowEditCashout,
-                wire_type = ctx.wireMethod
+                wire_type = ctx.wireMethod,
+                wire_transfer_fees = ctx.wireTransferFees
             )
         )
     }
@@ -89,12 +90,12 @@ private fun Routing.coreBankTokenApi(db: Database) {
             
             if (existingToken != null) {
                 // This block checks permissions ONLY IF the call was 
authenticated with a token
-                val refreshingToken = db.token.get(existingToken) ?: throw 
internalServerError(
+                val refreshingToken = db.token.access(existingToken, 
Instant.now()) ?: throw internalServerError(
                     "Token used to auth not found in the database!"
                 )
-                if (refreshingToken.scope == TokenScope.readonly && req.scope 
== TokenScope.readwrite)
+                if (!validScope(req.scope, refreshingToken.scope))
                     throw forbidden(
-                        "Cannot generate RW token from RO",
+                        "Impossible to refresh a token with a larger scope",
                         TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT
                     )
             }
@@ -120,7 +121,8 @@ private fun Routing.coreBankTokenApi(db: Database) {
                 creationTime = creationTime,
                 expirationTime = expirationTimestamp,
                 scope = req.scope,
-                isRefreshable = req.refreshable
+                isRefreshable = req.refreshable,
+                description = req.description
             )) {
                 throw internalServerError("Failed at inserting new token in 
the database")
             }
@@ -138,6 +140,15 @@ private fun Routing.coreBankTokenApi(db: Database) {
             db.token.delete(token)
             call.respond(HttpStatusCode.NoContent)
         }
+        get("/accounts/{USERNAME}/tokens") {
+            val params = PageParams.extract(call.request.queryParameters)
+            val tokens = db.token.page(params, username)
+            if (tokens.isEmpty()) {
+                call.respond(HttpStatusCode.NoContent)
+            } else {
+                call.respond(TokenInfos(tokens))
+            }
+        }
     }
 }
 
@@ -455,7 +466,8 @@ private fun Routing.coreBankTransactionsApi(db: Database, 
ctx: BankConfig) {
                 amount = amount,
                 timestamp = Instant.now(),
                 requestUid = req.request_uid,
-                is2fa = challenge != null
+                is2fa = challenge != null,
+                wireTransferFees = ctx.wireTransferFees
             )
             when (res) {
                 BankTransactionResult.UnknownDebtor -> throw 
unknownAccount(username)
@@ -489,9 +501,16 @@ private fun Routing.coreBankWithdrawalApi(db: Database, 
ctx: BankConfig) {
     auth(db, TokenScope.readwrite) {
         post("/accounts/{USERNAME}/withdrawals") {
             val req = call.receive<BankAccountCreateWithdrawalRequest>()
-            ctx.checkRegionalCurrency(req.amount)
+            req.amount?.run(ctx::checkRegionalCurrency)
+            req.suggested_amount?.run(ctx::checkRegionalCurrency)
             val opId = UUID.randomUUID()
-            when (db.withdrawal.create(username, opId, req.amount, 
Instant.now())) {
+            when (db.withdrawal.create(
+                username, 
+                opId, 
+                req.amount,
+                req.suggested_amount,
+                Instant.now()
+            )) {
                 WithdrawalCreationResult.UnknownAccount -> throw 
unknownAccount(username)
                 WithdrawalCreationResult.AccountIsExchange -> throw conflict(
                     "Exchange account cannot perform withdrawal operation",
@@ -514,7 +533,7 @@ private fun Routing.coreBankWithdrawalApi(db: Database, 
ctx: BankConfig) {
         post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") {
             val id = call.uuidPath("withdrawal_id")
             val challenge = call.checkChallenge(db, Operation.withdrawal)
-            when (db.withdrawal.confirm(username, id, Instant.now(), challenge 
!= null)) {
+            when (db.withdrawal.confirm(username, id, ctx.wireTransferFees, 
Instant.now(), challenge != null)) {
                 WithdrawalConfirmationResult.UnknownOperation -> throw 
notFound(
                     "Withdrawal operation $id not found",
                     TalerErrorCode.BANK_TRANSACTION_NOT_FOUND
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt
index b46cf875..79ca8f11 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt
@@ -28,7 +28,7 @@ import tech.libeufin.bank.db.Database
 import tech.libeufin.common.*
 
 fun Routing.revenueApi(db: Database, ctx: BankConfig) { 
-    auth(db, TokenScope.readonly) {
+    auth(db, TokenScope.revenue) {
         get("/accounts/{USERNAME}/taler-revenue/config") {
             call.respond(RevenueConfig(
                 currency = ctx.regionalCurrency
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
index 56bdba2d..ba97ada6 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt
@@ -148,6 +148,15 @@ private suspend fun doBasicAuth(db: Database, encoded: 
String): String {
     return login
 }
 
+fun validScope(required: TokenScope, scope: TokenScope): Boolean {
+    val validScopes = when (required) {
+        TokenScope.readonly -> setOf(TokenScope.readonly, TokenScope.readwrite)
+        TokenScope.readwrite -> setOf(TokenScope.readwrite)
+        TokenScope.revenue -> setOf(TokenScope.readonly, TokenScope.readwrite, 
TokenScope.revenue)
+        TokenScope.refreshable -> return true
+    }
+    return validScopes.contains(scope)
+}
 /**
  * Performs the secret-token HTTP Bearer Authentication.
  * 
@@ -169,12 +178,12 @@ private suspend fun ApplicationCall.doTokenAuth(
             e.message, TalerErrorCode.GENERIC_HTTP_HEADERS_MALFORMED
         )
     }
-    val token: BearerToken = db.token.get(decoded) ?: throw 
unauthorized("Unknown token")
+    val token: BearerToken = db.token.access(decoded, Instant.now()) ?: throw 
unauthorized("Unknown token")
     when {
         token.expirationTime.isBefore(Instant.now()) 
             -> throw unauthorized("Expired auth token")
 
-        token.scope == TokenScope.readonly && requiredScope == 
TokenScope.readwrite 
+        !validScope(requiredScope, token.scope)
             -> throw unauthorized("Auth token has insufficient scope")
 
         !token.isRefreshable && requiredScope == TokenScope.refreshable 
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 97ca874b..14187046 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -164,7 +164,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,?,true,NULL)
+                        FROM 
bank_transaction(?,'admin','bonus',(?,?)::taler_amount,?,true,NULL,(0, 
0)::taler_amount)
                     """).run {
                         setString(1, internalPayto.canonical)
                         setLong(2, bonus.value)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt 
b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt
index f7f1d09c..101deb9b 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt
@@ -19,12 +19,9 @@
 
 package tech.libeufin.bank.db
 
-import tech.libeufin.bank.BearerToken
-import tech.libeufin.bank.TokenScope
-import tech.libeufin.common.asInstant
-import tech.libeufin.common.db.executeUpdateViolation
-import tech.libeufin.common.db.oneOrNull
-import tech.libeufin.common.micros
+import tech.libeufin.bank.*
+import tech.libeufin.common.db.*
+import tech.libeufin.common.*
 import java.time.Instant
 
 /** Data access logic for auth tokens */
@@ -36,7 +33,8 @@ class TokenDAO(private val db: Database) {
         creationTime: Instant,
         expirationTime: Instant,
         scope: TokenScope,
-        isRefreshable: Boolean
+        isRefreshable: Boolean,
+        description: String?
     ): Boolean = db.serializable { conn ->
         // TODO single query
         val bankCustomer = conn.prepareStatement("""
@@ -52,8 +50,10 @@ class TokenDAO(private val db: Database) {
                 expiration_time,
                 scope,
                 bank_customer,
-                is_refreshable
-            ) VALUES (?, ?, ?, ?::token_scope_enum, ?, ?)
+                is_refreshable,
+                description,
+                last_access
+            ) VALUES (?,?,?,?::token_scope_enum,?,?,?,?)
         """)
         stmt.setBytes(1, content)
         stmt.setLong(2, creationTime.micros())
@@ -61,23 +61,27 @@ class TokenDAO(private val db: Database) {
         stmt.setString(4, scope.name)
         stmt.setLong(5, bankCustomer)
         stmt.setBoolean(6, isRefreshable)
+        stmt.setString(7, description)
+        stmt.setLong(8, creationTime.micros())
         stmt.executeUpdateViolation()
     }
     
     /** Get info for [token] */
-    suspend fun get(token: ByteArray): BearerToken? = db.conn { conn ->
+    suspend fun access(token: ByteArray, accessTime: Instant): BearerToken? = 
db.conn { conn ->
         val stmt = conn.prepareStatement("""
-            SELECT
+            UPDATE bearer_tokens
+                SET last_access=?
+            FROM customers
+            WHERE bank_customer=customer_id AND content=? AND deleted_at IS 
NULL
+            RETURNING
               creation_time,
               expiration_time,
               login,
               scope,
               is_refreshable
-            FROM bearer_tokens
-                JOIN customers ON bank_customer=customer_id
-            WHERE content=? AND deleted_at IS NULL
         """)
-        stmt.setBytes(1, token)
+        stmt.setLong(1, accessTime.micros())
+        stmt.setBytes(2, token)
         stmt.oneOrNull { 
             BearerToken(
                 creationTime = it.getLong("creation_time").asInstant(),
@@ -97,4 +101,37 @@ class TokenDAO(private val db: Database) {
         stmt.setBytes(1, token)
         stmt.execute()
     }
+
+    /** Get a page of all public accounts */
+    suspend fun page(params: PageParams, login: String): List<TokenInfo>
+        = db.page(
+            params,
+            "bearer_token_id",
+            """
+            SELECT
+              creation_time,
+              expiration_time,
+              scope,
+              is_refreshable,
+              description,
+              last_access,
+              bearer_token_id
+              FROM bearer_tokens JOIN customers
+                ON bank_customer=customer_id
+              WHERE deleted_at IS NULL AND login = ? AND
+            """,
+            {
+                setString(1, login)
+                1
+            }
+        ) {
+            TokenInfo(
+                creation_time = it.getTalerTimestamp("creation_time"),
+                expiration = it.getTalerTimestamp("expiration_time"),
+                scope = TokenScope.valueOf(it.getString("scope")),
+                isRefreshable = it.getBoolean("is_refreshable"),
+                description = it.getString("description"),
+                last_access = it.getTalerTimestamp("last_access"),
+            )
+        }
 }
\ 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 794eb04d..7a3e2cd5 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
@@ -51,6 +51,7 @@ class TransactionDAO(private val db: Database) {
         timestamp: Instant,
         is2fa: Boolean,
         requestUid: ShortHashCode?,
+        wireTransferFees: TalerAmount
     ): BankTransactionResult = db.serializable { conn ->
         val now = timestamp.micros()
         conn.transaction {
@@ -70,7 +71,7 @@ class TransactionDAO(private val db: Database) {
                     ,out_debtor_is_exchange
                     ,out_creditor_admin
                     ,out_idempotent
-                FROM bank_transaction(?,?,?,(?,?)::taler_amount,?,?,?)
+                FROM 
bank_transaction(?,?,?,(?,?)::taler_amount,?,?,?,(?,?)::taler_amount)
             """
             )
             stmt.setString(1, creditAccountPayto.canonical)
@@ -81,6 +82,8 @@ class TransactionDAO(private val db: Database) {
             stmt.setLong(6, now)
             stmt.setBoolean(7, is2fa)
             stmt.setBytes(8, requestUid?.raw)
+            stmt.setLong(9, wireTransferFees.value)
+            stmt.setInt(10, wireTransferFees.frac)
             stmt.executeQuery().use {
                 when {
                     !it.next() -> throw internalServerError("Bank transaction 
didn't properly return")
@@ -125,8 +128,7 @@ class TransactionDAO(private val db: Database) {
                                 // No error can happens because an opposite 
transaction already took place in the same transaction
                                 conn.prepareStatement("""
                                     SELECT bank_wire_transfer(
-                                        ?, ?, ?, (?, ?)::taler_amount, ?,
-                                        NULL, NULL, NULL
+                                        ?, ?, ?, (?, ?)::taler_amount, ?, (0, 
0)::taler_amount
                                     );
                                 """
                                 ).run {
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 efaa4b74..afb068e1 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt
@@ -43,7 +43,8 @@ class WithdrawalDAO(private val db: Database) {
     suspend fun create(
         login: String,
         uuid: UUID,
-        amount: TalerAmount,
+        amount: TalerAmount?,
+        suggested_amount: TalerAmount?,
         now: Instant
     ): WithdrawalCreationResult = db.serializable { conn ->
         val stmt = conn.prepareStatement("""
@@ -51,13 +52,27 @@ class WithdrawalDAO(private val db: Database) {
                 out_account_not_found,
                 out_account_is_exchange,
                 out_balance_insufficient
-            FROM create_taler_withdrawal(?, ?, (?,?)::taler_amount, ?);
+            FROM create_taler_withdrawal(
+                ?,?,
+                ${if (amount != null) "(?,?)::taler_amount" else "NULL"},
+                ${if (suggested_amount != null) "(?,?)::taler_amount" else 
"NULL"},
+                ?
+            );
         """)
         stmt.setString(1, login)
         stmt.setObject(2, uuid)
-        stmt.setLong(3, amount.value)
-        stmt.setInt(4, amount.frac)
-        stmt.setLong(5, now.micros())
+        var id = 3
+        if (amount != null) {
+            stmt.setLong(id, amount.value)
+            stmt.setInt(id+1, amount.frac)
+            id += 2
+        }
+        if (suggested_amount != null) {
+            stmt.setLong(id, suggested_amount.value)
+            stmt.setInt(id+1, suggested_amount.frac)
+            id += 2
+        }
+        stmt.setLong(id, now.micros())
         stmt.executeQuery().use {
             when {
                 !it.next() ->
@@ -98,13 +113,16 @@ class WithdrawalDAO(private val db: Database) {
         data object RequestPubReuse: WithdrawalSelectionResult
         data object UnknownAccount: WithdrawalSelectionResult
         data object AccountIsNotExchange: WithdrawalSelectionResult
+        data object MissingAmount: WithdrawalSelectionResult
+        data object AmountDiffers: WithdrawalSelectionResult
     }
 
-    /** Set details ([exchangePayto] & [reservePub]) for withdrawal operation 
[uuid] */
+    /** Set details ([exchangePayto] & [reservePub] & [amount]) for withdrawal 
operation [uuid] */
     suspend fun setDetails(
         uuid: UUID,
         exchangePayto: Payto,
-        reservePub: EddsaPublicKey
+        reservePub: EddsaPublicKey,
+        amount: TalerAmount?
     ): WithdrawalSelectionResult = db.serializable { conn ->
         val stmt = conn.prepareStatement("""
             SELECT
@@ -113,20 +131,31 @@ class WithdrawalDAO(private val db: Database) {
                 out_reserve_pub_reuse,
                 out_account_not_found,
                 out_account_is_not_exchange,
-                out_status
-            FROM select_taler_withdrawal(?, ?, ?, ?);
+                out_status,
+                out_missing_amount,
+                out_amount_differs
+            FROM select_taler_withdrawal(
+                ?, ?, ?, ?,
+                ${if (amount != null) "(?, ?)::taler_amount" else "NULL"}
+            );
         """
         )
         stmt.setObject(1, uuid)
         stmt.setBytes(2, reservePub.raw)
         stmt.setString(3, "Taler withdrawal $reservePub")
         stmt.setString(4, exchangePayto.canonical)
+        if (amount != null) {
+            stmt.setLong(5, amount.value)
+            stmt.setInt(6, amount.frac)
+        }
         stmt.executeQuery().use {
             when {
                 !it.next() ->
                     throw internalServerError("No result from DB procedure 
select_taler_withdrawal")
                 it.getBoolean("out_no_op") -> 
WithdrawalSelectionResult.UnknownOperation
                 it.getBoolean("out_already_selected") -> 
WithdrawalSelectionResult.AlreadySelected
+                it.getBoolean("out_missing_amount") -> 
WithdrawalSelectionResult.MissingAmount
+                it.getBoolean("out_amount_differs") -> 
WithdrawalSelectionResult.AmountDiffers
                 it.getBoolean("out_reserve_pub_reuse") -> 
WithdrawalSelectionResult.RequestPubReuse
                 it.getBoolean("out_account_not_found") -> 
WithdrawalSelectionResult.UnknownAccount
                 it.getBoolean("out_account_is_not_exchange") -> 
WithdrawalSelectionResult.AccountIsNotExchange
@@ -150,6 +179,7 @@ class WithdrawalDAO(private val db: Database) {
     suspend fun confirm(
         login: String,
         uuid: UUID,
+        wireTransferFees: TalerAmount,
         now: Instant,
         is2fa: Boolean
     ): WithdrawalConfirmationResult = db.serializable { conn ->
@@ -161,13 +191,15 @@ class WithdrawalDAO(private val db: Database) {
               out_not_selected,
               out_aborted,
               out_tan_required
-            FROM confirm_taler_withdrawal(?,?,?,?);
+            FROM confirm_taler_withdrawal(?,?,?,?,(?,?)::taler_amount);
         """
         )
         stmt.setString(1, login)
         stmt.setObject(2, uuid)
         stmt.setLong(3, now.micros())
         stmt.setBoolean(4, is2fa)
+        stmt.setLong(5, wireTransferFees.value)
+        stmt.setInt(6, wireTransferFees.frac)
         stmt.executeQuery().use {
             when {
                 !it.next() ->
@@ -242,6 +274,8 @@ class WithdrawalDAO(private val db: Database) {
                     END as status
                     ,(amount).val as amount_val
                     ,(amount).frac as amount_frac
+                    ,(suggested_amount).val as suggested_amount_val
+                    ,(suggested_amount).frac as suggested_amount_frac
                     ,selection_done     
                     ,aborted     
                     ,confirmation_done     
@@ -257,7 +291,8 @@ class WithdrawalDAO(private val db: Database) {
                 stmt.oneOrNull {
                     WithdrawalPublicInfo(
                         status = 
WithdrawalStatus.valueOf(it.getString("status")),
-                        amount = it.getAmount("amount", db.bankCurrency),
+                        amount = it.getOptAmount("amount", db.bankCurrency),
+                        suggested_amount = it.getOptAmount("suggested_amount", 
db.bankCurrency),
                         username = it.getString("login"),
                         selected_exchange_account = 
it.getString("selected_exchange_payto"),
                         selected_reserve_pub = 
it.getBytes("reserve_pub")?.run(::EddsaPublicKey)
@@ -280,6 +315,8 @@ class WithdrawalDAO(private val db: Database) {
                       END as status
                       ,(amount).val as amount_val
                       ,(amount).frac as amount_frac
+                      ,(suggested_amount).val as suggested_amount_val
+                      ,(suggested_amount).frac as suggested_amount_frac
                       ,selection_done     
                       ,aborted     
                       ,confirmation_done      
@@ -294,7 +331,10 @@ class WithdrawalDAO(private val db: Database) {
                 stmt.oneOrNull {
                     BankWithdrawalOperationStatus(
                         status = 
WithdrawalStatus.valueOf(it.getString("status")),
-                        amount = it.getAmount("amount", db.bankCurrency),
+                        amount = it.getOptAmount("amount", db.bankCurrency),
+                        suggested_amount = it.getOptAmount("suggested_amount", 
db.bankCurrency),
+                        max_amount = null,
+                        card_fees = null,
                         selection_done = it.getBoolean("selection_done"),
                         transfer_done = it.getBoolean("confirmation_done"),
                         aborted = it.getBoolean("aborted"),
@@ -308,7 +348,8 @@ class WithdrawalDAO(private val db: Database) {
                                 WireMethod.IBAN -> "iban"
                                 WireMethod.X_TALER_BANK -> "x-taler-bank"
                             } 
-                        )
+                        ),
+                        currency = db.bankCurrency
                     )
                 }
             }
diff --git a/bank/src/test/kotlin/AmountTest.kt 
b/bank/src/test/kotlin/AmountTest.kt
index a93a10ba..f0d09629 100644
--- a/bank/src/test/kotlin/AmountTest.kt
+++ b/bank/src/test/kotlin/AmountTest.kt
@@ -54,7 +54,8 @@ class AmountTest {
                 amount = due,
                 timestamp = Instant.now(),
                 is2fa = false,
-                requestUid = null
+                requestUid = null,
+                wireTransferFees = TalerAmount("KUDOS:0")
             )
             val txBool = when (txRes) {
                 BankTransactionResult.BalanceInsufficient -> false
@@ -64,20 +65,23 @@ class AmountTest {
 
             // Check whithdraw 
             stmt.executeUpdate()
-            val wRes = db.withdrawal.create(
-                login = "merchant",
-                uuid = UUID.randomUUID(),
-                amount = due,
-                now = Instant.now()
-            )
-            val wBool = when (wRes) {
-                WithdrawalCreationResult.BalanceInsufficient -> false
-                WithdrawalCreationResult.Success -> true
-                else -> throw Exception("Unexpected error $txRes")
+            for ((amount, suggested) in listOf(Pair(due, null), Pair(null, 
due), Pair(due, due))) {
+                val wRes = db.withdrawal.create(
+                    login = "merchant",
+                    uuid = UUID.randomUUID(),
+                    amount = due,
+                    suggested_amount = null,
+                    now = Instant.now()
+                )
+                val wBool = when (wRes) {
+                    WithdrawalCreationResult.BalanceInsufficient -> false
+                    WithdrawalCreationResult.Success -> true
+                    else -> throw Exception("Unexpected error $txRes")
+                }
+                // Logic must be the same
+                assertEquals(wBool, txBool)
             }
-
-            // Logic must be the same
-            assertEquals(wBool, txBool)
+            
             return txBool
         }
 
@@ -317,7 +321,7 @@ class AmountTest {
     }
 
     @Test
-    fun apiError() = bankSetup { _ -> 
+    fun apiError() = bankSetup { 
         val base = obj {
             "payto_uri" to "$exchangePayto?message=payout"
         }
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt 
b/bank/src/test/kotlin/BankIntegrationApiTest.kt
index 2fe33d63..67762ea3 100644
--- a/bank/src/test/kotlin/BankIntegrationApiTest.kt
+++ b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -30,27 +30,40 @@ import kotlin.test.assertEquals
 class BankIntegrationApiTest {
     // GET /taler-integration/config
     @Test
-    fun config() = bankSetup { _ ->
+    fun config() = bankSetup {
         client.get("/taler-integration/config").assertOk()
     }
 
     // GET /taler-integration/withdrawal-operation/UUID
     @Test
-    fun get() = bankSetup { _ ->
-        val amount = TalerAmount("KUDOS:9.0")
+    fun get() = bankSetup {
         // Check OK
-        client.postA("/accounts/customer/withdrawals") {
-            json { "amount" to amount } 
-        }.assertOkJson<BankAccountCreateWithdrawalResponse> { resp ->
-            val uuid = resp.taler_withdraw_uri.split("/").last()
-            client.get("/taler-integration/withdrawal-operation/$uuid")
-                .assertOkJson<BankWithdrawalOperationStatus> {
-                assert(!it.selection_done)
-                assert(!it.aborted)
-                assert(!it.transfer_done)
-                assertEquals(amount, it.amount)
-                assertEquals(listOf("iban"), it.wire_types)
-                assertEquals(amount, it.amount)
+        for (valid in listOf(
+            Pair(null, null),
+            Pair("KUDOS:1.0", null),
+            Pair(null, "KUDOS:2.0") ,
+            Pair("KUDOS:3.0", "KUDOS:4.0")
+        )) {
+            val amount = valid.first?.run(::TalerAmount)
+            val suggested = valid.second?.run(::TalerAmount)
+            client.postA("/accounts/merchant/withdrawals") {
+                json { 
+                    "amount" to amount
+                    "suggested_amount" to suggested
+                }
+            }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+                val uuid = it.taler_withdraw_uri.split("/").last()
+                client.get("/taler-integration/withdrawal-operation/$uuid")
+                    .assertOkJson<BankWithdrawalOperationStatus> {
+                    assert(!it.selection_done)
+                    assert(!it.aborted)
+                    assert(!it.transfer_done)
+                    assertEquals(it.card_fees, TalerAmount("KUDOS:0"))
+                    assertEquals(amount, it.amount)
+                    assertEquals(suggested, it.suggested_amount)
+                    assertEquals(listOf("iban"), it.wire_types)
+                    assertEquals("KUDOS", it.currency)
+                }
             }
         }
 
@@ -68,7 +81,7 @@ class BankIntegrationApiTest {
 
     // POST /taler-integration/withdrawal-operation/UUID
     @Test
-    fun select() = bankSetup { _ ->
+    fun select() = bankSetup {
         val reserve_pub = EddsaPublicKey.rand()
         val req = obj {
             "reserve_pub" to reserve_pub
@@ -88,7 +101,7 @@ class BankIntegrationApiTest {
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:1" } 
         }.assertOkJson<BankAccountCreateWithdrawalResponse> {
-            val uuid = it.taler_withdraw_uri.split("/").last()
+            val uuid = it.withdrawal_id
 
             // Check OK
             client.post("/taler-integration/withdrawal-operation/$uuid") {
@@ -115,12 +128,20 @@ class BankIntegrationApiTest {
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:1" } 
         }.assertOkJson<BankAccountCreateWithdrawalResponse> {
-            val uuid = it.taler_withdraw_uri.split("/").last()
+            val uuid = it.withdrawal_id
 
             // Check reserve_pub_reuse
             client.post("/taler-integration/withdrawal-operation/$uuid") {
                 json(req)
             }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
+
+            // Check amount differs
+            client.post("/taler-integration/withdrawal-operation/$uuid") {
+                json(req) {
+                    "amount" to "KUDOS:2"
+                }
+            }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)
+
             // Check unknown account
             client.post("/taler-integration/withdrawal-operation/$uuid") {
                 json {
@@ -128,6 +149,7 @@ class BankIntegrationApiTest {
                     "selected_exchange" to unknownPayto
                 }
             }.assertConflict(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+
             // Check account not exchange
             client.post("/taler-integration/withdrawal-operation/$uuid") {
                 json {
@@ -135,17 +157,51 @@ class BankIntegrationApiTest {
                     "selected_exchange" to merchantPayto
                 }
             }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE)
+
+            client.post("/taler-integration/withdrawal-operation/$uuid") {
+                json {
+                    "reserve_pub" to EddsaPublicKey.rand()
+                    "selected_exchange" to exchangePayto.canonical
+                    "amount" to "KUDOS:1"
+                }
+            }.assertOkJson<BankWithdrawalOperationPostResponse>()
+        }
+
+        client.postA("/accounts/merchant/withdrawals") {
+            json {}
+        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+            val uuid = it.withdrawal_id
+
+            // Check missing amount
+            client.post("/taler-integration/withdrawal-operation/$uuid") {
+                json {
+                    "reserve_pub" to EddsaPublicKey.rand()
+                    "selected_exchange" to exchangePayto.canonical
+                }
+            }.assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED)
+
+            client.post("/taler-integration/withdrawal-operation/$uuid") {
+                json {
+                    "reserve_pub" to EddsaPublicKey.rand()
+                    "selected_exchange" to exchangePayto.canonical
+                    "amount" to "KUDOS:1.1"
+                }
+            }.assertOkJson<BankWithdrawalOperationPostResponse>()
+            client.get("/taler-integration/withdrawal-operation/$uuid")
+                .assertOkJson<BankWithdrawalOperationStatus> {
+                assertEquals(TalerAmount("KUDOS:1.1"), it.amount)
+            }
         }
     }
 
     // POST /taler-integration/withdrawal-operation/UUID/abort
     @Test
-    fun abort() = bankSetup { _ ->
+    fun abort() = bankSetup {
         // Check abort created
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:1" } 
         }.assertOkJson<BankAccountCreateWithdrawalResponse> {
-            val uuid = it.taler_withdraw_uri.split("/").last()
+            val uuid = it.withdrawal_id
 
             // Check OK
             
client.postA("/taler-integration/withdrawal-operation/$uuid/abort").assertNoContent()
@@ -157,7 +213,7 @@ class BankIntegrationApiTest {
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:1" } 
         }.assertOkJson<BankAccountCreateWithdrawalResponse> {
-            val uuid = it.taler_withdraw_uri.split("/").last()
+            val uuid = it.withdrawal_id
             withdrawalSelect(uuid)
 
             // Check OK
@@ -170,7 +226,7 @@ class BankIntegrationApiTest {
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:1" } 
         }.assertOkJson<BankAccountCreateWithdrawalResponse> {
-            val uuid = it.taler_withdraw_uri.split("/").last()
+            val uuid = it.withdrawal_id
             withdrawalSelect(uuid)
             
client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent()
 
diff --git a/bank/src/test/kotlin/CommonApiTest.kt 
b/bank/src/test/kotlin/CommonApiTest.kt
index a31483b6..12edf53e 100644
--- a/bank/src/test/kotlin/CommonApiTest.kt
+++ b/bank/src/test/kotlin/CommonApiTest.kt
@@ -24,7 +24,7 @@ import tech.libeufin.common.*
 
 class CommonApiTest {
     @Test
-    fun commonErr() = bankSetup { _ -> 
+    fun commonErr() = bankSetup { 
         
client.get("/unknown").assertNotFound(TalerErrorCode.GENERIC_ENDPOINT_UNKNOWN)
         client.post("/config").assertStatus(HttpStatusCode.MethodNotAllowed, 
TalerErrorCode.GENERIC_METHOD_INVALID)
     }
diff --git a/bank/src/test/kotlin/ConversionApiTest.kt 
b/bank/src/test/kotlin/ConversionApiTest.kt
index c4aa0096..a736f839 100644
--- a/bank/src/test/kotlin/ConversionApiTest.kt
+++ b/bank/src/test/kotlin/ConversionApiTest.kt
@@ -26,13 +26,13 @@ import kotlin.test.assertEquals
 class ConversionApiTest {
     // GET /conversion-info/config
     @Test
-    fun config() = bankSetup { _ ->
+    fun config() = bankSetup {
         client.get("/conversion-info/config").assertOk()
     }
     
     // GET /conversion-info/cashout-rate
     @Test
-    fun cashoutRate() = bankSetup { _ ->
+    fun cashoutRate() = bankSetup {
         // Check conversion to
         
client.get("/conversion-info/cashout-rate?amount_debit=KUDOS:1").assertOkJson<ConversionResponse>
 {
             assertEquals(TalerAmount("KUDOS:1"), it.amount_debit)
@@ -67,7 +67,7 @@ class ConversionApiTest {
 
     // GET /conversion-info/cashin-rate
     @Test
-    fun cashinRate() = bankSetup { _ ->
+    fun cashinRate() = bankSetup {
         for ((amount, converted) in listOf(
             Pair(0.75, 0.58), Pair(0.32, 0.24), Pair(0.66, 0.51)
         )) {
@@ -117,7 +117,7 @@ class ConversionApiTest {
     }
 
     @Test
-    fun notImplemented() = bankSetup("test_no_conversion.conf") { _ ->
+    fun notImplemented() = bankSetup("test_no_conversion.conf") {
         client.get("/conversion-info/cashin-rate")
             .assertNotImplemented()
         client.get("/conversion-info/cashout-rate")
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt 
b/bank/src/test/kotlin/CoreBankApiTest.kt
index 43a65985..b7fc128c 100644
--- a/bank/src/test/kotlin/CoreBankApiTest.kt
+++ b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -36,13 +36,13 @@ import kotlin.test.assertNull
 class CoreBankConfigTest {
     // GET /config
     @Test
-    fun config() = bankSetup { _ -> 
+    fun config() = bankSetup { 
         client.get("/config").assertOk()
     }
 
     // GET /monitor
     @Test
-    fun monitor() = bankSetup { _ -> 
+    fun monitor() = bankSetup { 
         authRoutine(HttpMethod.Get, "/monitor", requireAdmin = true)
         // Check OK
         client.get("/monitor?timeframe=day&which=25") {
@@ -65,7 +65,7 @@ class CoreBankTokenApiTest {
             json { "scope" to "readonly" }
         }.assertOkJson<TokenSuccessResponse> {
             // Checking that the token lifetime defaulted to 24 hours.
-            val token = 
db.token.get(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)))
+            val token = 
db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)),
 Instant.now())
             val lifeTime = Duration.between(token!!.creationTime, 
token.expirationTime)
             assertEquals(Duration.ofDays(1), lifeTime)
         }
@@ -75,26 +75,68 @@ class CoreBankTokenApiTest {
             json { "scope" to "readonly" }
         }.assertOkJson<TokenSuccessResponse> {
             // Checking that the token lifetime defaulted to 24 hours.
-            val token = 
db.token.get(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)))
+            val token = 
db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)),
 Instant.now())
             val lifeTime = Duration.between(token!!.creationTime, 
token.expirationTime)
             assertEquals(Duration.ofDays(1), lifeTime)
         }
 
-        // Check refresh
+        // Check valid refresh scope
+        for ((fromScope, toScope) in listOf(
+            "readwrite" to "readwrite",
+            "readonly" to "readonly",
+            "revenue" to "revenue",
+            "readwrite" to "readonly",
+            "readwrite" to "revenue",
+            "readonly" to "revenue",
+        )) {
+            client.postA("/accounts/merchant/token") {
+                json { 
+                    "scope" to fromScope
+                    "refreshable" to true
+                }
+            }.assertOkJson<TokenSuccessResponse> {
+                val token = it.access_token
+                client.post("/accounts/merchant/token") {
+                    headers["Authorization"] = "Bearer $token"
+                    json { "scope" to toScope }
+                }.assertOk()
+            }
+        }
+
+        // Check invalid refresh scope
+        for ((fromScope, toScope) in listOf(
+            "readonly" to "readwrite",
+            "revenue" to "readonly",
+            "revenue" to "readwrite"
+        )) {
+            client.postA("/accounts/merchant/token") {
+                json { 
+                    "scope" to fromScope
+                    "refreshable" to true
+                }
+            }.assertOkJson<TokenSuccessResponse> {
+                val token = it.access_token
+                client.post("/accounts/merchant/token") {
+                    headers["Authorization"] = "Bearer $token"
+                    json { "scope" to toScope }
+                
}.assertForbidden(TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT)
+            }
+        }
+
+        // Check no refreshable
         client.postA("/accounts/merchant/token") {
             json { 
                 "scope" to "readonly"
-                "refreshable" to true
             }
         }.assertOkJson<TokenSuccessResponse> {
             val token = it.access_token
             client.post("/accounts/merchant/token") {
                 headers["Authorization"] = "Bearer $token"
                 json { "scope" to "readonly" }
-            }.assertOk()
+            }.assertUnauthorized()
         }
         
-        // Check'forever' case.
+        // Check 'forever' case.
         client.postA("/accounts/merchant/token") {
             json { 
                 "scope" to "readonly"
@@ -136,7 +178,7 @@ class CoreBankTokenApiTest {
 
     // DELETE /accounts/USERNAME/token
     @Test
-    fun delete() = bankSetup { _ -> 
+    fun delete() = bankSetup { 
         // TODO test restricted
         val token = client.postA("/accounts/merchant/token") {
             json { "scope" to "readonly" }
@@ -153,12 +195,43 @@ class CoreBankTokenApiTest {
         // Checking merchant can still be served by basic auth, after token 
deletion.
         client.getA("/accounts/merchant").assertOk()
     }
+
+    // GET /accounts/USERNAME/token
+    @Test
+    fun get() = bankSetup {
+        // Check OK
+        for (account in listOf("merchant", "customer")) {
+            client.getA("/accounts/$account/tokens").assertNoContent()
+        }
+        client.postA("/accounts/merchant/token") {
+            json { "scope" to "readonly" }
+        }.assertOk()
+        client.postA("/accounts/merchant/token") {
+            json { "scope" to "readwrite" }
+        }.assertOk()
+        client.postA("/accounts/customer/token") {
+            json {
+                "scope" to "revenue"
+                "description" to "description"
+            }
+        }.assertOk()
+        client.getA("/accounts/merchant/tokens").assertOkJson<TokenInfos> {
+            assertEquals(2, it.tokens.size)
+            for (token in it.tokens) {
+                assertNull(token.description)
+            }
+        }
+        client.getA("/accounts/customer/tokens").assertOkJson<TokenInfos> {
+            assertEquals(1, it.tokens.size)
+            assertEquals("description", it.tokens[0].description)
+        }
+    }
 }
 
 class CoreBankAccountsApiTest {
     // Testing the account creation and its idempotency
     @Test
-    fun create() = bankSetup { _ -> 
+    fun create() = bankSetup { 
         // Check generated payto
         obj {
             "username" to "john"
@@ -376,7 +449,7 @@ class CoreBankAccountsApiTest {
 
     // Test account created with bonus
     @Test
-    fun createBonus() = bankSetup(conf = "test_bonus.conf") { _ ->
+    fun createBonus() = bankSetup(conf = "test_bonus.conf") {
         val req = obj {
             "username" to "foo"
             "password" to "xyz"
@@ -411,7 +484,7 @@ class CoreBankAccountsApiTest {
 
     // Test admin-only account creation
     @Test
-    fun createRestricted() = bankSetup(conf = "test_restrict.conf") { _ -> 
+    fun createRestricted() = bankSetup(conf = "test_restrict.conf") { 
         authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true)
         client.post("/accounts") {
             pwAuth("admin")
@@ -425,7 +498,7 @@ class CoreBankAccountsApiTest {
 
     // Test admin-only account creation
     @Test
-    fun createTanErr() = bankSetup(conf = "test_tan_err.conf") { _ -> 
+    fun createTanErr() = bankSetup(conf = "test_tan_err.conf") { 
         client.post("/accounts") {
             pwAuth("admin")
             json {
@@ -546,7 +619,7 @@ class CoreBankAccountsApiTest {
 
     // Test admin-only account deletion
     @Test
-    fun deleteRestricted() = bankSetup(conf = "test_restrict.conf") { _ -> 
+    fun deleteRestricted() = bankSetup(conf = "test_restrict.conf") { 
         authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true)
         // Exchange is still restricted
         client.delete("/accounts/exchange") {
@@ -556,7 +629,7 @@ class CoreBankAccountsApiTest {
 
     // Test delete exchange account
     @Test
-    fun deleteNoConversion() = bankSetup(conf = "test_no_conversion.conf") { _ 
-> 
+    fun deleteNoConversion() = bankSetup(conf = "test_no_conversion.conf") { 
         // Exchange is no longer restricted
         client.deleteA("/accounts/exchange").assertNoContent()
     }
@@ -582,7 +655,7 @@ class CoreBankAccountsApiTest {
 
     // PATCH /accounts/USERNAME
     @Test
-    fun reconfig() = bankSetup { _ -> 
+    fun reconfig() = bankSetup { 
         authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true)
 
         // Check tan info
@@ -701,7 +774,7 @@ class CoreBankAccountsApiTest {
 
     // Test admin-only account patch
     @Test
-    fun patchRestricted() = bankSetup(conf = "test_restrict.conf") { _ -> 
+    fun patchRestricted() = bankSetup(conf = "test_restrict.conf") { 
         // Check restricted
         checkAdminOnly(
             obj { "name" to "Another Foo" },
@@ -725,7 +798,7 @@ class CoreBankAccountsApiTest {
 
     // Test TAN check account patch
     @Test
-    fun patchTanErr() = bankSetup(conf = "test_tan_err.conf") { _ -> 
+    fun patchTanErr() = bankSetup(conf = "test_tan_err.conf") { 
         // Check unsupported TAN channel
         client.patchA("/accounts/customer") {
             json {
@@ -736,7 +809,7 @@ class CoreBankAccountsApiTest {
 
     // PATCH /accounts/USERNAME/auth
     @Test
-    fun passwordChange() = bankSetup { _ -> 
+    fun passwordChange() = bankSetup { 
         authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", allowAdmin = 
true)
 
         // Changing the password.
@@ -873,7 +946,7 @@ class CoreBankAccountsApiTest {
 
     // GET /accounts/USERNAME
     @Test
-    fun get() = bankSetup { _ -> 
+    fun get() = bankSetup { 
         authRoutine(HttpMethod.Get, "/accounts/merchant", allowAdmin = true)
         // Check ok
         client.getA("/accounts/merchant").assertOkJson<AccountData> {
@@ -885,7 +958,7 @@ class CoreBankAccountsApiTest {
 class CoreBankTransactionsApiTest {
     // GET /transactions
     @Test
-    fun history() = bankSetup { _ -> 
+    fun history() = bankSetup { 
         authRoutine(HttpMethod.Get, "/accounts/merchant/transactions", 
allowAdmin = true)
         historyRoutine<BankAccountTransactionsResponse>(
             url = "/accounts/customer/transactions",
@@ -923,7 +996,7 @@ class CoreBankTransactionsApiTest {
 
     // GET /transactions/T_ID
     @Test
-    fun testById() = bankSetup { _ -> 
+    fun testById() = bankSetup { 
         authRoutine(HttpMethod.Get, "/accounts/merchant/transactions/42", 
allowAdmin = true)
 
         // Create transaction
@@ -1134,19 +1207,73 @@ class CoreBankTransactionsApiTest {
             }
         }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
     }
+
+    @Test
+    fun createWithFee() = bankSetup(conf = "test_with_fees.conf") {
+        // Init state
+        assertBalance("merchant", "+KUDOS:0")
+        assertBalance("customer", "+KUDOS:0")
+        assertBalance("admin", "+KUDOS:0")
+
+        // Check fee are sent to admin
+        tx("merchant", "KUDOS:3", "customer")
+        assertBalance("merchant", "-KUDOS:3.1")
+        assertBalance("customer", "+KUDOS:3")
+        assertBalance("admin", "+KUDOS:0.1")
+
+        // Check amount with fee are checked
+        for (amount in listOf("KUDOS:7", "KUDOS:6.9")) {
+            println(amount)
+            client.postA("/accounts/merchant/transactions") {
+                json {
+                    "payto_uri" to 
"$customerPayto?message=payout2&amount=$amount"
+                }
+            }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
+        }
+
+        // Check empty account
+        tx("merchant", "KUDOS:6.8", "customer")
+        assertBalance("merchant", "-KUDOS:10")
+        assertBalance("customer", "+KUDOS:9.8")
+        assertBalance("admin", "+KUDOS:0.2")
+
+        // Admin check no fee
+        tx("admin", "KUDOS:0.35", "merchant")
+        assertBalance("merchant", "-KUDOS:9.65")
+        assertBalance("admin", "-KUDOS:0.15")
+
+        // Admin recover from debt
+        tx("customer", "KUDOS:1", "merchant")
+        assertBalance("admin", "-KUDOS:0.05")
+        tx("customer", "KUDOS:1", "merchant")
+        assertBalance("merchant", "-KUDOS:7.65")
+        assertBalance("customer", "+KUDOS:7.6")
+        assertBalance("admin", "+KUDOS:0.05")
+    }
 }
 
 class CoreBankWithdrawalApiTest {
     // POST /accounts/USERNAME/withdrawals
     @Test
-    fun create() = bankSetup { _ ->
+    fun create() = bankSetup {
         authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals")
         
         // Check OK
-        client.postA("/accounts/merchant/withdrawals") {
-            json { "amount" to "KUDOS:9.0" } 
-        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
-            
assertEquals("taler+http://withdraw/localhost:80/taler-integration/${it.withdrawal_id}";,
 it.taler_withdraw_uri)
+        for (valid in listOf(
+            obj {}, 
+            obj { "amount" to "KUDOS:1.0" },
+            obj { "suggested_amount" to "KUDOS:2.0" }, 
+            obj {
+                "amount" to "KUDOS:3.0"
+                "suggested_amount" to "KUDOS:4.0"
+            }
+        )) {
+            // Check OK
+            client.postA("/accounts/merchant/withdrawals") {
+                json(valid)
+            }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+                
assertEquals("taler+http://withdraw/localhost:80/taler-integration/${it.withdrawal_id}";,
 it.taler_withdraw_uri)
+            }
         }
 
         // Check exchange account
@@ -1158,18 +1285,42 @@ class CoreBankWithdrawalApiTest {
         client.postA("/accounts/merchant/withdrawals") {
             json { "amount" to "KUDOS:90" } 
         }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
+        client.postA("/accounts/merchant/withdrawals") {
+            json { "suggested_amount" to "KUDOS:90" } 
+        }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
+
+        // Check wrong currency
+        client.postA("/accounts/merchant/withdrawals") {
+            json { "amount" to "EUR:90" } 
+        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
+        client.postA("/accounts/merchant/withdrawals") {
+            json { "suggested_amount" to "EUR:90" } 
+        }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
     }
 
     // GET /withdrawals/withdrawal_id
     @Test
-    fun get() = bankSetup { _ ->
-        val amount = TalerAmount("KUDOS:9.0")
+    fun get() = bankSetup {
         // Check OK
-        client.postA("/accounts/merchant/withdrawals") {
-            json { "amount" to amount}
-        }.assertOkJson<BankAccountCreateWithdrawalResponse> {
-            
client.get("/withdrawals/${it.withdrawal_id}").assertOkJson<WithdrawalPublicInfo>
 {
-                assertEquals(amount, it.amount)
+        for (valid in listOf(
+            Pair(null, null),
+            Pair("KUDOS:1.0", null),
+            Pair(null, "KUDOS:2.0") ,
+            Pair("KUDOS:3.0", "KUDOS:4.0")
+        )) {
+            val amount = valid.first?.run(::TalerAmount)
+            val suggested = valid.second?.run(::TalerAmount)
+            client.postA("/accounts/merchant/withdrawals") {
+                json { 
+                    "amount" to amount
+                    "suggested_amount" to suggested
+                }
+            }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+                client.get("/withdrawals/${it.withdrawal_id}")
+                    .assertOkJson<WithdrawalPublicInfo> {
+                    assertEquals(amount, it.amount)
+                    assertEquals(suggested, it.suggested_amount)
+                }
             }
         }
 
@@ -1186,7 +1337,7 @@ class CoreBankWithdrawalApiTest {
 
     // POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm
     @Test
-    fun confirm() = bankSetup { _ -> 
+    fun confirm() = bankSetup { 
         authRoutine(HttpMethod.Post, 
"/accounts/merchant/withdrawals/42/confirm")
         // Check confirm created
         client.postA("/accounts/merchant/withdrawals") {
@@ -1281,7 +1432,7 @@ class CoreBankWithdrawalApiTest {
 class CoreBankCashoutApiTest {
     // POST /accounts/{USERNAME}/cashouts
     @Test
-    fun create() = bankSetup { _ ->
+    fun create() = bankSetup {
         authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts")
 
         val req = obj {
@@ -1396,7 +1547,7 @@ class CoreBankCashoutApiTest {
 
     // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID}
     @Test
-    fun get() = bankSetup { _ ->
+    fun get() = bankSetup {
         authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts/42", 
allowAdmin = true)
         fillCashoutInfo("customer")
 
@@ -1444,7 +1595,7 @@ class CoreBankCashoutApiTest {
 
     // GET /accounts/{USERNAME}/cashouts
     @Test
-    fun history() = bankSetup { _ ->
+    fun history() = bankSetup {
         authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts", allowAdmin 
= true)
         historyRoutine<Cashouts>(
             url = "/accounts/customer/cashouts",
@@ -1456,7 +1607,7 @@ class CoreBankCashoutApiTest {
 
     // GET /cashouts
     @Test
-    fun globalHistory() = bankSetup { _ ->
+    fun globalHistory() = bankSetup {
         authRoutine(HttpMethod.Get, "/cashouts", requireAdmin = true)
         historyRoutine<GlobalCashouts>(
             url = "/cashouts",
@@ -1468,7 +1619,7 @@ class CoreBankCashoutApiTest {
     }
 
     @Test
-    fun notImplemented() = bankSetup("test_no_conversion.conf") { _ ->
+    fun notImplemented() = bankSetup("test_no_conversion.conf") {
         client.get("/accounts/customer/cashouts")
             .assertNotImplemented()
     }
@@ -1477,7 +1628,7 @@ class CoreBankCashoutApiTest {
 class CoreBankTanApiTest {
     // POST /accounts/{USERNAME}/challenge/{challenge_id}
     @Test
-    fun send() = bankSetup { _ ->
+    fun send() = bankSetup {
         authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42")
 
         suspend fun HttpResponse.expectChallenge(channel: TanChannel, info: 
String): HttpResponse {
@@ -1602,7 +1753,7 @@ class CoreBankTanApiTest {
 
     // POST /accounts/{USERNAME}/challenge/{challenge_id}
     @Test
-    fun sendTanErr() = bankSetup("test_tan_err.conf") { _ ->
+    fun sendTanErr() = bankSetup("test_tan_err.conf") {
         // Check fail
         client.patch("/accounts/merchant") {
             pwAuth("admin")
@@ -1621,7 +1772,7 @@ class CoreBankTanApiTest {
 
     // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm
     @Test
-    fun confirm() = bankSetup { _ ->
+    fun confirm() = bankSetup {
         authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm")
 
         fillTanInfo("merchant")
diff --git a/bank/src/test/kotlin/GcTest.kt b/bank/src/test/kotlin/GcTest.kt
index 17a3b9d0..8993df17 100644
--- a/bank/src/test/kotlin/GcTest.kt
+++ b/bank/src/test/kotlin/GcTest.kt
@@ -81,7 +81,7 @@ class GcTest {
         // Create test tokens
         for (time in listOf(now, clean)) {
             for (account in listOf("old_account", "recent_account")) {
-                assert(db.token.create(account, ByteArray(32).rand(), time, 
time, TokenScope.readonly, false))
+                assert(db.token.create(account, ByteArray(32).rand(), time, 
time, TokenScope.readonly, false, null))
                 db.tan.new(account, Operation.cashout, "", "", time, 0, 
Duration.ZERO, null, null)
             }
         }
@@ -98,26 +98,26 @@ class GcTest {
             for (time in times) {
                 val uuid = UUID.randomUUID()
                 assertEquals(
-                    db.withdrawal.create(account, uuid, from, time),
+                    db.withdrawal.create(account, uuid, from, null, time),
                     WithdrawalCreationResult.Success
                 )
                 assertIs<WithdrawalSelectionResult.Success>(
-                    db.withdrawal.setDetails(uuid, exchangePayto, 
EddsaPublicKey.rand())
+                    db.withdrawal.setDetails(uuid, exchangePayto, 
EddsaPublicKey.rand(), null)
                 )
                 assertEquals(
-                    db.withdrawal.confirm(account, uuid, time, false),
+                    db.withdrawal.confirm(account, uuid, 
TalerAmount("KUDOS:0"), time, false),
                     WithdrawalConfirmationResult.Success
                 )
                 assertIs<CashoutCreationResult.Success>(
                     db.cashout.create(account, ShortHashCode.rand(), from, to, 
"", time, false),
                 )
                 assertIs<BankTransactionResult.Success>(
-                    db.transaction.create(customerPayto, account, "", from, 
time, false, ShortHashCode.rand()),
+                    db.transaction.create(customerPayto, account, "", from, 
time, false, ShortHashCode.rand(), TalerAmount("KUDOS:0")),
                 )
             }
             for (time in listOf(now, abort, clean, delete)) {
                 assertEquals(
-                    db.withdrawal.create(account, UUID.randomUUID(), from, 
time),
+                    db.withdrawal.create(account, UUID.randomUUID(), from, 
null, time),
                     WithdrawalCreationResult.Success
                 )
             }
diff --git a/bank/src/test/kotlin/PaytoTest.kt 
b/bank/src/test/kotlin/PaytoTest.kt
index d8e09d01..d2a893fa 100644
--- a/bank/src/test/kotlin/PaytoTest.kt
+++ b/bank/src/test/kotlin/PaytoTest.kt
@@ -28,7 +28,7 @@ import kotlin.test.assertEquals
 class PaytoTest {
     // x-taler-bank
     @Test
-    fun xTalerBank() = bankSetup("test_x_taler_bank.conf") { _ ->
+    fun xTalerBank() = bankSetup("test_x_taler_bank.conf") {
         // Check Ok
         client.post("/accounts") {
             json {
diff --git a/bank/src/test/kotlin/RevenueApiTest.kt 
b/bank/src/test/kotlin/RevenueApiTest.kt
index 3c692f90..fb4fa170 100644
--- a/bank/src/test/kotlin/RevenueApiTest.kt
+++ b/bank/src/test/kotlin/RevenueApiTest.kt
@@ -24,7 +24,7 @@ import tech.libeufin.common.*
 class RevenueApiTest {
     // GET /accounts/{USERNAME}/taler-revenue/config
     @Test
-    fun config() = bankSetup { _ ->
+    fun config() = bankSetup {
         authRoutine(HttpMethod.Get, "/accounts/merchant/taler-revenue/config")
 
         client.getA("/accounts/merchant/taler-revenue/config").assertOk()
diff --git a/bank/src/test/kotlin/SecurityTest.kt 
b/bank/src/test/kotlin/SecurityTest.kt
index b74dc78d..339bdd3f 100644
--- a/bank/src/test/kotlin/SecurityTest.kt
+++ b/bank/src/test/kotlin/SecurityTest.kt
@@ -32,7 +32,7 @@ inline fun <reified B> HttpRequestBuilder.jsonDeflate(b: B) {
 
 class SecurityTest {
     @Test
-    fun bodySizeLimit() = bankSetup { _ ->
+    fun bodySizeLimit() = bankSetup {
         val valid_req = obj {
             "payto_uri" to "$exchangePayto?message=payout"
             "amount" to "KUDOS:0.3"
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt 
b/bank/src/test/kotlin/WireGatewayApiTest.kt
index 10482bdb..bbfd7f14 100644
--- a/bank/src/test/kotlin/WireGatewayApiTest.kt
+++ b/bank/src/test/kotlin/WireGatewayApiTest.kt
@@ -24,7 +24,7 @@ import tech.libeufin.common.*
 class WireGatewayApiTest {
     // GET /accounts/{USERNAME}/taler-wire-gateway/config
     @Test
-    fun config() = bankSetup { _ ->
+    fun config() = bankSetup {
         authRoutine(HttpMethod.Get, 
"/accounts/merchant/taler-wire-gateway/config")
 
         client.getA("/accounts/merchant/taler-wire-gateway/config").assertOk()
@@ -32,7 +32,7 @@ class WireGatewayApiTest {
 
     // POST /accounts/{USERNAME}/taler-wire-gateway/transfer
     @Test
-    fun transfer() = bankSetup { _ -> 
+    fun transfer() = bankSetup { 
         val valid_req = obj {
             "request_uid" to HashCode.rand()
             "amount" to "KUDOS:55"
@@ -190,7 +190,7 @@ class WireGatewayApiTest {
 
     // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming
     @Test
-    fun addIncoming() = bankSetup { _ -> 
+    fun addIncoming() = bankSetup { 
         val valid_req = obj {
             "amount" to "KUDOS:44"
             "reserve_pub" to EddsaPublicKey.rand()
diff --git a/build.gradle b/build.gradle
index f82d9f57..0233d8c3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,7 @@
 // This file is in the public domain.
 
 plugins {
-    id("org.jetbrains.kotlin.jvm") version "1.9.23"
+    id("org.jetbrains.kotlin.jvm") version "2.0.0"
     id("org.jetbrains.dokka") version "1.9.20"
     id("idea")
     id("java-library")
@@ -19,12 +19,12 @@ if 
(!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)){
 
 allprojects {
     ext {
-        set("kotlin_version", "1.9.23")
-        set("ktor_version", "2.3.9")
-        set("clikt_version", "4.2.2")
-        set("coroutines_version", "1.8.0")
+        set("kotlin_version", "2.0.0")
+        set("ktor_version", "2.3.11")
+        set("clikt_version", "4.4.0")
+        set("coroutines_version", "1.8.1")
         set("postgres_version", "42.7.3")
-        set("junixsocket_version", "2.9.0")
+        set("junixsocket_version", "2.9.1")
     }
 
     repositories {
diff --git a/common/build.gradle b/common/build.gradle
index cdc9c3e0..c2ed1b03 100644
--- a/common/build.gradle
+++ b/common/build.gradle
@@ -16,10 +16,10 @@ compileTestKotlin.kotlinOptions.jvmTarget = "17"
 sourceSets.main.java.srcDirs = ["src/main/kotlin"]
 
 dependencies {
-    implementation("ch.qos.logback:logback-classic:1.5.3")
+    implementation("ch.qos.logback:logback-classic:1.5.6")
     // Crypto
-    implementation("org.bouncycastle:bcprov-jdk18on:1.77")
-    implementation("org.bouncycastle:bcpkix-jdk18on:1.77")
+    implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
+    implementation("org.bouncycastle:bcpkix-jdk18on:1.78.1")
     // Database helper
     implementation("org.postgresql:postgresql:$postgres_version")
     implementation("com.zaxxer:HikariCP:5.1.0")
diff --git a/common/src/main/kotlin/TalerErrorCode.kt 
b/common/src/main/kotlin/TalerErrorCode.kt
index 367daca0..8cf73dc2 100644
--- a/common/src/main/kotlin/TalerErrorCode.kt
+++ b/common/src/main/kotlin/TalerErrorCode.kt
@@ -355,7 +355,7 @@ enum class TalerErrorCode(val code: Int) {
 
   /**
    * The backend could not locate a required template to generate an HTML 
reply. The system administrator should check if the resource files are 
installed in the correct location and are readable to the service.
-   * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR 
(500).
+   * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406).
    * (A value of 0 indicates that the error is generated client-side).
    */
   GENERIC_FAILED_TO_LOAD_TEMPLATE(74),
@@ -1946,7 +1946,7 @@ enum class TalerErrorCode(val code: Int) {
 
 
   /**
-   * The payto-URI hash did not match. Hence the request was denied.
+   * The KYC authorization signature was invalid. Hence the request was denied.
    * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
    * (A value of 0 indicates that the error is generated client-side).
    */
@@ -2017,6 +2017,22 @@ enum class TalerErrorCode(val code: Int) {
   EXCHANGE_KYC_WEBHOOK_UNAUTHORIZED(1938),
 
 
+  /**
+   * The exchange is unaware of the given requirement row.
+   * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  EXCHANGE_KYC_CHECK_REQUEST_UNKNOWN(1939),
+
+
+  /**
+   * The exchange has no account public key to check the KYC authorization 
signature against. Hence the request was denied. The user should do a wire 
transfer to the exchange with the KYC authorization key in the subject.
+   * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  EXCHANGE_KYC_CHECK_AUTHORIZATION_KEY_UNKNOWN(1940),
+
+
   /**
    * The exchange does not know a contract under the given contract public key.
    * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -3633,6 +3649,22 @@ enum class TalerErrorCode(val code: Int) {
   BANK_CONVERSION_AMOUNT_TO_SMALL(5147),
 
 
+  /**
+   * Specified amount will not work for this withdrawal.
+   * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  BANK_AMOUNT_DIFFERS(5148),
+
+
+  /**
+   * The backend requires an amount to be specified.
+   * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  BANK_AMOUNT_REQUIRED(5149),
+
+
   /**
    * The sync service failed find the account in its database.
    * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -4609,6 +4641,54 @@ enum class TalerErrorCode(val code: Int) {
   DONAU_EXCEEDING_DONATION_LIMIT(8610),
 
 
+  /**
+   * The Donau is not aware of the donation unit requested for the operation.
+   * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  DONAU_GENERIC_DONATION_UNIT_UNKNOWN(8611),
+
+
+  /**
+   * The Donau failed to talk to the process responsible for its private 
donation unit keys or the helpers had no donation units (properly) configured.
+   * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  DONAU_DONATION_UNIT_HELPER_UNAVAILABLE(8612),
+
+
+  /**
+   * The Donau failed to talk to the process responsible for its private 
signing keys.
+   * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  DONAU_SIGNKEY_HELPER_UNAVAILABLE(8613),
+
+
+  /**
+   * The response from the online signing key helper process was malformed.
+   * 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).
+   */
+  DONAU_SIGNKEY_HELPER_BUG(8614),
+
+
+  /**
+   * The number of segments included in the URI does not match the number of 
segments expected by the endpoint.
+   * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  DONAU_GENERIC_WRONG_NUMBER_OF_SEGMENTS(8615),
+
+
+  /**
+   * The signature of the donation receipt is not valid.
+   * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+   * (A value of 0 indicates that the error is generated client-side).
+   */
+  DONAU_DONATION_RECEIPT_SIGNATURE_INVALID(8616),
+
+
   /**
    * A generic error happened in the LibEuFin nexus.  See the enclose details 
JSON for more information.
    * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
diff --git a/contrib/bank.conf b/contrib/bank.conf
index a900e426..11cbab57 100644
--- a/contrib/bank.conf
+++ b/contrib/bank.conf
@@ -18,6 +18,9 @@ WIRE_TYPE =
 # Bank display name, used in webui and TAN messages. Default is "Taler Bank"
 # NAME = "Custom Bank"
 
+# Wire transfer execution fees.
+# WIRE_TRANSFER_FEES = KUDOS:10
+
 # Default debt limit for newly created accounts. Default is CURRENCY:0
 # DEFAULT_DEBT_LIMIT = KUDOS:200
 
diff --git a/contrib/wallet-core b/contrib/wallet-core
index ce923bfb..5934e007 160000
--- a/contrib/wallet-core
+++ b/contrib/wallet-core
@@ -1 +1 @@
-Subproject commit ce923bfba1bbbca7e52e1ef0b166a47376f91364
+Subproject commit 5934e007f637bd9834a811e67c0a030d7a59f2c0
diff --git a/database-versioning/libeufin-bank-0003.sql 
b/database-versioning/libeufin-bank-0005.sql
similarity index 54%
copy from database-versioning/libeufin-bank-0003.sql
copy to database-versioning/libeufin-bank-0005.sql
index 6956b3f8..b9fedfbd 100644
--- a/database-versioning/libeufin-bank-0003.sql
+++ b/database-versioning/libeufin-bank-0005.sql
@@ -15,20 +15,20 @@
 
 BEGIN;
 
-SELECT _v.register_patch('libeufin-bank-0003', NULL, NULL);
+SELECT _v.register_patch('libeufin-bank-0005', NULL, NULL);
 SET search_path TO libeufin_bank;
 
-CREATE TABLE bank_transaction_operations
-  (request_uid BYTEA UNIQUE CHECK (LENGTH(request_uid)=32)
-  ,bank_transaction INT8 UNIQUE NOT NULL
-    REFERENCES bank_account_transactions(bank_transaction_id)
-      ON DELETE CASCADE
-  );
-COMMENT ON TABLE bank_transaction_operations
-  IS 'Operation table for idempotent bank transactions.';
+-- Make withdrawal amount optional and add optional suggested_amount
+ALTER TABLE taler_withdrawal_operations ADD suggested_amount taler_amount;
+ALTER TABLE taler_withdrawal_operations ALTER COLUMN amount DROP NOT NULL;
 
-ALTER TABLE customers ADD deleted_at INT8;
-COMMENT ON COLUMN customers.deleted_at
-  IS 'Indicates a deletion request, we keep the account in the database until 
all its transactions have been deleted for compliance.';
+-- Add description and last_access to bearer_tokens
+ALTER TABLE bearer_tokens ADD description TEXT;
+ALTER TABLE bearer_tokens ADD last_access INT8;
+UPDATE bearer_tokens SET last_access=creation_time;
+ALTER TABLE bearer_tokens ALTER COLUMN last_access SET NOT NULL;
+
+-- Add new token scope 'revenue'
+ALTER TYPE token_scope_enum ADD VALUE 'revenue';
 
 COMMIT;
diff --git a/database-versioning/libeufin-bank-procedures.sql 
b/database-versioning/libeufin-bank-procedures.sql
index 4de1c261..7275ac47 100644
--- a/database-versioning/libeufin-bank-procedures.sql
+++ b/database-versioning/libeufin-bank-procedures.sql
@@ -320,9 +320,7 @@ SELECT
     in_subject,
     in_amount,
     in_timestamp,
-    NULL,
-    NULL,
-    NULL
+    (0, 0)::taler_amount
   ) as transfer;
 IF out_exchange_balance_insufficient THEN
   RETURN;
@@ -398,9 +396,7 @@ SELECT
     in_subject,
     in_amount,
     in_timestamp,
-    NULL,
-    NULL,
-    NULL
+    (0, 0)::taler_amount
   ) as transfer;
 IF out_debitor_balance_insufficient THEN
   RETURN;
@@ -418,6 +414,7 @@ CREATE FUNCTION bank_transaction(
   IN in_timestamp INT8,
   IN in_is_tan BOOLEAN,
   IN in_request_uid BYTEA,
+  IN in_wire_transfer_fees taler_amount,
   -- Error status
   OUT out_creditor_not_found BOOLEAN,
   OUT out_debtor_not_found BOOLEAN,
@@ -489,10 +486,12 @@ SELECT
     in_subject,
     in_amount,
     in_timestamp,
-    NULL,
-    NULL,
-    NULL
+    in_wire_transfer_fees
   ) as transfer;
+IF out_balance_insufficient THEN
+  RETURN;
+END IF;
+
 -- Store operation
 IF in_request_uid IS NOT NULL THEN
   INSERT INTO bank_transaction_operations (request_uid, bank_transaction)  
@@ -505,6 +504,7 @@ CREATE FUNCTION create_taler_withdrawal(
   IN in_account_username TEXT,
   IN in_withdrawal_uuid UUID,
   IN in_amount taler_amount,
+  IN in_suggested_amount taler_amount,
   IN in_now_date INT8,
    -- Error status
   OUT out_account_not_found BOOLEAN,
@@ -527,15 +527,23 @@ IF NOT FOUND OR out_account_is_exchange THEN
 END IF;
 
 -- Check enough funds
-SELECT account_balance_is_sufficient(account_id, in_amount) INTO 
out_balance_insufficient;
-IF out_balance_insufficient THEN
-  RETURN;
+IF in_amount IS NOT NULL THEN
+  SELECT account_balance_is_sufficient(account_id, in_amount) INTO 
out_balance_insufficient;
+  IF out_balance_insufficient THEN
+    RETURN;
+  END IF;
+END IF;
+IF in_suggested_amount IS NOT NULL THEN
+  SELECT account_balance_is_sufficient(account_id, in_suggested_amount) INTO 
out_balance_insufficient;
+  IF out_balance_insufficient THEN
+    RETURN;
+  END IF;
 END IF;
 
 -- Create withdrawal operation
 INSERT INTO taler_withdrawal_operations
-    (withdrawal_uuid, wallet_bank_account, amount, creation_date)
-  VALUES (in_withdrawal_uuid, account_id, in_amount, in_now_date);
+    (withdrawal_uuid, wallet_bank_account, amount, suggested_amount, 
creation_date)
+  VALUES (in_withdrawal_uuid, account_id, in_amount, in_suggested_amount, 
in_now_date);
 END $$;
 COMMENT ON FUNCTION create_taler_withdrawal IS 'Create a new withdrawal 
operation';
 
@@ -544,12 +552,15 @@ CREATE FUNCTION select_taler_withdrawal(
   IN in_reserve_pub BYTEA,
   IN in_subject TEXT,
   IN in_selected_exchange_payto TEXT,
+  IN in_amount taler_amount,
   -- Error status
   OUT out_no_op BOOLEAN,
   OUT out_already_selected BOOLEAN,
   OUT out_reserve_pub_reuse BOOLEAN,
   OUT out_account_not_found BOOLEAN,
   OUT out_account_is_not_exchange BOOLEAN,
+  OUT out_missing_amount BOOLEAN,
+  OUT out_amount_differs BOOLEAN,
   -- Success return
   OUT out_status TEXT
 )
@@ -566,11 +577,13 @@ SELECT
     ELSE 'selected'
   END,
   selection_done 
-    AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub 
!= in_reserve_pub)
-  INTO not_selected, out_status, out_already_selected
+    AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub 
!= in_reserve_pub OR amount != in_amount),
+  amount IS NULL AND in_amount IS NULL,
+  amount IS NOT NULL AND amount != in_amount
+  INTO not_selected, out_status, out_already_selected, out_missing_amount, 
out_amount_differs
   FROM taler_withdrawal_operations
   WHERE withdrawal_uuid=in_withdrawal_uuid;
-IF NOT FOUND OR out_already_selected THEN
+IF NOT FOUND OR out_already_selected OR out_missing_amount OR 
out_amount_differs THEN
   out_no_op=NOT FOUND;
   RETURN;
 END IF;
@@ -596,7 +609,11 @@ IF not_selected THEN
 
   -- Update withdrawal operation
   UPDATE taler_withdrawal_operations
-    SET selected_exchange_payto=in_selected_exchange_payto, 
reserve_pub=in_reserve_pub, subject=in_subject, selection_done=true
+    SET selected_exchange_payto=in_selected_exchange_payto,
+        reserve_pub=in_reserve_pub,
+        subject=in_subject,
+        selection_done=true,
+        amount=COALESCE(amount, in_amount)
     WHERE withdrawal_uuid=in_withdrawal_uuid;
 
   -- Notify status change
@@ -632,6 +649,7 @@ CREATE FUNCTION confirm_taler_withdrawal(
   IN in_withdrawal_uuid uuid,
   IN in_confirmation_date INT8,
   IN in_is_tan BOOLEAN,
+  IN in_wire_transfer_fees taler_amount,
   OUT out_no_op BOOLEAN,
   OUT out_balance_insufficient BOOLEAN,
   OUT out_creditor_not_found BOOLEAN,
@@ -677,7 +695,7 @@ IF NOT FOUND OR already_confirmed OR out_aborted OR 
out_not_selected THEN
   RETURN;
 END IF;
 
--- Check exchange account then 2faa
+-- Check exchange account then 2fa
 SELECT
   bank_account_id
   INTO exchange_bank_account_id
@@ -698,9 +716,7 @@ FROM bank_wire_transfer(
   subject_local,
   amount_local,
   in_confirmation_date,
-  NULL,
-  NULL,
-  NULL
+  in_wire_transfer_fees
 ) as transfer;
 IF out_balance_insufficient THEN
   RETURN;
@@ -726,9 +742,7 @@ CREATE FUNCTION bank_wire_transfer(
   IN in_subject TEXT,
   IN in_amount taler_amount,
   IN in_transaction_date INT8,
-  IN in_account_servicer_reference TEXT,
-  IN in_payment_information_id TEXT,
-  IN in_end_to_end_id TEXT,
+  IN in_fee taler_amount,
   -- Error status
   OUT out_balance_insufficient BOOLEAN,
   -- Success return
@@ -737,6 +751,12 @@ CREATE FUNCTION bank_wire_transfer(
 )
 LANGUAGE plpgsql AS $$
 DECLARE
+amount_with_fee taler_amount;
+admin_account_id INT8;
+admin_has_debt BOOLEAN;
+admin_balance taler_amount;
+admin_payto_uri TEXT;
+admin_name TEXT;
 debtor_has_debt BOOLEAN;
 debtor_balance taler_amount;
 debtor_max_debt taler_amount;
@@ -746,12 +766,7 @@ creditor_has_debt BOOLEAN;
 creditor_balance taler_amount;
 creditor_payto_uri TEXT;
 creditor_name TEXT;
-potential_balance taler_amount;
-new_debtor_balance taler_amount;
-new_debtor_balance_ok BOOLEAN;
-new_creditor_balance taler_amount;
-will_debtor_have_debt BOOLEAN;
-will_creditor_have_debt BOOLEAN;
+tmp_balance taler_amount;
 BEGIN
 -- Retrieve debtor info
 SELECT
@@ -768,7 +783,7 @@ SELECT
     JOIN customers ON customer_id=owning_customer_id
   WHERE bank_account_id=in_debtor_account_id;
 IF NOT FOUND THEN
-  RAISE EXCEPTION 'fuck debtor';
+  RAISE EXCEPTION 'Unknown debtor %', in_debtor_account_id;
 END IF;
 -- Retrieve creditor info
 SELECT
@@ -783,7 +798,30 @@ SELECT
     JOIN customers ON customer_id=owning_customer_id
   WHERE bank_account_id=in_creditor_account_id;
 IF NOT FOUND THEN
-  RAISE EXCEPTION 'fuck creditor %', in_creditor_account_id;
+  RAISE EXCEPTION 'Unknown creditor %', in_creditor_account_id;
+END IF;
+-- Retrieve admin info
+SELECT
+  bank_account_id, has_debt,
+  (balance).val, (balance).frac,
+  internal_payto_uri, customers.name
+  INTO 
+    admin_account_id, admin_has_debt,
+    admin_balance.val, admin_balance.frac,
+    admin_payto_uri, admin_name
+  FROM bank_accounts
+    JOIN customers ON customer_id=owning_customer_id
+  WHERE login = 'admin';
+IF NOT FOUND THEN
+  RAISE EXCEPTION 'No admin';
+END IF;
+
+IF in_fee != (0, 0)::taler_amount AND admin_account_id != in_debtor_account_id 
THEN
+  SELECT sum.val, sum.frac 
+    INTO amount_with_fee.val, amount_with_fee.frac 
+    FROM amount_add(in_amount, in_fee) as sum;
+ELSE
+  amount_with_fee = in_amount;
 END IF;
 
 -- DEBTOR SIDE
@@ -791,48 +829,44 @@ END IF;
 IF debtor_has_debt THEN 
   -- debt case: simply checking against the max debt allowed.
   SELECT sum.val, sum.frac 
-    INTO potential_balance.val, potential_balance.frac 
-    FROM amount_add(debtor_balance, in_amount) as sum;
+    INTO debtor_balance.val, debtor_balance.frac 
+    FROM amount_add(debtor_balance, amount_with_fee) as sum;
   SELECT NOT ok
     INTO out_balance_insufficient
     FROM amount_left_minus_right(debtor_max_debt,
-                                 potential_balance);
+                                 debtor_balance);
   IF out_balance_insufficient THEN
     RETURN;
   END IF;
-  new_debtor_balance=potential_balance;
-  will_debtor_have_debt=TRUE;
 ELSE -- not a debt account
   SELECT
     NOT ok,
     (diff).val, (diff).frac
     INTO
       out_balance_insufficient,
-      potential_balance.val,
-      potential_balance.frac
+      tmp_balance.val,
+      tmp_balance.frac
     FROM amount_left_minus_right(debtor_balance,
-                                 in_amount);
+                                 amount_with_fee);
   IF NOT out_balance_insufficient THEN -- debtor has enough funds in the 
(positive) balance.
-    new_debtor_balance=potential_balance;
-    will_debtor_have_debt=FALSE;
+    debtor_balance=tmp_balance;
   ELSE -- debtor will switch to debt: determine their new negative balance.
     SELECT
       (diff).val, (diff).frac
       INTO
-        new_debtor_balance.val, new_debtor_balance.frac
-      FROM amount_left_minus_right(in_amount,
+        debtor_balance.val, debtor_balance.frac
+      FROM amount_left_minus_right(amount_with_fee,
                                    debtor_balance);
-    will_debtor_have_debt=TRUE;
+    debtor_has_debt=TRUE;
     SELECT NOT ok
       INTO out_balance_insufficient
       FROM amount_left_minus_right(debtor_max_debt,
-                                   new_debtor_balance);
+                                   debtor_balance);
     IF out_balance_insufficient THEN
       RETURN;
     END IF;
   END IF;
 END IF;
-out_balance_insufficient=FALSE;
 
 -- CREDITOR SIDE.
 -- Here we figure out whether the creditor would switch
@@ -840,29 +874,57 @@ out_balance_insufficient=FALSE;
 -- accordingly.
 IF NOT creditor_has_debt THEN -- easy case.
   SELECT sum.val, sum.frac 
-    INTO new_creditor_balance.val, new_creditor_balance.frac 
+    INTO creditor_balance.val, creditor_balance.frac 
     FROM amount_add(creditor_balance, in_amount) as sum;
-  will_creditor_have_debt=FALSE;
 ELSE -- creditor had debit but MIGHT switch to credit.
   SELECT
     (diff).val, (diff).frac,
     NOT ok
     INTO
-      new_creditor_balance.val, new_creditor_balance.frac,
-      will_creditor_have_debt
+      tmp_balance.val, tmp_balance.frac,
+      creditor_has_debt
     FROM amount_left_minus_right(in_amount,
                                  creditor_balance);
-  IF will_creditor_have_debt THEN
+  IF NOT creditor_has_debt THEN
+    creditor_balance=tmp_balance;
+  ELSE
     -- the amount is not enough to bring the receiver
     -- to a credit state, switch operators to calculate the new balance.
     SELECT
       (diff).val, (diff).frac
-      INTO new_creditor_balance.val, new_creditor_balance.frac
+      INTO creditor_balance.val, creditor_balance.frac
       FROM amount_left_minus_right(creditor_balance,
                                   in_amount);
   END IF;
 END IF;
 
+-- ADMIN SIDE.
+-- Here we figure out whether the administrator would switch
+-- from debit to a credit situation, and adjust the balance
+-- accordingly.
+IF amount_with_fee != in_amount THEN
+  IF NOT admin_has_debt THEN -- easy case.
+    SELECT sum.val, sum.frac 
+      INTO admin_balance.val, admin_balance.frac 
+      FROM amount_add(admin_balance, in_fee) as sum;
+  ELSE -- creditor had debit but MIGHT switch to credit.
+    SELECT (diff).val, (diff).frac, NOT ok
+      INTO
+        tmp_balance.val, tmp_balance.frac,
+        admin_has_debt
+      FROM amount_left_minus_right(in_fee, admin_balance);
+    IF NOT admin_has_debt THEN
+      admin_balance=tmp_balance;
+    ELSE
+      -- the amount is not enough to bring the receiver
+      -- to a credit state, switch operators to calculate the new balance.
+      SELECT (diff).val, (diff).frac
+        INTO admin_balance.val, admin_balance.frac
+        FROM amount_left_minus_right(admin_balance, in_fee);
+    END IF;
+  END IF;
+END IF;
+
 -- now actually create the bank transaction.
 -- debtor side:
 INSERT INTO bank_account_transactions (
@@ -873,9 +935,6 @@ INSERT INTO bank_account_transactions (
   ,subject
   ,amount
   ,transaction_date
-  ,account_servicer_reference
-  ,payment_information_id
-  ,end_to_end_id
   ,direction
   ,bank_account_id
   )
@@ -887,9 +946,6 @@ VALUES (
   in_subject,
   in_amount,
   in_transaction_date,
-  in_account_servicer_reference,
-  in_payment_information_id,
-  in_end_to_end_id,
   'debit',
   in_debtor_account_id
 ) RETURNING bank_transaction_id INTO out_debit_row_id;
@@ -903,9 +959,6 @@ INSERT INTO bank_account_transactions (
   ,subject
   ,amount
   ,transaction_date
-  ,account_servicer_reference
-  ,payment_information_id
-  ,end_to_end_id
   ,direction
   ,bank_account_id
   )
@@ -917,9 +970,6 @@ VALUES (
   in_subject,
   in_amount,
   in_transaction_date,
-  in_account_servicer_reference,
-  in_payment_information_id,
-  in_end_to_end_id, -- does this interest the receiving party?
   'credit',
   in_creditor_account_id
 ) RETURNING bank_transaction_id INTO out_credit_row_id;
@@ -927,16 +977,58 @@ VALUES (
 -- checks and balances set up, now update bank accounts.
 UPDATE bank_accounts
 SET
-  balance=new_debtor_balance,
-  has_debt=will_debtor_have_debt
+  balance=debtor_balance,
+  has_debt=debtor_has_debt
 WHERE bank_account_id=in_debtor_account_id;
 
 UPDATE bank_accounts
 SET
-  balance=new_creditor_balance,
-  has_debt=will_creditor_have_debt
+  balance=creditor_balance,
+  has_debt=creditor_has_debt
 WHERE bank_account_id=in_creditor_account_id;
 
+-- Fee part
+IF amount_with_fee != in_amount THEN
+  INSERT INTO bank_account_transactions (
+    creditor_payto_uri
+    ,creditor_name
+    ,debtor_payto_uri
+    ,debtor_name
+    ,subject
+    ,amount
+    ,transaction_date
+    ,direction
+    ,bank_account_id
+    )
+  VALUES (
+    admin_payto_uri,
+    admin_name,
+    debtor_payto_uri,
+    debtor_name,
+    'wire transfer fee for tx ' || out_debit_row_id,
+    in_fee,
+    in_transaction_date,
+    'debit',
+    in_debtor_account_id
+  ), (
+    admin_payto_uri,
+    admin_name,
+    debtor_payto_uri,
+    debtor_name,
+    'wire transfer fee for tx ' || out_debit_row_id,
+    in_amount,
+    in_transaction_date,
+    'credit',
+    admin_account_id
+  );
+
+  UPDATE bank_accounts
+  SET
+    balance=admin_balance,
+    has_debt=admin_has_debt
+  WHERE bank_account_id=admin_account_id;
+END IF;
+
 -- notify new transaction
 PERFORM pg_notify('bank_tx', in_debtor_account_id || ' ' || 
in_creditor_account_id || ' ' || out_debit_row_id || ' ' || out_credit_row_id);
 END $$;
@@ -1002,9 +1094,7 @@ SELECT
     in_subject,
     converted_amount,
     in_now_date,
-    NULL,
-    NULL,
-    NULL
+    (0, 0)::taler_amount
   ) as transfer;
 IF out_balance_insufficient THEN
   RETURN;
@@ -1107,9 +1197,7 @@ FROM bank_wire_transfer(
   in_subject,
   in_amount_debit,
   in_now_date,
-  NULL,
-  NULL,
-  NULL
+  (0, 0)::taler_amount
 ) as transfer;
 IF out_balance_insufficient THEN
   RETURN;
diff --git a/database-versioning/libeufin-bank-0004.sql 
b/database-versioning/libeufin-nexus-0004.sql
similarity index 74%
copy from database-versioning/libeufin-bank-0004.sql
copy to database-versioning/libeufin-nexus-0004.sql
index 4d72ea70..5eb696ba 100644
--- a/database-versioning/libeufin-bank-0004.sql
+++ b/database-versioning/libeufin-nexus-0004.sql
@@ -15,11 +15,10 @@
 
 BEGIN;
 
-SELECT _v.register_patch('libeufin-bank-0004', NULL, NULL);
-SET search_path TO libeufin_bank;
+SELECT _v.register_patch('libeufin-nexus-0004', NULL, NULL);
 
-ALTER TABLE bank_accounts ADD min_cashout taler_amount;
-COMMENT ON COLUMN bank_accounts.min_cashout
-  IS 'Custom minimum cashout amount for this account';
+SET search_path TO libeufin_nexus;
 
+-- TODO fix this hack in a future update
+ALTER TABLE incoming_transactions ALTER COLUMN bank_id DROP NOT NULL;
 COMMIT;
diff --git a/database-versioning/libeufin-nexus-procedures.sql 
b/database-versioning/libeufin-nexus-procedures.sql
index db256da7..ce65edc3 100644
--- a/database-versioning/libeufin-nexus-procedures.sql
+++ b/database-versioning/libeufin-nexus-procedures.sql
@@ -228,15 +228,28 @@ CREATE FUNCTION register_incoming_and_talerable(
   ,OUT out_tx_id INT8
 )
 LANGUAGE plpgsql AS $$
+DECLARE 
+need_reconcile BOOLEAN;
 BEGIN
--- Check conflict
-IF EXISTS (
-  SELECT FROM talerable_incoming_transactions 
-  JOIN incoming_transactions USING(incoming_transaction_id)
-  WHERE reserve_public_key = in_reserve_public_key
-  AND bank_id != in_bank_id
-) THEN
-  out_reserve_pub_reuse = TRUE;
+-- Check if exists
+SELECT incoming_transaction_id, 
+    bank_id IS DISTINCT FROM in_bank_id, 
+    bank_id IS NULL AND amount = in_amount 
+                    AND debit_payto_uri = in_debit_payto_uri
+                    AND wire_transfer_subject = in_wire_transfer_subject
+  INTO out_tx_id, out_reserve_pub_reuse, need_reconcile
+  FROM talerable_incoming_transactions 
+  JOIN incoming_transactions USING(incoming_transaction_id) 
+  WHERE reserve_public_key = in_reserve_public_key;
+
+IF FOUND THEN
+  IF need_reconcile THEN
+    IF in_bank_id IS NOT NULL THEN
+      -- Update the bank_id now that we have it
+      UPDATE incoming_transactions SET bank_id = in_bank_id WHERE 
incoming_transaction_id = out_tx_id;
+    END IF;
+    out_reserve_pub_reuse=false;
+  END IF;
   RETURN;
 END IF;
 
diff --git a/gradle/wrapper/gradle-wrapper.jar 
b/gradle/wrapper/gradle-wrapper.jar
index d64cd491..e6441136 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and 
b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties 
b/gradle/wrapper/gradle-wrapper.properties
index b82aa23a..a4413138 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index 1aa94a42..b740cf13 100755
--- a/gradlew
+++ b/gradlew
@@ -55,7 +55,7 @@
 #       Darwin, MinGW, and NonStop.
 #
 #   (3) This script is generated from the Groovy template
-#       
https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       
https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
 #       within the Gradle project.
 #
 #       You can find Gradle at https://github.com/gradle/gradle/.
diff --git a/nexus/build.gradle b/nexus/build.gradle
index de8b8648..f7176de9 100644
--- a/nexus/build.gradle
+++ b/nexus/build.gradle
@@ -31,7 +31,7 @@ dependencies {
     implementation("io.ktor:ktor-client-cio:$ktor_version")
 
     // PDF generation
-    implementation("com.itextpdf:itext-core:8.0.3")
+    implementation("com.itextpdf:itext-core:8.0.4")
 
     // UNIX domain sockets support (used to connect to PostgreSQL)
     
implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version")
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
index f1e85513..e81cff4d 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt
@@ -122,6 +122,10 @@ suspend fun ingestIncomingPayment(
     accountType: AccountType
 ) {
     suspend fun bounce(msg: String) {
+        if (payment.bankId == null) {
+            logger.debug("$payment ignored: missing bank ID")
+            return;
+        }
         when (accountType) {
             AccountType.exchange -> {
                 val result = db.payment.registerMalformedIncoming(
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
index fce0b224..76f486fa 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt
@@ -271,8 +271,8 @@ sealed interface TxNotification {
 
 /** ISO20022 incoming payment */
 data class IncomingPayment(
-    /** ISO20022 AccountServicerReference */
-    val bankId: String,
+    /** ISO20022 UETR & TxID */
+    val bankId: String? = null, // Null when TxID is wrong with Atruvia's 
implementation of instant transactions
     val amount: TalerAmount,
     val wireTransferSubject: String,
     override val executionTime: Instant,
@@ -288,9 +288,9 @@ data class OutgoingPayment(
     /** ISO20022 MessageIdentification & EndToEndId */
     val messageId: String,
     val amount: TalerAmount,
-    val wireTransferSubject: String? = null, // not showing in camt.054
+    val wireTransferSubject: String? = null, // Some implementation does not 
provide this for recovery
     override val executionTime: Instant,
-    val creditPaytoUri: String? = null, // not showing in camt.054
+    val creditPaytoUri: String? = null // Some implementation does not provide 
this for recovery
 ): TxNotification {
     override fun toString(): String {
         return "OUT ${executionTime.fmtDate()} $amount '$messageId' 
creditor=$creditPaytoUri subject=\"$wireTransferSubject\""
@@ -324,29 +324,53 @@ fun parseTx(
     /*
         In ISO 20022 specifications, most fields are optional and the same 
information 
         can be written several times in different places. For libeufin, we're 
only 
-        interested in a subset of the available values that can be found in 
both camt.053 
-        and camt.054. This function should not fail on legitimate files and 
should simply 
-        warn when available information are insufficient.
+        interested in a subset of the available values that can be found in 
both camt.052,
+        camt.053 and camt.054. This function should not fail on legitimate 
files and should 
+        simply warn when available information are insufficient.
+
+        EBICS and ISO20022 do not provide a perfect transaction identifier. 
The best is the 
+        UETR (unique end-to-end transaction reference), which is a universally 
unique 
+        identifier (UUID). However, it is not supplied by all banks. TxId 
(TransactionIdentification) 
+        is a unique identification as assigned by the first instructing agent. 
As its format 
+        is ambiguous, its uniqueness is not guaranteed by the standard, and it 
is only 
+        supposed to be unique for a “pre-agreed period”, whatever that means. 
These two 
+        identifiers are optional in the standard, but have the advantage of 
being unique 
+        and can be used to track a transaction between banks so we use them 
when available.
+
+        It is also possible to use AccountServicerReference, which is a unique 
reference 
+        assigned by the account servicing institution. They can be present at 
several levels
+        (batch level, transaction level, etc.) and are often optional. They 
also have the 
+        disadvantage of being known only by the account servicing institution. 
They should 
+        therefore only be used as a last resort.
     */
 
-    /** Assert that transaction status is BOOK */
-    fun XmlDestructor.assertBooked(ref: String?) {
-        one("Sts") {
+    /** Check if a transaction status is BOOK */
+    fun XmlDestructor.isBooked(): Boolean {
+        // We check at the Sts or Sts/Cd level for retrocompatibility
+        return one("Sts") {
             val status = opt("Cd")?.text() ?: text()
-            require(status == "BOOK") {
-                "Found non booked entry $ref, stop parsing: expected BOOK got 
$status"
-            }
+            status == "BOOK"
         }
     }
 
+    /** Parse the instruction execution date */
     fun XmlDestructor.executionDate(): Instant {
         // Value date if present else booking date
-        return (opt("ValDt") ?: 
one("BookgDt")).one("Dt").date().atStartOfDay().toInstant(ZoneOffset.UTC)
+        val date = opt("ValDt") ?: one("BookgDt")
+        val parsed = date.opt("Dt") {
+            date().atStartOfDay()
+        } ?: date.one("DtTm") {
+            dateTime()
+        }
+        return parsed.toInstant(ZoneOffset.UTC)
     }
 
+    /** Parse original transaction ID generated by libeufin-nexus */
     fun XmlDestructor.nexusId(): String? =
+        // We check at the EndToEndId or MsgId level for retrocompatibility
         opt("Refs") { opt("EndToEndId")?.textProvided() ?: 
opt("MsgId")?.text() }
     
+    /** Parse and format transaction return reasons */
     fun XmlDestructor.returnReason(): String = one("RtrInf") {
         val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>()
         val info = opt("AddtlInf")?.text()
@@ -383,13 +407,14 @@ fun parseTx(
 
     XmlDestructor.fromStream(notifXml, "Document") { when (dialect) {
         Dialect.gls -> {
+            /** Common parsing logic for camt.052 and camt.053 */
             fun XmlDestructor.parseGlsInner() {
                 opt("Acct") {
                     // Sanity check on currency and IBAN ?
                 }
                 each("Ntry") {
+                    if (!isBooked()) return@each
                     val entryRef = opt("AcctSvcrRef")?.text()
-                    assertBooked(entryRef)
                     val bookDate = executionDate()
                     val kind = one("CdtDbtInd").enum<Kind>()
                     val amount = amount(acceptedCurrency)
@@ -438,38 +463,41 @@ fun parseTx(
                     }
                 }
             }
-            opt("BkToCstmrAcctRpt")?.each("Rpt") { // Camt.052
+            opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
+                // All transactions appear here the day after they are booked
                 parseGlsInner()
             }
-            opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
+            opt("BkToCstmrAcctRpt")?.each("Rpt") { // Camt.052
+                // Transactions might appear here first before the end of the 
day
                 parseGlsInner()
             }
             opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054
+                // Instant transactions appear here a few seconds after being 
booked
                 opt("Acct") {
                     // Sanity check on currency and IBAN ?
                 }
                 each("Ntry") {
+                    if (!isBooked()) return@each
+                    if (isReversalCode()) return@each
                     val entryRef = opt("AcctSvcrRef")?.text()
-                    assertBooked(entryRef)
                     val bookDate = executionDate()
                     val kind = one("CdtDbtInd").enum<Kind>()
                     val amount = amount(acceptedCurrency)
-                    if (!isReversalCode()) {
-                        one("NtryDtls").one("TxDtls") {
-                            val txRef = one("Refs").opt("AcctSvcrRef")?.text()
-                            val subject = opt("RmtInf")?.map("Ustrd") { text() 
}?.joinToString("")
-                            if (kind == Kind.CRDT) {
-                                val bankId = one("Refs").opt("TxId")?.text()
-                                val debtorPayto = opt("RltdPties") { 
payto("Dbtr") }
-                                txsInfo.add(TxInfo.Credit(
-                                    ref = bankId ?: txRef ?: entryRef,
-                                    bookDate = bookDate,
-                                    bankId = bankId,
-                                    amount = amount,
-                                    subject = subject,
-                                    debtorPayto = debtorPayto
-                                ))
-                            }
+                    one("NtryDtls").one("TxDtls") {
+                        val txRef = one("Refs").opt("AcctSvcrRef")?.text()
+                        val subject = opt("RmtInf")?.map("Ustrd") { text() 
}?.joinToString("")
+                        if (kind == Kind.CRDT) {
+                            val bankId = one("Refs").opt("TxId")?.text()
+                            val debtorPayto = opt("RltdPties") { payto("Dbtr") 
}
+                            txsInfo.add(TxInfo.Credit(
+                                ref = txRef ?: entryRef,
+                                bookDate = bookDate,
+                                // TODO use the bank ID again when Atruvia's 
implementation is fixed
+                                bankId = null,
+                                amount = amount,
+                                subject = subject,
+                                debtorPayto = debtorPayto
+                            ))
                         }
                     }
                 }
@@ -477,70 +505,79 @@ fun parseTx(
         }
         Dialect.postfinance -> {
             opt("BkToCstmrStmt")?.each("Stmt") { // Camt.053
+                /*
+                    All transactions appear here on the day following their 
booking. Alas, some 
+                    necessary metadata is missing, which is only present in 
camt.054. However, 
+                    this file contains the structured return reasons that are 
missing from the 
+                    camt.054 files. That's why we only use this file for this 
purpose.
+                */
                 opt("Acct") {
                     // Sanity check on currency and IBAN ?
                 }
                 each("Ntry") {
+                    if (!isBooked()) return@each
+                    // Non reversal transaction are handled in camt.054
+                    if (!isReversalCode()) return@each
+
                     val entryRef = opt("AcctSvcrRef")?.text()
-                    assertBooked(entryRef)
                     val bookDate = executionDate()
-                    if (isReversalCode()) {
-                        one("NtryDtls").one("TxDtls") {
-                            val kind = one("CdtDbtInd").enum<Kind>()
-                            if (kind == Kind.CRDT) {
-                                val txRef = 
opt("Refs")?.opt("AcctSvcrRef")?.text()
-                                val nexusId = nexusId()
-                                val reason = returnReason()
-                                txsInfo.add(TxInfo.CreditReversal(
-                                    ref = nexusId ?: txRef ?: entryRef,
-                                    bookDate = bookDate,
-                                    nexusId = nexusId,
-                                    reason = reason
-                                ))
-                            }
+                    one("NtryDtls").one("TxDtls") {
+                        val kind = one("CdtDbtInd").enum<Kind>()
+                        if (kind == Kind.CRDT) {
+                            val txRef = opt("Refs")?.opt("AcctSvcrRef")?.text()
+                            val nexusId = nexusId()
+                            val reason = returnReason()
+                            txsInfo.add(TxInfo.CreditReversal(
+                                ref = nexusId ?: txRef ?: entryRef,
+                                bookDate = bookDate,
+                                nexusId = nexusId,
+                                reason = reason
+                            ))
                         }
                     }
                 }
             }
             opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { // Camt.054
+                // Instant transactions appear here a moment after being booked
                 opt("Acct") {
                     // Sanity check on currency and IBAN ?
                 }
                 each("Ntry") {
+                    if (!isBooked()) return@each
+                    // Reversal are handled from camt.053
+                    if (isReversalCode()) return@each
+
                     val entryRef = opt("AcctSvcrRef")?.text()
-                    assertBooked(entryRef)
                     val bookDate = executionDate()
-                    if (!isReversalCode()) {
-                        one("NtryDtls").each("TxDtls") {
-                            val kind = one("CdtDbtInd").enum<Kind>()
-                            val amount = amount(acceptedCurrency)
-                            val txRef = one("Refs").opt("AcctSvcrRef")?.text()
-                            val subject = opt("RmtInf")?.map("Ustrd") { text() 
}?.joinToString("")
-                            when (kind) {
-                                Kind.CRDT -> {
-                                    val bankId = 
one("Refs").opt("UETR")?.text()
-                                    val debtorPayto = opt("RltdPties") { 
payto("Dbtr") }
-                                    txsInfo.add(TxInfo.Credit(
-                                        ref = bankId ?: txRef ?: entryRef,
-                                        bookDate = bookDate,
-                                        bankId = bankId,
-                                        amount = amount,
-                                        subject = subject,
-                                        debtorPayto = debtorPayto
-                                    ))
-                                }
-                                Kind.DBIT -> {
-                                    val nexusId = nexusId()
-                                    val creditorPayto = opt("RltdPties") { 
payto("Cdtr") }
-                                    txsInfo.add(TxInfo.Debit(
-                                        ref = nexusId ?: txRef ?: entryRef,
-                                        bookDate = bookDate,
-                                        nexusId = nexusId,
-                                        amount = amount,
-                                        subject = subject,
-                                        creditorPayto = creditorPayto
-                                    ))
-                                }
+                    one("NtryDtls").each("TxDtls") {
+                        val kind = one("CdtDbtInd").enum<Kind>()
+                        val amount = amount(acceptedCurrency)
+                        val txRef = one("Refs").opt("AcctSvcrRef")?.text()
+                        val subject = opt("RmtInf")?.map("Ustrd") { text() 
}?.joinToString("")
+                        when (kind) {
+                            Kind.CRDT -> {
+                                val bankId = one("Refs").opt("UETR")?.text()
+                                val debtorPayto = opt("RltdPties") { 
payto("Dbtr") }
+                                txsInfo.add(TxInfo.Credit(
+                                    ref = bankId ?: txRef ?: entryRef,
+                                    bookDate = bookDate,
+                                    bankId = bankId,
+                                    amount = amount,
+                                    subject = subject,
+                                    debtorPayto = debtorPayto
+                                ))
+                            }
+                            Kind.DBIT -> {
+                                val nexusId = nexusId()
+                                val creditorPayto = opt("RltdPties") { 
payto("Cdtr") }
+                                txsInfo.add(TxInfo.Debit(
+                                    ref = nexusId ?: txRef ?: entryRef,
+                                    bookDate = bookDate,
+                                    nexusId = nexusId,
+                                    amount = amount,
+                                    subject = subject,
+                                    creditorPayto = creditorPayto
+                                ))
                             }
                         }
                     }
@@ -604,8 +641,8 @@ private fun parseTxLogic(info: TxInfo): TxNotification {
             )
         }
         is TxInfo.Credit -> {
-            if (info.bankId == null)
-                throw TxErr("missing bank ID for Credit ${info.ref}")
+            /*if (info.bankId == null) TODO use the bank ID again when 
Atruvia's implementation is fixed
+                throw TxErr("missing bank ID for Credit ${info.ref}")*/
             if (info.subject == null)
                 throw TxErr("missing subject for Credit ${info.ref}")
             if (info.debtorPayto == null)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
index f93b1829..09d44cd0 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -346,7 +346,7 @@ class ListCmd: CliktCommand("List nexus transactions", name 
= "list") {
                         txs.map {
                             listOf(
                                 "${it.date} ${it.amount}",
-                                it.id,
+                                it.id.toString(),
                                 it.reservePub?.toString() ?: "",
                                 fmtPayto(it.debtor),
                                 it.subject
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt 
b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
index a07857cf..4a2d33bb 100644
--- a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
+++ b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt
@@ -283,7 +283,7 @@ data class IncomingTxMetadata(
     val amount: DecimalNumber,
     val subject: String,
     val debtor: String,
-    val id: String,
+    val id: String?,
     val reservePub: EddsaPublicKey?
 )
 
diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt
index 52b131cc..24db1e08 100644
--- a/nexus/src/test/kotlin/CliTest.kt
+++ b/nexus/src/test/kotlin/CliTest.kt
@@ -151,5 +151,8 @@ class CliTest {
         talerableOut(db)
         talerableIn(db)
         check()
+        // Check with null id
+        talerableIn(db, true)
+        check()
     }
 }
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/DatabaseTest.kt 
b/nexus/src/test/kotlin/DatabaseTest.kt
index 29a79799..8ceb5af4 100644
--- a/nexus/src/test/kotlin/DatabaseTest.kt
+++ b/nexus/src/test/kotlin/DatabaseTest.kt
@@ -19,7 +19,9 @@
 
 import org.junit.Test
 import tech.libeufin.common.*
+import tech.libeufin.common.db.*
 import tech.libeufin.nexus.db.InitiatedDAO.PaymentInitiationResult
+import tech.libeufin.nexus.db.*
 import tech.libeufin.nexus.*
 import java.time.Instant
 import kotlin.test.assertEquals
@@ -71,6 +73,23 @@ class OutgoingPaymentsTest {
     }
 }
 
+suspend fun Database.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: 
Int) {
+    conn {
+        val cIncoming = it.prepareStatement("SELECT count(*) FROM 
incoming_transactions").one { it.getInt(1) }
+        val cBounce = it.prepareStatement("SELECT count(*) FROM 
bounced_transactions").one { it.getInt(1) }
+        val cTalerable = it.prepareStatement("SELECT count(*) FROM 
talerable_incoming_transactions").one { it.getInt(1) }
+        assertEquals(Triple(nbIncoming, nbBounce, nbTalerable), 
Triple(cIncoming, cBounce, cTalerable))
+    }
+}
+
+suspend fun Database.inTxExists(id: String): Boolean = conn {
+    it.prepareStatement("SELECT EXISTS(SELECT FROM incoming_transactions WHERE 
bank_id = ?)").apply {
+        setString(1, id)
+    }.one {
+        it.getBoolean(1)
+    }
+}
+
 class IncomingPaymentsTest {
     // Tests creating and bouncing incoming payments in one DB transaction.
     @Test
@@ -125,6 +144,46 @@ class IncomingPaymentsTest {
             )
         }
     }
+
+    // Test creating an incoming taler transaction without and ID and 
reconcile it later again
+    @Test
+    fun reconcileMissingId() = setup { db, _ ->
+        // Register with missing ID
+        val reserve_pub = ShortHashCode.rand()
+        val incoming = genInPay("history test with $reserve_pub reserve pub")
+        val incomingMissingId = incoming.copy(bankId = null)
+        ingestIncomingPayment(db, incomingMissingId, AccountType.exchange)
+        db.checkCount(1, 0, 1)
+        assertFalse(db.inTxExists(incoming.bankId!!))
+
+        // Idempotent
+        ingestIncomingPayment(db, incomingMissingId, AccountType.exchange)
+        db.checkCount(1, 0, 1)
+
+        // Different metadata is bounced
+        ingestIncomingPayment(db, genInPay("another $reserve_pub reserve 
pub"), AccountType.exchange)
+        db.checkCount(2, 1, 1)
+
+        // Different medata with missing id is ignored
+        ingestIncomingPayment(db, incomingMissingId.copy(amount = 
TalerAmount("KUDOS:9")), AccountType.exchange)
+        db.checkCount(2, 1, 1)
+
+        // Recover bank ID when metadata match
+        ingestIncomingPayment(db, incoming, AccountType.exchange)
+        assertTrue(db.inTxExists(incoming.bankId!!))
+
+        // Idempotent
+        ingestIncomingPayment(db, incoming, AccountType.exchange)
+        db.checkCount(2, 1, 1)
+        
+        // Missing ID is ignored
+        ingestIncomingPayment(db, incomingMissingId, AccountType.exchange)
+        db.checkCount(2, 1, 1)
+
+        // Other ID is bounced known that we know the id
+        ingestIncomingPayment(db, incomingMissingId.copy(bankId = "NEW"), 
AccountType.exchange)
+        db.checkCount(3, 2, 1)
+    }
 }
 class PaymentInitiationsTest {
 
diff --git a/nexus/src/test/kotlin/Iso20022Test.kt 
b/nexus/src/test/kotlin/Iso20022Test.kt
index c0327d69..5ce1a326 100644
--- a/nexus/src/test/kotlin/Iso20022Test.kt
+++ b/nexus/src/test/kotlin/Iso20022Test.kt
@@ -171,7 +171,7 @@ class Iso20022Test {
         assertEquals(
             listOf(
                 IncomingPayment(
-                    bankId = "IS11PGENODEFF2DA8899900378806",
+                    bankId = null, //"IS11PGENODEFF2DA8899900378806",
                     amount = TalerAmount("EUR:2.5"),
                     wireTransferSubject = "Test ICT",
                     executionTime = instant("2024-05-05"),
diff --git a/nexus/src/test/kotlin/Parsing.kt b/nexus/src/test/kotlin/Parsing.kt
index 7f2bb162..515c2ee6 100644
--- a/nexus/src/test/kotlin/Parsing.kt
+++ b/nexus/src/test/kotlin/Parsing.kt
@@ -30,39 +30,14 @@ class Parsing {
     @Test
     fun reservePublicKey() {
         assertFails { parseIncomingTxMetadata("does not contain any reserve") }
-        
-        assertEquals(
-            
EddsaPublicKey("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"),
-            parseIncomingTxMetadata(
-                "noise 4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0 
noise"
-            )
-        )
-        assertEquals(
-            
EddsaPublicKey("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"),
-            parseIncomingTxMetadata(
-                "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0 noise to 
the right"
-            )
-        )
-        assertEquals(
-            
EddsaPublicKey("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"),
-            parseIncomingTxMetadata(
-                "noise to the left 
4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"
-            )
-        )
-        assertEquals(
-            
EddsaPublicKey("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"),
-            parseIncomingTxMetadata(
-                "    4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0     "
-            )
-        )
-        assertEquals(
-            
EddsaPublicKey("4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"),
-            parseIncomingTxMetadata("""
-                noise
-                4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0
-                noise
-            """)
-        )
+        val encoded = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"
+        val key = EddsaPublicKey(encoded)
+        assertEquals(key, parseIncomingTxMetadata("noise $encoded noise"))
+        assertEquals(key, parseIncomingTxMetadata("$encoded noise to the 
right"))
+        assertEquals(key, parseIncomingTxMetadata("noise to the left 
$encoded"))
+        assertEquals(key, parseIncomingTxMetadata("    $encoded     "))
+        assertEquals(key, parseIncomingTxMetadata("noise\n$encoded\nnoise"))
+        assertEquals(key, parseIncomingTxMetadata("Test+$encoded"))
         // Got the first char removed.
         assertFails { 
parseIncomingTxMetadata("MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0") }
     }
diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt 
b/nexus/src/test/kotlin/WireGatewayApiTest.kt
index d7b11536..e50a97c1 100644
--- a/nexus/src/test/kotlin/WireGatewayApiTest.kt
+++ b/nexus/src/test/kotlin/WireGatewayApiTest.kt
@@ -29,7 +29,7 @@ import tech.libeufin.nexus.*
 class WireGatewayApiTest {
     // GET /taler-wire-gateway/config
     @Test
-    fun config() = serverSetup { _ ->
+    fun config() = serverSetup {
         authRoutine(HttpMethod.Get, "/taler-wire-gateway/config")
 
         client.getA("/taler-wire-gateway/config").assertOk()
@@ -37,7 +37,7 @@ class WireGatewayApiTest {
 
     // POST /taler-wire-gateway/transfer
     @Test
-    fun transfer() = serverSetup { _ -> 
+    fun transfer() = serverSetup { 
         val valid_req = obj {
             "request_uid" to HashCode.rand()
             "amount" to "CHF:55"
@@ -179,7 +179,7 @@ class WireGatewayApiTest {
 
     // POST /taler-wire-gateway/admin/add-incoming
     @Test
-    fun addIncoming() = serverSetup { _ -> 
+    fun addIncoming() = serverSetup { 
         val valid_req = obj {
             "amount" to "CHF:44"
             "reserve_pub" to EddsaPublicKey.rand()
@@ -226,7 +226,7 @@ class WireGatewayApiTest {
     }
 
     @Test
-    fun noApi() = serverSetup("mini.conf") { _ ->
+    fun noApi() = serverSetup("mini.conf") {
         client.get("/taler-wire-gateway/config").assertNotImplemented()
     }
 }
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/helpers.kt b/nexus/src/test/kotlin/helpers.kt
index 0f428230..2ac29740 100644
--- a/nexus/src/test/kotlin/helpers.kt
+++ b/nexus/src/test/kotlin/helpers.kt
@@ -147,9 +147,15 @@ suspend fun talerableOut(db: Database) {
 }
 
 /** Ingest a talerable incoming transaction */
-suspend fun talerableIn(db: Database) {
+suspend fun talerableIn(db: Database, nullId: Boolean = false) {
     val reserve_pub = ShortHashCode.rand()
-    ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve 
pub"), AccountType.exchange)
+    ingestIncomingPayment(db, genInPay("history test with $reserve_pub reserve 
pub").run {
+        if (nullId) {
+            copy(bankId = null)
+        } else {
+            this
+        }
+    }, AccountType.exchange)
 }
 
 /** Ingest an incoming transaction */
diff --git a/testbench/src/main/kotlin/Main.kt 
b/testbench/src/main/kotlin/Main.kt
index 8f92d860..f2723b3e 100644
--- a/testbench/src/main/kotlin/Main.kt
+++ b/testbench/src/main/kotlin/Main.kt
@@ -99,7 +99,7 @@ class Cli : CliktCommand("Run integration tests on banks 
provider") {
         FREQUENCY = 5s
 
         [libeufin-nexusdb-postgres]
-        CONFIG = postgres:///libeufincheck
+        CONFIG = postgres:///libeufintestbench
         """)
         val cfg = loadConfig(conf)
 
diff --git a/testbench/src/test/kotlin/IntegrationTest.kt 
b/testbench/src/test/kotlin/IntegrationTest.kt
index 76637495..d2b9d8ef 100644
--- a/testbench/src/test/kotlin/IntegrationTest.kt
+++ b/testbench/src/test/kotlin/IntegrationTest.kt
@@ -106,7 +106,7 @@ class IntegrationTest {
             bankCmd.run("serve $flags")
         }
         
-        setup("conf/mini.conf") { _ ->
+        setup("conf/mini.conf") {
             // Check bank is running
             client.get("http://0.0.0.0:8080/public-accounts";).assertNoContent()
         }

-- 
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]