Compare commits
4 Commits
bcd56a92b7
...
3d4ace793d
| Author | SHA1 | Date | |
|---|---|---|---|
|
3d4ace793d
|
|||
|
93c1c8d861
|
|||
|
508975a086
|
|||
|
53087fa900
|
113
Cargo.lock
generated
113
Cargo.lock
generated
@@ -198,7 +198,6 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"comfy-table",
|
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"firefly-client",
|
"firefly-client",
|
||||||
"gocardless-client",
|
"gocardless-client",
|
||||||
@@ -414,17 +413,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "comfy-table"
|
|
||||||
version = "7.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b"
|
|
||||||
dependencies = [
|
|
||||||
"crossterm",
|
|
||||||
"unicode-segmentation",
|
|
||||||
"unicode-width",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -465,29 +453,6 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm"
|
|
||||||
version = "0.29.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"crossterm_winapi",
|
|
||||||
"document-features",
|
|
||||||
"parking_lot",
|
|
||||||
"rustix",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm_winapi"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -555,15 +520,6 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "document-features"
|
|
||||||
version = "0.2.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
|
||||||
dependencies = [
|
|
||||||
"litrs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenvy"
|
name = "dotenvy"
|
||||||
version = "0.15.7"
|
version = "0.15.7"
|
||||||
@@ -597,16 +553,6 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "errno"
|
|
||||||
version = "0.3.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "2.5.3"
|
version = "2.5.3"
|
||||||
@@ -1208,24 +1154,12 @@ version = "0.2.177"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "litrs"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1772,19 +1706,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "1.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.21.12"
|
version = "0.21.12"
|
||||||
@@ -2338,18 +2259,6 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-segmentation"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-width"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -2513,28 +2422,6 @@ version = "0.25.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
|
||||||
dependencies = [
|
|
||||||
"winapi-i686-pc-windows-gnu",
|
|
||||||
"winapi-x86_64-pc-windows-gnu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-i686-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ url = "2.5"
|
|||||||
wiremock = "0.5"
|
wiremock = "0.5"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
mockall = "0.11"
|
mockall = "0.11"
|
||||||
reqwest-middleware = "0.2"
|
reqwest-middleware = "0.2"
|
||||||
hyper = { version = "0.14", features = ["full"] }
|
hyper = { version = "0.14", features = ["full"] }
|
||||||
bytes = "1.0"
|
bytes = "1.0"
|
||||||
comfy-table = "7.1"
|
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Banks2FF
|
# Banks2FF
|
||||||
|
|
||||||
A robust command-line tool to synchronize bank transactions between various sources and destinations. Currently supports GoCardless (formerly Nordigen) to Firefly III, with extensible architecture for additional sources and destinations.
|
A robust command-line tool to synchronize bank transactions from GoCardless (formerly Nordigen) to Firefly III.
|
||||||
|
|
||||||
## ✨ Key Benefits
|
## ✨ Key Benefits
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ A robust command-line tool to synchronize bank transactions between various sour
|
|||||||
- **Reliable Operation**: Continues working even when some accounts need attention
|
- **Reliable Operation**: Continues working even when some accounts need attention
|
||||||
- **Safe Preview Mode**: Test changes before applying them to your finances
|
- **Safe Preview Mode**: Test changes before applying them to your finances
|
||||||
- **Rate Limit Aware**: Works within API limits to ensure consistent access
|
- **Rate Limit Aware**: Works within API limits to ensure consistent access
|
||||||
- **Flexible Account Linking**: Automatically match bank accounts to Firefly III accounts, with manual override options
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
@@ -32,52 +31,20 @@ A robust command-line tool to synchronize bank transactions between various sour
|
|||||||
### Usage
|
### Usage
|
||||||
```bash
|
```bash
|
||||||
# Sync all accounts (automatic date range)
|
# Sync all accounts (automatic date range)
|
||||||
cargo run -p banks2ff -- sync gocardless firefly
|
cargo run -p banks2ff
|
||||||
|
|
||||||
# Preview changes without saving
|
# Preview changes without saving
|
||||||
cargo run -p banks2ff -- --dry-run sync gocardless firefly
|
cargo run -p banks2ff -- --dry-run
|
||||||
|
|
||||||
# Sync specific date range
|
# Sync specific date range
|
||||||
cargo run -p banks2ff -- sync gocardless firefly --start 2023-01-01 --end 2023-01-31
|
cargo run -p banks2ff -- --start 2023-01-01 --end 2023-01-31
|
||||||
|
|
||||||
# List available sources and destinations
|
|
||||||
cargo run -p banks2ff -- sources
|
|
||||||
cargo run -p banks2ff -- destinations
|
|
||||||
|
|
||||||
# Inspect accounts
|
|
||||||
cargo run -p banks2ff -- accounts list
|
|
||||||
cargo run -p banks2ff -- accounts status
|
|
||||||
|
|
||||||
# Manage account links
|
|
||||||
cargo run -p banks2ff -- accounts link list
|
|
||||||
cargo run -p banks2ff -- accounts link create <source_account> <dest_account>
|
|
||||||
|
|
||||||
# Inspect transactions and cache
|
|
||||||
cargo run -p banks2ff -- transactions list <account_id>
|
|
||||||
cargo run -p banks2ff -- transactions cache-status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🖥️ CLI Structure
|
|
||||||
|
|
||||||
Banks2FF uses a structured command-line interface with the following commands:
|
|
||||||
|
|
||||||
- `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination
|
|
||||||
- `sources` - List all available source types
|
|
||||||
- `destinations` - List all available destination types
|
|
||||||
- `accounts list` - List all discovered accounts
|
|
||||||
- `accounts status` - Show sync status for all accounts
|
|
||||||
- `accounts link` - Manage account links between sources and destinations
|
|
||||||
- `transactions list <account_id>` - Show transaction information for a specific account
|
|
||||||
- `transactions cache-status` - Display cache status and statistics
|
|
||||||
- `transactions clear-cache` - Clear transaction cache (implementation pending)
|
|
||||||
|
|
||||||
Use `cargo run -p banks2ff -- --help` for detailed command information.
|
|
||||||
|
|
||||||
## 📋 What It Does
|
## 📋 What It Does
|
||||||
|
|
||||||
Banks2FF automatically:
|
Banks2FF automatically:
|
||||||
1. Connects to your bank accounts via GoCardless
|
1. Connects to your bank accounts via GoCardless
|
||||||
2. Discovers and links accounts between GoCardless and Firefly III (with auto-matching and manual options)
|
2. Finds matching accounts in your Firefly III instance
|
||||||
3. Downloads new transactions since your last sync
|
3. Downloads new transactions since your last sync
|
||||||
4. Adds them to Firefly III (avoiding duplicates)
|
4. Adds them to Firefly III (avoiding duplicates)
|
||||||
5. Handles errors gracefully - keeps working even if some accounts have issues
|
5. Handles errors gracefully - keeps working even if some accounts have issues
|
||||||
@@ -95,8 +62,7 @@ The cache requires `BANKS2FF_CACHE_KEY` to be set in your `.env` file for secure
|
|||||||
|
|
||||||
## 🔧 Troubleshooting
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
- **Unknown source/destination?** Use `sources` and `destinations` commands to see what's available
|
- **Account not syncing?** Check that the IBAN matches between GoCardless and Firefly III
|
||||||
- **Account not syncing?** Check that the IBAN matches between GoCardless and Firefly III, or use `accounts link` to create manual links
|
|
||||||
- **Missing transactions?** The tool syncs from the last transaction date forward
|
- **Missing transactions?** The tool syncs from the last transaction date forward
|
||||||
- **Rate limited?** The tool automatically handles API limits and retries appropriately
|
- **Rate limited?** The tool automatically handles API limits and retries appropriately
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,5 @@ pbkdf2 = "0.12"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|
||||||
# CLI formatting dependencies
|
|
||||||
comfy-table = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
mockall = { workspace = true }
|
mockall = { workspace = true }
|
||||||
|
|||||||
BIN
banks2ff/data/cache/accounts.enc
vendored
BIN
banks2ff/data/cache/accounts.enc
vendored
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
use crate::core::models::{Account, BankTransaction};
|
use crate::core::models::BankTransaction;
|
||||||
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
use crate::core::ports::{TransactionDestination, TransactionMatch};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -27,6 +27,31 @@ impl FireflyAdapter {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TransactionDestination for FireflyAdapter {
|
impl TransactionDestination for FireflyAdapter {
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>> {
|
||||||
|
let client = self.client.lock().await;
|
||||||
|
let accounts = client.search_accounts(iban).await?;
|
||||||
|
|
||||||
|
// Look for exact match on IBAN, ensuring account is active
|
||||||
|
for acc in accounts.data {
|
||||||
|
// Filter for active accounts only (default is usually active, but let's check if attribute exists)
|
||||||
|
// Note: The Firefly API spec v6.4.4 Account object has 'active' attribute as boolean.
|
||||||
|
let is_active = acc.attributes.active.unwrap_or(true);
|
||||||
|
|
||||||
|
if !is_active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(acc_iban) = acc.attributes.iban {
|
||||||
|
if acc_iban.replace(" ", "") == iban.replace(" ", "") {
|
||||||
|
return Ok(Some(acc.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
||||||
let client = self.client.lock().await;
|
let client = self.client.lock().await;
|
||||||
@@ -193,24 +218,4 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.into())
|
.map_err(|e| e.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
|
||||||
let client = self.client.lock().await;
|
|
||||||
let accounts = client.get_accounts("").await?;
|
|
||||||
let mut result = Vec::new();
|
|
||||||
|
|
||||||
for acc in accounts.data {
|
|
||||||
let is_active = acc.attributes.active.unwrap_or(true);
|
|
||||||
if is_active {
|
|
||||||
result.push(Account {
|
|
||||||
id: acc.id,
|
|
||||||
iban: acc.attributes.iban.unwrap_or_default(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use crate::adapters::gocardless::cache::AccountCache;
|
use crate::adapters::gocardless::cache::AccountCache;
|
||||||
use crate::adapters::gocardless::mapper::map_transaction;
|
use crate::adapters::gocardless::mapper::map_transaction;
|
||||||
use crate::adapters::gocardless::transaction_cache::AccountTransactionCache;
|
use crate::adapters::gocardless::transaction_cache::AccountTransactionCache;
|
||||||
use crate::core::models::{
|
use crate::core::models::{Account, BankTransaction};
|
||||||
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
|
|
||||||
};
|
|
||||||
use crate::core::ports::TransactionSource;
|
use crate::core::ports::TransactionSource;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -234,151 +232,4 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
);
|
);
|
||||||
Ok(transactions)
|
Ok(transactions)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
|
|
||||||
let mut client = self.client.lock().await;
|
|
||||||
let mut cache = self.cache.lock().await;
|
|
||||||
|
|
||||||
client.obtain_access_token().await?;
|
|
||||||
|
|
||||||
let requisitions = client.get_requisitions().await?;
|
|
||||||
let mut summaries = Vec::new();
|
|
||||||
|
|
||||||
for req in requisitions.results {
|
|
||||||
if req.status != "LN" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(agreement_id) = &req.agreement {
|
|
||||||
if client.is_agreement_expired(agreement_id).await? {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(req_accounts) = req.accounts {
|
|
||||||
for acc_id in req_accounts {
|
|
||||||
let iban = if let Some(iban) = cache.get_iban(&acc_id) {
|
|
||||||
iban
|
|
||||||
} else {
|
|
||||||
// Fetch if not cached
|
|
||||||
match client.get_account(&acc_id).await {
|
|
||||||
Ok(details) => {
|
|
||||||
let iban = details.iban.unwrap_or_default();
|
|
||||||
cache.insert(acc_id.clone(), iban.clone());
|
|
||||||
cache.save();
|
|
||||||
iban
|
|
||||||
}
|
|
||||||
Err(_) => "Unknown".to_string(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
summaries.push(AccountSummary {
|
|
||||||
id: acc_id,
|
|
||||||
iban,
|
|
||||||
currency: "EUR".to_string(), // Assuming EUR for now
|
|
||||||
status: "linked".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(summaries)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
|
|
||||||
let caches = self.transaction_caches.lock().await;
|
|
||||||
let mut statuses = Vec::new();
|
|
||||||
|
|
||||||
for (account_id, cache) in caches.iter() {
|
|
||||||
let iban = self
|
|
||||||
.cache
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.get_iban(account_id)
|
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
|
||||||
let transaction_count = cache.ranges.iter().map(|r| r.transactions.len()).sum();
|
|
||||||
let last_sync_date = cache.ranges.iter().map(|r| r.end_date).max();
|
|
||||||
|
|
||||||
statuses.push(AccountStatus {
|
|
||||||
account_id: account_id.clone(),
|
|
||||||
iban,
|
|
||||||
last_sync_date,
|
|
||||||
transaction_count,
|
|
||||||
status: if transaction_count > 0 {
|
|
||||||
"synced"
|
|
||||||
} else {
|
|
||||||
"pending"
|
|
||||||
}
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(statuses)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
|
|
||||||
let caches = self.transaction_caches.lock().await;
|
|
||||||
if let Some(cache) = caches.get(account_id) {
|
|
||||||
let total_count = cache.ranges.iter().map(|r| r.transactions.len()).sum();
|
|
||||||
let date_range = if cache.ranges.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let min_date = cache.ranges.iter().map(|r| r.start_date).min();
|
|
||||||
let max_date = cache.ranges.iter().map(|r| r.end_date).max();
|
|
||||||
min_date.and_then(|min| max_date.map(|max| (min, max)))
|
|
||||||
};
|
|
||||||
let last_updated = cache.ranges.iter().map(|r| r.end_date).max();
|
|
||||||
|
|
||||||
Ok(TransactionInfo {
|
|
||||||
account_id: account_id.to_string(),
|
|
||||||
total_count,
|
|
||||||
date_range,
|
|
||||||
last_updated,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(TransactionInfo {
|
|
||||||
account_id: account_id.to_string(),
|
|
||||||
total_count: 0,
|
|
||||||
date_range: None,
|
|
||||||
last_updated: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>> {
|
|
||||||
let mut infos = Vec::new();
|
|
||||||
|
|
||||||
// Account cache
|
|
||||||
let account_cache = self.cache.lock().await;
|
|
||||||
infos.push(CacheInfo {
|
|
||||||
account_id: None,
|
|
||||||
cache_type: "account".to_string(),
|
|
||||||
entry_count: account_cache.accounts.len(),
|
|
||||||
total_size_bytes: 0, // Not tracking size
|
|
||||||
last_updated: None, // Not tracking
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transaction caches
|
|
||||||
let transaction_caches = self.transaction_caches.lock().await;
|
|
||||||
for (account_id, cache) in transaction_caches.iter() {
|
|
||||||
infos.push(CacheInfo {
|
|
||||||
account_id: Some(account_id.clone()),
|
|
||||||
cache_type: "transaction".to_string(),
|
|
||||||
entry_count: cache.ranges.len(),
|
|
||||||
total_size_bytes: 0, // Not tracking
|
|
||||||
last_updated: cache.ranges.iter().map(|r| r.end_date).max(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(infos)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
|
||||||
self.get_accounts(None).await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,8 +108,15 @@ mod tests {
|
|||||||
fn test_map_normal_transaction() {
|
fn test_map_normal_transaction() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("123".into()),
|
transaction_id: Some("123".into()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2023-01-01".into()),
|
booking_date: Some("2023-01-01".into()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: TransactionAmount {
|
transaction_amount: TransactionAmount {
|
||||||
amount: "100.50".into(),
|
amount: "100.50".into(),
|
||||||
currency: "EUR".into(),
|
currency: "EUR".into(),
|
||||||
@@ -117,10 +124,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Shop".into()),
|
creditor_name: Some("Shop".into()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: Some("Groceries".into()),
|
remittance_information_unstructured: Some("Groceries".into()),
|
||||||
|
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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = map_transaction(t).unwrap();
|
let res = map_transaction(t).unwrap();
|
||||||
@@ -135,8 +151,15 @@ mod tests {
|
|||||||
fn test_map_multicurrency_transaction() {
|
fn test_map_multicurrency_transaction() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("124".into()),
|
transaction_id: Some("124".into()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2023-01-02".into()),
|
booking_date: Some("2023-01-02".into()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: TransactionAmount {
|
transaction_amount: TransactionAmount {
|
||||||
amount: "-10.00".into(),
|
amount: "-10.00".into(),
|
||||||
currency: "EUR".into(),
|
currency: "EUR".into(),
|
||||||
@@ -149,10 +172,19 @@ mod tests {
|
|||||||
}]),
|
}]),
|
||||||
creditor_name: Some("US Shop".into()),
|
creditor_name: Some("US Shop".into()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: 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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = map_transaction(t).unwrap();
|
let res = map_transaction(t).unwrap();
|
||||||
@@ -201,8 +233,15 @@ mod tests {
|
|||||||
fn test_map_transaction_invalid_amount() {
|
fn test_map_transaction_invalid_amount() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("125".into()),
|
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()),
|
booking_date: Some("2023-01-03".into()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: TransactionAmount {
|
transaction_amount: TransactionAmount {
|
||||||
amount: "0.00".into(),
|
amount: "0.00".into(),
|
||||||
currency: "EUR".into(),
|
currency: "EUR".into(),
|
||||||
@@ -210,10 +249,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Test".into()),
|
creditor_name: Some("Test".into()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: 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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(map_transaction(t).is_err());
|
assert!(map_transaction(t).is_err());
|
||||||
@@ -223,8 +271,15 @@ mod tests {
|
|||||||
fn test_map_transaction_invalid_currency() {
|
fn test_map_transaction_invalid_currency() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("126".into()),
|
transaction_id: Some("126".into()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2023-01-04".into()),
|
booking_date: Some("2023-01-04".into()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: TransactionAmount {
|
transaction_amount: TransactionAmount {
|
||||||
amount: "100.00".into(),
|
amount: "100.00".into(),
|
||||||
currency: "euro".into(),
|
currency: "euro".into(),
|
||||||
@@ -232,10 +287,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Test".into()),
|
creditor_name: Some("Test".into()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: 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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(map_transaction(t).is_err());
|
assert!(map_transaction(t).is_err());
|
||||||
@@ -245,8 +309,15 @@ mod tests {
|
|||||||
fn test_map_transaction_invalid_foreign_amount() {
|
fn test_map_transaction_invalid_foreign_amount() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("127".into()),
|
transaction_id: Some("127".into()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2023-01-05".into()),
|
booking_date: Some("2023-01-05".into()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: TransactionAmount {
|
transaction_amount: TransactionAmount {
|
||||||
amount: "-10.00".into(),
|
amount: "-10.00".into(),
|
||||||
currency: "EUR".into(),
|
currency: "EUR".into(),
|
||||||
@@ -259,10 +330,19 @@ mod tests {
|
|||||||
}]),
|
}]),
|
||||||
creditor_name: Some("Test".into()),
|
creditor_name: Some("Test".into()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: 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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(map_transaction(t).is_err());
|
assert!(map_transaction(t).is_err());
|
||||||
@@ -272,8 +352,15 @@ mod tests {
|
|||||||
fn test_map_transaction_invalid_foreign_currency() {
|
fn test_map_transaction_invalid_foreign_currency() {
|
||||||
let t = Transaction {
|
let t = Transaction {
|
||||||
transaction_id: Some("128".into()),
|
transaction_id: Some("128".into()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2023-01-06".into()),
|
booking_date: Some("2023-01-06".into()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: TransactionAmount {
|
transaction_amount: TransactionAmount {
|
||||||
amount: "-10.00".into(),
|
amount: "-10.00".into(),
|
||||||
currency: "EUR".into(),
|
currency: "EUR".into(),
|
||||||
@@ -286,10 +373,19 @@ mod tests {
|
|||||||
}]),
|
}]),
|
||||||
creditor_name: Some("Test".into()),
|
creditor_name: Some("Test".into()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: 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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(map_transaction(t).is_err());
|
assert!(map_transaction(t).is_err());
|
||||||
|
|||||||
@@ -340,8 +340,15 @@ mod tests {
|
|||||||
|
|
||||||
let transaction = Transaction {
|
let transaction = Transaction {
|
||||||
transaction_id: Some("test-tx-1".to_string()),
|
transaction_id: Some("test-tx-1".to_string()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2024-01-01".to_string()),
|
booking_date: Some("2024-01-01".to_string()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: gocardless_client::models::TransactionAmount {
|
transaction_amount: gocardless_client::models::TransactionAmount {
|
||||||
amount: "100.00".to_string(),
|
amount: "100.00".to_string(),
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
@@ -349,10 +356,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Test Creditor".to_string()),
|
creditor_name: Some("Test Creditor".to_string()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: Some("Test payment".to_string()),
|
remittance_information_unstructured: Some("Test payment".to_string()),
|
||||||
|
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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let range = CachedRange {
|
let range = CachedRange {
|
||||||
@@ -491,8 +507,15 @@ mod tests {
|
|||||||
let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
let tx1 = Transaction {
|
let tx1 = Transaction {
|
||||||
transaction_id: Some("tx1".to_string()),
|
transaction_id: Some("tx1".to_string()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2024-01-05".to_string()),
|
booking_date: Some("2024-01-05".to_string()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: gocardless_client::models::TransactionAmount {
|
transaction_amount: gocardless_client::models::TransactionAmount {
|
||||||
amount: "100.00".to_string(),
|
amount: "100.00".to_string(),
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
@@ -500,10 +523,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Creditor".to_string()),
|
creditor_name: Some("Creditor".to_string()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: Some("Payment".to_string()),
|
remittance_information_unstructured: Some("Payment".to_string()),
|
||||||
|
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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
cache.store_transactions(start1, end1, vec![tx1]);
|
cache.store_transactions(start1, end1, vec![tx1]);
|
||||||
|
|
||||||
@@ -517,8 +549,15 @@ mod tests {
|
|||||||
let end2 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
|
let end2 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
|
||||||
let tx2 = Transaction {
|
let tx2 = Transaction {
|
||||||
transaction_id: Some("tx2".to_string()),
|
transaction_id: Some("tx2".to_string()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2024-01-12".to_string()),
|
booking_date: Some("2024-01-12".to_string()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: gocardless_client::models::TransactionAmount {
|
transaction_amount: gocardless_client::models::TransactionAmount {
|
||||||
amount: "200.00".to_string(),
|
amount: "200.00".to_string(),
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
@@ -526,10 +565,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Creditor2".to_string()),
|
creditor_name: Some("Creditor2".to_string()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: Some("Payment2".to_string()),
|
remittance_information_unstructured: Some("Payment2".to_string()),
|
||||||
|
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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
cache.store_transactions(start2, end2, vec![tx2]);
|
cache.store_transactions(start2, end2, vec![tx2]);
|
||||||
|
|
||||||
@@ -549,9 +597,16 @@ mod tests {
|
|||||||
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||||
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||||
let tx1 = Transaction {
|
let tx1 = Transaction {
|
||||||
transaction_id: Some("dup".to_string()),
|
transaction_id: Some("tx1".to_string()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2024-01-05".to_string()),
|
booking_date: Some("2024-01-05".to_string()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: gocardless_client::models::TransactionAmount {
|
transaction_amount: gocardless_client::models::TransactionAmount {
|
||||||
amount: "100.00".to_string(),
|
amount: "100.00".to_string(),
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
@@ -559,10 +614,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Creditor".to_string()),
|
creditor_name: Some("Creditor".to_string()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: Some("Payment".to_string()),
|
remittance_information_unstructured: Some("Payment".to_string()),
|
||||||
|
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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
let tx2 = tx1.clone(); // Duplicate
|
let tx2 = tx1.clone(); // Duplicate
|
||||||
cache.store_transactions(start, end, vec![tx1, tx2]);
|
cache.store_transactions(start, end, vec![tx1, tx2]);
|
||||||
@@ -574,8 +638,15 @@ mod tests {
|
|||||||
fn test_get_cached_transactions() {
|
fn test_get_cached_transactions() {
|
||||||
let tx1 = Transaction {
|
let tx1 = Transaction {
|
||||||
transaction_id: Some("tx1".to_string()),
|
transaction_id: Some("tx1".to_string()),
|
||||||
|
entry_reference: None,
|
||||||
|
end_to_end_id: None,
|
||||||
|
mandate_id: None,
|
||||||
|
check_id: None,
|
||||||
|
creditor_id: None,
|
||||||
booking_date: Some("2024-01-05".to_string()),
|
booking_date: Some("2024-01-05".to_string()),
|
||||||
value_date: None,
|
value_date: None,
|
||||||
|
booking_date_time: None,
|
||||||
|
value_date_time: None,
|
||||||
transaction_amount: gocardless_client::models::TransactionAmount {
|
transaction_amount: gocardless_client::models::TransactionAmount {
|
||||||
amount: "100.00".to_string(),
|
amount: "100.00".to_string(),
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
@@ -583,10 +654,19 @@ mod tests {
|
|||||||
currency_exchange: None,
|
currency_exchange: None,
|
||||||
creditor_name: Some("Creditor".to_string()),
|
creditor_name: Some("Creditor".to_string()),
|
||||||
creditor_account: None,
|
creditor_account: None,
|
||||||
|
ultimate_creditor: None,
|
||||||
debtor_name: None,
|
debtor_name: None,
|
||||||
debtor_account: None,
|
debtor_account: None,
|
||||||
|
ultimate_debtor: None,
|
||||||
remittance_information_unstructured: Some("Payment".to_string()),
|
remittance_information_unstructured: Some("Payment".to_string()),
|
||||||
|
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,
|
proprietary_bank_transaction_code: None,
|
||||||
|
internal_transaction_id: None,
|
||||||
};
|
};
|
||||||
let range = CachedRange {
|
let range = CachedRange {
|
||||||
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
|
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
use crate::core::models::{AccountStatus, AccountSummary, CacheInfo, TransactionInfo};
|
|
||||||
use comfy_table::{presets::UTF8_FULL, Table};
|
|
||||||
|
|
||||||
pub enum OutputFormat {
|
|
||||||
Table,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait Formattable {
|
|
||||||
fn to_table(&self) -> Table;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_list_output<T: Formattable>(data: Vec<T>, format: &OutputFormat) {
|
|
||||||
if data.is_empty() {
|
|
||||||
println!("No data available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match format {
|
|
||||||
OutputFormat::Table => {
|
|
||||||
for item in data {
|
|
||||||
println!("{}", item.to_table());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement Formattable for the model structs
|
|
||||||
impl Formattable for AccountSummary {
|
|
||||||
fn to_table(&self) -> Table {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec!["ID", "IBAN", "Currency", "Status"]);
|
|
||||||
table.add_row(vec![
|
|
||||||
self.id.clone(),
|
|
||||||
mask_iban(&self.iban),
|
|
||||||
self.currency.clone(),
|
|
||||||
self.status.clone(),
|
|
||||||
]);
|
|
||||||
table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Formattable for AccountStatus {
|
|
||||||
fn to_table(&self) -> Table {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec![
|
|
||||||
"Account ID",
|
|
||||||
"IBAN",
|
|
||||||
"Last Sync",
|
|
||||||
"Transaction Count",
|
|
||||||
"Status",
|
|
||||||
]);
|
|
||||||
table.add_row(vec![
|
|
||||||
self.account_id.clone(),
|
|
||||||
mask_iban(&self.iban),
|
|
||||||
self.last_sync_date
|
|
||||||
.map(|d| d.to_string())
|
|
||||||
.unwrap_or_else(|| "Never".to_string()),
|
|
||||||
self.transaction_count.to_string(),
|
|
||||||
self.status.clone(),
|
|
||||||
]);
|
|
||||||
table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Formattable for TransactionInfo {
|
|
||||||
fn to_table(&self) -> Table {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec![
|
|
||||||
"Account ID",
|
|
||||||
"Total Transactions",
|
|
||||||
"Date Range",
|
|
||||||
"Last Updated",
|
|
||||||
]);
|
|
||||||
let date_range = self
|
|
||||||
.date_range
|
|
||||||
.map(|(start, end)| format!("{} to {}", start, end))
|
|
||||||
.unwrap_or_else(|| "N/A".to_string());
|
|
||||||
table.add_row(vec![
|
|
||||||
self.account_id.clone(),
|
|
||||||
self.total_count.to_string(),
|
|
||||||
date_range,
|
|
||||||
self.last_updated
|
|
||||||
.map(|d| d.to_string())
|
|
||||||
.unwrap_or_else(|| "Never".to_string()),
|
|
||||||
]);
|
|
||||||
table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Formattable for CacheInfo {
|
|
||||||
fn to_table(&self) -> Table {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec![
|
|
||||||
"Account ID",
|
|
||||||
"Cache Type",
|
|
||||||
"Entry Count",
|
|
||||||
"Size (bytes)",
|
|
||||||
"Last Updated",
|
|
||||||
]);
|
|
||||||
table.add_row(vec![
|
|
||||||
self.account_id.as_deref().unwrap_or("Global").to_string(),
|
|
||||||
self.cache_type.clone(),
|
|
||||||
self.entry_count.to_string(),
|
|
||||||
self.total_size_bytes.to_string(),
|
|
||||||
self.last_updated
|
|
||||||
.map(|d| d.to_string())
|
|
||||||
.unwrap_or_else(|| "Never".to_string()),
|
|
||||||
]);
|
|
||||||
table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask_iban(iban: &str) -> String {
|
|
||||||
if iban.len() <= 4 {
|
|
||||||
iban.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{}{}", "*".repeat(iban.len() - 4), &iban[iban.len() - 4..])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod formatters;
|
|
||||||
pub mod setup;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
use crate::adapters::firefly::client::FireflyAdapter;
|
|
||||||
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
|
||||||
use crate::debug::DebugLogger;
|
|
||||||
use anyhow::Result;
|
|
||||||
use firefly_client::client::FireflyClient;
|
|
||||||
use gocardless_client::client::GoCardlessClient;
|
|
||||||
use reqwest_middleware::ClientBuilder;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
pub struct AppContext {
|
|
||||||
pub source: GoCardlessAdapter,
|
|
||||||
pub destination: FireflyAdapter,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppContext {
|
|
||||||
pub async fn new(debug: bool) -> Result<Self> {
|
|
||||||
// Config Load
|
|
||||||
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");
|
|
||||||
|
|
||||||
let ff_url = env::var("FIREFLY_III_URL").expect("FIREFLY_III_URL not set");
|
|
||||||
let ff_key = env::var("FIREFLY_III_API_KEY").expect("FIREFLY_III_API_KEY not set");
|
|
||||||
|
|
||||||
// Clients
|
|
||||||
let gc_client = if debug {
|
|
||||||
let client = ClientBuilder::new(reqwest::Client::new())
|
|
||||||
.with(DebugLogger::new("gocardless"))
|
|
||||||
.build();
|
|
||||||
GoCardlessClient::with_client(&gc_url, &gc_id, &gc_key, Some(client))?
|
|
||||||
} else {
|
|
||||||
GoCardlessClient::new(&gc_url, &gc_id, &gc_key)?
|
|
||||||
};
|
|
||||||
|
|
||||||
let ff_client = if debug {
|
|
||||||
let client = ClientBuilder::new(reqwest::Client::new())
|
|
||||||
.with(DebugLogger::new("firefly"))
|
|
||||||
.build();
|
|
||||||
FireflyClient::with_client(&ff_url, &ff_key, Some(client))?
|
|
||||||
} else {
|
|
||||||
FireflyClient::new(&ff_url, &ff_key)?
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adapters
|
|
||||||
let source = GoCardlessAdapter::new(gc_client);
|
|
||||||
let destination = FireflyAdapter::new(ff_client);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
source,
|
|
||||||
destination,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AdapterInfo {
|
|
||||||
pub id: &'static str,
|
|
||||||
pub description: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_available_sources() -> Vec<AdapterInfo> {
|
|
||||||
vec![AdapterInfo {
|
|
||||||
id: "gocardless",
|
|
||||||
description: "GoCardless Bank Account Data API",
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_available_destinations() -> Vec<AdapterInfo> {
|
|
||||||
vec![AdapterInfo {
|
|
||||||
id: "firefly",
|
|
||||||
description: "Firefly III personal finance manager",
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_valid_source(source: &str) -> bool {
|
|
||||||
get_available_sources().iter().any(|s| s.id == source)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_valid_destination(destination: &str) -> bool {
|
|
||||||
get_available_destinations()
|
|
||||||
.iter()
|
|
||||||
.any(|d| d.id == destination)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_available_sources() {
|
|
||||||
let sources = get_available_sources();
|
|
||||||
assert_eq!(sources.len(), 1);
|
|
||||||
assert_eq!(sources[0].id, "gocardless");
|
|
||||||
assert_eq!(sources[0].description, "GoCardless Bank Account Data API");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_available_destinations() {
|
|
||||||
let destinations = get_available_destinations();
|
|
||||||
assert_eq!(destinations.len(), 1);
|
|
||||||
assert_eq!(destinations[0].id, "firefly");
|
|
||||||
assert_eq!(
|
|
||||||
destinations[0].description,
|
|
||||||
"Firefly III personal finance manager"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_valid_source() {
|
|
||||||
assert!(is_valid_source("gocardless"));
|
|
||||||
assert!(!is_valid_source("csv")); // Not implemented yet
|
|
||||||
assert!(!is_valid_source("camt053")); // Not implemented yet
|
|
||||||
assert!(!is_valid_source("mt940")); // Not implemented yet
|
|
||||||
assert!(!is_valid_source("invalid"));
|
|
||||||
assert!(!is_valid_source(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_valid_destination() {
|
|
||||||
assert!(is_valid_destination("firefly"));
|
|
||||||
assert!(!is_valid_destination("invalid"));
|
|
||||||
assert!(!is_valid_destination("gocardless"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
use crate::core::models::Account;
|
|
||||||
use anyhow::Result;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AccountLink {
|
|
||||||
pub id: String,
|
|
||||||
pub source_account_id: String,
|
|
||||||
pub dest_account_id: String,
|
|
||||||
pub alias: Option<String>,
|
|
||||||
pub auto_linked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
|
||||||
pub struct LinkStore {
|
|
||||||
pub links: Vec<AccountLink>,
|
|
||||||
pub source_accounts: HashMap<String, HashMap<String, Account>>, // outer key: source type, inner: account id
|
|
||||||
pub dest_accounts: HashMap<String, HashMap<String, Account>>, // outer key: dest type, inner: account id
|
|
||||||
next_id: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinkStore {
|
|
||||||
fn get_path() -> String {
|
|
||||||
let cache_dir =
|
|
||||||
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
|
||||||
format!("{}/links.json", cache_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load() -> Self {
|
|
||||||
let path = Self::get_path();
|
|
||||||
if Path::new(&path).exists() {
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(content) => match serde_json::from_str(&content) {
|
|
||||||
Ok(store) => return store,
|
|
||||||
Err(e) => warn!("Failed to parse link store: {}", e),
|
|
||||||
},
|
|
||||||
Err(e) => warn!("Failed to read link store: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) -> Result<()> {
|
|
||||||
let path = Self::get_path();
|
|
||||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let content = serde_json::to_string_pretty(self)?;
|
|
||||||
fs::write(path, content)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_link(
|
|
||||||
&mut self,
|
|
||||||
source_account: &Account,
|
|
||||||
dest_account: &Account,
|
|
||||||
auto_linked: bool,
|
|
||||||
) -> String {
|
|
||||||
let id = format!("link_{}", self.next_id);
|
|
||||||
self.next_id += 1;
|
|
||||||
let link = AccountLink {
|
|
||||||
id: id.clone(),
|
|
||||||
source_account_id: source_account.id.clone(),
|
|
||||||
dest_account_id: dest_account.id.clone(),
|
|
||||||
alias: None,
|
|
||||||
auto_linked,
|
|
||||||
};
|
|
||||||
self.links.push(link);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_alias(&mut self, link_id: &str, alias: String) -> Result<()> {
|
|
||||||
if let Some(link) = self.links.iter_mut().find(|l| l.id == link_id) {
|
|
||||||
link.alias = Some(alias);
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(anyhow::anyhow!("Link not found"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_link(&mut self, link_id: &str) -> Result<()> {
|
|
||||||
self.links.retain(|l| l.id != link_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_link_by_source(&self, source_id: &str) -> Option<&AccountLink> {
|
|
||||||
self.links.iter().find(|l| l.source_account_id == source_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_source_accounts(&mut self, source_type: &str, accounts: Vec<Account>) {
|
|
||||||
let type_map = self
|
|
||||||
.source_accounts
|
|
||||||
.entry(source_type.to_string())
|
|
||||||
.or_default();
|
|
||||||
for account in accounts {
|
|
||||||
type_map.insert(account.id.clone(), account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_dest_accounts(&mut self, dest_type: &str, accounts: Vec<Account>) {
|
|
||||||
let type_map = self.dest_accounts.entry(dest_type.to_string()).or_default();
|
|
||||||
for account in accounts {
|
|
||||||
type_map.insert(account.id.clone(), account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn auto_link_accounts(
|
|
||||||
source_accounts: &[Account],
|
|
||||||
dest_accounts: &[Account],
|
|
||||||
) -> Vec<(usize, usize)> {
|
|
||||||
let mut links = Vec::new();
|
|
||||||
for (i, source) in source_accounts.iter().enumerate() {
|
|
||||||
for (j, dest) in dest_accounts.iter().enumerate() {
|
|
||||||
if source.iban == dest.iban && !source.iban.is_empty() {
|
|
||||||
links.push((i, j));
|
|
||||||
break; // First match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Could add name similarity matching here
|
|
||||||
links
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
pub mod adapters;
|
|
||||||
pub mod linking;
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::Serialize;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ impl fmt::Debug for BankTransaction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub iban: String,
|
pub iban: String,
|
||||||
@@ -116,40 +115,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct AccountSummary {
|
|
||||||
pub id: String,
|
|
||||||
pub iban: String,
|
|
||||||
pub currency: String,
|
|
||||||
pub status: String, // e.g., "active", "expired", "linked"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct AccountStatus {
|
|
||||||
pub account_id: String,
|
|
||||||
pub iban: String,
|
|
||||||
pub last_sync_date: Option<NaiveDate>,
|
|
||||||
pub transaction_count: usize,
|
|
||||||
pub status: String, // e.g., "synced", "pending", "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct TransactionInfo {
|
|
||||||
pub account_id: String,
|
|
||||||
pub total_count: usize,
|
|
||||||
pub date_range: Option<(NaiveDate, NaiveDate)>,
|
|
||||||
pub last_updated: Option<NaiveDate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
pub struct CacheInfo {
|
|
||||||
pub account_id: Option<String>, // None for global, Some for per-account
|
|
||||||
pub cache_type: String, // e.g., "account", "transaction"
|
|
||||||
pub entry_count: usize,
|
|
||||||
pub total_size_bytes: usize,
|
|
||||||
pub last_updated: Option<NaiveDate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum SyncError {
|
pub enum SyncError {
|
||||||
#[error("End User Agreement {agreement_id} has expired")]
|
#[error("End User Agreement {agreement_id} has expired")]
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
use crate::core::models::{
|
use crate::core::models::{Account, BankTransaction};
|
||||||
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
@@ -26,15 +24,6 @@ pub trait TransactionSource: Send + Sync {
|
|||||||
start: NaiveDate,
|
start: NaiveDate,
|
||||||
end: NaiveDate,
|
end: NaiveDate,
|
||||||
) -> Result<Vec<BankTransaction>>;
|
) -> Result<Vec<BankTransaction>>;
|
||||||
|
|
||||||
/// Inspection methods for CLI
|
|
||||||
async fn list_accounts(&self) -> Result<Vec<AccountSummary>>;
|
|
||||||
async fn get_account_status(&self) -> Result<Vec<AccountStatus>>;
|
|
||||||
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo>;
|
|
||||||
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>>;
|
|
||||||
|
|
||||||
/// Account discovery for linking
|
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blanket implementation for references
|
// Blanket implementation for references
|
||||||
@@ -52,26 +41,6 @@ impl<T: TransactionSource> TransactionSource for &T {
|
|||||||
) -> Result<Vec<BankTransaction>> {
|
) -> Result<Vec<BankTransaction>> {
|
||||||
(**self).get_transactions(account_id, start, end).await
|
(**self).get_transactions(account_id, start, end).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
|
|
||||||
(**self).list_accounts().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
|
|
||||||
(**self).get_account_status().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
|
|
||||||
(**self).get_transaction_info(account_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>> {
|
|
||||||
(**self).get_cache_info().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
|
||||||
(**self).discover_accounts().await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -83,6 +52,7 @@ pub struct TransactionMatch {
|
|||||||
#[cfg_attr(test, automock)]
|
#[cfg_attr(test, automock)]
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TransactionDestination: Send + Sync {
|
pub trait TransactionDestination: Send + Sync {
|
||||||
|
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>>;
|
||||||
/// Get list of all active asset account IBANs to drive the sync
|
/// Get list of all active asset account IBANs to drive the sync
|
||||||
async fn get_active_account_ibans(&self) -> Result<Vec<String>>;
|
async fn get_active_account_ibans(&self) -> Result<Vec<String>>;
|
||||||
|
|
||||||
@@ -95,14 +65,15 @@ pub trait TransactionDestination: Send + Sync {
|
|||||||
) -> Result<Option<TransactionMatch>>;
|
) -> Result<Option<TransactionMatch>>;
|
||||||
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>;
|
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>;
|
||||||
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>;
|
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>;
|
||||||
|
|
||||||
/// Account discovery for linking
|
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blanket implementation for references
|
// Blanket implementation for references
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<T: TransactionDestination> TransactionDestination for &T {
|
impl<T: TransactionDestination> TransactionDestination for &T {
|
||||||
|
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>> {
|
||||||
|
(**self).resolve_account_id(iban).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
||||||
(**self).get_active_account_ibans().await
|
(**self).get_active_account_ibans().await
|
||||||
}
|
}
|
||||||
@@ -128,8 +99,4 @@ impl<T: TransactionDestination> TransactionDestination for &T {
|
|||||||
.update_transaction_external_id(id, external_id)
|
.update_transaction_external_id(id, external_id)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
|
||||||
(**self).discover_accounts().await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::core::linking::{auto_link_accounts, LinkStore};
|
|
||||||
use crate::core::models::{Account, SyncError};
|
use crate::core::models::{Account, SyncError};
|
||||||
use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource};
|
use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -39,29 +38,6 @@ pub async fn run_sync(
|
|||||||
.map_err(SyncError::SourceError)?;
|
.map_err(SyncError::SourceError)?;
|
||||||
info!("Found {} accounts from source", accounts.len());
|
info!("Found {} accounts from source", accounts.len());
|
||||||
|
|
||||||
// Discover all accounts and update linking
|
|
||||||
let all_source_accounts = source
|
|
||||||
.discover_accounts()
|
|
||||||
.await
|
|
||||||
.map_err(SyncError::SourceError)?;
|
|
||||||
let all_dest_accounts = destination
|
|
||||||
.discover_accounts()
|
|
||||||
.await
|
|
||||||
.map_err(SyncError::DestinationError)?;
|
|
||||||
|
|
||||||
let mut link_store = LinkStore::load();
|
|
||||||
link_store.update_source_accounts("gocardless", all_source_accounts.clone());
|
|
||||||
link_store.update_dest_accounts("firefly", all_dest_accounts.clone());
|
|
||||||
|
|
||||||
// Auto-link accounts
|
|
||||||
let links = auto_link_accounts(&all_source_accounts, &all_dest_accounts);
|
|
||||||
for (src_idx, dest_idx) in links {
|
|
||||||
let src = &all_source_accounts[src_idx];
|
|
||||||
let dest = &all_dest_accounts[dest_idx];
|
|
||||||
link_store.add_link(src, dest, true);
|
|
||||||
}
|
|
||||||
link_store.save().map_err(SyncError::SourceError)?;
|
|
||||||
|
|
||||||
// Default end date is Yesterday
|
// Default end date is Yesterday
|
||||||
let end_date =
|
let end_date =
|
||||||
cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1));
|
cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1));
|
||||||
@@ -79,7 +55,6 @@ pub async fn run_sync(
|
|||||||
&source,
|
&source,
|
||||||
&destination,
|
&destination,
|
||||||
&account,
|
&account,
|
||||||
&link_store,
|
|
||||||
cli_start_date,
|
cli_start_date,
|
||||||
end_date,
|
end_date,
|
||||||
dry_run,
|
dry_run,
|
||||||
@@ -131,19 +106,20 @@ async fn process_single_account(
|
|||||||
source: &impl TransactionSource,
|
source: &impl TransactionSource,
|
||||||
destination: &impl TransactionDestination,
|
destination: &impl TransactionDestination,
|
||||||
account: &Account,
|
account: &Account,
|
||||||
link_store: &LinkStore,
|
|
||||||
cli_start_date: Option<NaiveDate>,
|
cli_start_date: Option<NaiveDate>,
|
||||||
end_date: NaiveDate,
|
end_date: NaiveDate,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<IngestResult, SyncError> {
|
) -> Result<IngestResult, SyncError> {
|
||||||
let link_opt = link_store.find_link_by_source(&account.id);
|
let dest_id_opt = destination
|
||||||
let Some(link) = link_opt else {
|
.resolve_account_id(&account.iban)
|
||||||
|
.await
|
||||||
|
.map_err(SyncError::DestinationError)?;
|
||||||
|
let Some(dest_id) = dest_id_opt else {
|
||||||
return Err(SyncError::AccountSkipped {
|
return Err(SyncError::AccountSkipped {
|
||||||
account_id: account.id.clone(),
|
account_id: account.id.clone(),
|
||||||
reason: "No link found to destination account".to_string(),
|
reason: "Not found in destination".to_string(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
let dest_id = link.dest_account_id.clone();
|
|
||||||
|
|
||||||
info!("Resolved destination ID: {}", dest_id);
|
info!("Resolved destination ID: {}", dest_id);
|
||||||
|
|
||||||
@@ -289,14 +265,6 @@ mod tests {
|
|||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
|
|
||||||
source.expect_discover_accounts().returning(|| {
|
|
||||||
Ok(vec![Account {
|
|
||||||
id: "src_1".to_string(),
|
|
||||||
iban: "NL01".to_string(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
|
|
||||||
let tx = BankTransaction {
|
let tx = BankTransaction {
|
||||||
internal_id: "tx1".into(),
|
internal_id: "tx1".into(),
|
||||||
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
||||||
@@ -318,13 +286,8 @@ mod tests {
|
|||||||
dest.expect_get_active_account_ibans()
|
dest.expect_get_active_account_ibans()
|
||||||
.returning(|| Ok(vec!["NL01".to_string()]));
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
dest.expect_discover_accounts().returning(|| {
|
dest.expect_resolve_account_id()
|
||||||
Ok(vec![Account {
|
.returning(|_| Ok(Some("dest_1".into())));
|
||||||
id: "dest_1".to_string(),
|
|
||||||
iban: "NL01".to_string(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
|
|
||||||
dest.expect_get_last_transaction_date()
|
dest.expect_get_last_transaction_date()
|
||||||
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||||
@@ -353,14 +316,6 @@ mod tests {
|
|||||||
dest.expect_get_active_account_ibans()
|
dest.expect_get_active_account_ibans()
|
||||||
.returning(|| Ok(vec!["NL01".to_string()]));
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
dest.expect_discover_accounts().returning(|| {
|
|
||||||
Ok(vec![Account {
|
|
||||||
id: "dest_1".to_string(),
|
|
||||||
iban: "NL01".to_string(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
|
|
||||||
source.expect_get_accounts().with(always()).returning(|_| {
|
source.expect_get_accounts().with(always()).returning(|_| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
@@ -369,14 +324,6 @@ mod tests {
|
|||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
|
|
||||||
source.expect_discover_accounts().returning(|| {
|
|
||||||
Ok(vec![Account {
|
|
||||||
id: "src_1".to_string(),
|
|
||||||
iban: "NL01".to_string(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
|
|
||||||
source.expect_get_transactions().returning(|_, _, _| {
|
source.expect_get_transactions().returning(|_, _, _| {
|
||||||
Ok(vec![BankTransaction {
|
Ok(vec![BankTransaction {
|
||||||
internal_id: "tx1".into(),
|
internal_id: "tx1".into(),
|
||||||
@@ -391,6 +338,8 @@ mod tests {
|
|||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dest.expect_resolve_account_id()
|
||||||
|
.returning(|_| Ok(Some("dest_1".into())));
|
||||||
dest.expect_get_last_transaction_date()
|
dest.expect_get_last_transaction_date()
|
||||||
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||||
|
|
||||||
@@ -420,14 +369,6 @@ mod tests {
|
|||||||
dest.expect_get_active_account_ibans()
|
dest.expect_get_active_account_ibans()
|
||||||
.returning(|| Ok(vec!["NL01".to_string()]));
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
dest.expect_discover_accounts().returning(|| {
|
|
||||||
Ok(vec![Account {
|
|
||||||
id: "dest_1".to_string(),
|
|
||||||
iban: "NL01".to_string(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
|
|
||||||
source.expect_get_accounts().with(always()).returning(|_| {
|
source.expect_get_accounts().with(always()).returning(|_| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
@@ -436,14 +377,6 @@ mod tests {
|
|||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
|
|
||||||
source.expect_discover_accounts().returning(|| {
|
|
||||||
Ok(vec![Account {
|
|
||||||
id: "src_1".to_string(),
|
|
||||||
iban: "NL01".to_string(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
}])
|
|
||||||
});
|
|
||||||
|
|
||||||
let tx = BankTransaction {
|
let tx = BankTransaction {
|
||||||
internal_id: "tx1".into(),
|
internal_id: "tx1".into(),
|
||||||
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
||||||
@@ -460,6 +393,8 @@ mod tests {
|
|||||||
.expect_get_transactions()
|
.expect_get_transactions()
|
||||||
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
||||||
|
|
||||||
|
dest.expect_resolve_account_id()
|
||||||
|
.returning(|_| Ok(Some("dest_1".into())));
|
||||||
dest.expect_get_last_transaction_date()
|
dest.expect_get_last_transaction_date()
|
||||||
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
mod adapters;
|
mod adapters;
|
||||||
mod cli;
|
|
||||||
mod core;
|
mod core;
|
||||||
mod debug;
|
mod debug;
|
||||||
|
|
||||||
use crate::cli::formatters::{print_list_output, OutputFormat};
|
use crate::adapters::firefly::client::FireflyAdapter;
|
||||||
use crate::cli::setup::AppContext;
|
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
||||||
use crate::core::adapters::{
|
|
||||||
get_available_destinations, get_available_sources, is_valid_destination, is_valid_source,
|
|
||||||
};
|
|
||||||
use crate::core::linking::LinkStore;
|
|
||||||
use crate::core::ports::TransactionSource;
|
|
||||||
use crate::core::sync::run_sync;
|
use crate::core::sync::run_sync;
|
||||||
|
use crate::debug::DebugLogger;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::Parser;
|
||||||
|
use firefly_client::client::FireflyClient;
|
||||||
|
use gocardless_client::client::GoCardlessClient;
|
||||||
|
use reqwest_middleware::ClientBuilder;
|
||||||
|
use std::env;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -22,6 +21,14 @@ struct Args {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
config: Option<String>,
|
config: Option<String>,
|
||||||
|
|
||||||
|
/// Start date for synchronization (YYYY-MM-DD). Defaults to last transaction date + 1.
|
||||||
|
#[arg(short, long)]
|
||||||
|
start: Option<NaiveDate>,
|
||||||
|
|
||||||
|
/// End date for synchronization (YYYY-MM-DD). Defaults to yesterday.
|
||||||
|
#[arg(short, long)]
|
||||||
|
end: Option<NaiveDate>,
|
||||||
|
|
||||||
/// Dry run mode: Do not create or update transactions in Firefly III.
|
/// Dry run mode: Do not create or update transactions in Firefly III.
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
@@ -29,202 +36,59 @@ struct Args {
|
|||||||
/// Enable debug logging of HTTP requests/responses to ./debug_logs/
|
/// Enable debug logging of HTTP requests/responses to ./debug_logs/
|
||||||
#[arg(long, default_value_t = false)]
|
#[arg(long, default_value_t = false)]
|
||||||
debug: bool,
|
debug: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Commands,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
enum Commands {
|
|
||||||
/// Synchronize transactions between source and destination
|
|
||||||
Sync {
|
|
||||||
/// Source type (gocardless, csv, camt053, mt940)
|
|
||||||
source: String,
|
|
||||||
/// Destination type (firefly)
|
|
||||||
destination: String,
|
|
||||||
/// Start date for synchronization (YYYY-MM-DD)
|
|
||||||
#[arg(short, long)]
|
|
||||||
start: Option<NaiveDate>,
|
|
||||||
/// End date for synchronization (YYYY-MM-DD)
|
|
||||||
#[arg(short, long)]
|
|
||||||
end: Option<NaiveDate>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Manage accounts and linking
|
|
||||||
Accounts {
|
|
||||||
#[command(subcommand)]
|
|
||||||
subcommand: AccountCommands,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Manage transactions and cache
|
|
||||||
Transactions {
|
|
||||||
#[command(subcommand)]
|
|
||||||
subcommand: TransactionCommands,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// List all available source types
|
|
||||||
Sources,
|
|
||||||
/// List all available destination types
|
|
||||||
Destinations,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
enum LinkCommands {
|
|
||||||
/// List all account links
|
|
||||||
List,
|
|
||||||
/// Create a new account link
|
|
||||||
Create {
|
|
||||||
/// Source account identifier (ID, IBAN, or name)
|
|
||||||
source_account: String,
|
|
||||||
/// Destination account identifier (ID, IBAN, or name)
|
|
||||||
dest_account: String,
|
|
||||||
},
|
|
||||||
/// Delete an account link
|
|
||||||
Delete {
|
|
||||||
/// Link ID
|
|
||||||
link_id: String,
|
|
||||||
},
|
|
||||||
/// Set or update alias for a link
|
|
||||||
Alias {
|
|
||||||
/// Link ID
|
|
||||||
link_id: String,
|
|
||||||
/// Alias name
|
|
||||||
alias: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
enum AccountCommands {
|
|
||||||
/// Manage account links between sources and destinations
|
|
||||||
Link {
|
|
||||||
#[command(subcommand)]
|
|
||||||
subcommand: LinkCommands,
|
|
||||||
},
|
|
||||||
/// List all accounts
|
|
||||||
List,
|
|
||||||
/// Show account status
|
|
||||||
Status,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
enum TransactionCommands {
|
|
||||||
/// List transactions for an account
|
|
||||||
List {
|
|
||||||
/// Account ID to list transactions for
|
|
||||||
account_id: String,
|
|
||||||
},
|
|
||||||
/// Show cache status
|
|
||||||
CacheStatus,
|
|
||||||
/// Clear transaction cache
|
|
||||||
ClearCache,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
// Load environment variables first
|
// Initialize logging
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
// Initialize logging based on command type
|
|
||||||
// For sync command, show INFO logs by default (but allow RUST_LOG override)
|
|
||||||
// For other commands, only show warnings/errors by default (but allow RUST_LOG override)
|
|
||||||
let default_level = match args.command {
|
|
||||||
Commands::Sync { .. } => "info",
|
|
||||||
_ => "warn",
|
|
||||||
};
|
|
||||||
|
|
||||||
let log_level = std::env::var("RUST_LOG")
|
|
||||||
.map(|s| {
|
|
||||||
s.parse()
|
|
||||||
.unwrap_or(tracing_subscriber::EnvFilter::new(default_level))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
|
|
||||||
|
|
||||||
tracing_subscriber::fmt().with_env_filter(log_level).init();
|
|
||||||
|
|
||||||
info!("Starting banks2ff...");
|
info!("Starting banks2ff...");
|
||||||
if args.dry_run {
|
if args.dry_run {
|
||||||
info!("DRY RUN MODE ENABLED: No changes will be made to Firefly III.");
|
info!("DRY RUN MODE ENABLED: No changes will be made to Firefly III.");
|
||||||
}
|
}
|
||||||
|
|
||||||
match args.command {
|
// Config Load
|
||||||
Commands::Sync {
|
let gc_url = env::var("GOCARDLESS_URL")
|
||||||
source,
|
.unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string());
|
||||||
destination,
|
let gc_id = env::var("GOCARDLESS_ID").expect("GOCARDLESS_ID not set");
|
||||||
start,
|
let gc_key = env::var("GOCARDLESS_KEY").expect("GOCARDLESS_KEY not set");
|
||||||
end,
|
|
||||||
} => {
|
|
||||||
handle_sync(args.debug, source, destination, start, end, args.dry_run).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Commands::Sources => {
|
let ff_url = env::var("FIREFLY_III_URL").expect("FIREFLY_III_URL not set");
|
||||||
handle_sources().await?;
|
let ff_key = env::var("FIREFLY_III_API_KEY").expect("FIREFLY_III_API_KEY not set");
|
||||||
}
|
|
||||||
Commands::Destinations => {
|
|
||||||
handle_destinations().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Commands::Accounts { subcommand } => {
|
// Clients
|
||||||
handle_accounts(subcommand).await?;
|
let gc_client = if args.debug {
|
||||||
}
|
let client = ClientBuilder::new(reqwest::Client::new())
|
||||||
|
.with(DebugLogger::new("gocardless"))
|
||||||
|
.build();
|
||||||
|
GoCardlessClient::with_client(&gc_url, &gc_id, &gc_key, Some(client))?
|
||||||
|
} else {
|
||||||
|
GoCardlessClient::new(&gc_url, &gc_id, &gc_key)?
|
||||||
|
};
|
||||||
|
|
||||||
Commands::Transactions { subcommand } => {
|
let ff_client = if args.debug {
|
||||||
handle_transactions(subcommand).await?;
|
let client = ClientBuilder::new(reqwest::Client::new())
|
||||||
}
|
.with(DebugLogger::new("firefly"))
|
||||||
}
|
.build();
|
||||||
|
FireflyClient::with_client(&ff_url, &ff_key, Some(client))?
|
||||||
|
} else {
|
||||||
|
FireflyClient::new(&ff_url, &ff_key)?
|
||||||
|
};
|
||||||
|
|
||||||
Ok(())
|
// Adapters
|
||||||
}
|
let source = GoCardlessAdapter::new(gc_client);
|
||||||
|
let destination = FireflyAdapter::new(ff_client);
|
||||||
|
|
||||||
async fn handle_sync(
|
// Run
|
||||||
debug: bool,
|
match run_sync(source, destination, args.start, args.end, args.dry_run).await {
|
||||||
source: String,
|
|
||||||
destination: String,
|
|
||||||
start: Option<NaiveDate>,
|
|
||||||
end: Option<NaiveDate>,
|
|
||||||
dry_run: bool,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
// Validate source
|
|
||||||
if !is_valid_source(&source) {
|
|
||||||
let available = get_available_sources()
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.id)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
anyhow::bail!(
|
|
||||||
"Unknown source '{}'. Available sources: {}",
|
|
||||||
source,
|
|
||||||
available
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate destination
|
|
||||||
if !is_valid_destination(&destination) {
|
|
||||||
let available = get_available_destinations()
|
|
||||||
.iter()
|
|
||||||
.map(|d| d.id)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
anyhow::bail!(
|
|
||||||
"Unknown destination '{}'. Available destinations: {}",
|
|
||||||
destination,
|
|
||||||
available
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, only support gocardless -> firefly
|
|
||||||
if source != "gocardless" {
|
|
||||||
anyhow::bail!("Only 'gocardless' source is currently supported (implementation pending)");
|
|
||||||
}
|
|
||||||
if destination != "firefly" {
|
|
||||||
anyhow::bail!("Only 'firefly' destination is currently supported (implementation pending)");
|
|
||||||
}
|
|
||||||
|
|
||||||
let context = AppContext::new(debug).await?;
|
|
||||||
|
|
||||||
// Run sync
|
|
||||||
match run_sync(context.source, context.destination, start, end, dry_run).await {
|
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
info!("Sync completed successfully.");
|
info!("Sync completed successfully.");
|
||||||
info!(
|
info!(
|
||||||
@@ -246,161 +110,3 @@ async fn handle_sync(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_sources() -> anyhow::Result<()> {
|
|
||||||
println!("Available sources:");
|
|
||||||
for source in get_available_sources() {
|
|
||||||
println!(" {} - {}", source.id, source.description);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_destinations() -> anyhow::Result<()> {
|
|
||||||
println!("Available destinations:");
|
|
||||||
for destination in get_available_destinations() {
|
|
||||||
println!(" {} - {}", destination.id, destination.description);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_accounts(subcommand: AccountCommands) -> anyhow::Result<()> {
|
|
||||||
let context = AppContext::new(false).await?;
|
|
||||||
let format = OutputFormat::Table; // TODO: Add --json flag
|
|
||||||
|
|
||||||
match subcommand {
|
|
||||||
AccountCommands::Link {
|
|
||||||
subcommand: link_sub,
|
|
||||||
} => {
|
|
||||||
handle_link(link_sub).await?;
|
|
||||||
}
|
|
||||||
AccountCommands::List => {
|
|
||||||
let accounts = context.source.list_accounts().await?;
|
|
||||||
if accounts.is_empty() {
|
|
||||||
println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
|
|
||||||
} else {
|
|
||||||
print_list_output(accounts, &format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AccountCommands::Status => {
|
|
||||||
let status = context.source.get_account_status().await?;
|
|
||||||
if status.is_empty() {
|
|
||||||
println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data.");
|
|
||||||
} else {
|
|
||||||
print_list_output(status, &format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_transactions(subcommand: TransactionCommands) -> anyhow::Result<()> {
|
|
||||||
let context = AppContext::new(false).await?;
|
|
||||||
let format = OutputFormat::Table; // TODO: Add --json flag
|
|
||||||
|
|
||||||
match subcommand {
|
|
||||||
TransactionCommands::List { account_id } => {
|
|
||||||
let info = context.source.get_transaction_info(&account_id).await?;
|
|
||||||
if info.total_count == 0 {
|
|
||||||
println!("No transaction data found for account {}. Run 'banks2ff sync gocardless firefly' first to sync transactions.", account_id);
|
|
||||||
} else {
|
|
||||||
print_list_output(vec![info], &format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TransactionCommands::CacheStatus => {
|
|
||||||
let cache_info = context.source.get_cache_info().await?;
|
|
||||||
if cache_info.is_empty() {
|
|
||||||
println!("No cache data available. Run 'banks2ff sync gocardless firefly' first to populate caches.");
|
|
||||||
} else {
|
|
||||||
print_list_output(cache_info, &format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TransactionCommands::ClearCache => {
|
|
||||||
// TODO: Implement cache clearing
|
|
||||||
println!("Cache clearing not yet implemented");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
|
|
||||||
let mut link_store = LinkStore::load();
|
|
||||||
|
|
||||||
match subcommand {
|
|
||||||
LinkCommands::List => {
|
|
||||||
if link_store.links.is_empty() {
|
|
||||||
println!("No account links found.");
|
|
||||||
} else {
|
|
||||||
println!("Account Links:");
|
|
||||||
for link in &link_store.links {
|
|
||||||
let source_acc = link_store
|
|
||||||
.source_accounts
|
|
||||||
.get("gocardless")
|
|
||||||
.and_then(|m| m.get(&link.source_account_id));
|
|
||||||
let dest_acc = link_store
|
|
||||||
.dest_accounts
|
|
||||||
.get("firefly")
|
|
||||||
.and_then(|m| m.get(&link.dest_account_id));
|
|
||||||
let source_name = source_acc
|
|
||||||
.map(|a| format!("{} ({})", a.iban, a.id))
|
|
||||||
.unwrap_or_else(|| link.source_account_id.clone());
|
|
||||||
let dest_name = dest_acc
|
|
||||||
.map(|a| format!("{} ({})", a.iban, a.id))
|
|
||||||
.unwrap_or_else(|| link.dest_account_id.clone());
|
|
||||||
let alias_info = link
|
|
||||||
.alias
|
|
||||||
.as_ref()
|
|
||||||
.map(|a| format!(" [alias: {}]", a))
|
|
||||||
.unwrap_or_default();
|
|
||||||
println!(
|
|
||||||
" {}: {} ↔ {}{}",
|
|
||||||
link.id, source_name, dest_name, alias_info
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LinkCommands::Create {
|
|
||||||
source_account,
|
|
||||||
dest_account,
|
|
||||||
} => {
|
|
||||||
// Assume source_account is gocardless id, dest_account is firefly id
|
|
||||||
let source_acc = link_store
|
|
||||||
.source_accounts
|
|
||||||
.get("gocardless")
|
|
||||||
.and_then(|m| m.get(&source_account))
|
|
||||||
.cloned();
|
|
||||||
let dest_acc = link_store
|
|
||||||
.dest_accounts
|
|
||||||
.get("firefly")
|
|
||||||
.and_then(|m| m.get(&dest_account))
|
|
||||||
.cloned();
|
|
||||||
|
|
||||||
if let (Some(src), Some(dst)) = (source_acc, dest_acc) {
|
|
||||||
let link_id = link_store.add_link(&src, &dst, false);
|
|
||||||
link_store.save()?;
|
|
||||||
println!(
|
|
||||||
"Created link {} between {} and {}",
|
|
||||||
link_id, src.iban, dst.iban
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!("Account not found. Ensure accounts are discovered via sync first.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LinkCommands::Delete { link_id } => {
|
|
||||||
if link_store.remove_link(&link_id).is_ok() {
|
|
||||||
link_store.save()?;
|
|
||||||
println!("Deleted link {}", link_id);
|
|
||||||
} else {
|
|
||||||
println!("Link {} not found", link_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LinkCommands::Alias { link_id, alias } => {
|
|
||||||
if link_store.set_alias(&link_id, alias.clone()).is_ok() {
|
|
||||||
link_store.save()?;
|
|
||||||
println!("Set alias '{}' for link {}", alias, link_id);
|
|
||||||
} else {
|
|
||||||
println!("Link {} not found", link_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
@@ -59,10 +59,24 @@ pub struct TransactionBookedPending {
|
|||||||
pub struct Transaction {
|
pub struct Transaction {
|
||||||
#[serde(rename = "transactionId")]
|
#[serde(rename = "transactionId")]
|
||||||
pub transaction_id: Option<String>,
|
pub transaction_id: Option<String>,
|
||||||
|
#[serde(rename = "entryReference")]
|
||||||
|
pub entry_reference: Option<String>,
|
||||||
|
#[serde(rename = "endToEndId")]
|
||||||
|
pub end_to_end_id: Option<String>,
|
||||||
|
#[serde(rename = "mandateId")]
|
||||||
|
pub mandate_id: Option<String>,
|
||||||
|
#[serde(rename = "checkId")]
|
||||||
|
pub check_id: Option<String>,
|
||||||
|
#[serde(rename = "creditorId")]
|
||||||
|
pub creditor_id: Option<String>,
|
||||||
#[serde(rename = "bookingDate")]
|
#[serde(rename = "bookingDate")]
|
||||||
pub booking_date: Option<String>,
|
pub booking_date: Option<String>,
|
||||||
#[serde(rename = "valueDate")]
|
#[serde(rename = "valueDate")]
|
||||||
pub value_date: Option<String>,
|
pub value_date: Option<String>,
|
||||||
|
#[serde(rename = "bookingDateTime")]
|
||||||
|
pub booking_date_time: Option<String>,
|
||||||
|
#[serde(rename = "valueDateTime")]
|
||||||
|
pub value_date_time: Option<String>,
|
||||||
#[serde(rename = "transactionAmount")]
|
#[serde(rename = "transactionAmount")]
|
||||||
pub transaction_amount: TransactionAmount,
|
pub transaction_amount: TransactionAmount,
|
||||||
#[serde(rename = "currencyExchange")]
|
#[serde(rename = "currencyExchange")]
|
||||||
@@ -71,14 +85,32 @@ pub struct Transaction {
|
|||||||
pub creditor_name: Option<String>,
|
pub creditor_name: Option<String>,
|
||||||
#[serde(rename = "creditorAccount")]
|
#[serde(rename = "creditorAccount")]
|
||||||
pub creditor_account: Option<AccountDetails>,
|
pub creditor_account: Option<AccountDetails>,
|
||||||
|
#[serde(rename = "ultimateCreditor")]
|
||||||
|
pub ultimate_creditor: Option<String>,
|
||||||
#[serde(rename = "debtorName")]
|
#[serde(rename = "debtorName")]
|
||||||
pub debtor_name: Option<String>,
|
pub debtor_name: Option<String>,
|
||||||
#[serde(rename = "debtorAccount")]
|
#[serde(rename = "debtorAccount")]
|
||||||
pub debtor_account: Option<AccountDetails>,
|
pub debtor_account: Option<AccountDetails>,
|
||||||
|
#[serde(rename = "ultimateDebtor")]
|
||||||
|
pub ultimate_debtor: Option<String>,
|
||||||
#[serde(rename = "remittanceInformationUnstructured")]
|
#[serde(rename = "remittanceInformationUnstructured")]
|
||||||
pub remittance_information_unstructured: Option<String>,
|
pub remittance_information_unstructured: Option<String>,
|
||||||
|
#[serde(rename = "remittanceInformationUnstructuredArray")]
|
||||||
|
pub remittance_information_unstructured_array: Option<Vec<String>>,
|
||||||
|
#[serde(rename = "remittanceInformationStructured")]
|
||||||
|
pub remittance_information_structured: Option<String>,
|
||||||
|
#[serde(rename = "remittanceInformationStructuredArray")]
|
||||||
|
pub remittance_information_structured_array: Option<Vec<String>>,
|
||||||
|
#[serde(rename = "additionalInformation")]
|
||||||
|
pub additional_information: Option<String>,
|
||||||
|
#[serde(rename = "purposeCode")]
|
||||||
|
pub purpose_code: Option<String>,
|
||||||
|
#[serde(rename = "bankTransactionCode")]
|
||||||
|
pub bank_transaction_code: Option<String>,
|
||||||
#[serde(rename = "proprietaryBankTransactionCode")]
|
#[serde(rename = "proprietaryBankTransactionCode")]
|
||||||
pub proprietary_bank_transaction_code: Option<String>,
|
pub proprietary_bank_transaction_code: Option<String>,
|
||||||
|
#[serde(rename = "internalTransactionId")]
|
||||||
|
pub internal_transaction_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -102,4 +134,10 @@ pub struct CurrencyExchange {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AccountDetails {
|
pub struct AccountDetails {
|
||||||
pub iban: Option<String>,
|
pub iban: Option<String>,
|
||||||
|
pub bban: Option<String>,
|
||||||
|
pub pan: Option<String>,
|
||||||
|
#[serde(rename = "maskedPan")]
|
||||||
|
pub masked_pan: Option<String>,
|
||||||
|
pub msisdn: Option<String>,
|
||||||
|
pub currency: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
# CLI Refactor Plan: Decoupling for Multi-Source Financial Sync
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines a phased plan to refactor the `banks2ff` CLI from a tightly coupled, single-purpose sync tool into a modular, multi-source financial synchronization application. The refactor maintains the existing hexagonal architecture while enabling inspection of accounts, transactions, and sync status, support for multiple data sources (GoCardless, CSV, CAMT.053, MT940), and preparation for web API exposure.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- **Decouple CLI Architecture**: Separate CLI logic from core business logic to enable multiple entry points (CLI, web API)
|
|
||||||
- **Retain Sync Functionality**: Keep existing sync as primary subcommand with backward compatibility
|
|
||||||
- **Add Financial Entity Management**: Enable viewing/managing accounts, transactions, and sync status
|
|
||||||
- **Support Multiple Sources/Destinations**: Implement pluggable adapters for different data sources and destinations
|
|
||||||
- **Prepare for Web API**: Ensure core logic returns serializable data structures
|
|
||||||
- **Maintain Security**: Preserve financial data masking and compliance protocols
|
|
||||||
- **Follow Best Practices**: Adhere to Rust idioms, error handling, testing, and project guidelines
|
|
||||||
|
|
||||||
## Revised CLI Structure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
banks2ff [OPTIONS] <COMMAND>
|
|
||||||
|
|
||||||
OPTIONS:
|
|
||||||
--config <FILE> Path to config file
|
|
||||||
--dry-run Preview changes without applying
|
|
||||||
--debug Enable debug logging (advanced users)
|
|
||||||
|
|
||||||
COMMANDS:
|
|
||||||
sync <SOURCE> <DESTINATION> [OPTIONS]
|
|
||||||
Synchronize transactions between source and destination
|
|
||||||
--start <DATE> Start date (YYYY-MM-DD)
|
|
||||||
--end <DATE> End date (YYYY-MM-DD)
|
|
||||||
|
|
||||||
sources List all available source types
|
|
||||||
destinations List all available destination types
|
|
||||||
|
|
||||||
help Show help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: CLI Structure Refactor ✅ COMPLETED
|
|
||||||
|
|
||||||
**Objective**: Establish new subcommand architecture while preserving existing sync functionality.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. ✅ Refactor `main.rs` to use `clap::Subcommand` with nested enums for commands and subcommands
|
|
||||||
2. ✅ Extract environment loading and client initialization into a `cli::setup` module
|
|
||||||
3. ✅ Update argument parsing to handle source/destination as positional arguments
|
|
||||||
4. ✅ Implement basic command dispatch logic with placeholder handlers
|
|
||||||
5. ✅ Ensure backward compatibility for existing sync usage
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- ✅ Unit tests for new CLI argument parsing
|
|
||||||
- ✅ Integration tests verifying existing sync command works unchanged
|
|
||||||
- ✅ Mock tests for new subcommand structure
|
|
||||||
|
|
||||||
**Implementation Details:**
|
|
||||||
- Created `cli/` module with `setup.rs` containing `AppContext` for client initialization
|
|
||||||
- Implemented subcommand structure: `sync`, `accounts`, `transactions`, `status`, `sources`, `destinations`
|
|
||||||
- Added dynamic adapter registry in `core::adapters.rs` for discoverability and validation
|
|
||||||
- Implemented comprehensive input validation with helpful error messages
|
|
||||||
- Added conditional logging (INFO for sync, WARN for interactive commands)
|
|
||||||
- All placeholder commands log appropriate messages for future implementation
|
|
||||||
- Maintained all existing sync functionality and flags
|
|
||||||
|
|
||||||
### Phase 2: Core Port Extensions ✅ COMPLETED
|
|
||||||
|
|
||||||
**Objective**: Extend ports and adapters to support inspection capabilities.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. ✅ Add inspection methods to `TransactionSource` and `TransactionDestination` traits:
|
|
||||||
- `list_accounts()`: Return account summaries
|
|
||||||
- `get_account_status()`: Return sync status for accounts
|
|
||||||
- `get_transaction_info()`: Return transaction metadata
|
|
||||||
- `get_cache_info()`: Return caching status
|
|
||||||
2. ✅ Update existing adapters (GoCardless, Firefly) to implement new methods
|
|
||||||
3. ✅ Define serializable response structs in `core::models` for inspection data
|
|
||||||
4. ✅ Ensure all new methods handle errors gracefully with `anyhow`
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Unit tests for trait implementations on existing adapters
|
|
||||||
- Mock tests for new inspection methods
|
|
||||||
- Integration tests verifying data serialization
|
|
||||||
|
|
||||||
**Implementation Details:**
|
|
||||||
- Added `AccountSummary`, `AccountStatus`, `TransactionInfo`, and `CacheInfo` structs with `Serialize` and `Debug` traits
|
|
||||||
- Extended both `TransactionSource` and `TransactionDestination` traits with inspection methods
|
|
||||||
- Implemented methods in `GoCardlessAdapter` using existing client calls and cache data
|
|
||||||
- Implemented methods in `FireflyAdapter` using existing client calls
|
|
||||||
- All code formatted with `cargo fmt` and linted with `cargo clippy`
|
|
||||||
- Existing tests pass; new methods compile but not yet tested due to CLI not implemented
|
|
||||||
|
|
||||||
### Phase 3: Account Linking and Management ✅ COMPLETED
|
|
||||||
|
|
||||||
**Objective**: Implement comprehensive account linking between sources and destinations to enable reliable sync, with auto-linking where possible and manual overrides.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. ✅ Create `core::linking` module with data structures:
|
|
||||||
- `AccountLink`: Links source account ID to destination account ID with metadata
|
|
||||||
- `LinkStore`: Persistent storage for links, aliases, and account registries
|
|
||||||
- Auto-linking logic (IBAN/name similarity scoring)
|
|
||||||
|
|
||||||
2. ✅ Extend adapters with account discovery:
|
|
||||||
- `TransactionSource::discover_accounts()`: Full account list without filtering
|
|
||||||
- `TransactionDestination::discover_accounts()`: Full account list
|
|
||||||
|
|
||||||
3. ✅ Implement linking management:
|
|
||||||
- Auto-link on sync/account discovery (IBAN/name matches)
|
|
||||||
- CLI commands: `banks2ff accounts link list`, `banks2ff accounts link create <source_account> <dest_account>`, `banks2ff accounts link delete <link_id>`
|
|
||||||
- Alias support: `banks2ff accounts alias set <link_id> <alias>`, `banks2ff accounts alias update <link_id> <new_alias>`
|
|
||||||
|
|
||||||
4. ✅ Integrate with sync:
|
|
||||||
- Always discover accounts during sync and update stores
|
|
||||||
- Use links in `run_sync()` instead of IBAN-only matching
|
|
||||||
- Handle unlinked accounts (skip with warning or prompt for manual linking)
|
|
||||||
|
|
||||||
5. ✅ Update CLI help text:
|
|
||||||
- Explain linking process in `banks2ff accounts --help`
|
|
||||||
- Note that sync auto-discovers and attempts linking
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Unit tests for auto-linking algorithms
|
|
||||||
- Integration tests for various account scenarios (IBAN matches, name matches, no matches)
|
|
||||||
- Persistence tests for link store
|
|
||||||
- CLI tests for link management commands
|
|
||||||
|
|
||||||
**Implementation Details:**
|
|
||||||
- Created `core::linking` with `LinkStore` using nested `HashMap`s for organized storage by adapter type
|
|
||||||
- Extended traits with `discover_accounts()` and implemented in GoCardless/Firefly adapters
|
|
||||||
- Integrated account discovery and auto-linking into `run_sync()` with persistent storage
|
|
||||||
- Added CLI commands under `banks2ff accounts link` with full CRUD operations and alias support
|
|
||||||
- Updated README with new account linking feature, examples, and troubleshooting
|
|
||||||
|
|
||||||
### Phase 4: CLI Output and Formatting ✅ COMPLETED
|
|
||||||
|
|
||||||
**Objective**: Implement user-friendly output for inspection commands.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. ✅ Create `cli::formatters` module for consistent output formatting
|
|
||||||
2. ✅ Implement table-based display for accounts and transactions
|
|
||||||
3. ✅ Add JSON output option for programmatic use
|
|
||||||
4. ✅ Ensure sensitive data masking in all outputs
|
|
||||||
5. Add progress indicators for long-running operations (pending)
|
|
||||||
6. ✅ Implement `accounts` command with `list` and `status` subcommands
|
|
||||||
7. ✅ Implement `transactions` command with `list`, `cache-status`, and `clear-cache` subcommands
|
|
||||||
8. ✅ Add account and transaction inspection methods to adapter traits
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Unit tests for formatter functions
|
|
||||||
- Integration tests for CLI output with sample data
|
|
||||||
- Accessibility tests for output readability
|
|
||||||
- Unit tests for new command implementations
|
|
||||||
- Integration tests for account/transaction inspection
|
|
||||||
|
|
||||||
**Implementation Details:**
|
|
||||||
- Created `cli::formatters` module with `Formattable` trait and table formatting using `comfy-table`
|
|
||||||
- Implemented table display for `AccountSummary`, `AccountStatus`, `TransactionInfo`, and `CacheInfo` structs
|
|
||||||
- Added IBAN masking (showing only last 4 characters) for privacy
|
|
||||||
- Updated CLI structure with new `accounts` and `transactions` commands
|
|
||||||
- Added `print_list_output` function for displaying collections of data
|
|
||||||
- All code formatted with `cargo fmt` and linted with `cargo clippy`
|
|
||||||
|
|
||||||
### Phase 5: Status and Cache Management
|
|
||||||
|
|
||||||
**Objective**: Implement status overview and cache management commands.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Implement `status` command aggregating data from all adapters
|
|
||||||
2. Add cache inspection and clearing functionality to `transactions cache-status` and `transactions clear-cache`
|
|
||||||
3. Create status models for sync health metrics
|
|
||||||
4. Integrate with existing debug logging infrastructure
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Unit tests for status aggregation logic
|
|
||||||
- Integration tests for cache operations
|
|
||||||
- Mock tests for status data collection
|
|
||||||
|
|
||||||
### Phase 6: Sync Logic Updates
|
|
||||||
|
|
||||||
**Objective**: Make sync logic adapter-agnostic and reusable.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Modify `core::sync::run_sync()` to accept source/destination traits instead of concrete types
|
|
||||||
2. Update sync result structures to include inspection data
|
|
||||||
3. Refactor account processing to work with any `TransactionSource`
|
|
||||||
4. Ensure dry-run mode works with all adapter types
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Unit tests for sync logic with mock adapters
|
|
||||||
- Integration tests with different source/destination combinations
|
|
||||||
- Regression tests ensuring existing functionality unchanged
|
|
||||||
|
|
||||||
### Phase 7: Adapter Factory Implementation
|
|
||||||
|
|
||||||
**Objective**: Enable dynamic adapter instantiation for multiple sources/destinations.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Create `core::adapter_factory` module with factory functions
|
|
||||||
2. Implement source factory supporting "gocardless", "csv", "camt053", "mt940"
|
|
||||||
3. Implement destination factory supporting "firefly" (extensible for others)
|
|
||||||
4. Add configuration structs for adapter-specific settings
|
|
||||||
5. Integrate factory into CLI setup logic
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Unit tests for factory functions with valid/invalid inputs
|
|
||||||
- Mock tests for adapter creation
|
|
||||||
- Integration tests with real configurations
|
|
||||||
|
|
||||||
### Phase 8: Integration and Validation
|
|
||||||
|
|
||||||
**Objective**: Ensure all components work together and prepare for web API.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Full integration testing across all source/destination combinations
|
|
||||||
2. Performance testing with realistic data volumes
|
|
||||||
3. Documentation updates in `docs/architecture.md`
|
|
||||||
4. Code review against project guidelines
|
|
||||||
5. Update `AGENTS.md` with new development patterns
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- End-to-end tests for complete workflows
|
|
||||||
- Load tests for sync operations
|
|
||||||
- Security audits for data handling
|
|
||||||
- Compatibility tests with existing configurations
|
|
||||||
|
|
||||||
### Phase 9: File-Based Source Adapters
|
|
||||||
|
|
||||||
**Objective**: Implement adapters for file-based transaction sources.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Create `adapters::csv` module implementing `TransactionSource`
|
|
||||||
- Parse CSV files with configurable column mappings
|
|
||||||
- Implement caching similar to GoCardless adapter
|
|
||||||
- Add inspection methods for file status and transaction counts
|
|
||||||
2. Create `adapters::camt053` and `adapters::mt940` modules
|
|
||||||
- Parse respective financial file formats
|
|
||||||
- Implement transaction mapping and validation
|
|
||||||
- Add format-specific caching and inspection
|
|
||||||
3. Update `adapter_factory` to instantiate file adapters with file paths
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- Unit tests for file parsing with sample data
|
|
||||||
- Mock tests for adapter implementations
|
|
||||||
- Integration tests with fixture files from `tests/fixtures/`
|
|
||||||
- Performance tests for large file handling
|
|
||||||
|
|
||||||
## Architecture Considerations
|
|
||||||
|
|
||||||
- **Hexagonal Architecture**: Maintain separation between core business logic, ports, and adapters
|
|
||||||
- **Error Handling**: Use `thiserror` for domain errors, `anyhow` for application errors
|
|
||||||
- **Async Programming**: Leverage `tokio` for concurrent operations where beneficial
|
|
||||||
- **Testing Strategy**: Combine unit tests, integration tests, and mocks using `mockall`
|
|
||||||
- **Dependencies**: Add new crates only if necessary, preferring workspace dependencies
|
|
||||||
- **Code Organization**: Keep modules focused and single-responsibility
|
|
||||||
- **Performance**: Implement caching and batching for file-based sources
|
|
||||||
|
|
||||||
## Security and Compliance Notes
|
|
||||||
|
|
||||||
- **Financial Data Masking**: Never expose amounts, IBANs, or personal data in logs/outputs
|
|
||||||
- **Input Validation**: Validate all external data before processing
|
|
||||||
- **Error Messages**: Avoid sensitive information in error responses
|
|
||||||
- **Audit Trail**: Maintain structured logging for operations
|
|
||||||
- **Compliance**: Ensure GDPR/privacy compliance for financial data handling
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- All existing sync functionality preserved
|
|
||||||
- New commands work with all supported sources/destinations
|
|
||||||
- Core logic remains adapter-agnostic
|
|
||||||
- Comprehensive test coverage maintained
|
|
||||||
- Performance meets or exceeds current benchmarks
|
|
||||||
- Architecture supports future web API development</content>
|
|
||||||
<parameter name="filePath">specs/cli-refactor-plan.md
|
|
||||||
Reference in New Issue
Block a user