Instead of having hard-coded logic, which was inverted to boot, the currency exchane logic now uses the information about source and target currencies as received from GoCardless.
465 lines
16 KiB
Rust
465 lines
16 KiB
Rust
use crate::core::models::BankTransaction;
|
|
use anyhow::Result;
|
|
use gocardless_client::models::Transaction;
|
|
use rust_decimal::prelude::Signed;
|
|
use rust_decimal::Decimal;
|
|
use std::str::FromStr;
|
|
|
|
pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
|
let internal_id = tx
|
|
.transaction_id
|
|
.or(tx.internal_transaction_id)
|
|
.ok_or_else(|| anyhow::anyhow!("Transaction ID missing"))?;
|
|
|
|
let date_str = tx
|
|
.booking_date
|
|
.or(tx.value_date)
|
|
.ok_or_else(|| anyhow::anyhow!("Transaction date missing"))?;
|
|
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
|
|
|
|
let amount = Decimal::from_str(&tx.transaction_amount.amount)?;
|
|
validate_amount(&amount)?;
|
|
let currency = tx.transaction_amount.currency;
|
|
validate_currency(¤cy)?;
|
|
|
|
let mut foreign_amount = None;
|
|
let mut foreign_currency = None;
|
|
|
|
if let Some(exchanges) = tx.currency_exchange {
|
|
if let Some(exchange) = exchanges.first() {
|
|
if let (Some(source_curr), Some(target_curr), Some(rate_str)) = (
|
|
&exchange.source_currency,
|
|
&exchange.target_currency,
|
|
&exchange.exchange_rate,
|
|
) {
|
|
if let Ok(rate) = Decimal::from_str(rate_str) {
|
|
if !rate.is_zero() {
|
|
let (foreign_curr, calc) = if currency == *target_curr {
|
|
// Transaction is in target currency, foreign is source
|
|
(source_curr.clone(), amount.abs() / rate)
|
|
} else if currency == *source_curr {
|
|
// Transaction is in source currency, foreign is target
|
|
(target_curr.clone(), amount.abs() * rate)
|
|
} else {
|
|
// Unexpected currency configuration, skip
|
|
tracing::warn!("Transaction currency '{}' does not match exchange source '{}' or target '{}', skipping foreign amount calculation",
|
|
currency, source_curr, target_curr);
|
|
(String::new(), Decimal::ZERO) // dummy values, will be skipped
|
|
};
|
|
if !foreign_curr.is_empty() {
|
|
foreign_currency = Some(foreign_curr);
|
|
let sign = amount.signum();
|
|
foreign_amount = Some(calc * sign);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(ref fa) = foreign_amount {
|
|
validate_amount(fa)?;
|
|
}
|
|
if let Some(ref fc) = foreign_currency {
|
|
validate_currency(fc)?;
|
|
}
|
|
|
|
// Fallback for description: Remittance Unstructured -> Debtor/Creditor Name -> "Unknown"
|
|
let description = tx
|
|
.remittance_information_unstructured
|
|
.or(tx.creditor_name.clone())
|
|
.or(tx.debtor_name.clone())
|
|
.unwrap_or_else(|| "Unknown Transaction".to_string());
|
|
|
|
Ok(BankTransaction {
|
|
internal_id,
|
|
date,
|
|
amount,
|
|
currency,
|
|
foreign_amount,
|
|
foreign_currency,
|
|
description,
|
|
counterparty_name: tx.creditor_name.or(tx.debtor_name),
|
|
counterparty_iban: tx
|
|
.creditor_account
|
|
.and_then(|a| a.iban)
|
|
.or(tx.debtor_account.and_then(|a| a.iban)),
|
|
})
|
|
}
|
|
|
|
fn validate_amount(amount: &Decimal) -> Result<()> {
|
|
let abs = amount.abs();
|
|
if abs > Decimal::new(1_000_000_000, 0) {
|
|
return Err(anyhow::anyhow!(
|
|
"Amount exceeds reasonable bounds: {}",
|
|
amount
|
|
));
|
|
}
|
|
if abs == Decimal::ZERO {
|
|
return Err(anyhow::anyhow!("Amount cannot be zero"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_currency(currency: &str) -> Result<()> {
|
|
if currency.len() != 3 {
|
|
return Err(anyhow::anyhow!(
|
|
"Invalid currency code length: {}",
|
|
currency
|
|
));
|
|
}
|
|
if !currency.chars().all(|c| c.is_ascii_uppercase()) {
|
|
return Err(anyhow::anyhow!(
|
|
"Invalid currency code format: {}",
|
|
currency
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use gocardless_client::models::{CurrencyExchange, TransactionAmount};
|
|
|
|
#[test]
|
|
fn test_map_normal_transaction() {
|
|
let t = Transaction {
|
|
transaction_id: Some("123".into()),
|
|
entry_reference: None,
|
|
end_to_end_id: None,
|
|
mandate_id: None,
|
|
check_id: None,
|
|
creditor_id: None,
|
|
booking_date: Some("2023-01-01".into()),
|
|
value_date: None,
|
|
booking_date_time: None,
|
|
value_date_time: None,
|
|
transaction_amount: TransactionAmount {
|
|
amount: "100.50".into(),
|
|
currency: "EUR".into(),
|
|
},
|
|
currency_exchange: None,
|
|
creditor_name: Some("Shop".into()),
|
|
creditor_account: None,
|
|
ultimate_creditor: None,
|
|
debtor_name: None,
|
|
debtor_account: None,
|
|
ultimate_debtor: None,
|
|
remittance_information_unstructured: Some("Groceries".into()),
|
|
remittance_information_unstructured_array: None,
|
|
remittance_information_structured: None,
|
|
remittance_information_structured_array: None,
|
|
additional_information: None,
|
|
purpose_code: None,
|
|
bank_transaction_code: None,
|
|
proprietary_bank_transaction_code: None,
|
|
internal_transaction_id: None,
|
|
};
|
|
|
|
let res = map_transaction(t).unwrap();
|
|
assert_eq!(res.internal_id, "123");
|
|
assert_eq!(res.amount, Decimal::new(10050, 2));
|
|
assert_eq!(res.currency, "EUR");
|
|
assert_eq!(res.foreign_amount, None);
|
|
assert_eq!(res.description, "Groceries");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_multicurrency_transaction_target_to_source() {
|
|
let t = Transaction {
|
|
transaction_id: Some("124".into()),
|
|
entry_reference: None,
|
|
end_to_end_id: None,
|
|
mandate_id: None,
|
|
check_id: None,
|
|
creditor_id: None,
|
|
booking_date: Some("2023-01-02".into()),
|
|
value_date: None,
|
|
booking_date_time: None,
|
|
value_date_time: None,
|
|
transaction_amount: TransactionAmount {
|
|
amount: "-10.00".into(),
|
|
currency: "EUR".into(),
|
|
},
|
|
currency_exchange: Some(vec![CurrencyExchange {
|
|
source_currency: Some("USD".into()),
|
|
exchange_rate: Some("2.0".into()),
|
|
unit_currency: None,
|
|
target_currency: Some("EUR".into()),
|
|
}]),
|
|
creditor_name: Some("US Shop".into()),
|
|
creditor_account: None,
|
|
ultimate_creditor: None,
|
|
debtor_name: None,
|
|
debtor_account: None,
|
|
ultimate_debtor: None,
|
|
remittance_information_unstructured: None,
|
|
remittance_information_unstructured_array: None,
|
|
remittance_information_structured: None,
|
|
remittance_information_structured_array: None,
|
|
additional_information: None,
|
|
purpose_code: None,
|
|
bank_transaction_code: None,
|
|
proprietary_bank_transaction_code: None,
|
|
internal_transaction_id: None,
|
|
};
|
|
|
|
let res = map_transaction(t).unwrap();
|
|
assert_eq!(res.internal_id, "124");
|
|
assert_eq!(res.amount, Decimal::new(-1000, 2));
|
|
assert_eq!(res.foreign_currency, Some("USD".to_string()));
|
|
|
|
// Transaction in target (EUR), foreign in source (USD): 10.00 / 2.0 = 5.00, sign preserved (-5.00)
|
|
assert_eq!(res.foreign_amount, Some(Decimal::new(-500, 2)));
|
|
|
|
// Description fallback to creditor name
|
|
assert_eq!(res.description, "US Shop");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_multicurrency_transaction_source_to_target() {
|
|
let t = Transaction {
|
|
transaction_id: Some("125".into()),
|
|
entry_reference: None,
|
|
end_to_end_id: None,
|
|
mandate_id: None,
|
|
check_id: None,
|
|
creditor_id: None,
|
|
booking_date: Some("2023-01-03".into()),
|
|
value_date: None,
|
|
booking_date_time: None,
|
|
value_date_time: None,
|
|
transaction_amount: TransactionAmount {
|
|
amount: "-10.00".into(),
|
|
currency: "USD".into(),
|
|
},
|
|
currency_exchange: Some(vec![CurrencyExchange {
|
|
source_currency: Some("USD".into()),
|
|
exchange_rate: Some("2.0".into()),
|
|
unit_currency: None,
|
|
target_currency: Some("EUR".into()),
|
|
}]),
|
|
creditor_name: Some("EU Shop".into()),
|
|
creditor_account: None,
|
|
ultimate_creditor: None,
|
|
debtor_name: None,
|
|
debtor_account: None,
|
|
ultimate_debtor: None,
|
|
remittance_information_unstructured: None,
|
|
remittance_information_unstructured_array: None,
|
|
remittance_information_structured: None,
|
|
remittance_information_structured_array: None,
|
|
additional_information: None,
|
|
purpose_code: None,
|
|
bank_transaction_code: None,
|
|
proprietary_bank_transaction_code: None,
|
|
internal_transaction_id: None,
|
|
};
|
|
|
|
let res = map_transaction(t).unwrap();
|
|
assert_eq!(res.internal_id, "125");
|
|
assert_eq!(res.amount, Decimal::new(-1000, 2));
|
|
assert_eq!(res.foreign_currency, Some("EUR".to_string()));
|
|
|
|
// Transaction in source (USD), foreign in target (EUR): 10.00 * 2.0 = 20.00, sign preserved (-20.00)
|
|
assert_eq!(res.foreign_amount, Some(Decimal::new(-2000, 2)));
|
|
|
|
// Description fallback to creditor name
|
|
assert_eq!(res.description, "EU Shop");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_amount_zero() {
|
|
let amount = Decimal::ZERO;
|
|
assert!(validate_amount(&amount).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_amount_too_large() {
|
|
let amount = Decimal::new(2_000_000_000, 0);
|
|
assert!(validate_amount(&amount).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_currency_invalid_length() {
|
|
assert!(validate_currency("EU").is_err());
|
|
assert!(validate_currency("EURO").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_currency_not_uppercase() {
|
|
assert!(validate_currency("eur").is_err());
|
|
assert!(validate_currency("EuR").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_currency_valid() {
|
|
assert!(validate_currency("EUR").is_ok());
|
|
assert!(validate_currency("USD").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_transaction_invalid_amount() {
|
|
let t = Transaction {
|
|
transaction_id: Some("125".into()),
|
|
entry_reference: None,
|
|
end_to_end_id: None,
|
|
mandate_id: None,
|
|
check_id: None,
|
|
creditor_id: None,
|
|
booking_date: Some("2023-01-03".into()),
|
|
value_date: None,
|
|
booking_date_time: None,
|
|
value_date_time: None,
|
|
transaction_amount: TransactionAmount {
|
|
amount: "0.00".into(),
|
|
currency: "EUR".into(),
|
|
},
|
|
currency_exchange: None,
|
|
creditor_name: Some("Test".into()),
|
|
creditor_account: None,
|
|
ultimate_creditor: None,
|
|
debtor_name: None,
|
|
debtor_account: None,
|
|
ultimate_debtor: None,
|
|
remittance_information_unstructured: None,
|
|
remittance_information_unstructured_array: None,
|
|
remittance_information_structured: None,
|
|
remittance_information_structured_array: None,
|
|
additional_information: None,
|
|
purpose_code: None,
|
|
bank_transaction_code: None,
|
|
proprietary_bank_transaction_code: None,
|
|
internal_transaction_id: None,
|
|
};
|
|
|
|
assert!(map_transaction(t).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_transaction_invalid_currency() {
|
|
let t = Transaction {
|
|
transaction_id: Some("126".into()),
|
|
entry_reference: None,
|
|
end_to_end_id: None,
|
|
mandate_id: None,
|
|
check_id: None,
|
|
creditor_id: None,
|
|
booking_date: Some("2023-01-04".into()),
|
|
value_date: None,
|
|
booking_date_time: None,
|
|
value_date_time: None,
|
|
transaction_amount: TransactionAmount {
|
|
amount: "100.00".into(),
|
|
currency: "euro".into(),
|
|
},
|
|
currency_exchange: None,
|
|
creditor_name: Some("Test".into()),
|
|
creditor_account: None,
|
|
ultimate_creditor: None,
|
|
debtor_name: None,
|
|
debtor_account: None,
|
|
ultimate_debtor: None,
|
|
remittance_information_unstructured: None,
|
|
remittance_information_unstructured_array: None,
|
|
remittance_information_structured: None,
|
|
remittance_information_structured_array: None,
|
|
additional_information: None,
|
|
purpose_code: None,
|
|
bank_transaction_code: None,
|
|
proprietary_bank_transaction_code: None,
|
|
internal_transaction_id: None,
|
|
};
|
|
|
|
assert!(map_transaction(t).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_transaction_invalid_exchange_rate() {
|
|
let t = Transaction {
|
|
transaction_id: Some("127".into()),
|
|
entry_reference: None,
|
|
end_to_end_id: None,
|
|
mandate_id: None,
|
|
check_id: None,
|
|
creditor_id: None,
|
|
booking_date: Some("2023-01-05".into()),
|
|
value_date: None,
|
|
booking_date_time: None,
|
|
value_date_time: None,
|
|
transaction_amount: TransactionAmount {
|
|
amount: "-10.00".into(),
|
|
currency: "EUR".into(),
|
|
},
|
|
currency_exchange: Some(vec![CurrencyExchange {
|
|
source_currency: Some("USD".into()),
|
|
exchange_rate: Some("0".into()), // Invalid rate is handled by not setting foreign_amount
|
|
unit_currency: None,
|
|
target_currency: Some("EUR".into()),
|
|
}]),
|
|
creditor_name: Some("Test".into()),
|
|
creditor_account: None,
|
|
ultimate_creditor: None,
|
|
debtor_name: None,
|
|
debtor_account: None,
|
|
ultimate_debtor: None,
|
|
remittance_information_unstructured: None,
|
|
remittance_information_unstructured_array: None,
|
|
remittance_information_structured: None,
|
|
remittance_information_structured_array: None,
|
|
additional_information: None,
|
|
purpose_code: None,
|
|
bank_transaction_code: None,
|
|
proprietary_bank_transaction_code: None,
|
|
internal_transaction_id: None,
|
|
};
|
|
|
|
let res = map_transaction(t).unwrap();
|
|
assert_eq!(res.foreign_amount, None); // Invalid rate results in no foreign_amount
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_transaction_invalid_foreign_currency() {
|
|
let t = Transaction {
|
|
transaction_id: Some("128".into()),
|
|
entry_reference: None,
|
|
end_to_end_id: None,
|
|
mandate_id: None,
|
|
check_id: None,
|
|
creditor_id: None,
|
|
booking_date: Some("2023-01-06".into()),
|
|
value_date: None,
|
|
booking_date_time: None,
|
|
value_date_time: None,
|
|
transaction_amount: TransactionAmount {
|
|
amount: "-10.00".into(),
|
|
currency: "EUR".into(),
|
|
},
|
|
currency_exchange: Some(vec![CurrencyExchange {
|
|
source_currency: Some("usd".into()), // lowercase
|
|
exchange_rate: Some("1.10".into()),
|
|
unit_currency: None,
|
|
target_currency: Some("EUR".into()),
|
|
}]),
|
|
creditor_name: Some("Test".into()),
|
|
creditor_account: None,
|
|
ultimate_creditor: None,
|
|
debtor_name: None,
|
|
debtor_account: None,
|
|
ultimate_debtor: None,
|
|
remittance_information_unstructured: None,
|
|
remittance_information_unstructured_array: None,
|
|
remittance_information_structured: None,
|
|
remittance_information_structured_array: None,
|
|
additional_information: None,
|
|
purpose_code: None,
|
|
bank_transaction_code: None,
|
|
proprietary_bank_transaction_code: None,
|
|
internal_transaction_id: None,
|
|
};
|
|
|
|
assert!(map_transaction(t).is_err());
|
|
}
|
|
}
|