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(exchanges) = tx.currency_exchange {
|
||||||
if let Some(exchange) = exchanges.first() {
|
if let Some(exchange) = exchanges.first() {
|
||||||
if let (Some(source_curr), Some(rate_str)) =
|
if let (Some(source_curr), Some(target_curr), Some(rate_str)) = (
|
||||||
(&exchange.source_currency, &exchange.exchange_rate)
|
&exchange.source_currency,
|
||||||
{
|
&exchange.target_currency,
|
||||||
foreign_currency = Some(source_curr.clone());
|
&exchange.exchange_rate,
|
||||||
|
) {
|
||||||
if let Ok(rate) = Decimal::from_str(rate_str) {
|
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();
|
let sign = amount.signum();
|
||||||
foreign_amount = Some(calc * sign);
|
foreign_amount = Some(calc * sign);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref fa) = foreign_amount {
|
if let Some(ref fa) = foreign_amount {
|
||||||
validate_amount(fa)?;
|
validate_amount(fa)?;
|
||||||
@@ -149,7 +166,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_multicurrency_transaction() {
|
fn test_map_multicurrency_transaction_target_to_source() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("124".into()),
|
transaction_id: Some("124".into()),
|
||||||
entry_reference: None,
|
entry_reference: None,
|
||||||
@@ -167,7 +184,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
currency_exchange: Some(vec![CurrencyExchange {
|
currency_exchange: Some(vec![CurrencyExchange {
|
||||||
source_currency: Some("USD".into()),
|
source_currency: Some("USD".into()),
|
||||||
exchange_rate: Some("1.10".into()),
|
exchange_rate: Some("2.0".into()),
|
||||||
unit_currency: None,
|
unit_currency: None,
|
||||||
target_currency: Some("EUR".into()),
|
target_currency: Some("EUR".into()),
|
||||||
}]),
|
}]),
|
||||||
@@ -193,13 +210,65 @@ mod tests {
|
|||||||
assert_eq!(res.amount, Decimal::new(-1000, 2));
|
assert_eq!(res.amount, Decimal::new(-1000, 2));
|
||||||
assert_eq!(res.foreign_currency, Some("USD".to_string()));
|
assert_eq!(res.foreign_currency, Some("USD".to_string()));
|
||||||
|
|
||||||
// 10.00 * 1.10 = 11.00. Sign should be preserved (-11.00)
|
// 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(-1100, 2)));
|
assert_eq!(res.foreign_amount, Some(Decimal::new(-500, 2)));
|
||||||
|
|
||||||
// Description fallback to creditor name
|
// Description fallback to creditor name
|
||||||
assert_eq!(res.description, "US Shop");
|
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]
|
#[test]
|
||||||
fn test_validate_amount_zero() {
|
fn test_validate_amount_zero() {
|
||||||
let amount = Decimal::ZERO;
|
let amount = Decimal::ZERO;
|
||||||
@@ -307,7 +376,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_transaction_invalid_foreign_amount() {
|
fn test_map_transaction_invalid_exchange_rate() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("127".into()),
|
transaction_id: Some("127".into()),
|
||||||
entry_reference: None,
|
entry_reference: None,
|
||||||
@@ -325,7 +394,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
currency_exchange: Some(vec![CurrencyExchange {
|
currency_exchange: Some(vec![CurrencyExchange {
|
||||||
source_currency: Some("USD".into()),
|
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,
|
unit_currency: None,
|
||||||
target_currency: Some("EUR".into()),
|
target_currency: Some("EUR".into()),
|
||||||
}]),
|
}]),
|
||||||
@@ -346,7 +415,8 @@ mod tests {
|
|||||||
internal_transaction_id: None,
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user