Compare commits
1 Commits
e4b36d344c
...
c67378ff25
| Author | SHA1 | Date | |
|---|---|---|---|
| c67378ff25 |
@@ -148,7 +148,6 @@ mod tests {
|
||||
- Use `cargo fmt` for formatting
|
||||
- Use `cargo clippy` for linting
|
||||
- Ensure documentation for public APIs
|
||||
- _ALWAYS_ format and lint after making a change, and fix the linting errors
|
||||
|
||||
### 4. Commit Standards
|
||||
- Commit both code and tests together
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
use crate::core::models::BankTransaction;
|
||||
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use anyhow::Result;
|
||||
use tracing::instrument;
|
||||
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
||||
use crate::core::models::BankTransaction;
|
||||
use firefly_client::client::FireflyClient;
|
||||
use firefly_client::models::{
|
||||
TransactionSplitStore, TransactionSplitUpdate, TransactionStore, TransactionUpdate,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use firefly_client::models::{TransactionStore, TransactionSplitStore, TransactionUpdate, TransactionSplitUpdate};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::instrument;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
pub struct FireflyAdapter {
|
||||
client: Arc<Mutex<FireflyClient>>,
|
||||
@@ -80,9 +78,7 @@ impl TransactionDestination for FireflyAdapter {
|
||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
||||
let client = self.client.lock().await;
|
||||
// Fetch latest 1 transaction
|
||||
let tx_list = client
|
||||
.list_account_transactions(account_id, None, None)
|
||||
.await?;
|
||||
let tx_list = client.list_account_transactions(account_id, None, None).await?;
|
||||
|
||||
if let Some(first) = tx_list.data.first() {
|
||||
if let Some(split) = first.attributes.transactions.first() {
|
||||
@@ -97,24 +93,18 @@ impl TransactionDestination for FireflyAdapter {
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn find_transaction(
|
||||
&self,
|
||||
account_id: &str,
|
||||
tx: &BankTransaction,
|
||||
) -> Result<Option<TransactionMatch>> {
|
||||
async fn find_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<Option<TransactionMatch>> {
|
||||
let client = self.client.lock().await;
|
||||
|
||||
// Search window: +/- 3 days
|
||||
let start_date = tx.date - chrono::Duration::days(3);
|
||||
let end_date = tx.date + chrono::Duration::days(3);
|
||||
|
||||
let tx_list = client
|
||||
.list_account_transactions(
|
||||
let tx_list = client.list_account_transactions(
|
||||
account_id,
|
||||
Some(&start_date.format("%Y-%m-%d").to_string()),
|
||||
Some(&end_date.format("%Y-%m-%d").to_string()),
|
||||
)
|
||||
.await?;
|
||||
Some(&end_date.format("%Y-%m-%d").to_string())
|
||||
).await?;
|
||||
|
||||
// Filter logic
|
||||
for existing_tx in tx_list.data {
|
||||
@@ -165,30 +155,10 @@ impl TransactionDestination for FireflyAdapter {
|
||||
date: tx.date.format("%Y-%m-%d").to_string(),
|
||||
amount: tx.amount.abs().to_string(),
|
||||
description: tx.description.clone(),
|
||||
source_id: if !is_credit {
|
||||
Some(account_id.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
source_name: if is_credit {
|
||||
tx.counterparty_name
|
||||
.clone()
|
||||
.or(Some("Unknown Sender".to_string()))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
destination_id: if is_credit {
|
||||
Some(account_id.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
destination_name: if !is_credit {
|
||||
tx.counterparty_name
|
||||
.clone()
|
||||
.or(Some("Unknown Recipient".to_string()))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
source_id: if !is_credit { Some(account_id.to_string()) } else { None },
|
||||
source_name: if is_credit { tx.counterparty_name.clone().or(Some("Unknown Sender".to_string())) } else { None },
|
||||
destination_id: if is_credit { Some(account_id.to_string()) } else { None },
|
||||
destination_name: if !is_credit { tx.counterparty_name.clone().or(Some("Unknown Recipient".to_string())) } else { None },
|
||||
currency_code: Some(tx.currency.clone()),
|
||||
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
|
||||
foreign_currency_code: tx.foreign_currency.clone(),
|
||||
@@ -213,9 +183,6 @@ impl TransactionDestination for FireflyAdapter {
|
||||
external_id: Some(external_id.to_string()),
|
||||
}],
|
||||
};
|
||||
client
|
||||
.update_transaction(id, update)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
client.update_transaction(id, update).await.map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::adapters::gocardless::encryption::Encryption;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use crate::adapters::gocardless::encryption::Encryption;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct AccountCache {
|
||||
@@ -13,8 +13,7 @@ pub struct AccountCache {
|
||||
|
||||
impl AccountCache {
|
||||
fn get_path() -> String {
|
||||
let cache_dir =
|
||||
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
let cache_dir = std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
format!("{}/accounts.enc", cache_dir)
|
||||
}
|
||||
|
||||
@@ -37,24 +36,13 @@ impl AccountCache {
|
||||
|
||||
pub fn save(&self) {
|
||||
let path = Self::get_path();
|
||||
|
||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
warn!(
|
||||
"Failed to create cache folder '{}': {}",
|
||||
parent.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::to_vec(self) {
|
||||
Ok(json_data) => match Encryption::encrypt(&json_data) {
|
||||
Ok(encrypted_data) => {
|
||||
if let Err(e) = fs::write(&path, encrypted_data) {
|
||||
warn!("Failed to write cache file: {}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => warn!("Failed to encrypt cache: {}", e),
|
||||
},
|
||||
Err(e) => warn!("Failed to serialize cache: {}", e),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use crate::adapters::gocardless::cache::AccountCache;
|
||||
use crate::adapters::gocardless::mapper::map_transaction;
|
||||
use crate::adapters::gocardless::transaction_cache::AccountTransactionCache;
|
||||
use crate::core::models::{Account, BankTransaction};
|
||||
use crate::core::ports::TransactionSource;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use anyhow::Result;
|
||||
use tracing::{info, instrument, warn};
|
||||
use crate::core::ports::TransactionSource;
|
||||
use crate::core::models::{Account, BankTransaction};
|
||||
use crate::adapters::gocardless::mapper::map_transaction;
|
||||
use crate::adapters::gocardless::cache::AccountCache;
|
||||
use crate::adapters::gocardless::transaction_cache::AccountTransactionCache;
|
||||
use gocardless_client::client::GoCardlessClient;
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct GoCardlessAdapter {
|
||||
@@ -62,20 +62,14 @@ impl TransactionSource for GoCardlessAdapter {
|
||||
if let Some(agreement_id) = &req.agreement {
|
||||
match client.is_agreement_expired(agreement_id).await {
|
||||
Ok(true) => {
|
||||
debug!(
|
||||
"Skipping requisition {} - agreement {} has expired",
|
||||
req.id, agreement_id
|
||||
);
|
||||
warn!("Skipping requisition {} - agreement {} has expired", req.id, agreement_id);
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {
|
||||
// Agreement is valid, proceed
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to check agreement {} expiry: {}. Skipping requisition.",
|
||||
agreement_id, e
|
||||
);
|
||||
warn!("Failed to check agreement {} expiry: {}. Skipping requisition.", agreement_id, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +88,7 @@ impl TransactionSource for GoCardlessAdapter {
|
||||
cache.insert(acc_id.clone(), new_iban.clone());
|
||||
cache.save();
|
||||
iban_opt = Some(new_iban);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
// If rate limit hit here, we might want to skip this account and continue?
|
||||
// But get_account is critical to identify the account.
|
||||
@@ -125,28 +119,22 @@ impl TransactionSource for GoCardlessAdapter {
|
||||
}
|
||||
|
||||
// Optimization: Stop if we found all wanted accounts
|
||||
if wanted_set.is_some() && found_count >= target_count && target_count > 0 {
|
||||
info!(
|
||||
"Found all {} wanted accounts. Stopping search.",
|
||||
target_count
|
||||
);
|
||||
if let Some(_) = wanted_set {
|
||||
if found_count >= target_count && target_count > 0 {
|
||||
info!("Found all {} wanted accounts. Stopping search.", target_count);
|
||||
return Ok(accounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} matching accounts in GoCardless", accounts.len());
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn get_transactions(
|
||||
&self,
|
||||
account_id: &str,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Vec<BankTransaction>> {
|
||||
async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result<Vec<BankTransaction>> {
|
||||
let mut client = self.client.lock().await;
|
||||
client.obtain_access_token().await?;
|
||||
|
||||
@@ -167,43 +155,27 @@ impl TransactionSource for GoCardlessAdapter {
|
||||
|
||||
// Fetch missing ranges
|
||||
for (range_start, range_end) in uncovered_ranges {
|
||||
let response_result = client
|
||||
.get_transactions(
|
||||
let response_result = client.get_transactions(
|
||||
account_id,
|
||||
Some(&range_start.to_string()),
|
||||
Some(&range_end.to_string()),
|
||||
)
|
||||
.await;
|
||||
Some(&range_end.to_string())
|
||||
).await;
|
||||
|
||||
match response_result {
|
||||
Ok(response) => {
|
||||
let raw_txs = response.transactions.booked.clone();
|
||||
raw_transactions.extend(raw_txs.clone());
|
||||
cache.store_transactions(range_start, range_end, raw_txs);
|
||||
info!(
|
||||
"Fetched {} transactions for account {} in range {}-{}",
|
||||
response.transactions.booked.len(),
|
||||
account_id,
|
||||
range_start,
|
||||
range_end
|
||||
);
|
||||
}
|
||||
info!("Fetched {} transactions for account {} in range {}-{}", response.transactions.booked.len(), account_id, range_start, range_end);
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("429") {
|
||||
warn!(
|
||||
"Rate limit reached for account {} in range {}-{}. Skipping.",
|
||||
account_id, range_start, range_end
|
||||
);
|
||||
warn!("Rate limit reached for account {} in range {}-{}. Skipping.", account_id, range_start, range_end);
|
||||
continue;
|
||||
}
|
||||
if err_str.contains("401")
|
||||
&& (err_str.contains("expired") || err_str.contains("EUA"))
|
||||
{
|
||||
debug!(
|
||||
"EUA expired for account {} in range {}-{}. Skipping.",
|
||||
account_id, range_start, range_end
|
||||
);
|
||||
if err_str.contains("401") && (err_str.contains("expired") || err_str.contains("EUA")) {
|
||||
warn!("EUA expired for account {} in range {}-{}. Skipping.", account_id, range_start, range_end);
|
||||
continue;
|
||||
}
|
||||
return Err(e.into());
|
||||
@@ -223,13 +195,7 @@ impl TransactionSource for GoCardlessAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Total {} transactions for account {} in range {}-{}",
|
||||
transactions.len(),
|
||||
account_id,
|
||||
start,
|
||||
end
|
||||
);
|
||||
info!("Total {} transactions for account {} in range {}-{}", transactions.len(), account_id, start, end);
|
||||
Ok(transactions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
//! - Key derivation: ~50-100ms (computed once per operation)
|
||||
//! - Memory: Minimal additional overhead
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use anyhow::{anyhow, Result};
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use rand::RngCore;
|
||||
use sha2::Sha256;
|
||||
use std::env;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
const KEY_LEN: usize = 32; // 256-bit key
|
||||
const NONCE_LEN: usize = 12; // 96-bit nonce for AES-GCM
|
||||
@@ -72,8 +72,7 @@ impl Encryption {
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
// Encrypt
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, data)
|
||||
let ciphertext = cipher.encrypt(nonce, data)
|
||||
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
// Prepend salt and nonce to ciphertext: [salt(16)][nonce(12)][ciphertext]
|
||||
@@ -101,8 +100,7 @@ impl Encryption {
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
|
||||
// Decrypt
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
cipher.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| anyhow!("Decryption failed: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
use crate::core::models::BankTransaction;
|
||||
use anyhow::Result;
|
||||
use gocardless_client::models::Transaction;
|
||||
use rust_decimal::prelude::Signed;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::prelude::Signed;
|
||||
use std::str::FromStr;
|
||||
use anyhow::Result;
|
||||
use crate::core::models::BankTransaction;
|
||||
use gocardless_client::models::Transaction;
|
||||
|
||||
pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
||||
let internal_id = tx
|
||||
.transaction_id
|
||||
let internal_id = tx.transaction_id
|
||||
.ok_or_else(|| anyhow::anyhow!("Transaction ID missing"))?;
|
||||
|
||||
let date_str = tx
|
||||
.booking_date
|
||||
.or(tx.value_date)
|
||||
let date_str = tx.booking_date.or(tx.value_date)
|
||||
.ok_or_else(|| anyhow::anyhow!("Transaction date missing"))?;
|
||||
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
|
||||
|
||||
@@ -26,9 +23,7 @@ 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)
|
||||
{
|
||||
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;
|
||||
@@ -47,8 +42,7 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
||||
}
|
||||
|
||||
// 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.debtor_name.clone())
|
||||
.unwrap_or_else(|| "Unknown Transaction".to_string());
|
||||
@@ -62,20 +56,14 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
||||
foreign_currency,
|
||||
description,
|
||||
counterparty_name: tx.creditor_name.or(tx.debtor_name),
|
||||
counterparty_iban: tx
|
||||
.creditor_account
|
||||
.and_then(|a| a.iban)
|
||||
.or(tx.debtor_account.and_then(|a| a.iban)),
|
||||
counterparty_iban: tx.creditor_account.and_then(|a| a.iban).or(tx.debtor_account.and_then(|a| a.iban)),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
));
|
||||
return Err(anyhow::anyhow!("Amount exceeds reasonable bounds: {}", amount));
|
||||
}
|
||||
if abs == Decimal::ZERO {
|
||||
return Err(anyhow::anyhow!("Amount cannot be zero"));
|
||||
@@ -85,16 +73,10 @@ fn validate_amount(amount: &Decimal) -> Result<()> {
|
||||
|
||||
fn validate_currency(currency: &str) -> Result<()> {
|
||||
if currency.len() != 3 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid currency code length: {}",
|
||||
currency
|
||||
));
|
||||
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
|
||||
));
|
||||
return Err(anyhow::anyhow!("Invalid currency code format: {}", currency));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -102,7 +84,7 @@ fn validate_currency(currency: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gocardless_client::models::{CurrencyExchange, TransactionAmount};
|
||||
use gocardless_client::models::{TransactionAmount, CurrencyExchange};
|
||||
|
||||
#[test]
|
||||
fn test_map_normal_transaction() {
|
||||
@@ -179,6 +161,8 @@ mod tests {
|
||||
assert!(validate_amount(&amount).is_err());
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_validate_currency_invalid_length() {
|
||||
assert!(validate_currency("EU").is_err());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod cache;
|
||||
pub mod client;
|
||||
pub mod encryption;
|
||||
pub mod mapper;
|
||||
pub mod cache;
|
||||
pub mod encryption;
|
||||
pub mod transaction_cache;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::adapters::gocardless::encryption::Encryption;
|
||||
use anyhow::Result;
|
||||
use chrono::{Days, NaiveDate};
|
||||
use gocardless_client::models::Transaction;
|
||||
use chrono::{NaiveDate, Days};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use anyhow::Result;
|
||||
use crate::adapters::gocardless::encryption::Encryption;
|
||||
use gocardless_client::models::Transaction;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AccountTransactionCache {
|
||||
@@ -21,8 +21,7 @@ pub struct CachedRange {
|
||||
impl AccountTransactionCache {
|
||||
/// Get cache file path for an account
|
||||
fn get_cache_path(account_id: &str) -> String {
|
||||
let cache_dir =
|
||||
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
let cache_dir = std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
format!("{}/transactions/{}.enc", cache_dir, account_id)
|
||||
}
|
||||
|
||||
@@ -71,9 +70,7 @@ impl AccountTransactionCache {
|
||||
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
|
||||
for tx in &range.transactions {
|
||||
if let Some(booking_date_str) = &tx.booking_date {
|
||||
if let Ok(booking_date) =
|
||||
NaiveDate::parse_from_str(booking_date_str, "%Y-%m-%d")
|
||||
{
|
||||
if let Ok(booking_date) = NaiveDate::parse_from_str(booking_date_str, "%Y-%m-%d") {
|
||||
if booking_date >= start && booking_date <= end {
|
||||
result.push(tx.clone());
|
||||
}
|
||||
@@ -86,13 +83,8 @@ impl AccountTransactionCache {
|
||||
}
|
||||
|
||||
/// Get uncovered date ranges within requested period
|
||||
pub fn get_uncovered_ranges(
|
||||
&self,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Vec<(NaiveDate, NaiveDate)> {
|
||||
let mut covered_periods: Vec<(NaiveDate, NaiveDate)> = self
|
||||
.ranges
|
||||
pub fn get_uncovered_ranges(&self, start: NaiveDate, end: NaiveDate) -> Vec<(NaiveDate, NaiveDate)> {
|
||||
let mut covered_periods: Vec<(NaiveDate, NaiveDate)> = self.ranges
|
||||
.iter()
|
||||
.filter_map(|range| {
|
||||
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
|
||||
@@ -142,12 +134,7 @@ impl AccountTransactionCache {
|
||||
}
|
||||
|
||||
/// Store transactions for a date range, merging with existing cache
|
||||
pub fn store_transactions(
|
||||
&mut self,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
mut transactions: Vec<Transaction>,
|
||||
) {
|
||||
pub fn store_transactions(&mut self, start: NaiveDate, end: NaiveDate, mut transactions: Vec<Transaction>) {
|
||||
Self::deduplicate_transactions(&mut transactions);
|
||||
let new_range = CachedRange {
|
||||
start_date: start,
|
||||
@@ -164,12 +151,7 @@ impl AccountTransactionCache {
|
||||
let mut remaining = Vec::new();
|
||||
|
||||
for range in &self.ranges {
|
||||
if Self::ranges_overlap_or_adjacent(
|
||||
range.start_date,
|
||||
range.end_date,
|
||||
new_range.start_date,
|
||||
new_range.end_date,
|
||||
) {
|
||||
if Self::ranges_overlap_or_adjacent(range.start_date, range.end_date, new_range.start_date, new_range.end_date) {
|
||||
to_merge.push(range.clone());
|
||||
} else {
|
||||
remaining.push(range.clone());
|
||||
@@ -187,25 +169,15 @@ impl AccountTransactionCache {
|
||||
}
|
||||
|
||||
/// Check if two date ranges overlap
|
||||
fn ranges_overlap(
|
||||
start1: NaiveDate,
|
||||
end1: NaiveDate,
|
||||
start2: NaiveDate,
|
||||
end2: NaiveDate,
|
||||
) -> bool {
|
||||
fn ranges_overlap(start1: NaiveDate, end1: NaiveDate, start2: NaiveDate, end2: NaiveDate) -> bool {
|
||||
start1 <= end2 && start2 <= end1
|
||||
}
|
||||
|
||||
/// Check if two date ranges overlap or are adjacent
|
||||
fn ranges_overlap_or_adjacent(
|
||||
start1: NaiveDate,
|
||||
end1: NaiveDate,
|
||||
start2: NaiveDate,
|
||||
end2: NaiveDate,
|
||||
) -> bool {
|
||||
Self::ranges_overlap(start1, end1, start2, end2)
|
||||
|| (end1 + Days::new(1)) == start2
|
||||
|| (end2 + Days::new(1)) == start1
|
||||
fn ranges_overlap_or_adjacent(start1: NaiveDate, end1: NaiveDate, start2: NaiveDate, end2: NaiveDate) -> bool {
|
||||
Self::ranges_overlap(start1, end1, start2, end2) ||
|
||||
(end1 + Days::new(1)) == start2 ||
|
||||
(end2 + Days::new(1)) == start1
|
||||
}
|
||||
|
||||
/// Merge a list of ranges into minimal set
|
||||
@@ -222,12 +194,7 @@ impl AccountTransactionCache {
|
||||
let mut current = sorted[0].clone();
|
||||
|
||||
for range in sorted.into_iter().skip(1) {
|
||||
if Self::ranges_overlap_or_adjacent(
|
||||
current.start_date,
|
||||
current.end_date,
|
||||
range.start_date,
|
||||
range.end_date,
|
||||
) {
|
||||
if Self::ranges_overlap_or_adjacent(current.start_date, current.end_date, range.start_date, range.end_date) {
|
||||
// Merge
|
||||
current.start_date = current.start_date.min(range.start_date);
|
||||
current.end_date = current.end_date.max(range.end_date);
|
||||
@@ -260,8 +227,8 @@ impl AccountTransactionCache {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use std::env;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
fn setup_test_env(test_name: &str) -> String {
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
@@ -272,10 +239,7 @@ mod tests {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let cache_dir = format!(
|
||||
"tmp/test-cache-{}-{}-{}",
|
||||
test_name, random_suffix, timestamp
|
||||
);
|
||||
let cache_dir = format!("tmp/test-cache-{}-{}-{}", test_name, random_suffix, timestamp);
|
||||
env::set_var("BANKS2FF_CACHE_DIR", cache_dir.clone());
|
||||
cache_dir
|
||||
}
|
||||
@@ -297,6 +261,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_cache() {
|
||||
let cache_dir = setup_test_env("nonexistent");
|
||||
@@ -325,8 +291,7 @@ mod tests {
|
||||
// Ensure env vars are set before load
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Load
|
||||
let loaded =
|
||||
AccountTransactionCache::load("test_account_empty").expect("Load should succeed");
|
||||
let loaded = AccountTransactionCache::load("test_account_empty").expect("Load should succeed");
|
||||
|
||||
assert_eq!(loaded.account_id, "test_account_empty");
|
||||
assert!(loaded.ranges.is_empty());
|
||||
@@ -374,16 +339,12 @@ mod tests {
|
||||
// Ensure env vars are set before load
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Load
|
||||
let loaded =
|
||||
AccountTransactionCache::load("test_account_data").expect("Load should succeed");
|
||||
let loaded = AccountTransactionCache::load("test_account_data").expect("Load should succeed");
|
||||
|
||||
assert_eq!(loaded.account_id, "test_account_data");
|
||||
assert_eq!(loaded.ranges.len(), 1);
|
||||
assert_eq!(loaded.ranges[0].transactions.len(), 1);
|
||||
assert_eq!(
|
||||
loaded.ranges[0].transactions[0].transaction_id,
|
||||
Some("test-tx-1".to_string())
|
||||
);
|
||||
assert_eq!(loaded.ranges[0].transactions[0].transaction_id, Some("test-tx-1".to_string()));
|
||||
|
||||
cleanup_test_dir(&cache_dir);
|
||||
}
|
||||
@@ -465,20 +426,8 @@ mod tests {
|
||||
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
|
||||
let uncovered = cache.get_uncovered_ranges(start, end);
|
||||
assert_eq!(uncovered.len(), 2);
|
||||
assert_eq!(
|
||||
uncovered[0],
|
||||
(
|
||||
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
uncovered[1],
|
||||
(
|
||||
NaiveDate::from_ymd_opt(2024, 1, 21).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()
|
||||
)
|
||||
);
|
||||
assert_eq!(uncovered[0], (NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()));
|
||||
assert_eq!(uncovered[1], (NaiveDate::from_ymd_opt(2024, 1, 21).unwrap(), NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use chrono::NaiveDate;
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -32,20 +32,11 @@ impl fmt::Debug for BankTransaction {
|
||||
.field("date", &self.date)
|
||||
.field("amount", &"[REDACTED]")
|
||||
.field("currency", &self.currency)
|
||||
.field(
|
||||
"foreign_amount",
|
||||
&self.foreign_amount.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("foreign_amount", &self.foreign_amount.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("foreign_currency", &self.foreign_currency)
|
||||
.field("description", &"[REDACTED]")
|
||||
.field(
|
||||
"counterparty_name",
|
||||
&self.counterparty_name.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field(
|
||||
"counterparty_iban",
|
||||
&self.counterparty_iban.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("counterparty_name", &self.counterparty_name.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("counterparty_iban", &self.counterparty_iban.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::core::models::{Account, BankTransaction};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use anyhow::Result;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use crate::core::models::{BankTransaction, Account};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IngestResult {
|
||||
@@ -18,12 +18,7 @@ pub struct IngestResult {
|
||||
pub trait TransactionSource: Send + Sync {
|
||||
/// Fetch accounts. Optionally filter by a list of wanted IBANs to save requests.
|
||||
async fn get_accounts(&self, wanted_ibans: Option<Vec<String>>) -> Result<Vec<Account>>;
|
||||
async fn get_transactions(
|
||||
&self,
|
||||
account_id: &str,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Vec<BankTransaction>>;
|
||||
async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result<Vec<BankTransaction>>;
|
||||
}
|
||||
|
||||
// Blanket implementation for references
|
||||
@@ -33,12 +28,7 @@ impl<T: TransactionSource> TransactionSource for &T {
|
||||
(**self).get_accounts(wanted_ibans).await
|
||||
}
|
||||
|
||||
async fn get_transactions(
|
||||
&self,
|
||||
account_id: &str,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Vec<BankTransaction>> {
|
||||
async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result<Vec<BankTransaction>> {
|
||||
(**self).get_transactions(account_id, start, end).await
|
||||
}
|
||||
}
|
||||
@@ -58,11 +48,7 @@ pub trait TransactionDestination: Send + Sync {
|
||||
|
||||
// New granular methods for Healer Logic
|
||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>>;
|
||||
async fn find_transaction(
|
||||
&self,
|
||||
account_id: &str,
|
||||
transaction: &BankTransaction,
|
||||
) -> Result<Option<TransactionMatch>>;
|
||||
async fn find_transaction(&self, account_id: &str, transaction: &BankTransaction) -> Result<Option<TransactionMatch>>;
|
||||
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>;
|
||||
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>;
|
||||
}
|
||||
@@ -82,11 +68,7 @@ impl<T: TransactionDestination> TransactionDestination for &T {
|
||||
(**self).get_last_transaction_date(account_id).await
|
||||
}
|
||||
|
||||
async fn find_transaction(
|
||||
&self,
|
||||
account_id: &str,
|
||||
transaction: &BankTransaction,
|
||||
) -> Result<Option<TransactionMatch>> {
|
||||
async fn find_transaction(&self, account_id: &str, transaction: &BankTransaction) -> Result<Option<TransactionMatch>> {
|
||||
(**self).find_transaction(account_id, transaction).await
|
||||
}
|
||||
|
||||
@@ -95,8 +77,6 @@ impl<T: TransactionDestination> TransactionDestination for &T {
|
||||
}
|
||||
|
||||
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()> {
|
||||
(**self)
|
||||
.update_transaction_external_id(id, external_id)
|
||||
.await
|
||||
(**self).update_transaction_external_id(id, external_id).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::core::models::{Account, SyncError};
|
||||
use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource};
|
||||
use anyhow::Result;
|
||||
use chrono::{Local, NaiveDate};
|
||||
use tracing::{info, instrument, warn};
|
||||
use tracing::{info, warn, instrument};
|
||||
use crate::core::ports::{IngestResult, TransactionSource, TransactionDestination};
|
||||
use crate::core::models::{SyncError, Account};
|
||||
use chrono::{NaiveDate, Local};
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncResult {
|
||||
@@ -23,24 +24,14 @@ pub async fn run_sync(
|
||||
info!("Starting synchronization...");
|
||||
|
||||
// Optimization: Get active Firefly IBANs first
|
||||
let wanted_ibans = destination
|
||||
.get_active_account_ibans()
|
||||
.await
|
||||
.map_err(SyncError::DestinationError)?;
|
||||
info!(
|
||||
"Syncing {} active accounts from Firefly III",
|
||||
wanted_ibans.len()
|
||||
);
|
||||
let wanted_ibans = destination.get_active_account_ibans().await.map_err(SyncError::DestinationError)?;
|
||||
info!("Syncing {} active accounts from Firefly III", wanted_ibans.len());
|
||||
|
||||
let accounts = source
|
||||
.get_accounts(Some(wanted_ibans))
|
||||
.await
|
||||
.map_err(SyncError::SourceError)?;
|
||||
let accounts = source.get_accounts(Some(wanted_ibans)).await.map_err(SyncError::SourceError)?;
|
||||
info!("Found {} accounts from source", accounts.len());
|
||||
|
||||
// Default end date is Yesterday
|
||||
let end_date =
|
||||
cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1));
|
||||
let end_date = cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1));
|
||||
|
||||
let mut result = SyncResult::default();
|
||||
|
||||
@@ -51,33 +42,19 @@ pub async fn run_sync(
|
||||
info!("Processing account...");
|
||||
|
||||
// Process account with error handling
|
||||
match process_single_account(
|
||||
&source,
|
||||
&destination,
|
||||
&account,
|
||||
cli_start_date,
|
||||
end_date,
|
||||
dry_run,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match process_single_account(&source, &destination, &account, cli_start_date, end_date, dry_run).await {
|
||||
Ok(stats) => {
|
||||
result.accounts_processed += 1;
|
||||
result.ingest.created += stats.created;
|
||||
result.ingest.healed += stats.healed;
|
||||
result.ingest.duplicates += stats.duplicates;
|
||||
result.ingest.errors += stats.errors;
|
||||
info!(
|
||||
"Account {} sync complete. Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||
account.id, stats.created, stats.healed, stats.duplicates, stats.errors
|
||||
);
|
||||
info!("Account {} sync complete. Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||
account.id, stats.created, stats.healed, stats.duplicates, stats.errors);
|
||||
}
|
||||
Err(SyncError::AgreementExpired { agreement_id }) => {
|
||||
result.accounts_skipped_expired += 1;
|
||||
warn!(
|
||||
"Account {} skipped - associated agreement {} has expired",
|
||||
account.id, agreement_id
|
||||
);
|
||||
warn!("Account {} skipped - associated agreement {} has expired", account.id, agreement_id);
|
||||
}
|
||||
Err(SyncError::AccountSkipped { account_id, reason }) => {
|
||||
result.accounts_skipped_errors += 1;
|
||||
@@ -90,14 +67,10 @@ pub async fn run_sync(
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Synchronization finished. Processed: {}, Skipped (expired): {}, Skipped (errors): {}",
|
||||
result.accounts_processed, result.accounts_skipped_expired, result.accounts_skipped_errors
|
||||
);
|
||||
info!(
|
||||
"Total transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||
result.ingest.created, result.ingest.healed, result.ingest.duplicates, result.ingest.errors
|
||||
);
|
||||
info!("Synchronization finished. Processed: {}, Skipped (expired): {}, Skipped (errors): {}",
|
||||
result.accounts_processed, result.accounts_skipped_expired, result.accounts_skipped_errors);
|
||||
info!("Total transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||
result.ingest.created, result.ingest.healed, result.ingest.duplicates, result.ingest.errors);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -110,10 +83,7 @@ async fn process_single_account(
|
||||
end_date: NaiveDate,
|
||||
dry_run: bool,
|
||||
) -> Result<IngestResult, SyncError> {
|
||||
let dest_id_opt = destination
|
||||
.resolve_account_id(&account.iban)
|
||||
.await
|
||||
.map_err(SyncError::DestinationError)?;
|
||||
let dest_id_opt = destination.resolve_account_id(&account.iban).await.map_err(SyncError::DestinationError)?;
|
||||
let Some(dest_id) = dest_id_opt else {
|
||||
return Err(SyncError::AccountSkipped {
|
||||
account_id: account.id.clone(),
|
||||
@@ -128,34 +98,24 @@ async fn process_single_account(
|
||||
d
|
||||
} else {
|
||||
// Default: Latest transaction date + 1 day
|
||||
match destination
|
||||
.get_last_transaction_date(&dest_id)
|
||||
.await
|
||||
.map_err(SyncError::DestinationError)?
|
||||
{
|
||||
match destination.get_last_transaction_date(&dest_id).await.map_err(SyncError::DestinationError)? {
|
||||
Some(last_date) => last_date + chrono::Duration::days(1),
|
||||
None => {
|
||||
// If no transaction exists in Firefly, we assume this is a fresh sync.
|
||||
// Default to syncing last 30 days.
|
||||
end_date - chrono::Duration::days(30)
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
if start_date > end_date {
|
||||
info!(
|
||||
"Start date {} is after end date {}. Nothing to sync.",
|
||||
start_date, end_date
|
||||
);
|
||||
info!("Start date {} is after end date {}. Nothing to sync.", start_date, end_date);
|
||||
return Ok(IngestResult::default());
|
||||
}
|
||||
|
||||
info!("Syncing interval: {} to {}", start_date, end_date);
|
||||
|
||||
let transactions = match source
|
||||
.get_transactions(&account.id, start_date, end_date)
|
||||
.await
|
||||
{
|
||||
let transactions = match source.get_transactions(&account.id, start_date, end_date).await {
|
||||
Ok(txns) => txns,
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
@@ -180,11 +140,7 @@ async fn process_single_account(
|
||||
// Healer Logic Loop
|
||||
for tx in transactions {
|
||||
// 1. Check if it exists
|
||||
match destination
|
||||
.find_transaction(&dest_id, &tx)
|
||||
.await
|
||||
.map_err(SyncError::DestinationError)?
|
||||
{
|
||||
match destination.find_transaction(&dest_id, &tx).await.map_err(SyncError::DestinationError)? {
|
||||
Some(existing) => {
|
||||
if existing.has_external_id {
|
||||
// Already synced properly
|
||||
@@ -192,20 +148,11 @@ async fn process_single_account(
|
||||
} else {
|
||||
// Found "naked" transaction -> Heal it
|
||||
if dry_run {
|
||||
info!(
|
||||
"[DRY RUN] Would heal transaction {} (Firefly ID: {})",
|
||||
tx.internal_id, existing.id
|
||||
);
|
||||
info!("[DRY RUN] Would heal transaction {} (Firefly ID: {})", tx.internal_id, existing.id);
|
||||
stats.healed += 1;
|
||||
} else {
|
||||
info!(
|
||||
"Healing transaction {} (Firefly ID: {})",
|
||||
tx.internal_id, existing.id
|
||||
);
|
||||
if let Err(e) = destination
|
||||
.update_transaction_external_id(&existing.id, &tx.internal_id)
|
||||
.await
|
||||
{
|
||||
info!("Healing transaction {} (Firefly ID: {})", tx.internal_id, existing.id);
|
||||
if let Err(e) = destination.update_transaction_external_id(&existing.id, &tx.internal_id).await {
|
||||
tracing::error!("Failed to heal transaction: {}", e);
|
||||
stats.errors += 1;
|
||||
} else {
|
||||
@@ -213,13 +160,14 @@ async fn process_single_account(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// New transaction
|
||||
if dry_run {
|
||||
info!("[DRY RUN] Would create transaction {}", tx.internal_id);
|
||||
stats.created += 1;
|
||||
} else if let Err(e) = destination.create_transaction(&dest_id, &tx).await {
|
||||
} else {
|
||||
if let Err(e) = destination.create_transaction(&dest_id, &tx).await {
|
||||
// Firefly might still reject it as duplicate if hash matches, even if we didn't find it via heuristic
|
||||
// (unlikely if heuristic is good, but possible)
|
||||
let err_str = e.to_string();
|
||||
@@ -236,6 +184,7 @@ async fn process_single_account(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
@@ -243,10 +192,10 @@ async fn process_single_account(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::ports::{MockTransactionSource, MockTransactionDestination, TransactionMatch};
|
||||
use crate::core::models::{Account, BankTransaction};
|
||||
use crate::core::ports::{MockTransactionDestination, MockTransactionSource, TransactionMatch};
|
||||
use mockall::predicate::*;
|
||||
use rust_decimal::Decimal;
|
||||
use mockall::predicate::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sync_flow_create_new() {
|
||||
@@ -254,16 +203,13 @@ mod tests {
|
||||
let mut dest = MockTransactionDestination::new();
|
||||
|
||||
// Source setup
|
||||
source
|
||||
.expect_get_accounts()
|
||||
source.expect_get_accounts()
|
||||
.with(always()) // Match any argument
|
||||
.returning(|_| {
|
||||
Ok(vec![Account {
|
||||
.returning(|_| Ok(vec![Account {
|
||||
id: "src_1".to_string(),
|
||||
iban: "NL01".to_string(),
|
||||
currency: "EUR".to_string(),
|
||||
}])
|
||||
});
|
||||
}]));
|
||||
|
||||
let tx = BankTransaction {
|
||||
internal_id: "tx1".into(),
|
||||
@@ -278,8 +224,7 @@ mod tests {
|
||||
};
|
||||
let tx_clone = tx.clone();
|
||||
|
||||
source
|
||||
.expect_get_transactions()
|
||||
source.expect_get_transactions()
|
||||
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
||||
|
||||
// Destination setup
|
||||
@@ -316,16 +261,17 @@ mod tests {
|
||||
dest.expect_get_active_account_ibans()
|
||||
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||
|
||||
source.expect_get_accounts().with(always()).returning(|_| {
|
||||
Ok(vec![Account {
|
||||
source.expect_get_accounts()
|
||||
.with(always())
|
||||
.returning(|_| Ok(vec![Account {
|
||||
id: "src_1".to_string(),
|
||||
iban: "NL01".to_string(),
|
||||
currency: "EUR".to_string(),
|
||||
}])
|
||||
});
|
||||
}]));
|
||||
|
||||
source.expect_get_transactions().returning(|_, _, _| {
|
||||
Ok(vec![BankTransaction {
|
||||
source.expect_get_transactions()
|
||||
.returning(|_, _, _| Ok(vec![
|
||||
BankTransaction {
|
||||
internal_id: "tx1".into(),
|
||||
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
||||
amount: Decimal::new(100, 0),
|
||||
@@ -335,21 +281,19 @@ mod tests {
|
||||
description: "Test".into(),
|
||||
counterparty_name: None,
|
||||
counterparty_iban: None,
|
||||
}])
|
||||
});
|
||||
}
|
||||
]));
|
||||
|
||||
dest.expect_resolve_account_id()
|
||||
.returning(|_| Ok(Some("dest_1".into())));
|
||||
dest.expect_get_last_transaction_date()
|
||||
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||
dest.expect_resolve_account_id().returning(|_| Ok(Some("dest_1".into())));
|
||||
dest.expect_get_last_transaction_date().returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||
|
||||
// 1. Find -> Some(No External ID)
|
||||
dest.expect_find_transaction().times(1).returning(|_, _| {
|
||||
Ok(Some(TransactionMatch {
|
||||
dest.expect_find_transaction()
|
||||
.times(1)
|
||||
.returning(|_, _| Ok(Some(TransactionMatch {
|
||||
id: "ff_tx_1".to_string(),
|
||||
has_external_id: false,
|
||||
}))
|
||||
});
|
||||
})));
|
||||
|
||||
// 2. Update -> Ok
|
||||
dest.expect_update_transaction_external_id()
|
||||
@@ -369,13 +313,13 @@ mod tests {
|
||||
dest.expect_get_active_account_ibans()
|
||||
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||
|
||||
source.expect_get_accounts().with(always()).returning(|_| {
|
||||
Ok(vec![Account {
|
||||
source.expect_get_accounts()
|
||||
.with(always())
|
||||
.returning(|_| Ok(vec![Account {
|
||||
id: "src_1".to_string(),
|
||||
iban: "NL01".to_string(),
|
||||
currency: "EUR".to_string(),
|
||||
}])
|
||||
});
|
||||
}]));
|
||||
|
||||
let tx = BankTransaction {
|
||||
internal_id: "tx1".into(),
|
||||
@@ -389,17 +333,15 @@ mod tests {
|
||||
counterparty_iban: None,
|
||||
};
|
||||
|
||||
source
|
||||
.expect_get_transactions()
|
||||
source.expect_get_transactions()
|
||||
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
||||
|
||||
dest.expect_resolve_account_id()
|
||||
.returning(|_| Ok(Some("dest_1".into())));
|
||||
dest.expect_get_last_transaction_date()
|
||||
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||
dest.expect_resolve_account_id().returning(|_| Ok(Some("dest_1".into())));
|
||||
dest.expect_get_last_transaction_date().returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||
|
||||
// 1. Find -> None (New transaction)
|
||||
dest.expect_find_transaction().returning(|_, _| Ok(None));
|
||||
dest.expect_find_transaction()
|
||||
.returning(|_, _| Ok(None));
|
||||
|
||||
// 2. Create -> NEVER Called (Dry Run)
|
||||
dest.expect_create_transaction().never();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use chrono::Utc;
|
||||
use hyper::Body;
|
||||
use reqwest::{Request, Response};
|
||||
use reqwest_middleware::{Middleware, Next};
|
||||
use task_local_extensions::Extensions;
|
||||
use reqwest::{Request, Response};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use task_local_extensions::Extensions;
|
||||
use chrono::Utc;
|
||||
use hyper::Body;
|
||||
|
||||
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -51,11 +51,7 @@ impl Middleware for DebugLogger {
|
||||
log_content.push_str("# Request:\n");
|
||||
log_content.push_str(&format!("{} {} HTTP/1.1\n", req.method(), req.url()));
|
||||
for (key, value) in req.headers() {
|
||||
log_content.push_str(&format!(
|
||||
"{}: {}\n",
|
||||
key,
|
||||
value.to_str().unwrap_or("[INVALID]")
|
||||
));
|
||||
log_content.push_str(&format!("{}: {}\n", key, value.to_str().unwrap_or("[INVALID]")));
|
||||
}
|
||||
if let Some(body) = req.body() {
|
||||
if let Some(bytes) = body.as_bytes() {
|
||||
@@ -74,26 +70,13 @@ impl Middleware for DebugLogger {
|
||||
|
||||
// Response
|
||||
log_content.push_str("# Response:\n");
|
||||
log_content.push_str(&format!(
|
||||
"HTTP/1.1 {} {}\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Unknown")
|
||||
));
|
||||
log_content.push_str(&format!("HTTP/1.1 {} {}\n", status.as_u16(), status.canonical_reason().unwrap_or("Unknown")));
|
||||
for (key, value) in &headers {
|
||||
log_content.push_str(&format!(
|
||||
"{}: {}\n",
|
||||
key,
|
||||
value.to_str().unwrap_or("[INVALID]")
|
||||
));
|
||||
log_content.push_str(&format!("{}: {}\n", key, value.to_str().unwrap_or("[INVALID]")));
|
||||
}
|
||||
|
||||
// Read body
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
reqwest_middleware::Error::Middleware(anyhow::anyhow!(
|
||||
"Failed to read response body: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let body_bytes = response.bytes().await.map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!("Failed to read response body: {}", e)))?;
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
log_content.push_str(&format!("\n{}", body_str));
|
||||
|
||||
@@ -103,7 +86,9 @@ impl Middleware for DebugLogger {
|
||||
}
|
||||
|
||||
// Reconstruct response
|
||||
let mut builder = http::Response::builder().status(status).version(version);
|
||||
let mut builder = http::Response::builder()
|
||||
.status(status)
|
||||
.version(version);
|
||||
for (key, value) in &headers {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@ mod adapters;
|
||||
mod core;
|
||||
mod debug;
|
||||
|
||||
use crate::adapters::firefly::client::FireflyAdapter;
|
||||
use clap::Parser;
|
||||
use tracing::{info, error};
|
||||
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
||||
use crate::adapters::firefly::client::FireflyAdapter;
|
||||
use crate::core::sync::run_sync;
|
||||
use crate::debug::DebugLogger;
|
||||
use chrono::NaiveDate;
|
||||
use clap::Parser;
|
||||
use firefly_client::client::FireflyClient;
|
||||
use gocardless_client::client::GoCardlessClient;
|
||||
use firefly_client::client::FireflyClient;
|
||||
use reqwest_middleware::ClientBuilder;
|
||||
use std::env;
|
||||
use tracing::{error, info};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
@@ -56,8 +56,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Config Load
|
||||
let gc_url = env::var("GOCARDLESS_URL")
|
||||
.unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string());
|
||||
let gc_url = env::var("GOCARDLESS_URL").unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string());
|
||||
let gc_id = env::var("GOCARDLESS_ID").expect("GOCARDLESS_ID not set");
|
||||
let gc_key = env::var("GOCARDLESS_KEY").expect("GOCARDLESS_KEY not set");
|
||||
|
||||
@@ -91,19 +90,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
match run_sync(source, destination, args.start, args.end, args.dry_run).await {
|
||||
Ok(result) => {
|
||||
info!("Sync completed successfully.");
|
||||
info!(
|
||||
"Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
|
||||
result.accounts_processed,
|
||||
result.accounts_skipped_expired,
|
||||
result.accounts_skipped_errors
|
||||
);
|
||||
info!(
|
||||
"Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||
result.ingest.created,
|
||||
result.ingest.healed,
|
||||
result.ingest.duplicates,
|
||||
result.ingest.errors
|
||||
);
|
||||
info!("Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
|
||||
result.accounts_processed, result.accounts_skipped_expired, result.accounts_skipped_errors);
|
||||
info!("Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||
result.ingest.created, result.ingest.healed, result.ingest.duplicates, result.ingest.errors);
|
||||
}
|
||||
Err(e) => error!("Sync failed: {}", e),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::models::{AccountArray, TransactionArray, TransactionStore, TransactionUpdate};
|
||||
use reqwest::Url;
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
use tracing::instrument;
|
||||
use crate::models::{AccountArray, TransactionStore, TransactionArray, TransactionUpdate};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum FireflyError {
|
||||
@@ -28,16 +28,10 @@ impl FireflyClient {
|
||||
Self::with_client(base_url, access_token, None)
|
||||
}
|
||||
|
||||
pub fn with_client(
|
||||
base_url: &str,
|
||||
access_token: &str,
|
||||
client: Option<ClientWithMiddleware>,
|
||||
) -> Result<Self, FireflyError> {
|
||||
pub fn with_client(base_url: &str, access_token: &str, client: Option<ClientWithMiddleware>) -> Result<Self, FireflyError> {
|
||||
Ok(Self {
|
||||
base_url: Url::parse(base_url)?,
|
||||
client: client.unwrap_or_else(|| {
|
||||
reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()
|
||||
}),
|
||||
client: client.unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()),
|
||||
access_token: access_token.to_string(),
|
||||
})
|
||||
}
|
||||
@@ -45,7 +39,8 @@ impl FireflyClient {
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_accounts(&self, _iban: &str) -> Result<AccountArray, FireflyError> {
|
||||
let mut url = self.base_url.join("/api/v1/accounts")?;
|
||||
url.query_pairs_mut().append_pair("type", "asset");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("type", "asset");
|
||||
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
@@ -62,15 +57,10 @@ impl FireflyClient {
|
||||
}
|
||||
|
||||
#[instrument(skip(self, transaction))]
|
||||
pub async fn store_transaction(
|
||||
&self,
|
||||
transaction: TransactionStore,
|
||||
) -> Result<(), FireflyError> {
|
||||
pub async fn store_transaction(&self, transaction: TransactionStore) -> Result<(), FireflyError> {
|
||||
let url = self.base_url.join("/api/v1/transactions")?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
let response = self.client.post(url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("accept", "application/json")
|
||||
.json(&transaction)
|
||||
@@ -80,25 +70,15 @@ impl FireflyClient {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(FireflyError::ApiError(format!(
|
||||
"Store Transaction Failed {}: {}",
|
||||
status, text
|
||||
)));
|
||||
return Err(FireflyError::ApiError(format!("Store Transaction Failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_account_transactions(
|
||||
&self,
|
||||
account_id: &str,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<TransactionArray, FireflyError> {
|
||||
let mut url = self
|
||||
.base_url
|
||||
.join(&format!("/api/v1/accounts/{}/transactions", account_id))?;
|
||||
pub async fn list_account_transactions(&self, account_id: &str, start: Option<&str>, end: Option<&str>) -> Result<TransactionArray, FireflyError> {
|
||||
let mut url = self.base_url.join(&format!("/api/v1/accounts/{}/transactions", account_id))?;
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
if let Some(s) = start {
|
||||
@@ -115,18 +95,10 @@ impl FireflyClient {
|
||||
}
|
||||
|
||||
#[instrument(skip(self, update))]
|
||||
pub async fn update_transaction(
|
||||
&self,
|
||||
id: &str,
|
||||
update: TransactionUpdate,
|
||||
) -> Result<(), FireflyError> {
|
||||
let url = self
|
||||
.base_url
|
||||
.join(&format!("/api/v1/transactions/{}", id))?;
|
||||
pub async fn update_transaction(&self, id: &str, update: TransactionUpdate) -> Result<(), FireflyError> {
|
||||
let url = self.base_url.join(&format!("/api/v1/transactions/{}", id))?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.put(url)
|
||||
let response = self.client.put(url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("accept", "application/json")
|
||||
.json(&update)
|
||||
@@ -136,19 +108,14 @@ impl FireflyClient {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(FireflyError::ApiError(format!(
|
||||
"Update Transaction Failed {}: {}",
|
||||
status, text
|
||||
)));
|
||||
return Err(FireflyError::ApiError(format!("Update Transaction Failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_authenticated<T: DeserializeOwned>(&self, url: Url) -> Result<T, FireflyError> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
let response = self.client.get(url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("accept", "application/json")
|
||||
.send()
|
||||
@@ -157,10 +124,7 @@ impl FireflyClient {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(FireflyError::ApiError(format!(
|
||||
"API request failed {}: {}",
|
||||
status, text
|
||||
)));
|
||||
return Err(FireflyError::ApiError(format!("API request failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
let data = response.json().await?;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use firefly_client::client::FireflyClient;
|
||||
use firefly_client::models::{TransactionSplitStore, TransactionStore};
|
||||
use std::fs;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use firefly_client::models::{TransactionStore, TransactionSplitStore};
|
||||
use wiremock::matchers::{method, path, header};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use std::fs;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_accounts() {
|
||||
@@ -21,10 +21,7 @@ async fn test_search_accounts() {
|
||||
|
||||
assert_eq!(accounts.data.len(), 1);
|
||||
assert_eq!(accounts.data[0].attributes.name, "Checking Account");
|
||||
assert_eq!(
|
||||
accounts.data[0].attributes.iban.as_deref(),
|
||||
Some("NL01BANK0123456789")
|
||||
);
|
||||
assert_eq!(accounts.data[0].attributes.iban.as_deref(), Some("NL01BANK0123456789"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use crate::models::{
|
||||
Account, EndUserAgreement, PaginatedResponse, Requisition, TokenResponse, TransactionsResponse,
|
||||
};
|
||||
use reqwest::Url;
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, instrument};
|
||||
use crate::models::{TokenResponse, PaginatedResponse, Requisition, Account, TransactionsResponse, EndUserAgreement};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum GoCardlessError {
|
||||
@@ -41,17 +39,10 @@ impl GoCardlessClient {
|
||||
Self::with_client(base_url, secret_id, secret_key, None)
|
||||
}
|
||||
|
||||
pub fn with_client(
|
||||
base_url: &str,
|
||||
secret_id: &str,
|
||||
secret_key: &str,
|
||||
client: Option<ClientWithMiddleware>,
|
||||
) -> Result<Self, GoCardlessError> {
|
||||
pub fn with_client(base_url: &str, secret_id: &str, secret_key: &str, client: Option<ClientWithMiddleware>) -> Result<Self, GoCardlessError> {
|
||||
Ok(Self {
|
||||
base_url: Url::parse(base_url)?,
|
||||
client: client.unwrap_or_else(|| {
|
||||
reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()
|
||||
}),
|
||||
client: client.unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()),
|
||||
secret_id: secret_id.to_string(),
|
||||
secret_key: secret_key.to_string(),
|
||||
access_token: None,
|
||||
@@ -76,47 +67,40 @@ impl GoCardlessClient {
|
||||
};
|
||||
|
||||
debug!("Requesting new access token");
|
||||
let response = self.client.post(url).json(&body).send().await?;
|
||||
let response = self.client.post(url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(GoCardlessError::ApiError(format!(
|
||||
"Token request failed {}: {}",
|
||||
status, text
|
||||
)));
|
||||
return Err(GoCardlessError::ApiError(format!("Token request failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse = response.json().await?;
|
||||
self.access_token = Some(token_resp.access);
|
||||
self.access_expires_at =
|
||||
Some(chrono::Utc::now() + chrono::Duration::seconds(token_resp.access_expires as i64));
|
||||
self.access_expires_at = Some(chrono::Utc::now() + chrono::Duration::seconds(token_resp.access_expires as i64));
|
||||
debug!("Access token obtained");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_requisitions(
|
||||
&self,
|
||||
) -> Result<PaginatedResponse<Requisition>, GoCardlessError> {
|
||||
pub async fn get_requisitions(&self) -> Result<PaginatedResponse<Requisition>, GoCardlessError> {
|
||||
let url = self.base_url.join("/api/v2/requisitions/")?;
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_agreements(
|
||||
&self,
|
||||
) -> Result<PaginatedResponse<EndUserAgreement>, GoCardlessError> {
|
||||
pub async fn get_agreements(&self) -> Result<PaginatedResponse<EndUserAgreement>, GoCardlessError> {
|
||||
let url = self.base_url.join("/api/v2/agreements/enduser/")?;
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_agreement(&self, id: &str) -> Result<EndUserAgreement, GoCardlessError> {
|
||||
let url = self
|
||||
.base_url
|
||||
.join(&format!("/api/v2/agreements/enduser/{}/", id))?;
|
||||
let url = self.base_url.join(&format!("/api/v2/agreements/enduser/{}/", id))?;
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
@@ -148,15 +132,8 @@ impl GoCardlessClient {
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_transactions(
|
||||
&self,
|
||||
account_id: &str,
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> Result<TransactionsResponse, GoCardlessError> {
|
||||
let mut url = self
|
||||
.base_url
|
||||
.join(&format!("/api/v2/accounts/{}/transactions/", account_id))?;
|
||||
pub async fn get_transactions(&self, account_id: &str, date_from: Option<&str>, date_to: Option<&str>) -> Result<TransactionsResponse, GoCardlessError> {
|
||||
let mut url = self.base_url.join(&format!("/api/v2/accounts/{}/transactions/", account_id))?;
|
||||
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
@@ -171,17 +148,10 @@ impl GoCardlessClient {
|
||||
self.get_authenticated(url).await
|
||||
}
|
||||
|
||||
async fn get_authenticated<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
url: Url,
|
||||
) -> Result<T, GoCardlessError> {
|
||||
let token = self.access_token.as_ref().ok_or(GoCardlessError::ApiError(
|
||||
"No access token available. Call obtain_access_token() first.".into(),
|
||||
))?;
|
||||
async fn get_authenticated<T: for<'de> Deserialize<'de>>(&self, url: Url) -> Result<T, GoCardlessError> {
|
||||
let token = self.access_token.as_ref().ok_or(GoCardlessError::ApiError("No access token available. Call obtain_access_token() first.".into()))?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
let response = self.client.get(url)
|
||||
.bearer_auth(token)
|
||||
.header("accept", "application/json")
|
||||
.send()
|
||||
@@ -190,10 +160,7 @@ impl GoCardlessClient {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
return Err(GoCardlessError::ApiError(format!(
|
||||
"API request failed {}: {}",
|
||||
status, text
|
||||
)));
|
||||
return Err(GoCardlessError::ApiError(format!("API request failed {}: {}", status, text)));
|
||||
}
|
||||
|
||||
let data = response.json().await?;
|
||||
|
||||
Reference in New Issue
Block a user