libeufin

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

config.rs (10857B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::{collections::BTreeMap, path::PathBuf};
     21 
     22 use compact_str::CompactString;
     23 use jiff::Span;
     24 use serde::{Deserialize, Serialize};
     25 use taler_api::{
     26     Serve,
     27     config::DbCfg,
     28     error::{ApiResult, failure},
     29 };
     30 use taler_common::{
     31     config::{CfgErr, Config},
     32     error_code::ErrorCode,
     33     map_config,
     34     types::amount::{Amount, Currency},
     35 };
     36 use taler_macros::EnumMeta;
     37 use tracing::warn;
     38 use url::Url;
     39 use uuid::Uuid;
     40 
     41 use crate::{TanChannel, payto::PaytoCtx, pw::PwCrypto};
     42 
     43 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     44 pub struct CurrencySpecification {
     45     pub name: CompactString,
     46     pub num_fractional_input_digits: u64,
     47     pub num_fractional_normal_digits: u64,
     48     pub num_fractional_trailing_zero_digits: u64,
     49     pub alt_unit_names: BTreeMap<CompactString, CompactString>,
     50 }
     51 
     52 #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)]
     53 #[enum_meta(Str)]
     54 #[allow(non_camel_case_types)]
     55 pub enum WireMethod {
     56     iban,
     57     #[enum_meta(rename = "x-taler-bank")]
     58     x_taler_bank,
     59 }
     60 
     61 /** Configuration for libeufin-bank */
     62 pub struct BankCfg {
     63     pub cfg: Config,
     64     pub name: CompactString,
     65     pub base_url: Url,
     66     pub regional_currency: Currency,
     67     pub regional_currency_spec: CurrencySpecification,
     68     pub wire_transfer_fees: Amount,
     69     pub min_amount: Amount,
     70     pub max_amount: Amount,
     71     pub allow_registration: bool,
     72     pub allow_account_deletion: bool,
     73     pub allow_edit_name: bool,
     74     pub allow_edit_cashout: bool,
     75     pub default_debt_limit: Amount,
     76     pub registration_bonus: Amount,
     77     pub suggested_withdrawal_exchange: Option<String>,
     78     pub fiat: Option<(Currency, CurrencySpecification)>,
     79     pub spa_path: Option<String>,
     80     pub fallback_spa: PathBuf,
     81     pub tan_channels: BTreeMap<TanChannel, (String, BTreeMap<CompactString, CompactString>)>,
     82     pub ctx: PaytoCtx,
     83     pub wire_method: WireMethod,
     84     pub pw_crypto: PwCrypto,
     85     pub gc_abort_after: Span,
     86     pub gc_clean_after: Span,
     87     pub gc_delete_after: Span,
     88     pub pwd_check_quality: bool,
     89     pub basic_auth_compat: bool,
     90     pub db_cfg: DbCfg,
     91     pub serve: Serve,
     92 }
     93 
     94 impl BankCfg {
     95     fn currency_specification(
     96         cfg: &Config,
     97         currency: &Currency,
     98     ) -> Result<CurrencySpecification, CfgErr> {
     99         for s in cfg.sections() {
    100             if !s.name.starts_with("currency-") {
    101                 continue;
    102             }
    103             // ENABLED has to be checked before anything else in the section as
    104             // currencies.conf may carry disabled sections whose CODE
    105             // is not a valid currency and parsing those would abort the whole
    106             // configuration.
    107             if !s.boolean("enabled").require()? {
    108                 continue;
    109             }
    110             if s.currency("code").require()? == *currency {
    111                 return Ok(CurrencySpecification {
    112                     name: s.cstr("name").require()?,
    113                     num_fractional_input_digits: s.number("fractional_input_digits").require()?,
    114                     num_fractional_normal_digits: s.number("fractional_normal_digits").require()?,
    115                     num_fractional_trailing_zero_digits: s
    116                         .number("fractional_trailing_zero_digits")
    117                         .require()?,
    118                     alt_unit_names: s.json("alt_unit_names").require()?,
    119                 });
    120             }
    121         }
    122         warn!(target: "config", "Missing currency specification for {currency}, using sane defaults");
    123         Ok(CurrencySpecification {
    124             name: currency.as_ref().into(),
    125             num_fractional_input_digits: 2,
    126             num_fractional_normal_digits: 2,
    127             num_fractional_trailing_zero_digits: 2,
    128             alt_unit_names: [("0".into(), currency.as_ref().into())].into(),
    129         })
    130     }
    131 
    132     pub fn parse(cfg: &Config) -> Result<Self, CfgErr> {
    133         let s = cfg.section("libeufin-bank");
    134         let base_url = s.base_url("base_url").require()?;
    135         let hostname = base_url.host_str().unwrap_or_default().into();
    136 
    137         let wire_method = s
    138             .parse("payment target type", "wire_type")
    139             .default(WireMethod::iban)?;
    140 
    141         let currency = s.currency("currency").require()?;
    142         let allow_conversion = s.boolean("allow_conversion").default(false)?;
    143         let fiat = if allow_conversion {
    144             let currency = s.currency("fiat_currency").require()?;
    145             let spec = Self::currency_specification(cfg, &currency)?;
    146             Some((currency, spec))
    147         } else {
    148             None
    149         };
    150         let zero = Amount::zero(&currency);
    151         let max = Amount::max(&currency);
    152         let mut tan_channels = BTreeMap::new();
    153         for channel in TanChannel::entries {
    154             if let Some(path) = s.path(&format!("tan_{channel}")).opt()? {
    155                 tan_channels.insert(
    156                     *channel,
    157                     (
    158                         path,
    159                         s.json(&format!("tan_{channel}_env"))
    160                             .default(BTreeMap::default())?,
    161                     ),
    162                 );
    163             }
    164         }
    165         Ok(BankCfg {
    166             name: s
    167                 .cstr("name")
    168                 .default(CompactString::const_new("Taler Bank"))?,
    169             base_url,
    170             regional_currency: currency,
    171             regional_currency_spec: Self::currency_specification(cfg, &currency)?,
    172             wire_transfer_fees: s.amount("wire_transfer_fees", &currency).default(zero)?,
    173             min_amount: s
    174                 .amount("min_wire_transfer_amount", &currency)
    175                 .default(zero)?,
    176             max_amount: s
    177                 .amount("max_wire_transfer_amount", &currency)
    178                 .default(max)?,
    179             allow_registration: s.boolean("allow_registration").default(false)?,
    180             allow_account_deletion: s.boolean("allow_account_deletion").default(false)?,
    181             allow_edit_name: s.boolean("allow_edit_name").default(false)?,
    182             allow_edit_cashout: s.boolean("allow_edit_cashout_payto_uri").default(false)?,
    183             default_debt_limit: s.amount("default_debt_limit", &currency).default(zero)?,
    184             registration_bonus: s.amount("registration_bonus", &currency).default(zero)?,
    185             suggested_withdrawal_exchange: s.str("suggested_withdrawal_exchange").opt()?,
    186             fiat,
    187             spa_path: s.path("spa").opt()?,
    188             fallback_spa: PathBuf::from(cfg.section("paths").path("datadir").require()?)
    189                 .join("spa"),
    190             tan_channels,
    191             ctx: PaytoCtx {
    192                 bic: s.parse("bic", "iban_payto_bic").opt()?,
    193                 hostname,
    194             },
    195             wire_method,
    196             pw_crypto: map_config!(s, "password hash algorithm", "pwd_hash_algorithm",
    197                 "bcrypt" => {
    198                     s.json::<BcryptCfg>("pwd_hash_config").require()?.into()
    199                 }
    200             )
    201             .require()?,
    202             gc_abort_after: s.span("gc_abort_after").require()?,
    203             gc_clean_after: s.span("gc_clean_after").require()?,
    204             gc_delete_after: s.span("gc_delete_after").require()?,
    205             pwd_check_quality: s.boolean("pwd_check").require()?,
    206             basic_auth_compat: s.boolean("pwd_auth_compat").require()?,
    207             serve: Serve::parse(&s)?,
    208             db_cfg: DbCfg::parse(cfg.section("libeufin-bankdb-postgres"))?,
    209             cfg: cfg.clone(),
    210         })
    211     }
    212 
    213     pub fn check_regio(&self, amount: &Amount) -> ApiResult<()> {
    214         if amount.currency != self.regional_currency {
    215             Err(failure(
    216                 ErrorCode::GENERIC_CURRENCY_MISMATCH,
    217                 format_args!(
    218                     "Wrong currency: expected regional currency {} got {}",
    219                     self.regional_currency, amount.currency
    220                 ),
    221             ))
    222         } else {
    223             Ok(())
    224         }
    225     }
    226 
    227     pub fn check_fiat(&self, amount: &Amount) -> ApiResult<()> {
    228         if let Some(fiat_currency) = self.fiat_currency()
    229             && amount.currency != *fiat_currency
    230         {
    231             Err(failure(
    232                 ErrorCode::GENERIC_CURRENCY_MISMATCH,
    233                 format_args!(
    234                     "Wrong currency: expected fiat currency {fiat_currency} got {}",
    235                     amount.currency
    236                 ),
    237             ))
    238         } else {
    239             Ok(())
    240         }
    241     }
    242 
    243     pub fn fiat_currency(&self) -> Option<&Currency> {
    244         self.fiat.as_ref().map(|it| &it.0)
    245     }
    246 
    247     /// Builds the taler://withdraw-URI. Such URI will serve the requests
    248     /// from wallets, when they need to manage the operation.
    249     pub fn taler_withdraw_uri(&self, uuid: Uuid) -> Url {
    250         let base = &self.base_url;
    251 
    252         // Determine the correct schema/protocol
    253         let protocol = if base.scheme() == "http" {
    254             "taler+http"
    255         } else {
    256             "taler"
    257         };
    258 
    259         // Extract host and optional port
    260         let host = base.host_str().unwrap_or("");
    261         let port_suffix = base.port().map(|p| format!(":{}", p)).unwrap_or_default();
    262 
    263         // Get the path and ensure it handles trailing slashes properly
    264         let path = base.path();
    265 
    266         Url::parse(&format!(
    267             "{}://withdraw/{}{}{}taler-integration/{}",
    268             protocol, host, port_suffix, path, uuid
    269         ))
    270         .unwrap()
    271     }
    272 
    273     /// Builds the confirmation web UI URL.
    274     pub fn withdraw_confirm_url(&self, id: Uuid) -> String {
    275         format!("{}webui/#/operation/{}", self.base_url, id)
    276     }
    277 }
    278 
    279 fn deserialize_bcrypt_cost<'de, D>(deserializer: D) -> Result<u32, D::Error>
    280 where
    281     D: serde::Deserializer<'de>,
    282 {
    283     let cost = u32::deserialize(deserializer)?;
    284 
    285     if (4..=31).contains(&cost) {
    286         Ok(cost)
    287     } else {
    288         Err(serde::de::Error::custom(format!(
    289             "expected bcrypt cost in [4, 31], got {cost}"
    290         )))
    291     }
    292 }
    293 #[derive(serde::Deserialize)]
    294 struct BcryptCfg {
    295     #[serde(deserialize_with = "deserialize_bcrypt_cost")]
    296     cost: u32,
    297 }
    298 
    299 impl From<BcryptCfg> for PwCrypto {
    300     fn from(val: BcryptCfg) -> Self {
    301         PwCrypto::Bcrypt { cost: val.cost }
    302     }
    303 }