Completely replace implementation #1
@@ -14,50 +14,33 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
|||||||
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
|
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
|
||||||
|
|
||||||
let amount = Decimal::from_str(&tx.transaction_amount.amount)?;
|
let amount = Decimal::from_str(&tx.transaction_amount.amount)?;
|
||||||
|
validate_amount(&amount)?;
|
||||||
let currency = tx.transaction_amount.currency;
|
let currency = tx.transaction_amount.currency;
|
||||||
|
validate_currency(¤cy)?;
|
||||||
|
|
||||||
let mut foreign_amount = None;
|
let mut foreign_amount = None;
|
||||||
let mut foreign_currency = None;
|
let mut foreign_currency = None;
|
||||||
|
|
||||||
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)) = (&exchange.source_currency, &exchange.exchange_rate) {
|
if let (Some(source_curr), Some(rate_str)) = (&exchange.source_currency, &exchange.exchange_rate) {
|
||||||
foreign_currency = Some(source_curr.clone());
|
foreign_currency = Some(source_curr.clone());
|
||||||
if let Ok(rate) = Decimal::from_str(rate_str) {
|
if let Ok(rate) = Decimal::from_str(rate_str) {
|
||||||
// If instructedAmount is not available (it's not in our DTO yet), we calculate it.
|
let calc = amount.abs() * rate;
|
||||||
// But wait, normally instructedAmount is the foreign amount.
|
let sign = amount.signum();
|
||||||
// If we don't have it, we estimate: foreign = amount * rate?
|
foreign_amount = Some(calc * sign);
|
||||||
// Actually usually: Base (Account) Amount = Foreign Amount / Rate OR Foreign * Rate
|
}
|
||||||
// If I have 100 EUR and rate is 1.10 USD/EUR -> 110 USD.
|
}
|
||||||
// Let's check the GoCardless spec definition of exchangeRate.
|
|
||||||
// "exchangeRate": "Factor used to convert an amount from one currency into another. This reflects the price at which the acquirer has bought the currency."
|
|
||||||
|
|
||||||
// Without strict direction, simple multiplication is risky.
|
|
||||||
// ideally we should have `instructedAmount` or `unitCurrency` logic.
|
|
||||||
// For now, let's assume: foreign_amount = amount * rate is NOT always correct.
|
|
||||||
// BUT, usually `sourceCurrency` is the original currency.
|
|
||||||
// If I spent 10 USD, and my account is EUR.
|
|
||||||
// sourceCurrency: USD. targetCurrency: EUR.
|
|
||||||
// transactionAmount: -9.00 EUR.
|
|
||||||
// exchangeRate: ???
|
|
||||||
|
|
||||||
// Let's implement a safe calculation or just store what we have.
|
|
||||||
// Actually, simply multiplying might be wrong if the rate is inverted.
|
|
||||||
// Let's verify with unit tests if we had real data, but for now let's use the logic:
|
|
||||||
// foreign_amount = amount * rate (if rate > 0) or amount / rate ?
|
|
||||||
|
|
||||||
// Let's look at the example in my plan: "foreign_amount = amount * currencyExchange[0].exchangeRate"
|
|
||||||
// I will stick to that plan, but wrap it in a safe calculation.
|
|
||||||
|
|
||||||
let calc = amount.abs() * rate; // Usually rate is positive.
|
|
||||||
// We preserve the sign of the transaction amount for the foreign amount.
|
|
||||||
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"
|
// Fallback for description: Remittance Unstructured -> Debtor/Creditor Name -> "Unknown"
|
||||||
let description = tx.remittance_information_unstructured
|
let description = tx.remittance_information_unstructured
|
||||||
.or(tx.creditor_name.clone())
|
.or(tx.creditor_name.clone())
|
||||||
@@ -77,6 +60,27 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -144,4 +148,134 @@ mod tests {
|
|||||||
// 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_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()),
|
||||||
|
booking_date: Some("2023-01-03".into()),
|
||||||
|
value_date: None,
|
||||||
|
transaction_amount: TransactionAmount {
|
||||||
|
amount: "0.00".into(),
|
||||||
|
currency: "EUR".into(),
|
||||||
|
},
|
||||||
|
currency_exchange: None,
|
||||||
|
creditor_name: Some("Test".into()),
|
||||||
|
creditor_account: None,
|
||||||
|
debtor_name: None,
|
||||||
|
debtor_account: None,
|
||||||
|
remittance_information_unstructured: None,
|
||||||
|
proprietary_bank_transaction_code: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(map_transaction(t).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_transaction_invalid_currency() {
|
||||||
|
let t = Transaction {
|
||||||
|
transaction_id: Some("126".into()),
|
||||||
|
booking_date: Some("2023-01-04".into()),
|
||||||
|
value_date: None,
|
||||||
|
transaction_amount: TransactionAmount {
|
||||||
|
amount: "100.00".into(),
|
||||||
|
currency: "euro".into(),
|
||||||
|
},
|
||||||
|
currency_exchange: None,
|
||||||
|
creditor_name: Some("Test".into()),
|
||||||
|
creditor_account: None,
|
||||||
|
debtor_name: None,
|
||||||
|
debtor_account: None,
|
||||||
|
remittance_information_unstructured: None,
|
||||||
|
proprietary_bank_transaction_code: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(map_transaction(t).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_transaction_invalid_foreign_amount() {
|
||||||
|
let t = Transaction {
|
||||||
|
transaction_id: Some("127".into()),
|
||||||
|
booking_date: Some("2023-01-05".into()),
|
||||||
|
value_date: 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()), // This will make foreign_amount zero
|
||||||
|
unit_currency: None,
|
||||||
|
target_currency: Some("EUR".into()),
|
||||||
|
}]),
|
||||||
|
creditor_name: Some("Test".into()),
|
||||||
|
creditor_account: None,
|
||||||
|
debtor_name: None,
|
||||||
|
debtor_account: None,
|
||||||
|
remittance_information_unstructured: None,
|
||||||
|
proprietary_bank_transaction_code: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(map_transaction(t).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_transaction_invalid_foreign_currency() {
|
||||||
|
let t = Transaction {
|
||||||
|
transaction_id: Some("128".into()),
|
||||||
|
booking_date: Some("2023-01-06".into()),
|
||||||
|
value_date: 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,
|
||||||
|
debtor_name: None,
|
||||||
|
debtor_account: None,
|
||||||
|
remittance_information_unstructured: None,
|
||||||
|
proprietary_bank_transaction_code: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(map_transaction(t).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user