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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user