commit 8487b5351280a80d721a46ee8ce30cfd29de7bec parent a752f81acf05d1610452913e84f685eafbc43571 Author: Antoine A <> Date: Wed, 25 Oct 2023 13:25:38 +0000 Add conversion_internal_to_fiat Diffstat:
14 files changed, 711 insertions(+), 379 deletions(-)
diff --git a/bank/conf/test.conf b/bank/conf/test.conf @@ -4,7 +4,15 @@ DEFAULT_CUSTOMER_DEBT_LIMIT = KUDOS:100 DEFAULT_ADMIN_DEBT_LIMIT = KUDOS:10000 REGISTRATION_BONUS_ENABLED = NO SUGGESTED_WITHDRAWAL_EXCHANGE = https://exchange.example.com +have_cashout = YES +fiat_currency = FIAT [libeufin-bankdb-postgres] SQL_DIR = $DATADIR/sql/ -CONFIG = postgresql:///libeufincheck -\ No newline at end of file +CONFIG = postgresql:///libeufincheck + +[libeufin-bank-conversion] +buy_at_ratio = 0.8 +sell_at_ratio = 1.25 +buy_in_fee = 0.002 +sell_out_fee = 0.003 +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -28,7 +28,7 @@ import io.ktor.server.routing.* import net.taler.common.errorcodes.TalerErrorCode import java.util.* -fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) { +fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { get("/taler-integration/config") { call.respond(TalerIntegrationConfigResponse( currency = ctx.currency, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/bank/src/main/kotlin/tech/libeufin/bank/Config.kt @@ -24,11 +24,72 @@ import TalerConfigError import org.slf4j.Logger import org.slf4j.LoggerFactory import kotlinx.serialization.json.Json +import kotlinx.serialization.Serializable import tech.libeufin.util.DatabaseConfig private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Config") private val BANK_CONFIG_SOURCE = ConfigSource("libeufin-bank", "libeufin-bank") + +/** + * Application the parsed configuration. + */ +data class BankConfig( + /** + * Main, internal currency of the bank. + */ + val currency: String, + val currencySpecification: CurrencySpecification, + /** + * Restrict account registration to the administrator. + */ + val restrictRegistration: Boolean, + /** + * Restrict account deletion to the administrator. + */ + val restrictAccountDeletion: Boolean, + /** + * Default limit for the debt that a customer can have. + * Can be adjusted per account after account creation. + */ + val defaultCustomerDebtLimit: TalerAmount, + /** + * Debt limit of the admin account. + */ + val defaultAdminDebtLimit: TalerAmount, + /** + * If true, transfer a registration bonus from the admin + * account to the newly created account. + */ + val registrationBonusEnabled: Boolean, + /** + * Only set if registration bonus is enabled. + */ + val registrationBonus: TalerAmount?, + /** + * Exchange that the bank suggests to wallets for withdrawal. + */ + val suggestedWithdrawalExchange: String?, + /** + * URL where the user should be redirected to complete the captcha. + * It can contain the substring "{woid}" that is going to be replaced + * with the withdrawal operation id and should point where the bank + * SPA is located. + */ + val spaCaptchaURL: String?, + val haveCashout: Boolean, + val fiatCurrency: String?, + val conversionInfo: ConversionInfo? +) + +@Serializable +data class ConversionInfo ( + val buy_at_ratio: DecimalNumber, + val sell_at_ratio: DecimalNumber, + val buy_in_fee: DecimalNumber, + val sell_out_fee: DecimalNumber, +) + data class ServerConfig( val method: String, val port: Int @@ -54,15 +115,15 @@ fun TalerConfig.loadServerConfig(): ServerConfig = catchError { ) } -fun TalerConfig.loadBankApplicationContext(): BankApplicationContext = catchError { +fun TalerConfig.loadBankConfig(): BankConfig = catchError { val currency = requireString("libeufin-bank", "currency") val currencySpecification = sections.find { it.startsWith("CURRENCY-") && requireBoolean(it, "enabled") && requireString(it, "code") == currency }?.let { loadCurrencySpecification(it) } ?: throw TalerConfigError("missing currency specification for $currency") - BankApplicationContext( + val haveCashout = lookupBoolean("libeufin-bank", "have_cashout") ?: false; + BankConfig( currency = currency, restrictRegistration = lookupBoolean("libeufin-bank", "restrict_registration") ?: false, - cashoutCurrency = lookupString("libeufin-bank", "cashout_currency"), defaultCustomerDebtLimit = requireAmount("libeufin-bank", "default_customer_debt_limit", currency), registrationBonusEnabled = lookupBoolean("libeufin-bank", "registration_bonus_enabled") ?: false, registrationBonus = requireAmount("libeufin-bank", "registration_bonus", currency), @@ -70,7 +131,19 @@ fun TalerConfig.loadBankApplicationContext(): BankApplicationContext = catchErro defaultAdminDebtLimit = requireAmount("libeufin-bank", "default_admin_debt_limit", currency), spaCaptchaURL = lookupString("libeufin-bank", "spa_captcha_url"), restrictAccountDeletion = lookupBoolean("libeufin-bank", "restrict_account_deletion") ?: true, - currencySpecification = currencySpecification + currencySpecification = currencySpecification, + haveCashout = haveCashout, + fiatCurrency = if (haveCashout) { requireString("libeufin-bank", "fiat_currency") } else { null }, + conversionInfo = if (haveCashout) { loadConversionInfo() } else { null } + ) +} + +private fun TalerConfig.loadConversionInfo(): ConversionInfo = catchError { + ConversionInfo( + buy_at_ratio = requireDecimalNumber("libeufin-bank-conversion", "buy_at_ratio"), + sell_at_ratio = requireDecimalNumber("libeufin-bank-conversion", "sell_at_ratio"), + buy_in_fee = requireDecimalNumber("libeufin-bank-conversion", "buy_in_fee"), + sell_out_fee = requireDecimalNumber("libeufin-bank-conversion", "sell_out_fee") ) } @@ -103,6 +176,16 @@ private fun TalerConfig.requireAmount(section: String, option: String, currency: amount } +private fun TalerConfig.requireDecimalNumber(section: String, option: String): DecimalNumber = catchError { + val numberStr = lookupString(section, option) ?: + throw TalerConfigError("expected decimal number for section $section, option $option, but config value is empty") + try { + DecimalNumber(numberStr) + } catch (e: Exception) { + throw TalerConfigError("expected decimal number for section $section, option $option, but number is malformed") + } +} + private fun <R> catchError(lambda: () -> R): R { try { return lambda() diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -5,22 +5,29 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.* import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit import java.util.* import kotlin.random.Random +import net.taler.common.errorcodes.TalerErrorCode +import net.taler.wallet.crypto.Base32Crockford +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.util.* private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") -fun Routing.coreBankApi(db: Database, ctx: BankApplicationContext) { +fun Routing.coreBankApi(db: Database, ctx: BankConfig) { get("/config") { - call.respond(Config(ctx.currencySpecification)) + call.respond( + Config( + currency = ctx.currencySpecification, + have_cashout = ctx.haveCashout, + fiat_currency = ctx.fiatCurrency, + conversion_info = ctx.conversionInfo + ) + ) } get("/monitor") { call.authAdmin(db, TokenScope.readonly) @@ -40,55 +47,60 @@ private fun Routing.coreBankTokenApi(db: Database) { val maybeAuthToken = call.getAuthToken() val req = call.receive<TokenRequest>() /** - * This block checks permissions ONLY IF the call was authenticated - * with a token. Basic auth gets always granted. + * This block checks permissions ONLY IF the call was authenticated with a token. Basic auth + * gets always granted. */ if (maybeAuthToken != null) { val tokenBytes = Base32Crockford.decode(maybeAuthToken) - val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError( - "Token used to auth not found in the database!" - ) - if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) throw forbidden( - "Cannot generate RW token from RO", TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT - ) - } - val tokenBytes = ByteArray(32).apply { - Random.nextBytes(this) + val refreshingToken = + db.bearerTokenGet(tokenBytes) + ?: throw internalServerError( + "Token used to auth not found in the database!" + ) + if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) + throw forbidden( + "Cannot generate RW token from RO", + TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT + ) } + val tokenBytes = ByteArray(32).apply { Random.nextBytes(this) } val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION val creationTime = Instant.now() - val expirationTimestamp = if (tokenDuration == ChronoUnit.FOREVER.duration) { - logger.debug("Creating 'forever' token.") - Instant.MAX - } else { - try { - logger.debug("Creating token with days duration: ${tokenDuration.toDays()}") - creationTime.plus(tokenDuration) - } catch (e: Exception) { - logger.error("Could not add token duration to current time: ${e.message}") - throw badRequest("Bad token duration: ${e.message}") - } - } - val customerDbRow = db.customerGetFromLogin(login)?.dbRowId ?: throw internalServerError( - "Could not get customer '$login' database row ID" - ) - val token = BearerToken( - bankCustomer = customerDbRow, - content = tokenBytes, - creationTime = creationTime, - expirationTime = expirationTimestamp, - scope = req.scope, - isRefreshable = req.refreshable - ) + val expirationTimestamp = + if (tokenDuration == ChronoUnit.FOREVER.duration) { + logger.debug("Creating 'forever' token.") + Instant.MAX + } else { + try { + logger.debug("Creating token with days duration: ${tokenDuration.toDays()}") + creationTime.plus(tokenDuration) + } catch (e: Exception) { + logger.error("Could not add token duration to current time: ${e.message}") + throw badRequest("Bad token duration: ${e.message}") + } + } + val customerDbRow = + db.customerGetFromLogin(login)?.dbRowId + ?: throw internalServerError( + "Could not get customer '$login' database row ID" + ) + val token = + BearerToken( + bankCustomer = customerDbRow, + content = tokenBytes, + creationTime = creationTime, + expirationTime = expirationTimestamp, + scope = req.scope, + isRefreshable = req.refreshable + ) if (!db.bearerTokenCreate(token)) - throw internalServerError("Failed at inserting new token in the database") + throw internalServerError("Failed at inserting new token in the database") call.respond( - TokenSuccessResponse( - access_token = Base32Crockford.encode(tokenBytes), expiration = TalerProtocolTimestamp( - t_s = expirationTimestamp + TokenSuccessResponse( + access_token = Base32Crockford.encode(tokenBytes), + expiration = TalerProtocolTimestamp(t_s = expirationTimestamp) ) - ) ) } delete("/accounts/{USERNAME}/token") { @@ -96,127 +108,168 @@ private fun Routing.coreBankTokenApi(db: Database) { val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.") /** - * Not sanity-checking the token, as it was used by the authentication already. - * If harder errors happen, then they'll get Ktor respond with 500. + * Not sanity-checking the token, as it was used by the authentication already. If harder + * errors happen, then they'll get Ktor respond with 500. */ db.bearerTokenDelete(Base32Crockford.decode(token)) /** - * Responding 204 regardless of it being actually deleted or not. - * If it wasn't found, then it must have been deleted before we - * reached here, but the token was valid as it served the authentication - * => no reason to fail the request. + * Responding 204 regardless of it being actually deleted or not. If it wasn't found, then + * it must have been deleted before we reached here, but the token was valid as it served + * the authentication => no reason to fail the request. */ call.respond(HttpStatusCode.NoContent) } } -private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationContext) { - post("/accounts") { +private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankConfig) { + post("/accounts") { // check if only admin is allowed to create new accounts if (ctx.restrictRegistration) { call.authAdmin(db, TokenScope.readwrite) } // auth passed, proceed with activity. val req = call.receive<RegisterAccountRequest>() // Prohibit reserved usernames: - if (reservedAccounts.contains(req.username)) throw forbidden( - "Username '${req.username}' is reserved.", - TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT - ) + if (reservedAccounts.contains(req.username)) + throw forbidden( + "Username '${req.username}' is reserved.", + TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT + ) // Checking idempotency. val maybeCustomerExists = - db.customerGetFromLogin(req.username) // Can be null if previous call crashed before completion. - val maybeHasBankAccount = maybeCustomerExists.run { - if (this == null) return@run null - db.bankAccountGetFromOwnerId(this.expectRowId()) - } + db.customerGetFromLogin( + req.username + ) // Can be null if previous call crashed before completion. + val maybeHasBankAccount = + maybeCustomerExists.run { + if (this == null) return@run null + db.bankAccountGetFromOwnerId(this.expectRowId()) + } val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri()) if (maybeCustomerExists != null && maybeHasBankAccount != null) { - logger.debug("Registering username was found: ${maybeCustomerExists.login}") // Checking _all_ the details are the same. + logger.debug( + "Registering username was found: ${maybeCustomerExists.login}" + ) // Checking _all_ the details are the same. val isIdentic = - maybeCustomerExists.name == req.name && - maybeCustomerExists.email == req.challenge_contact_data?.email && - maybeCustomerExists.phone == req.challenge_contact_data?.phone && - maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && - CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) && - maybeHasBankAccount.isPublic == req.is_public && - maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && - maybeHasBankAccount.internalPaytoUri.canonical == internalPayto.canonical + maybeCustomerExists.name == req.name && + maybeCustomerExists.email == req.challenge_contact_data?.email && + maybeCustomerExists.phone == req.challenge_contact_data?.phone && + maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && + CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) && + maybeHasBankAccount.isPublic == req.is_public && + maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && + maybeHasBankAccount.internalPaytoUri.canonical == + internalPayto.canonical if (isIdentic) { call.respond(HttpStatusCode.Created) return@post } throw conflict( - "Idempotency check failed.", - TalerErrorCode.TALER_EC_END // FIXME: provide appropriate EC. + "Idempotency check failed.", + TalerErrorCode.TALER_EC_END // FIXME: provide appropriate EC. ) } // From here: fresh user being added. - val newCustomer = Customer( - login = req.username, - name = req.name, - email = req.challenge_contact_data?.email, - phone = req.challenge_contact_data?.phone, - cashoutPayto = req.cashout_payto_uri, // Following could be gone, if included in cashout_payto_uri - cashoutCurrency = ctx.cashoutCurrency, - passwordHash = CryptoUtil.hashpw(req.password), - ) - val newCustomerRowId = db.customerCreate(newCustomer) - ?: throw internalServerError("New customer INSERT failed despite the previous checks") // Crashing here won't break data consistency between customers and bank accounts, because of the idempotency. Client will just have to retry. + val newCustomer = + Customer( + login = req.username, + name = req.name, + email = req.challenge_contact_data?.email, + phone = req.challenge_contact_data?.phone, + cashoutPayto = + req.cashout_payto_uri, // Following could be gone, if included in + // cashout_payto_uri + cashoutCurrency = ctx.fiatCurrency, + passwordHash = CryptoUtil.hashpw(req.password), + ) + val newCustomerRowId = + db.customerCreate(newCustomer) + ?: throw internalServerError( + "New customer INSERT failed despite the previous checks" + ) // Crashing here won't break data consistency between customers and bank + // accounts, because of the idempotency. Client will just have to retry. val maxDebt = ctx.defaultCustomerDebtLimit - val newBankAccount = BankAccount( - hasDebt = false, - internalPaytoUri = internalPayto, - owningCustomerId = newCustomerRowId, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = maxDebt - ) - val newBankAccountId = db.bankAccountCreate(newBankAccount) - ?: throw internalServerError("Could not INSERT bank account despite all the checks.") + val newBankAccount = + BankAccount( + hasDebt = false, + internalPaytoUri = internalPayto, + owningCustomerId = newCustomerRowId, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = maxDebt + ) + val newBankAccountId = + db.bankAccountCreate(newBankAccount) + ?: throw internalServerError( + "Could not INSERT bank account despite all the checks." + ) // The new account got created, now optionally award the registration // bonus to it. - val bonusAmount = if (ctx.registrationBonusEnabled && !req.is_taler_exchange) ctx.registrationBonus else null + val bonusAmount = + if (ctx.registrationBonusEnabled && !req.is_taler_exchange) ctx.registrationBonus + else null if (bonusAmount != null) { val adminCustomer = - db.customerGetFromLogin("admin") ?: throw internalServerError("Admin customer not found") - val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) - ?: throw internalServerError("Admin bank account not found") - val adminPaysBonus = BankInternalTransaction( - creditorAccountId = newBankAccountId, - debtorAccountId = adminBankAccount.expectRowId(), - amount = bonusAmount, - subject = "Registration bonus.", - transactionDate = Instant.now() - ) + db.customerGetFromLogin("admin") + ?: throw internalServerError("Admin customer not found") + val adminBankAccount = + db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) + ?: throw internalServerError("Admin bank account not found") + val adminPaysBonus = + BankInternalTransaction( + creditorAccountId = newBankAccountId, + debtorAccountId = adminBankAccount.expectRowId(), + amount = bonusAmount, + subject = "Registration bonus.", + transactionDate = Instant.now() + ) when (db.bankTransactionCreate(adminPaysBonus)) { - BankTransactionResult.NO_CREDITOR -> throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") - BankTransactionResult.NO_DEBTOR -> throw internalServerError("Bonus impossible: admin not found.") - BankTransactionResult.BALANCE_INSUFFICIENT -> throw internalServerError("Bonus impossible: admin has insufficient balance.") - BankTransactionResult.SAME_ACCOUNT -> throw internalServerError("Bonus impossible: admin should not be creditor.") - BankTransactionResult.SUCCESS -> { /* continue the execution */ } + BankTransactionResult.NO_CREDITOR -> + throw internalServerError( + "Bonus impossible: creditor not found, despite its recent creation." + ) + BankTransactionResult.NO_DEBTOR -> + throw internalServerError("Bonus impossible: admin not found.") + BankTransactionResult.BALANCE_INSUFFICIENT -> + throw internalServerError( + "Bonus impossible: admin has insufficient balance." + ) + BankTransactionResult.SAME_ACCOUNT -> + throw internalServerError("Bonus impossible: admin should not be creditor.") + BankTransactionResult.SUCCESS -> { + /* continue the execution */ + } } } call.respond(HttpStatusCode.Created) } delete("/accounts/{USERNAME}") { - val (login, _) = call.authCheck(db, TokenScope.readwrite, withAdmin = true, requireAdmin = ctx.restrictAccountDeletion) + val (login, _) = + call.authCheck( + db, + TokenScope.readwrite, + withAdmin = true, + requireAdmin = ctx.restrictAccountDeletion + ) // Not deleting reserved names. - if (reservedAccounts.contains(login)) throw forbidden( - "Cannot delete reserved accounts", - TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT - ) + if (reservedAccounts.contains(login)) + throw forbidden( + "Cannot delete reserved accounts", + TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT + ) when (db.customerDeleteIfBalanceIsZero(login)) { - CustomerDeletionResult.CUSTOMER_NOT_FOUND -> throw notFound( - "Customer '$login' not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - CustomerDeletionResult.BALANCE_NOT_ZERO -> throw conflict( - "Balance is not zero.", - TalerErrorCode.TALER_EC_NONE // FIXME: need EC. - ) + CustomerDeletionResult.CUSTOMER_NOT_FOUND -> + throw notFound( + "Customer '$login' not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + CustomerDeletionResult.BALANCE_NOT_ZERO -> + throw conflict( + "Balance is not zero.", + TalerErrorCode.TALER_EC_NONE // FIXME: need EC. + ) CustomerDeletionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) } } @@ -227,38 +280,43 @@ private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationCo // authentication OK, go on. val req = call.receive<AccountReconfiguration>() /** - * This object holds the details of the customer that's affected - * by this operation, as it MAY differ from the one being authenticated. - * This typically happens when admin did the request. + * This object holds the details of the customer that's affected by this operation, as it + * MAY differ from the one being authenticated. This typically happens when admin did the + * request. */ - val accountCustomer = db.customerGetFromLogin(login) ?: throw notFound( - "Account $login not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC. - ) + val accountCustomer = + db.customerGetFromLogin(login) + ?: throw notFound( + "Account $login not found", + talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC. + ) // Check if a non-admin user tried to change their legal name if (!isAdmin && (req.name != null) && (req.name != accountCustomer.name)) - throw forbidden("non-admin user cannot change their legal name") + throw forbidden("non-admin user cannot change their legal name") // Preventing identical data to be overridden. - val bankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId()) - ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.") - if ( - (req.is_exchange == bankAccount.isTalerExchange) && - (req.cashout_address == accountCustomer.cashoutPayto) && - (req.name == accountCustomer.name) && - (req.challenge_contact_data?.phone == accountCustomer.phone) && - (req.challenge_contact_data?.email == accountCustomer.email) - ) { + val bankAccount = + db.bankAccountGetFromOwnerId(accountCustomer.expectRowId()) + ?: throw internalServerError( + "Customer '${accountCustomer.login}' lacks bank account." + ) + if ((req.is_exchange == bankAccount.isTalerExchange) && + (req.cashout_address == accountCustomer.cashoutPayto) && + (req.name == accountCustomer.name) && + (req.challenge_contact_data?.phone == accountCustomer.phone) && + (req.challenge_contact_data?.email == accountCustomer.email) + ) { call.respond(HttpStatusCode.NoContent) return@patch } - val dbRes = db.accountReconfig( - login = accountCustomer.login, - name = req.name, - cashoutPayto = req.cashout_address, - emailAddress = req.challenge_contact_data?.email, - isTalerExchange = req.is_exchange, - phoneNumber = req.challenge_contact_data?.phone - ) + val dbRes = + db.accountReconfig( + login = accountCustomer.login, + name = req.name, + cashoutPayto = req.cashout_address, + emailAddress = req.challenge_contact_data?.email, + isTalerExchange = req.is_exchange, + phoneNumber = req.challenge_contact_data?.phone + ) when (dbRes) { AccountReconfigDBResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) AccountReconfigDBResult.CUSTOMER_NOT_FOUND -> { @@ -276,13 +334,13 @@ private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationCo val (login, _) = call.authCheck(db, TokenScope.readwrite) val req = call.receive<AccountPasswordChange>() val hashedPassword = CryptoUtil.hashpw(req.new_password) - if (!db.customerChangePassword( - login, - hashedPassword - )) throw notFound( - "Account '$login' not found (despite it being authenticated by this call)", - talerEc = TalerErrorCode.TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND. - ) + if (!db.customerChangePassword(login, hashedPassword)) + throw notFound( + "Account '$login' not found (despite it being authenticated by this call)", + talerEc = + TalerErrorCode + .TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND. + ) call.respond(HttpStatusCode.NoContent) } get("/public-accounts") { @@ -299,9 +357,10 @@ private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationCo // Get optional param. val maybeFilter: String? = call.request.queryParameters["filter_name"] logger.debug("Filtering on '${maybeFilter}'") - val queryParam = if (maybeFilter != null) { - "%${maybeFilter}%" - } else "%" + val queryParam = + if (maybeFilter != null) { + "%${maybeFilter}%" + } else "%" val accounts = db.accountsGetForAdmin(queryParam) if (accounts.isEmpty()) { call.respond(HttpStatusCode.NoContent) @@ -311,211 +370,249 @@ private fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationCo } get("/accounts/{USERNAME}") { val (login, _) = call.authCheck(db, TokenScope.readonly, withAdmin = true) - val customerData = db.customerGetFromLogin(login) ?: throw notFound( - "Customer '$login' not found in the database.", - talerEc = TalerErrorCode.TALER_EC_END - ) - val bankAccountData = db.bankAccountGetFromOwnerId(customerData.expectRowId()) - ?: throw internalServerError("Customer '$login' had no bank account despite they are customer.'") - val balance = Balance( - amount = bankAccountData.balance ?: throw internalServerError("Account '${customerData.login}' lacks balance!"), - credit_debit_indicator = if (bankAccountData.hasDebt) { - CorebankCreditDebitInfo.debit - } else { - CorebankCreditDebitInfo.credit - } - ) + val customerData = + db.customerGetFromLogin(login) + ?: throw notFound( + "Customer '$login' not found in the database.", + talerEc = TalerErrorCode.TALER_EC_END + ) + val bankAccountData = + db.bankAccountGetFromOwnerId(customerData.expectRowId()) + ?: throw internalServerError( + "Customer '$login' had no bank account despite they are customer.'" + ) + val balance = + Balance( + amount = bankAccountData.balance + ?: throw internalServerError( + "Account '${customerData.login}' lacks balance!" + ), + credit_debit_indicator = + if (bankAccountData.hasDebt) { + CorebankCreditDebitInfo.debit + } else { + CorebankCreditDebitInfo.credit + } + ) call.respond( - AccountData( - name = customerData.name, - balance = balance, - debit_threshold = bankAccountData.maxDebt, - payto_uri = bankAccountData.internalPaytoUri, - contact_data = ChallengeContactData( - email = customerData.email, phone = customerData.phone - ), - cashout_payto_uri = customerData.cashoutPayto, - ) + AccountData( + name = customerData.name, + balance = balance, + debit_threshold = bankAccountData.maxDebt, + payto_uri = bankAccountData.internalPaytoUri, + contact_data = + ChallengeContactData( + email = customerData.email, + phone = customerData.phone + ), + cashout_payto_uri = customerData.cashoutPayto, + ) ) } } -private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { +private fun Routing.coreBankTransactionsApi(db: Database, ctx: BankConfig) { get("/accounts/{USERNAME}/transactions") { call.authCheck(db, TokenScope.readonly) val params = HistoryParams.extract(call.request.queryParameters) val bankAccount = call.bankAccount(db) - val history: List<BankAccountTransactionInfo> = db.bankPoolHistory(params, bankAccount.bankAccountId!!) + val history: List<BankAccountTransactionInfo> = + db.bankPoolHistory(params, bankAccount.bankAccountId!!) call.respond(BankAccountTransactionsResponse(history)) } get("/accounts/{USERNAME}/transactions/{T_ID}") { call.authCheck(db, TokenScope.readonly) val tId = call.expectUriComponent("T_ID") - val txRowId = try { - tId.toLong() - } catch (e: Exception) { - logger.error(e.message) - throw badRequest("TRANSACTION_ID is not a number: ${tId}") - } - + val txRowId = + try { + tId.toLong() + } catch (e: Exception) { + logger.error(e.message) + throw badRequest("TRANSACTION_ID is not a number: ${tId}") + } + val bankAccount = call.bankAccount(db) - val tx = db.bankTransactionGetFromInternalId(txRowId) ?: throw notFound( - "Bank transaction '$tId' not found", - TalerErrorCode.TALER_EC_BANK_TRANSACTION_NOT_FOUND - ) + val tx = + db.bankTransactionGetFromInternalId(txRowId) + ?: throw notFound( + "Bank transaction '$tId' not found", + TalerErrorCode.TALER_EC_BANK_TRANSACTION_NOT_FOUND + ) if (tx.bankAccountId != bankAccount.bankAccountId) // TODO not found ? - throw unauthorized("Client has no rights over the bank transaction: $tId") + throw unauthorized("Client has no rights over the bank transaction: $tId") call.respond( - BankAccountTransactionInfo( - amount = tx.amount, - creditor_payto_uri = tx.creditorPaytoUri, - debtor_payto_uri = tx.debtorPaytoUri, - date = TalerProtocolTimestamp(tx.transactionDate), - direction = tx.direction, - subject = tx.subject, - row_id = txRowId - ) + BankAccountTransactionInfo( + amount = tx.amount, + creditor_payto_uri = tx.creditorPaytoUri, + debtor_payto_uri = tx.debtorPaytoUri, + date = TalerProtocolTimestamp(tx.transactionDate), + direction = tx.direction, + subject = tx.subject, + row_id = txRowId + ) ) } post("/accounts/{USERNAME}/transactions") { - val (login, _ ) = call.authCheck(db, TokenScope.readwrite) + val (login, _) = call.authCheck(db, TokenScope.readwrite) val tx = call.receive<BankAccountTransactionCreate>() val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") - val amount = tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire transfer lacks amount") + val amount = + tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire transfer lacks amount") ctx.checkInternalCurrency(amount) - val result = db.bankTransaction( - creditAccountPayto = tx.payto_uri, - debitAccountUsername = login, - subject = subject, - amount = amount, - timestamp = Instant.now(), - ) + val result = + db.bankTransaction( + creditAccountPayto = tx.payto_uri, + debitAccountUsername = login, + subject = subject, + amount = amount, + timestamp = Instant.now(), + ) when (result) { - BankTransactionResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - BankTransactionResult.SAME_ACCOUNT -> throw conflict( - "Wire transfer attempted with credit and debit party being the same bank account", - TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT - ) - BankTransactionResult.NO_DEBTOR -> throw notFound( - "Customer $login not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - BankTransactionResult.NO_CREDITOR -> throw notFound( - "Creditor account was not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) + BankTransactionResult.BALANCE_INSUFFICIENT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + BankTransactionResult.SAME_ACCOUNT -> + throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + BankTransactionResult.NO_DEBTOR -> + throw notFound( + "Customer $login not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + BankTransactionResult.NO_CREDITOR -> + throw notFound( + "Creditor account was not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) } } } -fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankApplicationContext) { +fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { post("/accounts/{USERNAME}/withdrawals") { val (login, _) = call.authCheck(db, TokenScope.readwrite) - val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds. - + val req = + call.receive< + BankAccountCreateWithdrawalRequest>() // Checking that the user has enough + // funds. + ctx.checkInternalCurrency(req.amount) val opId = UUID.randomUUID() when (db.talerWithdrawalCreate(login, opId, req.amount)) { - WithdrawalCreationResult.ACCOUNT_NOT_FOUND -> throw notFound( - "Customer $login not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict( - "Exchange account cannot perform withdrawal operation", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - WithdrawalCreationResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient funds to withdraw with Taler", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) + WithdrawalCreationResult.ACCOUNT_NOT_FOUND -> + throw notFound( + "Customer $login not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE -> + throw conflict( + "Exchange account cannot perform withdrawal operation", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + WithdrawalCreationResult.BALANCE_INSUFFICIENT -> + throw conflict( + "Insufficient funds to withdraw with Taler", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) WithdrawalCreationResult.SUCCESS -> { - val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL") + val bankBaseUrl = + call.request.getBaseUrl() + ?: throw internalServerError("Bank could not find its own base URL") call.respond( - BankAccountCreateWithdrawalResponse( - withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) - ) + BankAccountCreateWithdrawalResponse( + withdrawal_id = opId.toString(), + taler_withdraw_uri = + getTalerWithdrawUri(bankBaseUrl, opId.toString()) + ) ) } - } + } } get("/withdrawals/{withdrawal_id}") { val op = call.getWithdrawal(db, "withdrawal_id") call.respond( - BankAccountGetWithdrawalResponse( - amount = op.amount, - aborted = op.aborted, - confirmation_done = op.confirmationDone, - selection_done = op.selectionDone, - selected_exchange_account = op.selectedExchangePayto, - selected_reserve_pub = op.reservePub - ) + BankAccountGetWithdrawalResponse( + amount = op.amount, + aborted = op.aborted, + confirmation_done = op.confirmationDone, + selection_done = op.selectionDone, + selected_exchange_account = op.selectedExchangePayto, + selected_reserve_pub = op.reservePub + ) ) } post("/withdrawals/{withdrawal_id}/abort") { val opId = call.uuidUriComponent("withdrawal_id") when (db.talerWithdrawalAbort(opId)) { - WithdrawalAbortResult.NOT_FOUND -> throw notFound( - "Withdrawal operation $opId not found", - TalerErrorCode.TALER_EC_END - ) - WithdrawalAbortResult.CONFIRMED -> throw conflict( - "Cannot abort confirmed withdrawal", - TalerErrorCode.TALER_EC_END - ) + WithdrawalAbortResult.NOT_FOUND -> + throw notFound( + "Withdrawal operation $opId not found", + TalerErrorCode.TALER_EC_END + ) + WithdrawalAbortResult.CONFIRMED -> + throw conflict("Cannot abort confirmed withdrawal", TalerErrorCode.TALER_EC_END) WithdrawalAbortResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) } - } post("/withdrawals/{withdrawal_id}/confirm") { val opId = call.uuidUriComponent("withdrawal_id") when (db.talerWithdrawalConfirm(opId, Instant.now())) { - WithdrawalConfirmationResult.OP_NOT_FOUND -> throw notFound( - "Withdrawal operation $opId not found", - TalerErrorCode.TALER_EC_END - ) - WithdrawalConfirmationResult.ABORTED -> throw conflict( - "Cannot confirm an aborted withdrawal", - TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT - ) - WithdrawalConfirmationResult.NOT_SELECTED -> throw LibeufinBankException( - httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError( - hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code - ) - ) - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> throw conflict( - "Exchange to withdraw from not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) + WithdrawalConfirmationResult.OP_NOT_FOUND -> + throw notFound( + "Withdrawal operation $opId not found", + TalerErrorCode.TALER_EC_END + ) + WithdrawalConfirmationResult.ABORTED -> + throw conflict( + "Cannot confirm an aborted withdrawal", + TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT + ) + WithdrawalConfirmationResult.NOT_SELECTED -> + throw LibeufinBankException( + httpStatus = HttpStatusCode.UnprocessableEntity, + talerError = + TalerError( + hint = "Cannot confirm an unselected withdrawal", + code = TalerErrorCode.TALER_EC_END.code + ) + ) + WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> + throw conflict( + "Exchange to withdraw from not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) WithdrawalConfirmationResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) } } } -fun Routing.coreBankCashoutApi(db: Database, ctx: BankApplicationContext) { +fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) { post("/accounts/{USERNAME}/cashouts") { val (login, _) = call.authCheck(db, TokenScope.readwrite) - val req = call.receive<CashoutRequest>() // Checking that the user has enough funds. - + val req = call.receive<CashoutRequest>() + ctx.checkInternalCurrency(req.amount_debit) - ctx.checkCashoutCurrency(req.amount_credit) + ctx.checkFiatCurrency(req.amount_credit) - // TODO + // TODO } post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") { val (login, _) = call.authCheck(db, TokenScope.readwrite) - // TODO + // TODO } post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") { val (login, _) = call.authCheck(db, TokenScope.readwrite) @@ -534,6 +631,19 @@ fun Routing.coreBankCashoutApi(db: Database, ctx: BankApplicationContext) { // TODO } get("/cashout-rate") { - // TODO + val params = CashoutRateParams.extract(call.request.queryParameters) + + params.debit?.let { ctx.checkInternalCurrency(it) } + params.credit?.let { ctx.checkFiatCurrency(it) } + + if (params.debit != null) { + val credit = db.conversionInternalToFiat(params.debit) + if (params.credit != null && params.credit != credit) { + throw badRequest("Bad conversion expected $credit got $params.credit") + } + call.respond(CashoutConversionResponse(params.debit, credit)) + } else { + // TODO + } } -} -\ No newline at end of file +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -1532,6 +1532,32 @@ class Database(dbConfig: String, private val bankCurrency: String, private val f } ?: throw internalServerError("No result from DB procedure stats_get_frame") } + + suspend fun conversionUpdateConfig(cfg: ConversionInfo) = conn { conn -> + val stmt = conn.prepareStatement("CALL conversion_config_update((?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount, (?, ?)::taler_amount)") + stmt.setLong(1, cfg.buy_at_ratio.value) + stmt.setInt(2, cfg.buy_at_ratio.frac) + stmt.setLong(3, cfg.sell_at_ratio.value) + stmt.setInt(4, cfg.sell_at_ratio.frac) + stmt.setLong(5, cfg.buy_in_fee.value) + stmt.setInt(6, cfg.buy_in_fee.frac) + stmt.setLong(7, cfg.sell_out_fee.value) + stmt.setInt(8, cfg.sell_out_fee.frac) + stmt.executeUpdate() + } + + suspend fun conversionInternalToFiat(internalAmount: TalerAmount): TalerAmount = conn { conn -> + val stmt = conn.prepareStatement("SELECT fiat_amount.val AS amount_val, fiat_amount.frac AS amount_frac FROM conversion_internal_to_fiat((?, ?)::taler_amount) as fiat_amount") + stmt.setLong(1, internalAmount.value) + stmt.setInt(2, internalAmount.frac) + stmt.oneOrNull { + TalerAmount( + value = it.getLong("amount_val"), + frac = it.getInt("amount_frac"), + currency = fiatCurrency!! + ) + }!! + } } /** Result status of customer account deletion */ diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -70,58 +70,6 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") val TOKEN_DEFAULT_DURATION: java.time.Duration = Duration.ofDays(1L) /** - * Application context with the parsed configuration. - */ -data class BankApplicationContext( - /** - * Main, internal currency of the bank. - */ - val currency: String, - val currencySpecification: CurrencySpecification, - /** - * Restrict account registration to the administrator. - */ - val restrictRegistration: Boolean, - /** - * Restrict account deletion to the administrator. - */ - val restrictAccountDeletion: Boolean, - /** - * Cashout currency, if cashouts are supported. - */ - val cashoutCurrency: String?, - /** - * Default limit for the debt that a customer can have. - * Can be adjusted per account after account creation. - */ - val defaultCustomerDebtLimit: TalerAmount, - /** - * Debt limit of the admin account. - */ - val defaultAdminDebtLimit: TalerAmount, - /** - * If true, transfer a registration bonus from the admin - * account to the newly created account. - */ - val registrationBonusEnabled: Boolean, - /** - * Only set if registration bonus is enabled. - */ - val registrationBonus: TalerAmount?, - /** - * Exchange that the bank suggests to wallets for withdrawal. - */ - val suggestedWithdrawalExchange: String?, - /** - * URL where the user should be redirected to complete the captcha. - * It can contain the substring "{woid}" that is going to be replaced - * with the withdrawal operation id and should point where the bank - * SPA is located. - */ - val spaCaptchaURL: String?, -) - -/** * This plugin inflates the requests that have "Content-Encoding: deflate" */ val corebankDecompressionPlugin = createApplicationPlugin("RequestingBodyDecompression") { @@ -153,7 +101,7 @@ val corebankDecompressionPlugin = createApplicationPlugin("RequestingBodyDecompr /** * Set up web server handlers for the Taler corebank API. */ -fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) { +fun Application.corebankWebApp(db: Database, ctx: BankConfig) { install(CallLogging) { this.level = Level.DEBUG this.logger = tech.libeufin.bank.logger @@ -337,14 +285,14 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") override fun run() { val cfg = talerConfig(configFile) - val ctx = cfg.loadBankApplicationContext() + val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() val serverCfg = cfg.loadServerConfig() if (serverCfg.method.lowercase() != "tcp") { logger.info("Can only serve libeufin-bank via TCP") exitProcess(1) } - val db = Database(dbCfg.dbConnStr, ctx.currency, null) + val db = Database(dbCfg.dbConnStr, ctx.currency, ctx.fiatCurrency) runBlocking { if (!maybeCreateAdminAccount(db, ctx)) // logs provided by the helper exitProcess(1) @@ -365,9 +313,9 @@ class ChangePw : CliktCommand("Change account password", name = "passwd") { override fun run() { val cfg = talerConfig(configFile) - val ctx = cfg.loadBankApplicationContext() + val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val db = Database(dbCfg.dbConnStr, ctx.currency, null) + val db = Database(dbCfg.dbConnStr, ctx.currency, ctx.fiatCurrency) runBlocking { if (!maybeCreateAdminAccount(db, ctx)) // logs provided by the helper exitProcess(1) @@ -442,7 +390,7 @@ class BankConfigGet : CliktCommand("Lookup config value", name = "get") { } } -class BankConfig : CliktCommand("Dump the configuration", name = "config") { +class BankConfigCmd : CliktCommand("Dump the configuration", name = "config") { init { subcommands(BankConfigDump(), BankConfigPathsub(), BankConfigGet()) } @@ -453,7 +401,7 @@ class BankConfig : CliktCommand("Dump the configuration", name = "config") { class LibeufinBankCommand : CliktCommand() { init { versionOption(getVersion()) - subcommands(ServeBank(), BankDbInit(), ChangePw(), BankConfig()) + subcommands(ServeBank(), BankDbInit(), ChangePw(), BankConfigCmd()) } override fun run() = Unit diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -289,6 +289,63 @@ class TalerAmount { } } +@Serializable(with = DecimalNumber.Serializer::class) +class DecimalNumber { + val value: Long + val frac: Int + + constructor(encoded: String) { + fun badAmount(hint: String): Exception = + badRequest(hint, TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT) + + val match = PATTERN.matchEntire(encoded) ?: throw badAmount("Invalid decimal number format"); + val (value, frac) = match.destructured + this.value = value.toLongOrNull() ?: throw badAmount("Invalid value") + if (this.value > TalerAmount.MAX_VALUE) throw badAmount("Value specified in decimal number is too large") + this.frac = if (frac.isEmpty()) { + 0 + } else { + var tmp = frac.toIntOrNull() ?: throw badAmount("Invalid fractional value") + repeat(8 - frac.length) { + tmp *= 10 + } + tmp + } + } + + override fun equals(other: Any?): Boolean { + return other is DecimalNumber && + other.value == this.value && + other.frac == this.frac + } + + override fun toString(): String { + if (frac == 0) { + return "$value" + } else { + return "$value.${frac.toString().padStart(8, '0')}" + .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 + } + } + + internal object Serializer : KSerializer<DecimalNumber> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: DecimalNumber) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): DecimalNumber { + return DecimalNumber(decoder.decodeString()) + } + } + + companion object { + private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?"); + } +} + /** * Internal representation of relative times. The diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -327,15 +327,15 @@ data class Cashout( ) // Type to return as GET /config response -@Serializable // Never used to parse JSON. +@Serializable data class Config( val currency: CurrencySpecification, + val have_cashout: Boolean, + val fiat_currency: String?, + val conversion_info: ConversionInfo? ) { val name: String = "libeufin-bank" val version: String = "0:0:0" - val have_cashout: Boolean = false - // Following might probably get renamed: - val fiat_currency: String? = null } enum class CorebankCreditDebitInfo { @@ -558,6 +558,12 @@ data class CashoutConfirm( val tan: String ) +@Serializable +data class CashoutConversionResponse( + val amount_debit: TalerAmount, + val amount_credit: TalerAmount, +) + /** * Request to an /admin/add-incoming request from * the Taler Wire Gateway API. diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -36,7 +36,7 @@ import kotlin.math.abs private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") -fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { +fun Routing.wireGatewayApi(db: Database, ctx: BankConfig) { get("/taler-wire-gateway/config") { call.respond(TWGConfigResponse(currency = ctx.currency)) return@get diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -116,16 +116,16 @@ fun badRequest( ) ) -fun BankApplicationContext.checkInternalCurrency(amount: TalerAmount) { +fun BankConfig.checkInternalCurrency(amount: TalerAmount) { if (amount.currency != currency) throw badRequest( "Wrong currency: expected internal currency $currency got ${amount.currency}", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH ) } -fun BankApplicationContext.checkCashoutCurrency(amount: TalerAmount) { - if (amount.currency != cashoutCurrency) throw badRequest( - "Wrong currency: expected cashout currency $cashoutCurrency got ${amount.currency}", +fun BankConfig.checkFiatCurrency(amount: TalerAmount) { + if (amount.currency != fiatCurrency) throw badRequest( + "Wrong currency: expected fiat currency $fiatCurrency got ${amount.currency}", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH ) } @@ -250,6 +250,29 @@ data class HistoryParams( } } +data class CashoutRateParams( + val debit: TalerAmount?, val credit: TalerAmount? +) { + companion object { + fun extract(params: Parameters): CashoutRateParams { + val debit = try { + params["amount_debit"]?.run(::TalerAmount) + } catch (e: Exception) { + throw badRequest("Param 'amount_debit' not a taler amount") + } + val credit = try { + params["amount_credit"]?.run(::TalerAmount) + } catch (e: Exception) { + throw badRequest("Param 'amount_credit' not a taler amount") + } + if (debit == null && credit == null) { + throw badRequest("Either param 'amount_debit' or 'amount_credit' is required") + } + return CashoutRateParams(debit, credit) + } + } +} + /** * This function creates the admin account ONLY IF it was * NOT found in the database. It sets it to a random password that @@ -257,7 +280,7 @@ data class HistoryParams( * * It returns false in case of problems, true otherwise. */ -suspend fun maybeCreateAdminAccount(db: Database, ctx: BankApplicationContext): Boolean { +suspend fun maybeCreateAdminAccount(db: Database, ctx: BankConfig): Boolean { val maybeAdminCustomer = db.customerGetFromLogin("admin") val adminCustomerId: Long = if (maybeAdminCustomer == null) { logger.debug("Creating admin's customer row") diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -889,4 +889,29 @@ class CoreBankWithdrawalApiTest { // Check unknown client.post("/withdrawals/${UUID.randomUUID()}/confirm").assertNotFound() } +} + + +class CoreBankCashoutApiTest { + // GET /cashout-rate + @Test + fun rate() = bankSetup { _ -> + // Check conversion + client.get("/cashout-rate?amount_debit=KUDOS:1").assertOk().run { + val resp = Json.decodeFromString<CashoutConversionResponse>(bodyAsText()) + assertEquals(TalerAmount("FIAT:1.247"), resp.amount_credit) + } + // Check OK + client.get("/cashout-rate?amount_debit=KUDOS:1&amount_credit=FIAT:1.247").assertOk() + // Check bad conversion + client.get("/cashout-rate?amount_debit=KUDOS:1&amount_credit=FIAT:1.25").assertBadRequest() + + // No amount + client.get("/cashout-rate").assertBadRequest() + // Wrong currency + client.get("/cashout-rate?amount_debit=FIAT:1").assertBadRequest() + .assertBadRequest().assertErr(TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH) + client.get("/cashout-rate?amount_credit=KUDOS:1").assertBadRequest() + .assertBadRequest().assertErr(TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH) + } } \ No newline at end of file diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -51,15 +51,16 @@ val bankAccountExchange = BankAccount( fun setup( conf: String = "test.conf", - lambda: suspend (Database, BankApplicationContext) -> Unit + lambda: suspend (Database, BankConfig) -> Unit ) { val config = talerConfig("conf/$conf") val dbCfg = config.loadDbConfig() resetDatabaseTables(dbCfg, "libeufin-bank") initializeDatabaseTables(dbCfg, "libeufin-bank") - val ctx = config.loadBankApplicationContext() - Database(dbCfg.dbConnStr, ctx.currency, null).use { + val ctx = config.loadBankConfig() + Database(dbCfg.dbConnStr, ctx.currency, ctx.fiatCurrency).use { runBlocking { + ctx.conversionInfo?.run { it.conversionUpdateConfig(this) } lambda(it, ctx) } } diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -425,4 +425,13 @@ COMMENT ON COLUMN regional_stats.internal_taler_payments_volume IS 'how much int -- end of: Statistics +-- start of: Conversion + +CREATE TABLE IF NOT EXISTS config ( + key TEXT NOT NULL PRIMARY KEY, + value JSONB NOT NULL +); + +-- end of: Conversion + COMMIT; diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -38,11 +38,11 @@ CREATE OR REPLACE FUNCTION amount_mul( ) LANGUAGE plpgsql AS $$ DECLARE -tmp NUMERIC(24, 8); -- 16 digit for val and 8 for frac + tmp NUMERIC(24, 8); -- 16 digit for val and 8 for frac BEGIN -- TODO write custom multiplication logic to get more control over rounding tmp = (a.val::numeric(24, 8) + a.frac::numeric(24, 8) / 100000000) * (b.val::numeric(24, 8) + b.frac::numeric(24, 8) / 100000000); - product = (trunc(tmp)::bigint, (tmp * 100000000 % 100000000)::int); + product = (trunc(tmp)::int8, (tmp * 100000000 % 100000000)::int4); IF (product.val > 1::bigint<<52) THEN RAISE EXCEPTION 'amount value overflowed'; END IF; @@ -1108,3 +1108,39 @@ BEGIN ,internal_taler_payments_volume = (SELECT amount_add(s.internal_taler_payments_volume, amount)); END LOOP; END $$; + +CREATE OR REPLACE PROCEDURE conversion_config_update( + IN buy_at_ratio taler_amount, + IN sell_at_ratio taler_amount, + IN buy_in_fee taler_amount, + IN sell_out_fee taler_amount +) +LANGUAGE sql AS $$ + INSERT INTO config (key, value) VALUES ('buy_at_ratio', jsonb_build_object('val', buy_at_ratio.val, 'frac', buy_at_ratio.frac)); + INSERT INTO config (key, value) VALUES ('sell_at_ratio', jsonb_build_object('val', sell_at_ratio.val, 'frac', sell_at_ratio.frac)); + INSERT INTO config (key, value) VALUES ('buy_in_fee', jsonb_build_object('val', buy_in_fee.val, 'frac', buy_in_fee.frac)); + INSERT INTO config (key, value) VALUES ('sell_out_fee', jsonb_build_object('val', sell_out_fee.val, 'frac', sell_out_fee.frac)); +$$; + +CREATE OR REPLACE FUNCTION conversion_internal_to_fiat( + IN internal_amount taler_amount, + OUT fiat_amount taler_amount +) +LANGUAGE plpgsql AS $$ +DECLARE + sell_at_ratio taler_amount; + sell_out_fee taler_amount; + calculation_ok BOOLEAN; +BEGIN + SELECT value['val']::int8, value['frac']::int4 INTO sell_at_ratio.val, sell_at_ratio.frac FROM config WHERE key='sell_at_ratio'; + SELECT value['val']::int8, value['frac']::int4 INTO sell_out_fee.val, sell_out_fee.frac FROM config WHERE key='sell_out_fee'; + + SELECT product.val, product.frac INTO fiat_amount.val, fiat_amount.frac FROM amount_mul(internal_amount, sell_at_ratio) as product; + SELECT (diff).val, (diff).frac, ok INTO fiat_amount.val, fiat_amount.frac, calculation_ok FROM amount_left_minus_right(fiat_amount, sell_out_fee); + + IF NOT calculation_ok THEN + fiat_amount = (0, 0); -- TODO how to handle zero and less than zero ? + END IF; +END $$; + +COMMIT; +\ No newline at end of file