[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.
[Prev in Thread] |
Current Thread |
[Next in Thread] |
- [libeufin] branch master updated: Circuit API: implement cash-out.,
gnunet <=