use std::str::FromStr;
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc};
use rust_decimal::Decimal;
use serde::{self, Deserialize, Deserializer};
use sgmlish::Parser;
use crate::{Result, error::Error};
fn deserialize_datetime<'de, D>(deserializer: D) -> std::result::Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error as SerdeErr;
let s = String::deserialize(deserializer)?;
let (dt_str, offset_str) = s.find('[').map_or_else(
|| (&s[..], "0"),
|idx| (&s[..idx], &s[idx + 1..s.len() - 1]),
);
let offset_num = offset_str
.split(':')
.next()
.ok_or_else(|| SerdeErr::custom(format!("invalid timezone offset format: {offset_str}")))?
.parse::<f32>()
.map_err(serde::de::Error::custom)?;
if offset_num.abs() > 12.0 {
return Err(SerdeErr::custom(format!(
"timezone offset too large or small: {offset_num}"
)));
}
#[allow(clippy::cast_possible_truncation)]
let offset_secs = (offset_num * 3600.0).round() as i32;
let offset = FixedOffset::east_opt(offset_secs)
.ok_or_else(|| SerdeErr::custom(format!("invalid timezone offset: {offset_num}")))?;
let naive = match dt_str.chars().count() {
18 => NaiveDateTime::parse_from_str(dt_str, "%Y%m%d%H%M%S%.3f"),
14 => NaiveDateTime::parse_from_str(dt_str, "%Y%m%d%H%M%S"),
8 => Ok(NaiveDate::parse_from_str(dt_str, "%Y%m%d")
.map_err(|err| SerdeErr::custom(format!("unable to parse as date: {dt_str}: {err}")))?
.and_hms_opt(0, 0, 0)
.expect("couldn't set time to 00:00:00")),
_ => {
return Err(SerdeErr::custom(format!("invalid datetime: {dt_str}")));
}
};
naive
.map_err(|err| SerdeErr::custom(format!("unable to parse '{s}' as datetime: {err}")))?
.and_local_timezone(offset)
.single()
.map(|dt| dt.with_timezone(&Utc))
.ok_or_else(|| SerdeErr::custom(format!("ambiguous or invalid local datetime: {s}")))
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub struct BankTransactionList {
pub dtstart: Option<String>,
pub dtend: Option<String>,
#[serde(rename = "STMTTRN")]
pub transactions: Vec<Transaction>,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub struct Transaction {
#[serde(rename = "TRNTYPE")]
pub transaction_type: TransactionType,
#[serde(rename = "DTPOSTED")]
#[serde(deserialize_with = "deserialize_datetime")]
pub date_posted: DateTime<Utc>,
#[serde(rename = "TRNAMT")]
pub amount: Decimal,
#[serde(rename = "FITID")]
pub id: String,
pub name: Option<String>,
pub memo: Option<String>,
pub currency: Option<Currency>,
}
#[derive(Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum TransactionType {
Credit,
Debit,
#[serde(rename = "INT")]
Interest,
#[serde(rename = "DIV")]
Dividend,
Fee,
#[serde(rename = "SRVCHG")]
ServiceCharge,
#[serde(rename = "DEP")]
Deposit,
Atm,
#[serde(rename = "POS")]
PointOfSale,
#[serde(rename = "XFER")]
Transfer,
Check,
Payment,
Cash,
#[serde(rename = "DIRECTDEP")]
DirectDeposit,
DirectDebit,
#[serde(rename = "REPEATPMT")]
RepeatPayment,
Other,
}
#[derive(Debug, Deserialize, Eq, PartialEq)]
pub struct Currency {
#[serde(rename = "CURRATE")]
pub rate: Decimal,
#[serde(rename = "CURSYM")]
pub symbol: String,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct SignOnMessageResponse {
#[serde(rename = "SONRS")]
pub response: SignOnResponse,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct FinancialInstitution {
#[serde(rename = "ORG")]
pub organization: String,
#[serde(rename = "FID")]
pub id: Option<String>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct SignOnResponse {
#[serde(rename = "STATUS")]
pub status: Status,
#[serde(rename = "DTSERVER")]
#[serde(deserialize_with = "deserialize_datetime")]
pub server_date: DateTime<Utc>,
#[serde(rename = "LANGUAGE")]
pub language: Language,
#[serde(rename = "FI")]
pub financial_institution: Option<FinancialInstitution>,
}
#[derive(Debug, PartialEq)]
pub struct Language(isolang::Language);
impl<'de> serde::Deserialize<'de> for Language {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let lang = String::deserialize(d)?.trim().to_lowercase();
let parsed = lang.parse().map_err(serde::de::Error::custom)?;
Ok(Language(parsed))
}
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct CreditCardMessageResponse {
#[serde(rename = "CCSTMTTRNRS")]
pub transaction_response: CreditCardStatementTransactionResponse,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct CreditCardStatementTransactionResponse {
#[serde(rename = "TRNUID")]
pub transaction_id: String,
#[serde(rename = "STATUS")]
pub status: Status,
#[serde(rename = "CCSTMTRS")]
pub statement: CreditCardStatementResponse,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct CreditCardStatementResponse {
#[serde(rename = "CURDEF")]
pub currency: String,
#[serde(rename = "CCACCTFROM")]
pub account: Account,
#[serde(rename = "BANKTRANLIST")]
pub bank_transactions: Option<BankTransactionList>,
#[serde(rename = "LEDGERBAL")]
pub ledger_balance: Balance,
#[serde(rename = "AVAILBAL")]
pub available_balance: Option<Balance>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct Account {
#[serde(rename = "ACCTID")]
pub id: String,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct Status {
#[serde(rename = "CODE")]
pub code: u32,
#[serde(rename = "SEVERITY")]
pub severity: String,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct Balance {
#[serde(rename = "BALAMT")]
pub amount: String,
#[serde(rename = "DTASOF")]
#[serde(deserialize_with = "deserialize_datetime")]
pub date: DateTime<Utc>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct BankMessageResponse {
#[serde(rename = "STMTTRNRS")]
pub transaction_response: StatementTransactionResponse,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct StatementTransactionResponse {
#[serde(rename = "TRNUID")]
pub transaction_id: String,
#[serde(rename = "STATUS")]
pub status: Status,
#[serde(rename = "STMTRS")]
pub statement: StatementResponse,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct StatementResponse {
#[serde(rename = "CURDEF")]
pub currency: String,
#[serde(rename = "BANKACCTFROM")]
pub account: Option<BankAccount>,
#[serde(rename = "BANKTRANLIST")]
pub bank_transactions: Option<BankTransactionList>,
#[serde(rename = "LEDGERBAL")]
pub ledger_balance: Option<Balance>,
#[serde(rename = "AVAILBAL")]
pub available_balance: Option<Balance>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct BankAccount {
#[serde(rename = "BANKID")]
pub bank_id: String,
#[serde(rename = "ACCTID")]
pub id: String,
#[serde(rename = "ACCTTYPE")]
pub account_type: AccountType,
}
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum AccountType {
Checking,
Savings,
Moneymrkt,
Creditline,
Cma,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct Body {
#[serde(rename = "SIGNONMSGSRSV1")]
pub sign_on: Option<SignOnMessageResponse>,
#[serde(rename = "CREDITCARDMSGSRSV1")]
pub credit_card: Option<CreditCardMessageResponse>,
#[serde(rename = "BANKMSGSRSV1")]
pub bank: Option<BankMessageResponse>,
}
impl FromStr for Body {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let sgml = Parser::builder()
.expand_entities(|entity| match entity {
"lt" => Some("<"),
"gt" => Some(">"),
"amp" => Some("&"),
"nbsp" => Some(" "),
_ => None,
})
.parse(s)?;
let sgml = sgmlish::transforms::normalize_end_tags(sgml)?;
Ok(sgmlish::from_fragment::<Body>(sgml)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Timelike, Utc};
use serde::de::IntoDeserializer;
use serde::de::value::{Error as SerdeError, StrDeserializer};
#[test]
fn test_parse_date_spec_example() {
let examples = [
(
"19961005132200.124[-5:EST]",
Utc.with_ymd_and_hms(1996, 10, 5, 18, 22, 0)
.unwrap()
.with_nanosecond(124_000_000)
.unwrap(),
),
(
"19961005132200.124[-5]",
Utc.with_ymd_and_hms(1996, 10, 5, 18, 22, 0)
.unwrap()
.with_nanosecond(124_000_000)
.unwrap(),
),
(
"19961005132200.124",
Utc.with_ymd_and_hms(1996, 10, 5, 13, 22, 0)
.unwrap()
.with_nanosecond(124_000_000)
.unwrap(),
),
(
"19961005132200",
Utc.with_ymd_and_hms(1996, 10, 5, 13, 22, 0).unwrap(),
),
(
"19961005",
Utc.with_ymd_and_hms(1996, 10, 5, 0, 0, 0).unwrap(),
),
];
for (input, expected) in examples {
let deserializer: StrDeserializer<SerdeError> = input.into_deserializer();
assert_eq!(deserialize_datetime(deserializer), Ok(expected));
}
}
#[test]
fn test_alt_timezone_formats() {
let examples = [
"19961005132200.124[-5:EST]",
"19961005132200.124[-5]",
"19961005132200.124[-5:]",
"19961005132200.124[-5.0:EST]",
"19961005212200.124[+3:EST]",
"19961005212200.124[+3]",
"19961005212200.124[+3:]",
"19961005212200.124[+3.0:EST]",
];
let expected = Utc
.with_ymd_and_hms(1996, 10, 5, 18, 22, 0)
.unwrap()
.with_nanosecond(124_000_000)
.unwrap();
for input in examples {
let deserializer: StrDeserializer<SerdeError> = input.into_deserializer();
assert_eq!(deserialize_datetime(deserializer), Ok(expected));
}
}
}