diff --git a/banks2ff/src/adapters/gocardless/mapper.rs b/banks2ff/src/adapters/gocardless/mapper.rs index a71205f..e536dc2 100644 --- a/banks2ff/src/adapters/gocardless/mapper.rs +++ b/banks2ff/src/adapters/gocardless/mapper.rs @@ -14,50 +14,33 @@ pub fn map_transaction(tx: Transaction) -> Result { 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(¤cy)?; 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 { }) } +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::*; @@ -137,11 +141,141 @@ mod tests { assert_eq!(res.internal_id, "124"); 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))); - + // 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()); + } }