libeufin

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

config.rs (10532B)


      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                 && s.currency("code").require()? == *currency
    102                 && s.boolean("enabled").require()?
    103             {
    104                 return Ok(CurrencySpecification {
    105                     name: s.cstr("name").require()?,
    106                     num_fractional_input_digits: s.number("fractional_input_digits").require()?,
    107                     num_fractional_normal_digits: s.number("fractional_normal_digits").require()?,
    108                     num_fractional_trailing_zero_digits: s
    109                         .number("fractional_trailing_zero_digits")
    110                         .require()?,
    111                     alt_unit_names: s.json("alt_unit_names").require()?,
    112                 });
    113             }
    114         }
    115         warn!(target: "config", "Missing currency specification for {currency}, using sane defaults");
    116         Ok(CurrencySpecification {
    117             name: currency.as_ref().into(),
    118             num_fractional_input_digits: 2,
    119             num_fractional_normal_digits: 2,
    120             num_fractional_trailing_zero_digits: 2,
    121             alt_unit_names: [("0".into(), currency.as_ref().into())].into(),
    122         })
    123     }
    124 
    125     pub fn parse(cfg: &Config) -> Result<Self, CfgErr> {
    126         let s = cfg.section("libeufin-bank");
    127         let base_url = s.base_url("base_url").require()?;
    128         let hostname = base_url.host_str().unwrap_or_default().into();
    129 
    130         let wire_method = s
    131             .parse("payment target type", "wire_type")
    132             .default(WireMethod::iban)?;
    133 
    134         let currency = s.currency("currency").require()?;
    135         let allow_conversion = s.boolean("allow_conversion").default(false)?;
    136         let fiat = if allow_conversion {
    137             let currency = s.currency("fiat_currency").require()?;
    138             let spec = Self::currency_specification(cfg, &currency)?;
    139             Some((currency, spec))
    140         } else {
    141             None
    142         };
    143         let zero = Amount::zero(&currency);
    144         let max = Amount::max(&currency);
    145         let mut tan_channels = BTreeMap::new();
    146         for channel in TanChannel::entries {
    147             if let Some(path) = s.path(&format!("tan_{channel}")).opt()? {
    148                 tan_channels.insert(
    149                     *channel,
    150                     (
    151                         path,
    152                         s.json(&format!("tan_{channel}_env"))
    153                             .default(BTreeMap::default())?,
    154                     ),
    155                 );
    156             }
    157         }
    158         Ok(BankCfg {
    159             name: s
    160                 .cstr("name")
    161                 .default(CompactString::const_new("Taler Bank"))?,
    162             base_url,
    163             regional_currency: currency,
    164             regional_currency_spec: Self::currency_specification(cfg, &currency)?,
    165             wire_transfer_fees: s.amount("wire_transfer_fees", &currency).default(zero)?,
    166             min_amount: s
    167                 .amount("min_wire_transfer_amount", &currency)
    168                 .default(zero)?,
    169             max_amount: s
    170                 .amount("max_wire_transfer_amount", &currency)
    171                 .default(max)?,
    172             allow_registration: s.boolean("allow_registration").default(false)?,
    173             allow_account_deletion: s.boolean("allow_account_deletion").default(false)?,
    174             allow_edit_name: s.boolean("allow_edit_name").default(false)?,
    175             allow_edit_cashout: s.boolean("allow_edit_cashout_payto_uri").default(false)?,
    176             default_debt_limit: s.amount("default_debt_limit", &currency).default(zero)?,
    177             registration_bonus: s.amount("registration_bonus", &currency).default(zero)?,
    178             suggested_withdrawal_exchange: s.str("suggested_withdrawal_exchange").opt()?,
    179             fiat,
    180             spa_path: s.path("spa").opt()?,
    181             fallback_spa: PathBuf::from(cfg.section("paths").path("datadir").require()?)
    182                 .join("spa"),
    183             tan_channels,
    184             ctx: PaytoCtx {
    185                 bic: s.parse("bic", "iban_payto_bic").opt()?,
    186                 hostname,
    187             },
    188             wire_method,
    189             pw_crypto: map_config!(s, "password hash algorithm", "pwd_hash_algorithm",
    190                 "bcrypt" => {
    191                     s.json::<BcryptCfg>("pwd_hash_config").require()?.into()
    192                 }
    193             )
    194             .require()?,
    195             gc_abort_after: s.span("gc_abort_after").require()?,
    196             gc_clean_after: s.span("gc_clean_after").require()?,
    197             gc_delete_after: s.span("gc_delete_after").require()?,
    198             pwd_check_quality: s.boolean("pwd_check").require()?,
    199             basic_auth_compat: s.boolean("pwd_auth_compat").require()?,
    200             serve: Serve::parse(&s)?,
    201             db_cfg: DbCfg::parse(cfg.section("libeufin-bankdb-postgres"))?,
    202             cfg: cfg.clone(),
    203         })
    204     }
    205 
    206     pub fn check_regio(&self, amount: &Amount) -> ApiResult<()> {
    207         if amount.currency != self.regional_currency {
    208             Err(failure(
    209                 ErrorCode::GENERIC_CURRENCY_MISMATCH,
    210                 format_args!(
    211                     "Wrong currency: expected regional currency {} got {}",
    212                     self.regional_currency, amount.currency
    213                 ),
    214             ))
    215         } else {
    216             Ok(())
    217         }
    218     }
    219 
    220     pub fn check_fiat(&self, amount: &Amount) -> ApiResult<()> {
    221         if let Some(fiat_currency) = self.fiat_currency()
    222             && amount.currency != *fiat_currency
    223         {
    224             Err(failure(
    225                 ErrorCode::GENERIC_CURRENCY_MISMATCH,
    226                 format_args!(
    227                     "Wrong currency: expected fiat currency {fiat_currency} got {}",
    228                     amount.currency
    229                 ),
    230             ))
    231         } else {
    232             Ok(())
    233         }
    234     }
    235 
    236     pub fn fiat_currency(&self) -> Option<&Currency> {
    237         self.fiat.as_ref().map(|it| &it.0)
    238     }
    239 
    240     /// Builds the taler://withdraw-URI. Such URI will serve the requests
    241     /// from wallets, when they need to manage the operation.
    242     pub fn taler_withdraw_uri(&self, uuid: Uuid) -> Url {
    243         let base = &self.base_url;
    244 
    245         // Determine the correct schema/protocol
    246         let protocol = if base.scheme() == "http" {
    247             "taler+http"
    248         } else {
    249             "taler"
    250         };
    251 
    252         // Extract host and optional port
    253         let host = base.host_str().unwrap_or("");
    254         let port_suffix = base.port().map(|p| format!(":{}", p)).unwrap_or_default();
    255 
    256         // Get the path and ensure it handles trailing slashes properly
    257         let path = base.path();
    258 
    259         Url::parse(&format!(
    260             "{}://withdraw/{}{}{}taler-integration/{}",
    261             protocol, host, port_suffix, path, uuid
    262         ))
    263         .unwrap()
    264     }
    265 
    266     /// Builds the confirmation web UI URL.
    267     pub fn withdraw_confirm_url(&self, id: Uuid) -> String {
    268         format!("{}webui/#/operation/{}", self.base_url, id)
    269     }
    270 }
    271 
    272 fn deserialize_bcrypt_cost<'de, D>(deserializer: D) -> Result<u32, D::Error>
    273 where
    274     D: serde::Deserializer<'de>,
    275 {
    276     let cost = u32::deserialize(deserializer)?;
    277 
    278     if (4..=31).contains(&cost) {
    279         Ok(cost)
    280     } else {
    281         Err(serde::de::Error::custom(format!(
    282             "expected bcrypt cost in [4, 31], got {cost}"
    283         )))
    284     }
    285 }
    286 #[derive(serde::Deserialize)]
    287 struct BcryptCfg {
    288     #[serde(deserialize_with = "deserialize_bcrypt_cost")]
    289     cost: u32,
    290 }
    291 
    292 impl From<BcryptCfg> for PwCrypto {
    293     fn from(val: BcryptCfg) -> Self {
    294         PwCrypto::Bcrypt { cost: val.cost }
    295     }
    296 }