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.
This commit is contained in:
2025-12-06 16:47:11 +01:00
parent 31bd02f974
commit 58b6994372

View File

@@ -27,18 +27,35 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
if let Some(exchanges) = tx.currency_exchange {
if let Some(exchange) = exchanges.first() {
if let (Some(source_curr), Some(rate_str)) =
(&exchange.source_currency, &exchange.exchange_rate)
{
foreign_currency = Some(source_curr.clone());
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) {
let calc = amount.abs() * rate;
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)?;
@@ -149,7 +166,7 @@ mod tests {
}
#[test]
fn test_map_multicurrency_transaction() {
fn test_map_multicurrency_transaction_target_to_source() {
let t = Transaction {
transaction_id: Some("124".into()),
entry_reference: None,
@@ -167,7 +184,7 @@ mod tests {
},
currency_exchange: Some(vec![CurrencyExchange {
source_currency: Some("USD".into()),
exchange_rate: Some("1.10".into()),
exchange_rate: Some("2.0".into()),
unit_currency: None,
target_currency: Some("EUR".into()),
}]),
@@ -193,13 +210,65 @@ mod tests {
assert_eq!(res.amount, Decimal::new(-1000, 2));
assert_eq!(res.foreign_currency, Some("USD".to_string()));
// 10.00 * 1.10 = 11.00. Sign should be preserved (-11.00)
assert_eq!(res.foreign_amount, Some(Decimal::new(-1100, 2)));
// 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;
@@ -307,7 +376,7 @@ mod tests {
}
#[test]
fn test_map_transaction_invalid_foreign_amount() {
fn test_map_transaction_invalid_exchange_rate() {
let t = Transaction {
transaction_id: Some("127".into()),
entry_reference: None,
@@ -325,7 +394,7 @@ mod tests {
},
currency_exchange: Some(vec![CurrencyExchange {
source_currency: Some("USD".into()),
exchange_rate: Some("0".into()), // This will make foreign_amount zero
exchange_rate: Some("0".into()), // Invalid rate is handled by not setting foreign_amount
unit_currency: None,
target_currency: Some("EUR".into()),
}]),
@@ -346,7 +415,8 @@ mod tests {
internal_transaction_id: None,
};
assert!(map_transaction(t).is_err());
let res = map_transaction(t).unwrap();
assert_eq!(res.foreign_amount, None); // Invalid rate results in no foreign_amount
}
#[test]