libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 28fa97a6a4ed143d47bd2c3a0a4aafbf41e3ea00
parent 0ebb94f89e3373533c2e21ff56272095ae12292d
Author: MS <ms@taler.net>
Date:   Wed,  4 Jan 2023 08:33:13 +0100

Circuit API: implement cash-out.

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

diff --git 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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)