fioapi 0.3.1

Async Rust client for the Fio banka REST API
Documentation
use crate::error::FioError;
use chrono::NaiveDate;
use log::debug;
use rust_decimal::Decimal;
use serde::de::Error as DeError;
use serde::{Deserialize, Deserializer};
use serde_json::Value;

#[derive(Debug, Clone, Deserialize)]
pub struct AccountInfo {
    #[serde(rename = "accountId")]
    pub account_id: Option<String>,
    #[serde(rename = "bankId")]
    pub bank_id: Option<String>,
    pub currency: Option<String>,
    pub iban: Option<String>,
    pub bic: Option<String>,
    #[serde(rename = "openingBalance")]
    pub opening_balance: Option<Decimal>,
    #[serde(rename = "closingBalance")]
    pub closing_balance: Option<Decimal>,
    #[serde(rename = "dateStart", deserialize_with = "deserialize_date_opt")]
    pub date_start: Option<NaiveDate>,
    #[serde(rename = "dateEnd", deserialize_with = "deserialize_date_opt")]
    pub date_end: Option<NaiveDate>,
    #[serde(rename = "yearList")]
    pub year_list: Option<i32>,
    #[serde(rename = "idList")]
    pub id_list: Option<i32>,
    #[serde(rename = "idFrom")]
    pub id_from: Option<i64>,
    #[serde(rename = "idTo")]
    pub id_to: Option<i64>,
    #[serde(rename = "idLastDownload")]
    pub id_last_download: Option<i64>,
}

#[derive(Debug, Clone)]
pub struct Transaction {
    pub transaction_id: i64,
    pub date: NaiveDate,
    pub amount: Decimal,
    pub currency: String,
    pub account_id: Option<String>,
    pub account_name: Option<String>,
    pub bank_id: Option<String>,
    pub bank_name: Option<String>,
    pub ks: Option<i64>,
    pub vs: Option<i64>,
    pub ss: Option<i64>,
    pub user_identification: Option<String>,
    pub message_from_sender: Option<String>,
    pub transaction_type: Option<String>,
    pub executor: Option<String>,
    pub specification: Option<String>,
    pub comment: Option<String>,
    pub bic: Option<String>,
    pub order_id: Option<i64>,
    pub payer_reference: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct FioResponse {
    #[serde(rename = "accountStatement")]
    pub account_statement: AccountStatement,
}

impl FioResponse {
    pub fn account_info(&self) -> &AccountInfo {
        &self.account_statement.info
    }

    pub fn transactions(&self) -> Result<Vec<Transaction>, FioError> {
        self.account_statement
            .transaction_list
            .transaction
            .iter()
            .map(Transaction::try_from)
            .collect()
    }
}

#[derive(Debug, Deserialize)]
pub struct AccountStatement {
    pub info: AccountInfo,

    #[serde(rename = "transactionList")]
    pub transaction_list: TransactionList,
}

#[derive(Debug, Deserialize)]
pub struct TransactionList {
    pub(crate) transaction: Vec<RawTransaction>,
}

#[derive(Debug, Deserialize)]
struct ColumnValue<T> {
    value: T,
}

#[derive(Debug, Deserialize)]
pub(crate) struct RawTransaction {
    #[serde(rename = "column22")]
    transaction_id: ColumnValue<Value>,

    #[serde(rename = "column0")]
    date: ColumnValue<String>,

    #[serde(rename = "column1")]
    amount: ColumnValue<Decimal>,

    #[serde(rename = "column14")]
    currency: ColumnValue<String>,

    #[serde(rename = "column2")]
    account_id: Option<ColumnValue<Value>>,

    #[serde(rename = "column10")]
    account_name: Option<ColumnValue<Value>>,

    #[serde(rename = "column3")]
    bank_id: Option<ColumnValue<Value>>,

    #[serde(rename = "column12")]
    bank_name: Option<ColumnValue<Value>>,

    #[serde(rename = "column4")]
    ks: Option<ColumnValue<Value>>,

    #[serde(rename = "column5")]
    vs: Option<ColumnValue<Value>>,

    #[serde(rename = "column6")]
    ss: Option<ColumnValue<Value>>,

    #[serde(rename = "column7")]
    user_identification: Option<ColumnValue<Value>>,

    #[serde(rename = "column16")]
    remittance_info: Option<ColumnValue<Value>>,

    #[serde(rename = "column8")]
    transaction_type: Option<ColumnValue<Value>>,

    #[serde(rename = "column9")]
    executor: Option<ColumnValue<Value>>,

    #[serde(rename = "column18")]
    specification: Option<ColumnValue<Value>>,

    #[serde(rename = "column25")]
    comment: Option<ColumnValue<Value>>,

    #[serde(rename = "column26")]
    bic: Option<ColumnValue<Value>>,

    #[serde(rename = "column17")]
    order_id: Option<ColumnValue<Value>>,

    #[serde(rename = "column27")]
    payer_reference: Option<ColumnValue<Value>>,
}

fn deserialize_date_opt<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
    D: Deserializer<'de>,
{
    let raw: Option<String> = Option::deserialize(deserializer)?;
    match raw {
        None => Ok(None),
        Some(s) if s.is_empty() => Ok(None),
        Some(s) => parse_date(&s)
            .map(Some)
            .ok_or_else(|| D::Error::custom("invalid date value")),
    }
}

fn parse_date(raw: &str) -> Option<NaiveDate> {
    let prefix = raw.get(0..10)?;
    NaiveDate::parse_from_str(prefix, "%Y-%m-%d").ok()
}

fn json_value_to_string(value: &Value) -> Option<String> {
    match value {
        Value::String(s) => Some(s.clone()),
        Value::Number(n) => Some(n.to_string()),
        Value::Bool(b) => Some(b.to_string()),
        _ => None,
    }
}

fn optional_string(value: &Option<ColumnValue<Value>>) -> Option<String> {
    value
        .as_ref()
        .and_then(|c| json_value_to_string(&c.value))
        .filter(|s| !s.is_empty())
}

fn parse_i64_value(value: &Value) -> Option<i64> {
    match value {
        Value::Number(n) => n.as_i64(),
        Value::String(s) => s.parse().ok(),
        _ => None,
    }
}

fn optional_i64(value: &Option<ColumnValue<Value>>) -> Option<i64> {
    value.as_ref().and_then(|c| parse_i64_value(&c.value))
}

impl TryFrom<&RawTransaction> for Transaction {
    type Error = FioError;

    fn try_from(raw: &RawTransaction) -> Result<Self, Self::Error> {
        let transaction_id =
            parse_i64_value(&raw.transaction_id.value).ok_or(FioError::InvalidResponse)?;
        let date = parse_date(&raw.date.value).ok_or(FioError::InvalidResponse)?;

        Ok(Transaction {
            transaction_id,
            date,
            amount: raw.amount.value,
            currency: raw.currency.value.clone(),
            account_id: optional_string(&raw.account_id),
            account_name: optional_string(&raw.account_name),
            bank_id: optional_string(&raw.bank_id),
            bank_name: optional_string(&raw.bank_name),
            ks: optional_i64(&raw.ks),
            vs: optional_i64(&raw.vs),
            ss: optional_i64(&raw.ss),
            user_identification: optional_string(&raw.user_identification),
            message_from_sender: optional_string(&raw.remittance_info),
            transaction_type: optional_string(&raw.transaction_type),
            executor: optional_string(&raw.executor),
            specification: optional_string(&raw.specification),
            comment: optional_string(&raw.comment),
            bic: optional_string(&raw.bic),
            order_id: optional_i64(&raw.order_id),
            payer_reference: optional_string(&raw.payer_reference),
        })
    }
}

pub fn parse_account_info(data: &str) -> Result<AccountInfo, FioError> {
    let parsed: FioResponse = serde_json::from_str(data).map_err(|_| FioError::InvalidResponse)?;
    debug!("Parsed account info");
    Ok(parsed.account_statement.info)
}

pub fn parse_transactions(data: &str) -> Result<Vec<Transaction>, FioError> {
    let parsed: FioResponse = serde_json::from_str(data).map_err(|_| FioError::InvalidResponse)?;
    let txns = parsed.transactions()?;
    debug!("Parsed {} transactions", txns.len());
    Ok(txns)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::str::FromStr;

    fn sample_payload() -> String {
        let payload = json!({
            "accountStatement": {
                "info": {
                    "accountId": "2000000000",
                    "bankId": "2010",
                    "currency": "CZK",
                    "iban": "CZ1000000000002000000000",
                    "bic": "FIOZSKBA",
                    "openingBalance": "100.00",
                    "closingBalance": "200.00",
                    "dateStart": "2023-01-01+0000",
                    "dateEnd": "2023-01-02+0000",
                    "yearList": 2023,
                    "idList": 1,
                    "idFrom": 123,
                    "idTo": 124,
                    "idLastDownload": 124
                },
                "transactionList": {
                    "transaction": [
                        {
                            "column22": { "value": 10001 },
                            "column0": { "value": "2023-01-02+0000" },
                            "column1": { "value": "50.25" },
                            "column14": { "value": "CZK" },
                            "column2": { "value": "123456789" },
                            "column10": { "value": "John Doe" },
                            "column3": { "value": "2010" },
                            "column12": { "value": "Fio banka" },
                            "column4": { "value": "0558" },
                            "column5": { "value": "12345" },
                            "column6": { "value": "001" },
                            "column7": { "value": "user info" },
                            "column16": { "value": "payment" },
                            "column8": { "value": "type" },
                            "column9": { "value": "executor" },
                            "column18": { "value": "spec" },
                            "column25": { "value": "comment" },
                            "column26": { "value": "BICCODE" },
                            "column17": { "value": 77 },
                            "column27": { "value": "payer" }
                        }
                    ]
                }
            }
        });
        payload.to_string()
    }

    #[test]
    fn parses_account_info() {
        let json = sample_payload();
        let info = parse_account_info(&json).expect("info should parse");
        assert_eq!(info.account_id.as_deref(), Some("2000000000"));
        assert_eq!(info.currency.as_deref(), Some("CZK"));
        assert_eq!(info.date_start, NaiveDate::from_ymd_opt(2023, 1, 1));
    }

    #[test]
    fn parses_transactions() {
        let json = sample_payload();
        let txns = parse_transactions(&json).expect("transactions should parse");
        assert_eq!(txns.len(), 1);
        let txn = &txns[0];
        assert_eq!(txn.transaction_id, 10001);
        assert_eq!(txn.amount, Decimal::from_str("50.25").unwrap());
        assert_eq!(txn.date, NaiveDate::from_ymd_opt(2023, 1, 2).unwrap());
        assert_eq!(txn.vs, Some(12345));
        assert_eq!(txn.order_id, Some(77));
    }
}