gnunet-svn
[Top][All Lists]
Advanced

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

[libeufin] branch master updated: Circuit API: implement cash-out.


From: gnunet
Subject: [libeufin] branch master updated: Circuit API: implement cash-out.
Date: Wed, 04 Jan 2023 08:34:05 +0100

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

ms pushed a commit to branch master
in repository libeufin.

The following commit(s) were added to refs/heads/master by this push:
     new 28fa97a6 Circuit API: implement cash-out.
28fa97a6 is described below

commit 28fa97a6a4ed143d47bd2c3a0a4aafbf41e3ea00
Author: MS <ms@taler.net>
AuthorDate: Wed Jan 4 08:33:13 2023 +0100

    Circuit API: implement cash-out.
---
 nexus/build.gradle                                 |   1 +
 nexus/src/test/kotlin/JsonTest.kt                  |  21 +-
 nexus/src/test/kotlin/SandboxCircuitApiTest.kt     | 254 ++++++++++-
 .../kotlin/tech/libeufin/sandbox/CircuitApi.kt     | 474 +++++++++++++++++++--
 .../src/main/kotlin/tech/libeufin/sandbox/DB.kt    |  33 +-
 .../tech/libeufin/sandbox/EbicsProtocolBackend.kt  |   2 +-
 .../main/kotlin/tech/libeufin/sandbox/Helpers.kt   | 114 +++--
 .../src/main/kotlin/tech/libeufin/sandbox/Main.kt  |   6 +-
 .../kotlin/tech/libeufin/sandbox/bankAccount.kt    |  10 +-
 util/src/main/kotlin/CryptoUtil.kt                 |   6 +-
 util/src/main/kotlin/amounts.kt                    |   1 +
 11 files changed, 812 insertions(+), 110 deletions(-)

diff --git a/nexus/build.gradle b/nexus/build.gradle
index f1ccfb22..75f4254c 100644
--- a/nexus/build.gradle
+++ b/nexus/build.gradle
@@ -104,6 +104,7 @@ test {
     failFast = true
     testLogging.showStandardStreams = false
     environment.put("LIBEUFIN_SANDBOX_ADMIN_PASSWORD", "foo")
+    environment.put("LIBEUFIN_CASHOUT_TEST_TAN", "foo")
 }
 
 application {
diff --git a/nexus/src/test/kotlin/JsonTest.kt 
b/nexus/src/test/kotlin/JsonTest.kt
index b67aad44..a1024f85 100644
--- a/nexus/src/test/kotlin/JsonTest.kt
+++ b/nexus/src/test/kotlin/JsonTest.kt
@@ -1,4 +1,5 @@
 import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
 import org.junit.Test
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
 import com.fasterxml.jackson.module.kotlin.readValue
@@ -6,10 +7,13 @@ import io.ktor.client.plugins.*
 import io.ktor.client.request.*
 import io.ktor.http.*
 import io.ktor.server.testing.*
+import org.junit.Ignore
 import tech.libeufin.nexus.server.CreateBankConnectionFromBackupRequestJson
 import tech.libeufin.nexus.server.CreateBankConnectionFromNewRequestJson
 import tech.libeufin.sandbox.sandboxApp
 
+enum class EnumTest { TEST }
+data class EnumWrapper(val enum_test: EnumTest)
 
 class JsonTest {
 
@@ -28,7 +32,20 @@ class JsonTest {
         assert(roundTripNew.data.toString() == "{}" && roundTripNew.type == 
"ebics" && roundTripNew.name == "new-connection")
     }
 
-    /*@Test
+    // Tests how Jackson+Kotlin handle enum types.  Fails if an exception is 
thrown
+    @Test
+    fun enumTest() {
+        val m = jacksonObjectMapper()
+         m.readValue<EnumWrapper>("{\"enum_test\":\"TEST\"}")
+        m.readValue<EnumTest>("\"TEST\"")
+    }
+
+    /**
+     * Ignored because this test was only used to check
+     * the logs, as opposed to assert over values.
+     */
+    @Ignore
+    @Test
     fun testSandboxJsonParsing() {
         testApplication {
             application(sandboxApp)
@@ -38,5 +55,5 @@ class JsonTest {
                 setBody("{}")
             }
         }
-    }*/
+    }
 }
\ No newline at end of file
diff --git a/nexus/src/test/kotlin/SandboxCircuitApiTest.kt 
b/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
index edc7ba74..61699ca6 100644
--- a/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
+++ b/nexus/src/test/kotlin/SandboxCircuitApiTest.kt
@@ -1,11 +1,17 @@
+import com.fasterxml.jackson.databind.ObjectMapper
+import io.ktor.client.plugins.*
 import io.ktor.client.plugins.auth.*
 import io.ktor.client.plugins.auth.providers.*
 import io.ktor.client.request.*
 import io.ktor.client.statement.*
+import io.ktor.http.*
 import io.ktor.server.testing.*
+import io.ktor.util.*
 import kotlinx.coroutines.runBlocking
+import org.jetbrains.exposed.sql.transactions.transaction
 import org.junit.Test
-import tech.libeufin.sandbox.sandboxApp
+import tech.libeufin.nexus.server.client
+import tech.libeufin.sandbox.*
 
 class SandboxCircuitApiTest {
     // Get /config, fails if != 200.
@@ -21,22 +27,258 @@ class SandboxCircuitApiTest {
             }
         }
     }
+    @Test
+    fun contactDataValidation() {
+        // Phone number.
+        assert(checkPhoneNumber("+987"))
+        assert(!checkPhoneNumber("987"))
+        assert(!checkPhoneNumber("foo"))
+        assert(!checkPhoneNumber(""))
+        assert(!checkPhoneNumber("+00"))
+        assert(checkPhoneNumber("+4900"))
+        // E-mail address
+        assert(checkEmailAddress("test@example.com"))
+        assert(!checkEmailAddress("0@0.0"))
+        assert(!checkEmailAddress("foo.bar"))
+        assert(checkEmailAddress("foo.bar@example.com"))
+        assert(!checkEmailAddress("foo+bar@example.com"))
+    }
 
-    // Tests the registration logic.  Triggers
-    // any error code, following at least one execution
-    // path.
+    // Test the creation and confirmation of a cash-out operation.
+    @Test
+    fun cashout() {
+        withTestDatabase {
+            prepSandboxDb()
+            testApplication {
+                application(sandboxApp)
+                // Register a new account.
+                var R = client.post("/demobanks/default/circuit-api/accounts") 
{
+                    expectSuccess = true
+                    contentType(ContentType.Application.Json)
+                    basicAuth("admin", "foo")
+                    setBody("""
+                            {"username":"shop",
+                             "password": "secret",
+                             "contact_data": {},
+                             "name": "Test",
+                             "cashout_address": "payto://iban/SAMPLE"          
         
+                             }
+                        """.trimIndent())
+                }
+                // Give initial balance to the new account.
+                val demobank = getDefaultDemobank()
+                transaction { demobank.usersDebtLimit = 0 }
+                val initialBalance = "TESTKUDOS:50.00"
+                val balanceAfterCashout = "TESTKUDOS:30.00"
+                wireTransfer(
+                    debitAccount = "admin",
+                    creditAccount = "shop",
+                    subject = "cash-out",
+                    amount = initialBalance
+                )
+                // Check the balance before cashing out.
+                R = client.get("/demobanks/default/access-api/accounts/shop") {
+                    basicAuth("shop", "secret")
+                }
+                val mapper = ObjectMapper()
+                var respJson = mapper.readTree(R.bodyAsText())
+                assert(respJson.get("balance").get("amount").asText() == 
initialBalance)
+                // Configure the user phone number, before the cash-out.
+                R = 
client.patch("/demobanks/default/circuit-api/accounts/shop") {
+                    contentType(ContentType.Application.Json)
+                    basicAuth("shop", "secret")
+                    setBody("""
+                        {
+                          "contact_data": {
+                            "phone": "+98765"
+                          },
+                          "cashout_address": "payto://iban/SAMPLE"
+                        }
+                    """.trimIndent())
+                }
+                assert(R.status.value == HttpStatusCode.NoContent.value)
+                /**
+                 * Cash-out a portion.  Ordering a cash-out of 20 TESTKUDOS
+                 * should result in the following final amount, that the user
+                 * will see as incoming in the fiat bank account: 19 = 20 * 
0.95 - 0.00.
+                 * Note: ratios and fees are currently hard-coded.
+                 */
+                R = client.post("/demobanks/default/circuit-api/cashouts") {
+                    contentType(ContentType.Application.Json)
+                    basicAuth("shop", "secret")
+                    setBody("""{
+                        "amount_debit": "TESTKUDOS:20",
+                        "amount_credit": "KUDOS:19"
+                    }""".trimIndent())
+                }
+                assert(R.status.value == HttpStatusCode.Accepted.value)
+                var operationUuid = 
mapper.readTree(R.readBytes()).get("uuid").asText()
+                // Check that the operation is found by the bank.
+                R = 
client.get("/demobanks/default/circuit-api/cashouts/${operationUuid}") {
+                    // Asking as the Admin but for the 'shop' account.
+                    basicAuth("admin", "foo")
+                }
+                // Check that the status is pending.
+                assert(mapper.readTree(R.readBytes()).get("status").asText() 
== "PENDING")
+                // Now confirm the operation.
+                R = 
client.post("/demobanks/default/circuit-api/cashouts/${operationUuid}/confirm") 
{
+                    basicAuth("shop", "secret")
+                    contentType(ContentType.Application.Json)
+                    setBody("{\"tan\":\"foo\"}")
+                    expectSuccess = true
+                }
+                // Check that the operation is found by the bank and set to 
'confirmed'.
+                R = 
client.get("/demobanks/default/circuit-api/cashouts/${operationUuid}") {
+                    // Asking as the Admin but for the 'shop' account.
+                    basicAuth("foo", "foo")
+                }
+                assert(mapper.readTree(R.readBytes()).get("status").asText() 
== "CONFIRMED")
+                // Check that the amount got deducted by the account.
+                R = client.get("/demobanks/default/access-api/accounts/shop") {
+                    basicAuth("shop", "secret")
+                }
+                respJson = mapper.readTree(R.bodyAsText())
+                assert(respJson.get("balance").get("amount").asText() == 
balanceAfterCashout)
+
+                // Create a new cash-out and delete it.
+                R = client.post("/demobanks/default/circuit-api/cashouts") {
+                    contentType(ContentType.Application.Json)
+                    basicAuth("shop", "secret")
+                    setBody("""{
+                        "amount_debit": "TESTKUDOS:20",
+                        "amount_credit": "KUDOS:19"
+                    }""".trimIndent())
+                }
+                assert(R.status.value == HttpStatusCode.Accepted.value)
+                val toAbort = 
mapper.readTree(R.readBytes()).get("uuid").asText()
+                // Check it exists.
+                R = 
client.get("/demobanks/default/circuit-api/cashouts/${toAbort}") {
+                    // Asking as the Admin but for the 'shop' account.
+                    basicAuth("foo", "foo")
+                }
+                assert(R.status.value == HttpStatusCode.OK.value)
+                // Ask to delete the operation.
+                R = 
client.post("/demobanks/default/circuit-api/cashouts/${toAbort}/abort") {
+                    basicAuth("admin", "foo")
+                }
+                assert(R.status.value == HttpStatusCode.NoContent.value)
+                // Check actual disappearance.
+                R = 
client.get("/demobanks/default/circuit-api/cashouts/${toAbort}") {
+                    // Asking as the Admin but for the 'shop' account.
+                    basicAuth("foo", "foo")
+                }
+                assert(R.status.value == HttpStatusCode.NotFound.value)
+                // Ask to delete a confirmed operation.
+                R = 
client.post("/demobanks/default/circuit-api/cashouts/${operationUuid}/abort") {
+                    basicAuth("admin", "foo")
+                }
+                assert(R.status.value == 
HttpStatusCode.PreconditionFailed.value)
+            }
+        }
+    }
+
+    // Test user registration and deletion.
     @Test
     fun registration() {
         withSandboxTestDatabase {
             testApplication {
                 application(sandboxApp)
                 runBlocking {
-                    client.post("/demobanks/default/circuit-api/accounts") {
+                    // Successful registration.
+                    var R = 
client.post("/demobanks/default/circuit-api/accounts") {
+                        expectSuccess = true
+                        contentType(ContentType.Application.Json)
+                        basicAuth("admin", "foo")
+                        setBody("""
+                            {"username":"shop",
+                             "password": "secret",
+                             "contact_data": {},
+                             "name": "Test",
+                             "cashout_address": "payto://iban/SAMPLE"          
         
+                             }
+                        """.trimIndent())
+                    }
+                    assert(R.status.value == HttpStatusCode.NoContent.value)
+                    // Check accounts list.
+                    R = client.get("/demobanks/default/circuit-api/accounts") {
                         basicAuth("admin", "foo")
+                        expectSuccess = true
+                    }
+                    println(R.bodyAsText())
+                    // Update contact data.
+                    R = 
client.patch("/demobanks/default/circuit-api/accounts/shop") {
+                        contentType(ContentType.Application.Json)
+                        basicAuth("shop", "secret")
+                        setBody("""
+                            {"contact_data": {"email": "user@example.com"},
+                             "cashout_address": "payto://iban/SAMPLE"
+                            }
+                        """.trimIndent())
+                    }
+                    assert(R.status.value == HttpStatusCode.NoContent.value)
+                    // Get user data via the Access API.
+                    R = 
client.get("/demobanks/default/access-api/accounts/shop") {
+                        basicAuth("shop", "secret")
+                    }
+                    assert(R.status.value == HttpStatusCode.OK.value)
+                    // Get Circuit data via the Circuit API.
+                    R = 
client.get("/demobanks/default/circuit-api/accounts/shop") {
+                        basicAuth("shop", "secret")
+                    }
+                    println(R.bodyAsText())
+                    assert(R.status.value == HttpStatusCode.OK.value)
+                    // Change password.
+                    R = 
client.patch("/demobanks/default/circuit-api/accounts/shop/auth") {
+                        basicAuth("shop", "secret")
+                        setBody("{\"new_password\":\"new_secret\"}")
+                        contentType(ContentType.Application.Json)
+                    }
+                    assert(R.status.value == HttpStatusCode.NoContent.value)
+                    // Check that the password changed: expect 401 with 
previous password.
+                    R = 
client.get("/demobanks/default/access-api/accounts/shop") {
+                        basicAuth("shop", "secret")
                     }
+                    assert(R.status.value == HttpStatusCode.Unauthorized.value)
+                    // Check that the password changed: expect 200 with 
current password.
+                    R = 
client.get("/demobanks/default/access-api/accounts/shop") {
+                        basicAuth("shop", "new_secret")
+                    }
+                    assert(R.status.value == HttpStatusCode.OK.value)
+                    // Change user balance.
+                    val account = transaction {
+                        val account = BankAccountEntity.find {
+                            BankAccountsTable.label eq "shop"
+                        }.firstOrNull() ?: throw Exception("Circuit test 
account not found in the database!")
+                        account.bonus("TESTKUDOS:30")
+                        account
+                    }
+                    // Delete account.  Fails because the balance is not zero.
+                    R = 
client.delete("/demobanks/default/circuit-api/accounts/shop") {
+                        basicAuth("admin", "foo")
+                    }
+                    assert(R.status.value == 
HttpStatusCode.PreconditionFailed.value)
+                    // Bring the balance again to zero
+                    transaction {
+                        wireTransfer(
+                            "shop",
+                            "admin",
+                            "default",
+                            "deletion condition",
+                            "TESTKUDOS:30"
+                        )
+                    }
+                    // Now delete the account successfully.
+                    R = 
client.delete("/demobanks/default/circuit-api/accounts/shop") {
+                        basicAuth("admin", "foo")
+                    }
+                    assert(R.status.value == HttpStatusCode.NoContent.value)
+                    // Check actual deletion.
+                    R = 
client.get("/demobanks/default/access-api/accounts/shop") {
+                        basicAuth("shop", "secret")
+                    }
+                    assert(R.status.value == HttpStatusCode.NotFound.value)
                 }
             }
         }
-
     }
 }
\ No newline at end of file
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
index a923c052..e28770f7 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/CircuitApi.kt
@@ -6,14 +6,30 @@ import io.ktor.server.request.*
 import io.ktor.server.response.*
 import io.ktor.server.routing.*
 import org.jetbrains.exposed.sql.transactions.transaction
-import tech.libeufin.util.InvalidPaytoError
-import tech.libeufin.util.conflict
-import tech.libeufin.util.parsePayto
+import tech.libeufin.sandbox.CashoutOperationsTable.uuid
+import tech.libeufin.util.*
+import java.math.BigDecimal
+import java.math.MathContext
+import java.util.*
 
 // CIRCUIT API TYPES
+data class CircuitCashoutRequest(
+    val subject: String?,
+    val amount_debit: String, // As specified by the user via the SPA.
+    val amount_credit: String, // What actually to transfer after the rates.
+    /**
+     * The String type here allows more flexibility with regard to
+     * the supported TAN methods.  This way, supported TAN methods
+     * can be specified via the configuration or when starting the
+     * bank.  OTOH, catching unsupported TAN methods only via the
+     * 'enum' type would require to change the source code upon every
+     * change in the TAN policy.
+     */
+    val tan_channel: String?
+)
 
 // Configuration response:
-class ConfigResp(
+data class ConfigResp(
     val name: String = "circuit",
     val version: String = SANDBOX_VERSION,
     val ratios_and_fees: RatioAndFees
@@ -21,70 +37,445 @@ class ConfigResp(
 
 // After fixing #7527, the values held by this
 // type must be read from the configuration.
-class RatioAndFees(
+data class RatioAndFees(
     val buy_at_ratio: Float = 1F,
-    val sell_at_ratio: Float = 0.05F,
+    val sell_at_ratio: Float = 0.95F,
     val buy_in_fee: Float = 0F,
     val sell_out_fee: Float = 0F
 )
+val ratiosAndFees = RatioAndFees()
 
 // User registration request
-class CircuitAccountRequest(
+data class CircuitAccountRequest(
     val username: String,
     val password: String,
-    val contact_data: CircuitAccountData,
+    val contact_data: CircuitContactData,
     val name: String,
     val cashout_address: String, // payto
     val internal_iban: String? // Shall be "= null" ?
 )
 // User contact data to send the TAN.
-class CircuitAccountData(
+data class CircuitContactData(
     val email: String?,
     val phone: String?
 )
 
+data class CircuitAccountReconfiguration(
+    val contact_data: CircuitContactData,
+    val cashout_address: String
+)
+
+data class AccountPasswordChange(
+    val new_password: String
+)
+
+/**
+ * That doesn't belong to the Access API because it
+ * contains the cash-out address and the contact data.
+ */
+data class CircuitAccountInfo(
+    val username: String,
+    val iban: String,
+    val contact_data: CircuitContactData,
+    val name: String,
+    val cashout_address: String
+)
+
+data class CashoutConfirmation(val tan: String)
+
+// Validate phone number
+fun checkPhoneNumber(phoneNumber: String): Boolean {
+    // From Taler TypeScript
+    // /^\+[0-9 ]*$/;
+    val regex = "^\\+[1-9][0-9]+$"
+    val R = Regex(regex)
+    return R.matches(phoneNumber)
+}
+
+// Validate e-mail address
+fun checkEmailAddress(emailAddress: String): Boolean {
+    // From Taler TypeScript:
+    // 
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+    val regex = "^[a-z0-9\\.]+@[a-z0-9\\.]+\\.[a-z]{2,3}$"
+    val R = Regex(regex)
+    return R.matches(emailAddress)
+}
+
+fun throwIfInstitutionalName(resourceName: String) {
+    if (resourceName == "bank" || resourceName == "admin") {
+        val msg = "Can't operate on institutional resource '$resourceName'"
+        logger.info(msg)
+        throw forbidden(msg)
+    }
+}
+
+fun generateCashoutSubject(
+    amountCredit: AmountWithCurrency,
+    amountDebit: AmountWithCurrency
+): String {
+    return "Cash-out of 
${amountDebit.currency}:${amountDebit.amount.toPlainString()}" +
+            " to 
${amountCredit.currency}:${amountCredit.amount.toPlainString()}"
+}
+
 /**
- * Allows only the administrator to add new accounts.
+ * NOTE: future versions take the supported TAN method from
+ * the configuration, or options passed when starting the bank.
  */
+enum class SupportedTanChannels { SMS, EMAIL }
+fun isTanChannelSupported(tanMethod: String): Boolean {
+    return listOf(SupportedTanChannels.SMS.name, 
SupportedTanChannels.EMAIL.name).contains(tanMethod.uppercase())
+}
+
 fun circuitApi(circuitRoute: Route) {
+    // Abort a cash-out operation.
+    circuitRoute.post("/cashouts/{uuid}/abort") {
+        val user = call.request.basicAuth()
+        val uuid = call.getUriComponent("uuid")
+        val maybeOperation = transaction {
+            CashoutOperationEntity.find {
+                CashoutOperationsTable.uuid eq UUID.fromString(uuid)
+            }.firstOrNull()
+        }
+        if (maybeOperation == null) {
+            val msg = "Cash-out operation $uuid not found."
+            logger.debug(msg)
+            throw notFound(msg)
+        }
+        if (maybeOperation.state == CashoutOperationState.CONFIRMED) {
+            val msg = "Cash-out operation '$uuid' was confirmed already."
+            logger.info(msg)
+            throw SandboxError(HttpStatusCode.PreconditionFailed, msg)
+        }
+        if (maybeOperation.state != CashoutOperationState.PENDING) {
+            val msg = "Found an unsupported cash-out operation state: 
${maybeOperation.state}"
+            logger.error(msg)
+            throw internalServerError(msg)
+        }
+        // Operation found and pending: delete from the database.
+        transaction { maybeOperation.delete() }
+        call.respond(HttpStatusCode.NoContent)
+        return@post
+    }
+    // Confirm a cash-out operation
+    circuitRoute.post("/cashouts/{uuid}/confirm") {
+        val user = call.request.basicAuth()
+        // Exclude admin from this operation.
+        if (user == "admin" || user == "bank") {
+            val msg = "Institutional user '$user' shouldn't confirm any 
cash-out."
+            logger.warn(msg)
+            throw conflict(msg)
+        }
+        // Get the operation identifier.
+        val operationUuid = call.getUriComponent("uuid")
+        val op = transaction {
+            CashoutOperationEntity.find {
+                uuid eq UUID.fromString(operationUuid)
+            }.firstOrNull()
+        }
+        // 404 if the operation is not found.
+        if (op == null) {
+            val msg = "Cash-out operation $operationUuid not found"
+            logger.debug(msg)
+            throw notFound(msg)
+        }
+        // 412 if the operation got already confirmefd.
+        if (op.state == CashoutOperationState.CONFIRMED) {
+            val msg = "Cash-out operation $operationUuid was already 
confirmed."
+            logger.debug(msg)
+            throw SandboxError(HttpStatusCode.PreconditionFailed, msg)
+        }
+        /**
+         * Check the TAN.  Give precedence to the TAN found
+         * in the environment, for testing purposes.  If that's
+         * not found, then check with the actual TAN found in
+         * the database.
+         */
+        val req = call.receive<CashoutConfirmation>()
+        val maybeTanFromEnv = System.getenv("LIBEUFIN_CASHOUT_TEST_TAN")
+        val checkTan = maybeTanFromEnv ?: op.tan
+        if (req.tan != checkTan) {
+            logger.debug("The confirmation of '${op.uuid}' has a wrong TAN 
'${req.tan}'")
+            throw forbidden("wrong TAN")
+        }
+        /**
+         * Correct TAN.  Wire the funds to the admin's bank account.  After
+         * this step, the conversion monitor should detect this payment and
+         * soon initiate the final transfer towards the user fiat bank account.
+         * NOTE: the funds availability got already checked when this operation
+         * was created.  On top of that, the 'wireTransfer()' helper does also
+         * check for funds availability.  */
+        wireTransfer(
+            debitAccount = op.account,
+            creditAccount = "admin",
+            subject = op.subject,
+            amount = op.amountDebit
+        )
+        transaction { op.state = CashoutOperationState.CONFIRMED }
+        call.respond(HttpStatusCode.NoContent)
+        return@post
+    }
+    // Retrieve the status of a cash-out operation.
+    circuitRoute.get("/cashouts/{uuid}") {
+        val user = call.request.basicAuth()
+        val operationUuid = call.getUriComponent("uuid")
+        // Get the operation from the database.
+        val maybeOperation = transaction {
+            CashoutOperationEntity.find {
+                uuid eq UUID.fromString(operationUuid)
+            }.firstOrNull()
+        }
+        if (maybeOperation == null) {
+            val msg = "Cash-out operation $operationUuid not found."
+            logger.info(msg)
+            throw notFound(msg)
+        }
+        call.respond(object { val status = maybeOperation.state })
+        return@get
+    }
+    // Create a cash-out operation.
+    circuitRoute.post("/cashouts") {
+        val user = call.request.basicAuth()
+        if (user == "admin" || user == "bank") throw forbidden("$user can't 
cash-out.")
+        // No suitable default user, when the authentication is disabled.
+        if (user == null) throw SandboxError(
+            HttpStatusCode.ServiceUnavailable,
+            "This endpoint isn't served when the authentication is disabled."
+        )
+        val req = call.receive<CircuitCashoutRequest>()
+
+        // validate amounts: well-formed and supported currency.
+        val amountDebit = parseAmount(req.amount_debit) // amount before rates.
+        val amountCredit = parseAmount(req.amount_credit) // amount after 
rates, as expected by the client
+        val demobank = ensureDemobank(call)
+        if (amountDebit.currency != demobank.currency) {
+            val msg = "The '${req::amount_debit.name}' field has the wrong 
currency"
+            logger.info(msg)
+            throw badRequest(msg)
+        }
+        if (amountCredit.currency == demobank.currency) {
+            val msg = "The '${req::amount_credit.name}' field didn't change 
the currency."
+            logger.info(msg)
+            throw badRequest(msg)
+        }
+        // check if TAN is supported.
+        val tanChannel = req.tan_channel?.uppercase() ?: 
SupportedTanChannels.SMS.name
+        if (!isTanChannelSupported(tanChannel)) {
+            val msg = "TAN method $tanChannel not supported."
+            logger.info(msg)
+            throw SandboxError(HttpStatusCode.ServiceUnavailable, msg)
+        }
+        // check if the user contact data would allow the TAN channel.
+        val customer = getCustomer(username = user)
+        if ((tanChannel == SupportedTanChannels.EMAIL.name)
+        and (customer.email == null)) {
+            logger.info("TAN can't be sent via e-mail.  User '$user' didn't 
share any address.")
+            throw conflict("E-mail address not found.  Can't send the TAN")
+        }
+        if ((tanChannel == SupportedTanChannels.SMS.name)
+            and (customer.phone == null)) {
+            logger.info("TAN can't be sent via SMS.  User '$user' didn't share 
any phone number.")
+            throw conflict("Phone number not found.  Can't send the TAN")
+        }
+        // check rates correctness
+        val sellRatio = BigDecimal(ratiosAndFees.sell_at_ratio.toString())
+        val sellFee = BigDecimal(ratiosAndFees.sell_out_fee.toString())
+        val amountCreditCheck = (amountDebit.amount * sellRatio) - sellFee
+        val commonRounding = MathContext(2) // ensures both amounts end with 
".XY"
+        if (amountCreditCheck.round(commonRounding) != 
amountCredit.amount.round(commonRounding)) {
+            val msg = "Rates application are incorrect." +
+                    "  The expected amount to credit is: 
${amountCreditCheck}," +
+                    " but ${amountCredit.amount.toPlainString()} was 
specified."
+            logger.info(msg)
+            throw badRequest(msg)
+        }
+        // check that the balance is sufficient
+        val balance = getBalance(user, withPending = true)
+        val balanceCheck = balance - amountDebit.amount
+        if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > 
BigDecimal(demobank.usersDebtLimit)) {
+            val msg = "Cash-out not possible due to insufficient funds.  
Balance ${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}"
+            logger.info(msg)
+            throw SandboxError(HttpStatusCode.PreconditionFailed, msg)
+        }
+        // generate a subject if that's missing
+        val cashoutSubject = req.subject ?: generateCashoutSubject(
+            amountCredit = amountCredit,
+            amountDebit = amountDebit
+        )
+        val op = transaction {
+            CashoutOperationEntity.new {
+                this.amountDebit = req.amount_debit
+                this.subject = cashoutSubject
+                this.creationTime = getUTCnow().toInstant().epochSecond
+                this.tanChannel = tanChannel
+                this.account = user
+                this.tan = getRandomString(5)
+            }
+        }
+        // Send the TAN.
+        when (tanChannel) {
+            SupportedTanChannels.EMAIL.name -> {
+                // TBD
+            }
+            SupportedTanChannels.SMS.name -> {
+                // TBD
+            }
+            else -> {
+                val msg = "The bank didn't catch a unsupported TAN channel: 
$tanChannel."
+                logger.error(msg)
+                throw internalServerError(msg)
+            }
+        }
+        call.respond(HttpStatusCode.Accepted, object {val uuid = op.uuid})
+        return@post
+    }
+    // Get Circuit-relevant account data.
+    circuitRoute.get("/accounts/{resourceName}") {
+        val username = call.request.basicAuth()
+        val resourceName = call.getUriComponent("resourceName")
+        throwIfInstitutionalName(resourceName)
+        allowOwnerOrAdmin(username, resourceName)
+        val customer = getCustomer(resourceName)
+        val bankAccount = getBankAccountFromLabel(resourceName)
+        /**
+         * Throwing when name or cash-out address aren't found ensures
+         * that the customer was indeed added via the Circuit API, as opposed
+         * to the Access API.
+         */
+        val potentialError = "$resourceName not managed by the Circuit API."
+        call.respond(CircuitAccountInfo(
+            username = customer.username,
+            name = customer.name ?: throw notFound(potentialError),
+            cashout_address = customer.cashout_address ?: throw 
notFound(potentialError),
+            contact_data = CircuitContactData(
+                email = customer.email,
+                phone = customer.phone
+            ),
+            iban = bankAccount.iban
+        ))
+        return@get
+    }
+    // Get summary of all the accounts.
+    circuitRoute.get("/accounts") {
+        call.request.basicAuth(onlyAdmin = true)
+        val customers = mutableListOf<Any>()
+        transaction {
+            DemobankCustomerEntity.all().forEach {
+                customers.add(object {
+                    val username = it.username
+                    val name = it.name
+                })
+            }
+        }
+        call.respond(object {val customers = customers})
+        return@get
+    }
+
+    // Change password.
+    circuitRoute.patch("/accounts/{customerUsername}/auth") {
+        val username = call.request.basicAuth()
+        val customerUsername = call.getUriComponent("customerUsername")
+        throwIfInstitutionalName(customerUsername)
+        allowOwnerOrAdmin(username, customerUsername)
+        // Flow here means admin or username have the rights for this 
operation.
+        val req = call.receive<AccountPasswordChange>()
+        /**
+         * The resource/customer might still not exist, in case admin has 
requested.
+         * On the other hand, when ordinary customers request, their existence 
is checked
+         * along the basic authentication check.
+         */
+        transaction {
+            val customer = getCustomer(customerUsername) // throws 404, if not 
found.
+            customer.passwordHash = CryptoUtil.hashpw(req.new_password)
+        }
+        call.respond(HttpStatusCode.NoContent)
+        return@patch
+    }
+    // Change account (mostly contact) data.
+    circuitRoute.patch("/accounts/{resourceName}") {
+        val username = call.request.basicAuth()
+        if (username == null) {
+            val msg = "Authentication disabled, don't have a default for this 
request."
+            logger.info(msg)
+            throw internalServerError(msg)
+        }
+        val resourceName = call.getUriComponent("resourceName")
+        throwIfInstitutionalName(resourceName)
+        allowOwnerOrAdmin(username, resourceName)
+        // account found and authentication succeeded
+        val req = call.receive<CircuitAccountReconfiguration>()
+        if ((req.contact_data.email != null) && 
(!checkEmailAddress(req.contact_data.email))) {
+            val msg = "Invalid e-mail address: ${req.contact_data.email}"
+            logger.warn(msg)
+            throw badRequest(msg)
+        }
+        if ((req.contact_data.phone != null) && 
(!checkPhoneNumber(req.contact_data.phone))) {
+            val msg = "Invalid phone number: ${req.contact_data.phone}"
+            logger.warn(msg)
+            throw badRequest(msg)
+        }
+        try { parsePayto(req.cashout_address) } catch (e: InvalidPaytoError) {
+            val msg = "Invalid cash-out address: ${req.cashout_address}"
+            logger.warn(msg)
+            throw badRequest(msg)
+        }
+        transaction {
+            val user = getCustomer(resourceName)
+            user.email = req.contact_data.email
+            user.phone = req.contact_data.phone
+            user.cashout_address = req.cashout_address
+        }
+        call.respond(HttpStatusCode.NoContent)
+        return@patch
+    }
+    // Create new account.
     circuitRoute.post("/accounts") {
         call.request.basicAuth(onlyAdmin = true)
         val req = call.receive<CircuitAccountRequest>()
         // Validity and availability check on the input data.
         if (req.contact_data.email != null) {
+            if (!checkEmailAddress(req.contact_data.email)) {
+                val msg = "Invalid e-mail address: ${req.contact_data.email}.  
Won't register"
+                logger.warn(msg)
+                throw badRequest(msg)
+            }
             val maybeEmailConflict = DemobankCustomerEntity.find {
                 DemobankCustomersTable.email eq req.contact_data.email
             }.firstOrNull()
             if (maybeEmailConflict != null) {
                 // Warning since two individuals claimed one same e-mail 
address.
-                logger.warn("Won't register user ${req.username}: e-mail 
conflict on ${req.contact_data.email}")
-                throw conflict("E-mail address already in use!")
+                val msg = "Won't register user ${req.username}: e-mail 
conflict on ${req.contact_data.email}"
+                logger.warn(msg)
+                throw conflict(msg)
             }
-            // Syntactic validation.  Warn on error, since UI could avoid this.
-            // FIXME
-            // From Taler TypeScript:
-            // 
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
         }
         if (req.contact_data.phone != null) {
+            if (!checkEmailAddress(req.contact_data.phone)) {
+                val msg = "Invalid phone number: ${req.contact_data.phone}.  
Won't register"
+                logger.warn(msg)
+                throw badRequest(msg)
+            }
             val maybePhoneConflict = DemobankCustomerEntity.find {
                 DemobankCustomersTable.phone eq req.contact_data.phone
             }.firstOrNull()
             if (maybePhoneConflict != null) {
                 // Warning since two individuals claimed one same phone number.
-                logger.warn("Won't register user ${req.username}: phone 
conflict on ${req.contact_data.email}")
-                throw conflict("Phone number already in use!")
+                val msg = "Won't register user ${req.username}: phone conflict 
on ${req.contact_data.phone}"
+                logger.warn(msg)
+                throw conflict(msg)
             }
-            // Syntactic validation.  Warn on error, since UI could avoid this.
-            // FIXME
-            // From Taler TypeScript
-            // /^\+[0-9 ]*$/;
-        }
-        // Check that cash-out address parses.
-        try {
-            parsePayto(req.cashout_address)
-        } catch (e: InvalidPaytoError) {
+        }
+        /**
+         * Check that cash-out address parses.  IBAN is not
+         * check-summed in this version; the cash-out operation
+         * just fails for invalid IBANs and the user has then
+         * the chance to update their IBAN.
+         */
+        try { parsePayto(req.cashout_address) }
+        catch (e: InvalidPaytoError) {
             // Warning because the UI could avoid this.
-            logger.warn("Won't register account ${req.username}: invalid 
cash-out address: ${req.cashout_address}")
+            val invalidPaytoError = "Won't register account ${req.username}: 
invalid cash-out address: ${req.cashout_address}"
+            logger.warn(invalidPaytoError)
+            throw badRequest(invalidPaytoError)
         }
         transaction {
             val newAccount = insertNewAccount(
@@ -94,12 +485,37 @@ fun circuitApi(circuitRoute: Route) {
             )
             newAccount.customer.phone = req.contact_data.phone
             newAccount.customer.email = req.contact_data.email
+            newAccount.customer.cashout_address = req.cashout_address
         }
         call.respond(HttpStatusCode.NoContent)
         return@post
     }
+    // Get (conversion rates via) config values.
     circuitRoute.get("/config") {
-        call.respond(ConfigResp(ratios_and_fees = RatioAndFees()))
+        call.respond(ConfigResp(ratios_and_fees = ratiosAndFees))
         return@get
     }
+    // Only Admin and only when balance is zero.
+    circuitRoute.delete("/accounts/{resourceName}") {
+        call.request.basicAuth(onlyAdmin = true)
+        val resourceName = call.getUriComponent("resourceName")
+        throwIfInstitutionalName(resourceName)
+        val bankAccount = getBankAccountFromLabel(resourceName)
+        val customer = getCustomer(resourceName)
+        val balance = getBalance(bankAccount)
+        if (balance != BigDecimal.ZERO) {
+            val msg = "Account $resourceName doesn't have zero balance.  Won't 
delete it"
+            logger.error(msg)
+            throw SandboxError(
+                HttpStatusCode.PreconditionFailed,
+                "Account balance is not zero."
+            )
+        }
+        transaction {
+            bankAccount.delete()
+            customer.delete()
+        }
+        call.respond(HttpStatusCode.NoContent)
+        return@delete
+    }
 }
\ No newline at end of file
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
index 13bc8165..ec8fe1a9 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/DB.kt
@@ -122,6 +122,7 @@ object DemobankCustomersTable : LongIdTable() {
     val name = text("name").nullable()
     val email = text("email").nullable()
     val phone = text("phone").nullable()
+    val cashout_address = text("cashout_address").nullable()
 }
 
 class DemobankCustomerEntity(id: EntityID<Long>) : LongEntity(id) {
@@ -131,6 +132,7 @@ class DemobankCustomerEntity(id: EntityID<Long>) : 
LongEntity(id) {
     var name by DemobankCustomersTable.name
     var email by DemobankCustomersTable.email
     var phone by DemobankCustomersTable.phone
+    var cashout_address by DemobankCustomersTable.cashout_address
 }
 
 /**
@@ -429,6 +431,34 @@ class BankAccountStatementEntity(id: EntityID<Int>) : 
IntEntity(id) {
     var balanceClbd by BankAccountStatementsTable.balanceClbd
 }
 
+enum class CashoutOperationState { CONFIRMED, PENDING }
+object CashoutOperationsTable : LongIdTable() {
+    val uuid = uuid("uuid").autoGenerate()
+    /**
+     * This amount is the one the user entered in the cash-out
+     * dialog.  That will show up as the outgoing transfer in their
+     * local currency bank account.
+     */
+    val amountDebit = text("amountDebit")
+    val subject = text("subject")
+    val creationTime = long("creationTime") // in seconds.
+    val tanChannel = text("tanChannel")
+    val account = text("account")
+    val tan = text("tan")
+    val state = enumeration("state", 
CashoutOperationState::class).default(CashoutOperationState.PENDING)
+}
+
+class CashoutOperationEntity(id: EntityID<Long>) : LongEntity(id) {
+    companion object : 
LongEntityClass<CashoutOperationEntity>(CashoutOperationsTable)
+    var uuid by CashoutOperationsTable.uuid
+    var amountDebit by CashoutOperationsTable.amountDebit
+    var subject by CashoutOperationsTable.subject
+    var creationTime by CashoutOperationsTable.creationTime
+    var tanChannel by CashoutOperationsTable.tanChannel
+    var account by CashoutOperationsTable.account
+    var tan by CashoutOperationsTable.tan
+    var state by CashoutOperationsTable.state
+}
 object TalerWithdrawalsTable : LongIdTable() {
     val wopid = uuid("wopid").autoGenerate()
     val amount = text("amount") // $currency:x.y
@@ -506,7 +536,8 @@ fun dbCreateTables(dbConnectionString: String) {
             BankAccountReportsTable,
             BankAccountStatementsTable,
             TalerWithdrawalsTable,
-            DemobankCustomersTable
+            DemobankCustomersTable,
+            CashoutOperationsTable
         )
     }
 }
diff --git 
a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
index e2de73ef..7ab5696c 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/EbicsProtocolBackend.kt
@@ -782,7 +782,7 @@ private fun handleEbicsC52(requestContext: RequestContext): 
ByteArray {
         requestContext.subscriber,
         dateRange = null
     )
-    SandboxAssert(
+    sandboxAssert(
         report.size == 1,
         "C52 response contains more than one Camt.052 document"
     )
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
index 078e0546..1723a8ab 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt
@@ -74,61 +74,59 @@ fun insertNewAccount(username: String,
         logger.info("Username: $username not allowed.")
         throw forbidden("Username: $username is not allowed.")
     }
-
-    val demobankFromDb = getDemobank(demobank)
-    // Bank's fault, because when this function gets
-    // called, the demobank must exist.
-    if (demobankFromDb == null) {
-        logger.error("Demobank '$demobank' not found.  Won't add account 
$username")
-        throw internalServerError("Demobank $demobank not found.  Won't add 
account $username")
-    }
-    // Generate a IBAN if the caller didn't provide one.
-    val newIban = iban ?: getIban()
-    // Check IBAN collisions.
-    val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq 
newIban).firstOrNull()
-    if (checkIbanExist != null) {
-        logger.info("IBAN $newIban not available.  Won't register username 
$username")
-        throw conflict("IBAN $iban not available.")
-    }
-    // Check username availability.
-    val checkCustomerExist = transaction {
-        DemobankCustomerEntity.find {
+    return transaction {
+        val demobankFromDb = getDemobank(demobank)
+        // Bank's fault, because when this function gets
+        // called, the demobank must exist.
+        if (demobankFromDb == null) {
+            logger.error("Demobank '$demobank' not found.  Won't add account 
$username")
+            throw internalServerError("Demobank $demobank not found.  Won't 
add account $username")
+        }
+        // Generate a IBAN if the caller didn't provide one.
+        val newIban = iban ?: getIban()
+        // Check IBAN collisions.
+        val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq 
newIban).firstOrNull()
+        if (checkIbanExist != null) {
+            logger.info("IBAN $newIban not available.  Won't register username 
$username")
+            throw conflict("IBAN $iban not available.")
+        }
+        // Check username availability.
+        val checkCustomerExist = DemobankCustomerEntity.find {
             DemobankCustomersTable.username eq username
         }.firstOrNull()
+        if (checkCustomerExist != null) {
+            throw SandboxError(
+                HttpStatusCode.Conflict,
+                "Username $username not available."
+            )
+        }
+        val newCustomer = DemobankCustomerEntity.new {
+            this.username = username
+            passwordHash = CryptoUtil.hashpw(password)
+            this.name = name // nullable
+        }
+        // Actual account creation.
+        val newBankAccount = BankAccountEntity.new {
+            this.iban = newIban
+            /**
+             * For now, keep same semantics of Pybank: a username
+             * is AS WELL a bank account label.  In other words, it
+             * identifies a customer AND a bank account.  The reason
+             * to have the two values (label and owner) is to allow
+             * multiple bank accounts being owned by one customer.
+             */
+            label = username
+            owner = username
+            this.demoBank = demobankFromDb
+            this.isPublic = isPublic
+        }
+        if (demobankFromDb.withSignupBonus)
+            newBankAccount.bonus("${demobankFromDb.currency}:100")
+        AccountPair(customer = newCustomer, bankAccount = newBankAccount)
     }
-    if (checkCustomerExist != null) {
-        throw SandboxError(
-            HttpStatusCode.Conflict,
-            "Username $username not available."
-        )
-    }
-    val newCustomer = DemobankCustomerEntity.new {
-        this.username = username
-        passwordHash = CryptoUtil.hashpw(password)
-        this.name = name // nullable
-    }
-    // Actual account creation.
-    val newBankAccount = BankAccountEntity.new {
-        this.iban = newIban
-        /**
-         * For now, keep same semantics of Pybank: a username
-         * is AS WELL a bank account label.  In other words, it
-         * identifies a customer AND a bank account.  The reason
-         * to have the two values (label and owner) is to allow
-         * multiple bank accounts being owned by one customer.
-         */
-        label = username
-        owner = username
-        this.demoBank = demobankFromDb
-        this.isPublic = isPublic
-    }
-    if (demobankFromDb.withSignupBonus)
-        newBankAccount.bonus("${demobankFromDb.currency}:100")
-    return AccountPair(customer = newCustomer, bankAccount = newBankAccount)
 }
 
 /**
- *
  * Return true if access to the bank account can be granted,
  * false otherwise.
  *
@@ -183,7 +181,7 @@ fun ApplicationRequest.basicAuth(onlyAdmin: Boolean = 
false): String? {
     return credentials.first
 }
 
-fun SandboxAssert(condition: Boolean, reason: String) {
+fun sandboxAssert(condition: Boolean, reason: String) {
     if (!condition) throw SandboxError(HttpStatusCode.InternalServerError, 
reason)
 }
 
@@ -238,9 +236,11 @@ fun getHistoryElementFromTransactionRow(
  * customer to own multiple bank accounts.
  */
 fun getCustomer(username: String): DemobankCustomerEntity {
-    return DemobankCustomerEntity.find {
-        DemobankCustomersTable.username eq username
-    }.firstOrNull() ?: throw notFound("Customer '${username}' not found")
+    return transaction {
+        DemobankCustomerEntity.find {
+            DemobankCustomersTable.username eq username
+        }.firstOrNull()
+    } ?: throw notFound("Customer '${username}' not found")
 }
 
 /**
@@ -265,14 +265,6 @@ fun getPersonNameFromCustomer(customerUsername: String): 
String {
         }
     }
 }
-fun getFirstDemobank(): DemobankConfigEntity {
-  return transaction {
-      DemobankConfigEntity.all().firstOrNull() ?: throw SandboxError(
-          HttpStatusCode.InternalServerError,
-          "Cannot find one demobank, please create one!"
-      )
-  }
-}
 
 fun getDefaultDemobank(): DemobankConfigEntity {
     return transaction {
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
index 391ff97e..2b39819e 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt
@@ -1560,15 +1560,13 @@ val sandboxApp: Application.() -> Unit = {
                         )
                     }
                     val req = call.receive<CustomerRegistration>()
-                    val newAccount = transaction {
-                        insertNewAccount(
+                    val newAccount = insertNewAccount(
                             req.username,
                             req.password,
                             name = req.name,
                             iban = req.iban,
                             isPublic = req.isPublic
-                        )
-                    }
+                    )
                     val balance = getBalance(newAccount.bankAccount, 
withPending = true)
                     call.respond(object {
                         val balance = object {
diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt 
b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
index 1705292c..33c565cc 100644
--- a/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
+++ b/sandbox/src/main/kotlin/tech/libeufin/sandbox/bankAccount.kt
@@ -69,11 +69,12 @@ fun getBalance(accountLabel: String, withPending: Boolean = 
false): BigDecimal {
 fun wireTransfer(
     debitAccount: String,
     creditAccount: String,
-    demobank: String,
+    demobank: String = "default",
     subject: String,
     amount: String, // $currency:x.y
     pmtInfId: String? = null
 ): String {
+    logger.debug("Maybe wire transfer: $debitAccount -> $creditAccount, 
$subject, $amount")
     val args: Triple<BankAccountEntity, BankAccountEntity, 
DemobankConfigEntity> = transaction {
         val demobankDb = ensureDemobank(demobank)
         val debitAccountDb = getBankAccountFromLabel(debitAccount, demobankDb)
@@ -113,11 +114,16 @@ fun wireTransfer(
     if (checkAmount.currency != demobank.currency)
         throw badRequest("Won't wire transfer with currency: 
${checkAmount.currency}")
     // Check funds are sufficient.
+    /**
+     * Using 'pending' balance because Libeufin never books.  The
+     * reason is that booking is not Taler-relevant.
+     */
     val pendingBalance = getBalance(debitAccount, withPending = true)
     val maxDebt = if (debitAccount.label == "admin") {
         demobank.bankDebtLimit
     } else demobank.usersDebtLimit
-    if ((pendingBalance - checkAmount.amount).abs() > 
BigDecimal.valueOf(maxDebt.toLong())) {
+    val balanceCheck = pendingBalance - checkAmount.amount
+    if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > 
BigDecimal.valueOf(maxDebt.toLong())) {
         logger.info("Account ${debitAccount.label} would surpass debit 
threshold of $maxDebt.  Rollback wire transfer")
         throw SandboxError(HttpStatusCode.PreconditionFailed, "Insufficient 
funds")
     }
diff --git a/util/src/main/kotlin/CryptoUtil.kt 
b/util/src/main/kotlin/CryptoUtil.kt
index 87c5dfca..97c0bd32 100644
--- a/util/src/main/kotlin/CryptoUtil.kt
+++ b/util/src/main/kotlin/CryptoUtil.kt
@@ -310,12 +310,10 @@ object CryptoUtil {
         return "sha256-salted\$$salt\$$pwh"
     }
 
-    /**
-     * Throws error when credentials don't match.  Only returns in case of 
success.
-     */
+    // Throws error when credentials don't match.  Only returns in case of 
success.
     fun checkPwOrThrow(pw: String, storedPwHash: String): Boolean {
         if(!this.checkpw(pw, storedPwHash)) throw UtilError(
-            HttpStatusCode.Forbidden,
+            HttpStatusCode.Unauthorized,
             "Credentials did not match",
             LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED
         )
diff --git a/util/src/main/kotlin/amounts.kt b/util/src/main/kotlin/amounts.kt
index 1d157de6..8ba14192 100644
--- a/util/src/main/kotlin/amounts.kt
+++ b/util/src/main/kotlin/amounts.kt
@@ -25,6 +25,7 @@ import io.ktor.http.*
 val re = Regex("^([0-9]+(\\.[0-9]+)?)$")
 val reWithSign = Regex("^-?([0-9]+(\\.[0-9]+)?)$")
 
+
 fun validatePlainAmount(plainAmount: String, withSign: Boolean = false): 
Boolean {
     if (withSign) return reWithSign.matches(plainAmount)
     return re.matches(plainAmount)

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