Files
banks2ff/banks2ff/src/adapters/gocardless/mapper.rs
Jacob Kiers 58b6994372 fix(mapper): Make currency exchange more robust
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.
2025-12-06 16:48:49 +01:00

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(&currency)?;
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());
}
}