Completely replace implementation #1

Manually merged
jjkiers merged 12 commits from push-wtvsvxromnno into master 2025-11-29 00:25:36 +00:00
Showing only changes of commit 1dd251c379 - Show all commits

View File

@@ -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 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(rate_str)) = (&exchange.source_currency, &exchange.exchange_rate) {
foreign_currency = Some(source_curr.clone());
if let Ok(rate) = Decimal::from_str(rate_str) {
// If instructedAmount is not available (it's not in our DTO yet), we calculate it.
// But wait, normally instructedAmount is the foreign amount.
// If we don't have it, we estimate: foreign = amount * rate?
// 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(source_curr), Some(rate_str)) = (&exchange.source_currency, &exchange.exchange_rate) {
foreign_currency = Some(source_curr.clone());
if let Ok(rate) = Decimal::from_str(rate_str) {
let calc = amount.abs() * rate;
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())
@@ -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)]
mod tests {
use super::*;
@@ -144,4 +148,134 @@ mod tests {
// Description fallback to creditor name
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());
}
}