Compare commits
7 Commits
master
...
7f55009c8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
7f55009c8b
|
|||
| 8db9ba4a78 | |||
| 1c566071ba | |||
| 2824c7448c | |||
| e4b36d344c | |||
| b8f8d8cdfb | |||
| 68dafe9225 |
45
AGENTS.md
45
AGENTS.md
@@ -137,7 +137,6 @@ mod tests {
|
|||||||
- Keep core business logic separate from external integrations
|
- Keep core business logic separate from external integrations
|
||||||
- Use workspace dependencies consistently
|
- Use workspace dependencies consistently
|
||||||
- When working from a spec, update the spec with the current status as soon as you finish something
|
- When working from a spec, update the spec with the current status as soon as you finish something
|
||||||
- **MANDATORY**: After making ANY code change, complete the Post-Change Verification Checklist (see Code Quality section below)
|
|
||||||
|
|
||||||
### 2. Testing
|
### 2. Testing
|
||||||
- Write tests alongside code in `#[cfg(test)]` modules
|
- Write tests alongside code in `#[cfg(test)]` modules
|
||||||
@@ -150,20 +149,9 @@ mod tests {
|
|||||||
- Use `cargo fmt` for formatting
|
- Use `cargo fmt` for formatting
|
||||||
- Use `cargo clippy` for linting
|
- Use `cargo clippy` for linting
|
||||||
- Ensure documentation for public APIs
|
- Ensure documentation for public APIs
|
||||||
- **MANDATORY: ALWAYS format and lint after making ANY change, and fix ALL linting errors and warnings**
|
- _ALWAYS_ format and lint after making a change, and fix linting errors and warnings
|
||||||
- When a change is end-user visible, update the README.md. Use the README.md documentation guidelines
|
- When a change is end-user visible, update the README.md. Use the README.md documentation guidelines
|
||||||
- Always clean up unused code. No todo's or unused code is allowed after a change. Remove unused variables, functions, imports, etc. Do NOT hide unused code with underscores - delete it!
|
- Always clean up unused code. No todo's or unused code is allowed after a change
|
||||||
|
|
||||||
#### Post-Change Verification Checklist (MANDATORY)
|
|
||||||
After making ANY code change, you MUST run these commands and fix any issues:
|
|
||||||
|
|
||||||
1. **Format code**: `cargo fmt --all`
|
|
||||||
2. **Run linter**: `cargo clippy --all-targets --all-features -- -D warnings`
|
|
||||||
3. **Run tests**: `cargo test --workspace`
|
|
||||||
4. **Build project**: `cargo build --workspace`
|
|
||||||
5. **Clean up unused code**: Remove any unused variables, functions, imports, etc.
|
|
||||||
|
|
||||||
**FAILURE TO COMPLETE THIS CHECKLIST WILL RESULT IN CODE REJECTION**
|
|
||||||
|
|
||||||
### 4. Commit Standards
|
### 4. Commit Standards
|
||||||
- *Always* ensure the workspace compiles: `cargo build --workspace`
|
- *Always* ensure the workspace compiles: `cargo build --workspace`
|
||||||
@@ -176,21 +164,6 @@ After making ANY code change, you MUST run these commands and fix any issues:
|
|||||||
|
|
||||||
## Project Structure Guidelines
|
## Project Structure Guidelines
|
||||||
|
|
||||||
### Workspace Structure
|
|
||||||
|
|
||||||
This project is a Cargo workspace containing three crates:
|
|
||||||
|
|
||||||
- **banks2ff/**: Main CLI application (source in `banks2ff/src/`)
|
|
||||||
- **firefly-client/**: Standalone Firefly III API client library (source in `firefly-client/src/`)
|
|
||||||
- **gocardless-client/**: Standalone GoCardless API client library (source in `gocardless-client/src/`)
|
|
||||||
|
|
||||||
**Navigation Guidelines:**
|
|
||||||
- Always identify which crate contains the relevant code before searching or editing
|
|
||||||
- Use the root `Cargo.toml` workspace members to confirm crate boundaries
|
|
||||||
- For main application logic: look in `banks2ff/src/`
|
|
||||||
- For API client implementations: check `firefly-client/src/` or `gocardless-client/src/` as appropriate
|
|
||||||
- When uncertain, search across the entire workspace using tools like `grep` with appropriate paths
|
|
||||||
|
|
||||||
### Core Module (`banks2ff/src/core/`)
|
### Core Module (`banks2ff/src/core/`)
|
||||||
- **models.rs**: Domain entities (BankTransaction, Account)
|
- **models.rs**: Domain entities (BankTransaction, Account)
|
||||||
- **ports.rs**: Trait definitions (TransactionSource, TransactionDestination)
|
- **ports.rs**: Trait definitions (TransactionSource, TransactionDestination)
|
||||||
@@ -201,20 +174,6 @@ This project is a Cargo workspace containing three crates:
|
|||||||
- **firefly/**: Firefly III API integration
|
- **firefly/**: Firefly III API integration
|
||||||
- Each adapter implements the appropriate port trait
|
- Each adapter implements the appropriate port trait
|
||||||
|
|
||||||
### Commands Module (`banks2ff/src/commands/`)
|
|
||||||
- **sync.rs**: Sync command handler
|
|
||||||
- **accounts/**: Account management commands
|
|
||||||
- **mod.rs**: Account command dispatch
|
|
||||||
- **link.rs**: Account linking logic and LinkCommands dispatch
|
|
||||||
- **list.rs**: Account listing handler
|
|
||||||
- **status.rs**: Account status handler
|
|
||||||
- **transactions/**: Transaction management commands
|
|
||||||
- **mod.rs**: Transaction command dispatch
|
|
||||||
- **list.rs**: Transaction listing handler
|
|
||||||
- **cache.rs**: Cache status handler
|
|
||||||
- **clear.rs**: Cache clearing handler
|
|
||||||
- **list.rs**: Source/destination listing handler
|
|
||||||
|
|
||||||
### Client Libraries
|
### Client Libraries
|
||||||
- **gocardless-client/**: Standalone GoCardless API wrapper
|
- **gocardless-client/**: Standalone GoCardless API wrapper
|
||||||
- **firefly-client/**: Standalone Firefly III API wrapper
|
- **firefly-client/**: Standalone Firefly III API wrapper
|
||||||
|
|||||||
118
Cargo.lock
generated
118
Cargo.lock
generated
@@ -199,11 +199,9 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"comfy-table",
|
"comfy-table",
|
||||||
"dialoguer",
|
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"firefly-client",
|
"firefly-client",
|
||||||
"gocardless-client",
|
"gocardless-client",
|
||||||
"hkdf",
|
|
||||||
"http",
|
"http",
|
||||||
"hyper",
|
"hyper",
|
||||||
"mockall",
|
"mockall",
|
||||||
@@ -216,7 +214,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"task-local-extensions",
|
"task-local-extensions",
|
||||||
"temp-env",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -437,19 +434,6 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "console"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4"
|
|
||||||
dependencies = [
|
|
||||||
"encode_unicode",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"unicode-width",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -543,18 +527,6 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dialoguer"
|
|
||||||
version = "0.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
|
|
||||||
dependencies = [
|
|
||||||
"console",
|
|
||||||
"shell-words",
|
|
||||||
"tempfile",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "difflib"
|
name = "difflib"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -610,12 +582,6 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "encode_unicode"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -656,12 +622,6 @@ dependencies = [
|
|||||||
"instant",
|
"instant",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrand"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -776,7 +736,7 @@ version = "1.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
|
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand 1.9.0",
|
"fastrand",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -864,18 +824,6 @@ dependencies = [
|
|||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.3.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"r-efi",
|
|
||||||
"wasip2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ghash"
|
name = "ghash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -949,15 +897,6 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hkdf"
|
|
||||||
version = "0.12.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
|
||||||
dependencies = [
|
|
||||||
"hmac",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac"
|
name = "hmac"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1587,12 +1526,6 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "r-efi"
|
|
||||||
version = "5.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "radium"
|
name = "radium"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -2003,12 +1936,6 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "shell-words"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -2155,28 +2082,6 @@ dependencies = [
|
|||||||
"pin-utils",
|
"pin-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "temp-env"
|
|
||||||
version = "0.3.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050"
|
|
||||||
dependencies = [
|
|
||||||
"parking_lot",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tempfile"
|
|
||||||
version = "3.23.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
|
||||||
dependencies = [
|
|
||||||
"fastrand 2.3.0",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"once_cell",
|
|
||||||
"rustix",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termtree"
|
name = "termtree"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -2534,15 +2439,6 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasip2"
|
|
||||||
version = "1.0.1+wasi-0.2.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.105"
|
version = "0.2.105"
|
||||||
@@ -2961,12 +2857,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen"
|
|
||||||
version = "0.46.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -3046,12 +2936,6 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zeroize"
|
|
||||||
version = "1.8.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
22
Cargo.toml
22
Cargo.toml
@@ -24,22 +24,12 @@ rust_decimal = { version = "1.33", features = ["serde-float"] }
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
clap = { version = "4.4", features = ["derive", "env"] }
|
clap = { version = "4.4", features = ["derive", "env"] }
|
||||||
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.11", default-features = false, features = ["json", "multipart", "rustls-tls"] }
|
||||||
url = "2.5"
|
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"
|
comfy-table = "7.1"
|
||||||
http = "0.2"
|
|
||||||
task-local-extensions = "0.1"
|
|
||||||
aes-gcm = "0.10"
|
|
||||||
pbkdf2 = "0.12"
|
|
||||||
hkdf = "0.12"
|
|
||||||
rand = "0.8"
|
|
||||||
sha2 = "0.10"
|
|
||||||
temp-env = "0.3"
|
|
||||||
dialoguer = "0.12"
|
|
||||||
walkdir = "2.4"
|
|
||||||
|
|||||||
96
README.md
96
README.md
@@ -11,18 +11,16 @@ 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
|
||||||
- **Smart Account Linking**: Automatically match bank accounts to Firefly III accounts, with interactive and intelligent manual linking options
|
- **Flexible Account Linking**: Automatically match bank accounts to Firefly III accounts, with manual override options
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Rust (latest stable)
|
- Rust (latest stable)
|
||||||
- GoCardless Bank Account Data account
|
- GoCardless Bank Account Data account
|
||||||
- Running Firefly III instance
|
- Running Firefly III instance
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Copy environment template: `cp env.example .env`
|
1. Copy environment template: `cp env.example .env`
|
||||||
2. Fill in your credentials in `.env`:
|
2. Fill in your credentials in `.env`:
|
||||||
- `GOCARDLESS_ID`: Your GoCardless Secret ID
|
- `GOCARDLESS_ID`: Your GoCardless Secret ID
|
||||||
@@ -32,7 +30,6 @@ A robust command-line tool to synchronize bank transactions between various sour
|
|||||||
- `BANKS2FF_CACHE_KEY`: Required encryption key for secure transaction caching
|
- `BANKS2FF_CACHE_KEY`: Required encryption key for secure transaction caching
|
||||||
|
|
||||||
### 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 -- sync gocardless firefly
|
||||||
@@ -49,20 +46,14 @@ cargo run -p banks2ff -- destinations
|
|||||||
|
|
||||||
# Inspect accounts
|
# Inspect accounts
|
||||||
cargo run -p banks2ff -- accounts list
|
cargo run -p banks2ff -- accounts list
|
||||||
cargo run -p banks2ff -- accounts list gocardless # Only GoCardless accounts
|
|
||||||
cargo run -p banks2ff -- accounts list firefly # Only Firefly III accounts
|
|
||||||
cargo run -p banks2ff -- accounts status
|
cargo run -p banks2ff -- accounts status
|
||||||
|
|
||||||
# Manage account links
|
# Manage account links
|
||||||
cargo run -p banks2ff -- accounts link list
|
cargo run -p banks2ff -- accounts link list
|
||||||
cargo run -p banks2ff -- accounts link create # Interactive mode - guided account selection
|
cargo run -p banks2ff -- accounts link create <source_account> <dest_account>
|
||||||
cargo run -p banks2ff -- accounts link create "Account Name" # Smart mode - auto-detect source/destination
|
|
||||||
cargo run -p banks2ff -- accounts link create <source> <dest> # Direct mode - for scripts
|
|
||||||
|
|
||||||
# Inspect transactions and cache
|
# Inspect transactions and cache
|
||||||
cargo run -p banks2ff -- transactions list # Interactive account selection
|
cargo run -p banks2ff -- transactions list <account_id>
|
||||||
cargo run -p banks2ff -- transactions list "Account Name" # By name/IBAN
|
|
||||||
cargo run -p banks2ff -- transactions list --details # Show actual transactions
|
|
||||||
cargo run -p banks2ff -- transactions cache-status
|
cargo run -p banks2ff -- transactions cache-status
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -73,11 +64,12 @@ Banks2FF uses a structured command-line interface with the following commands:
|
|||||||
- `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination
|
- `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination
|
||||||
- `sources` - List all available source types
|
- `sources` - List all available source types
|
||||||
- `destinations` - List all available destination types
|
- `destinations` - List all available destination types
|
||||||
- `accounts list [gocardless|firefly]` - List all discovered accounts (optionally filter by adapter type)
|
- `accounts list` - List all discovered accounts
|
||||||
- `accounts status` - Show sync status for all accounts
|
- `accounts status` - Show sync status for all accounts
|
||||||
- `accounts link` - Manage account links between sources and destinations (with interactive and smart modes)
|
- `accounts link` - Manage account links between sources and destinations
|
||||||
- `transactions list [account] [--details] [--limit N]` - Show transaction summary or details for an account (interactive selection if no account specified)
|
- `transactions list <account_id>` - Show transaction information for a specific account
|
||||||
- `transactions cache-status` - Display cache status and statistics
|
- `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.
|
Use `cargo run -p banks2ff -- --help` for detailed command information.
|
||||||
|
|
||||||
@@ -85,77 +77,11 @@ Use `cargo run -p banks2ff -- --help` for detailed command information.
|
|||||||
|
|
||||||
Banks2FF automatically:
|
Banks2FF automatically:
|
||||||
1. Connects to your bank accounts via GoCardless
|
1. Connects to your bank accounts via GoCardless
|
||||||
2. Discovers accounts and provides intelligent linking between GoCardless and Firefly III
|
2. Discovers and links accounts between GoCardless and Firefly III (with auto-matching and manual options)
|
||||||
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
|
||||||
|
|
||||||
The account linking system automatically matches accounts by IBAN, but also provides interactive tools for manual linking when needed.
|
|
||||||
|
|
||||||
## 🔗 Smart Account Linking
|
|
||||||
|
|
||||||
Banks2FF provides multiple ways to link your bank accounts to Firefly III accounts:
|
|
||||||
|
|
||||||
### Interactive Mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p banks2ff -- accounts link create
|
|
||||||
```
|
|
||||||
|
|
||||||
Shows you unlinked bank accounts and guides you through selecting destination accounts with human-readable names.
|
|
||||||
|
|
||||||
### Smart Resolution
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p banks2ff -- accounts link create "Main Checking"
|
|
||||||
```
|
|
||||||
|
|
||||||
Automatically finds accounts by name, IBAN, or ID and shows appropriate linking options.
|
|
||||||
|
|
||||||
### Direct Linking (for Scripts)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p banks2ff -- accounts link create <source_id> <destination_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
Perfect for automation - uses exact account IDs for reliable scripting.
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
- **Auto-Linking**: Automatically matches accounts with identical IBANs during sync
|
|
||||||
- **Manual Override**: Create custom links when auto-matching isn't sufficient
|
|
||||||
- **Constraint Enforcement**: One bank account can only link to one Firefly account (prevents duplicates)
|
|
||||||
- **Human-Friendly**: Uses account names and masked IBANs for easy identification
|
|
||||||
|
|
||||||
## 📊 Transaction Inspection
|
|
||||||
|
|
||||||
Banks2FF provides flexible ways to inspect your transaction data without needing to access Firefly III directly:
|
|
||||||
|
|
||||||
### Summary View (Default)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p banks2ff -- transactions list
|
|
||||||
```
|
|
||||||
|
|
||||||
Shows an interactive menu of accounts with transaction data, then displays summary statistics including total count, date range, and last update.
|
|
||||||
|
|
||||||
### Transaction Details
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p banks2ff -- transactions list --details --limit 50
|
|
||||||
```
|
|
||||||
|
|
||||||
Shows recent transactions with amounts, descriptions, and counterparties.
|
|
||||||
|
|
||||||
### Account Selection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p banks2ff -- transactions list "Main Checking"
|
|
||||||
cargo run -p banks2ff -- transactions list NL12ABCD0123456789
|
|
||||||
```
|
|
||||||
|
|
||||||
Find accounts by name, IBAN, or ID. Use no argument for interactive selection.
|
|
||||||
|
|
||||||
## 🔐 Secure Transaction Caching
|
## 🔐 Secure Transaction Caching
|
||||||
|
|
||||||
Banks2FF automatically caches your transaction data to make future syncs much faster:
|
Banks2FF automatically caches your transaction data to make future syncs much faster:
|
||||||
@@ -170,11 +96,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
|
- **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, or use `accounts link create` for interactive linking
|
- **Account not syncing?** Check that the IBAN matches between GoCardless and Firefly III, or use `accounts link` to create manual links
|
||||||
- **Can't find account by name?** Use `accounts list` to see all available accounts with their IDs and names
|
|
||||||
- **Link creation failed?** Each bank account can only link to one Firefly account - check existing links with `accounts link list`
|
|
||||||
- **No transactions showing?** Use `transactions list` to check if data has been cached; run sync first if needed
|
|
||||||
- **Can't find account for transactions?** Use `transactions list` without arguments for interactive account selection
|
|
||||||
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -29,20 +29,17 @@ gocardless-client = { path = "../gocardless-client" }
|
|||||||
reqwest-middleware = { workspace = true }
|
reqwest-middleware = { workspace = true }
|
||||||
hyper = { workspace = true }
|
hyper = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
http = { workspace = true }
|
http = "0.2"
|
||||||
task-local-extensions = { workspace = true }
|
task-local-extensions = "0.1"
|
||||||
|
|
||||||
# Encryption dependencies
|
# Encryption dependencies
|
||||||
aes-gcm = { workspace = true }
|
aes-gcm = "0.10"
|
||||||
pbkdf2 = { workspace = true }
|
pbkdf2 = "0.12"
|
||||||
hkdf = { workspace = true }
|
rand = "0.8"
|
||||||
rand = { workspace = true }
|
sha2 = "0.10"
|
||||||
sha2 = { workspace = true }
|
|
||||||
|
|
||||||
# CLI formatting dependencies
|
# CLI formatting dependencies
|
||||||
comfy-table = { workspace = true }
|
comfy-table = { workspace = true }
|
||||||
dialoguer = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
mockall = { workspace = true }
|
mockall = { workspace = true }
|
||||||
temp-env = { workspace = true }
|
|
||||||
|
|||||||
BIN
banks2ff/data/cache/accounts.enc
vendored
Normal file
BIN
banks2ff/data/cache/accounts.enc
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,4 @@
|
|||||||
use crate::core::cache::{AccountCache, CachedAccount};
|
use crate::core::models::{Account, BankTransaction};
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::models::{Account, AccountSummary, 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;
|
||||||
@@ -17,20 +15,42 @@ use tracing::instrument;
|
|||||||
|
|
||||||
pub struct FireflyAdapter {
|
pub struct FireflyAdapter {
|
||||||
client: Arc<Mutex<FireflyClient>>,
|
client: Arc<Mutex<FireflyClient>>,
|
||||||
config: Config,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FireflyAdapter {
|
impl FireflyAdapter {
|
||||||
pub fn new(client: FireflyClient, config: Config) -> Self {
|
pub fn new(client: FireflyClient) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Arc::new(Mutex::new(client)),
|
client: Arc::new(Mutex::new(client)),
|
||||||
config,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TransactionDestination for FireflyAdapter {
|
impl TransactionDestination for FireflyAdapter {
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
|
||||||
|
let client = self.client.lock().await;
|
||||||
|
// Get all asset accounts. Note: Pagination might be needed if user has > 50 accounts.
|
||||||
|
// For typical users, 50 is enough. If needed we can loop pages.
|
||||||
|
// The client `get_accounts` method hardcodes limit=default. We should probably expose a list_all method or loop here.
|
||||||
|
// For now, let's assume page 1 covers it or use search.
|
||||||
|
|
||||||
|
let accounts = client.get_accounts("").await?; // Argument ignored in current impl
|
||||||
|
let mut ibans = Vec::new();
|
||||||
|
|
||||||
|
for acc in accounts.data {
|
||||||
|
let is_active = acc.attributes.active.unwrap_or(true);
|
||||||
|
if is_active {
|
||||||
|
if let Some(iban) = acc.attributes.iban {
|
||||||
|
if !iban.is_empty() {
|
||||||
|
ibans.push(iban);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ibans)
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
||||||
let client = self.client.lock().await;
|
let client = self.client.lock().await;
|
||||||
@@ -115,44 +135,35 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
let is_credit = tx.amount.is_sign_positive();
|
let is_credit = tx.amount.is_sign_positive();
|
||||||
let transaction_type = if is_credit { "deposit" } else { "withdrawal" };
|
let transaction_type = if is_credit { "deposit" } else { "withdrawal" };
|
||||||
|
|
||||||
// Determine source and destination based on IBAN linking
|
|
||||||
let (source_id, source_name, destination_id, destination_name) = if is_credit {
|
|
||||||
// Deposit: money coming in, source is counterparty, destination is user's account
|
|
||||||
let destination_id = Some(account_id.to_string());
|
|
||||||
let (source_id, source_name) = if let Some(iban) = &tx.counterparty_iban {
|
|
||||||
if let Some(acc_id) = self.find_account_by_iban(iban) {
|
|
||||||
(Some(acc_id), None)
|
|
||||||
} else {
|
|
||||||
(None, tx.counterparty_name.clone())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(None, tx.counterparty_name.clone())
|
|
||||||
};
|
|
||||||
(source_id, source_name, destination_id, None)
|
|
||||||
} else {
|
|
||||||
// Withdrawal: money going out, source is user's account, destination is counterparty
|
|
||||||
let source_id = Some(account_id.to_string());
|
|
||||||
let (destination_id, destination_name) = if let Some(iban) = &tx.counterparty_iban {
|
|
||||||
if let Some(acc_id) = self.find_account_by_iban(iban) {
|
|
||||||
(Some(acc_id), None)
|
|
||||||
} else {
|
|
||||||
(None, tx.counterparty_name.clone())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(None, tx.counterparty_name.clone())
|
|
||||||
};
|
|
||||||
(source_id, None, destination_id, destination_name)
|
|
||||||
};
|
|
||||||
|
|
||||||
let split = TransactionSplitStore {
|
let split = TransactionSplitStore {
|
||||||
transaction_type: transaction_type.to_string(),
|
transaction_type: transaction_type.to_string(),
|
||||||
date: tx.date.format("%Y-%m-%d").to_string(),
|
date: tx.date.format("%Y-%m-%d").to_string(),
|
||||||
amount: tx.amount.abs().to_string(),
|
amount: tx.amount.abs().to_string(),
|
||||||
description: tx.description.clone(),
|
description: tx.description.clone(),
|
||||||
source_id,
|
source_id: if !is_credit {
|
||||||
source_name,
|
Some(account_id.to_string())
|
||||||
destination_id,
|
} else {
|
||||||
destination_name,
|
None
|
||||||
|
},
|
||||||
|
source_name: if is_credit {
|
||||||
|
tx.counterparty_name
|
||||||
|
.clone()
|
||||||
|
.or(Some("Unknown Sender".to_string()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
destination_id: if is_credit {
|
||||||
|
Some(account_id.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
destination_name: if !is_credit {
|
||||||
|
tx.counterparty_name
|
||||||
|
.clone()
|
||||||
|
.or(Some("Unknown Recipient".to_string()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
currency_code: Some(tx.currency.clone()),
|
currency_code: Some(tx.currency.clone()),
|
||||||
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
|
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
|
||||||
foreign_currency_code: tx.foreign_currency.clone(),
|
foreign_currency_code: tx.foreign_currency.clone(),
|
||||||
@@ -186,70 +197,15 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
||||||
let client = self.client.lock().await;
|
let client = self.client.lock().await;
|
||||||
let accounts = client.get_accounts().await?;
|
let accounts = client.get_accounts("").await?;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
||||||
// Cache the accounts
|
|
||||||
let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone());
|
|
||||||
let mut cache =
|
|
||||||
crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption);
|
|
||||||
|
|
||||||
for acc in accounts.data {
|
for acc in accounts.data {
|
||||||
// Cache all accounts, regardless of active status
|
|
||||||
let ff_account = crate::core::cache::FireflyAccount {
|
|
||||||
id: acc.id.clone(),
|
|
||||||
name: acc.attributes.name.clone(),
|
|
||||||
account_type: acc.attributes.account_type.clone(),
|
|
||||||
iban: acc.attributes.iban.clone(),
|
|
||||||
active: acc.attributes.active,
|
|
||||||
order: acc.attributes.order,
|
|
||||||
created_at: acc.attributes.created_at.clone(),
|
|
||||||
account_role: acc.attributes.account_role.clone(),
|
|
||||||
object_group_id: acc.attributes.object_group_id.clone(),
|
|
||||||
object_group_title: acc.attributes.object_group_title.clone(),
|
|
||||||
object_group_order: acc.attributes.object_group_order,
|
|
||||||
currency_id: acc.attributes.currency_id.clone(),
|
|
||||||
currency_name: acc.attributes.currency_name.clone(),
|
|
||||||
currency_code: acc.attributes.currency_code.clone(),
|
|
||||||
currency_symbol: acc.attributes.currency_symbol.clone(),
|
|
||||||
currency_decimal_places: acc.attributes.currency_decimal_places,
|
|
||||||
primary_currency_id: acc.attributes.primary_currency_id.clone(),
|
|
||||||
primary_currency_name: acc.attributes.primary_currency_name.clone(),
|
|
||||||
primary_currency_code: acc.attributes.primary_currency_code.clone(),
|
|
||||||
primary_currency_symbol: acc.attributes.primary_currency_symbol.clone(),
|
|
||||||
primary_currency_decimal_places: acc.attributes.primary_currency_decimal_places,
|
|
||||||
opening_balance: acc.attributes.opening_balance.clone(),
|
|
||||||
pc_opening_balance: acc.attributes.pc_opening_balance.clone(),
|
|
||||||
debt_amount: acc.attributes.debt_amount.clone(),
|
|
||||||
pc_debt_amount: acc.attributes.pc_debt_amount.clone(),
|
|
||||||
notes: acc.attributes.notes.clone(),
|
|
||||||
monthly_payment_date: acc.attributes.monthly_payment_date.clone(),
|
|
||||||
credit_card_type: acc.attributes.credit_card_type.clone(),
|
|
||||||
account_number: acc.attributes.account_number.clone(),
|
|
||||||
bic: acc.attributes.bic.clone(),
|
|
||||||
opening_balance_date: acc.attributes.opening_balance_date.clone(),
|
|
||||||
liability_type: acc.attributes.liability_type.clone(),
|
|
||||||
liability_direction: acc.attributes.liability_direction.clone(),
|
|
||||||
interest: acc.attributes.interest.clone(),
|
|
||||||
interest_period: acc.attributes.interest_period.clone(),
|
|
||||||
include_net_worth: acc.attributes.include_net_worth,
|
|
||||||
longitude: acc.attributes.longitude,
|
|
||||||
latitude: acc.attributes.latitude,
|
|
||||||
zoom_level: acc.attributes.zoom_level,
|
|
||||||
last_activity: acc.attributes.last_activity.clone(),
|
|
||||||
};
|
|
||||||
cache.insert(crate::core::cache::CachedAccount::Firefly(Box::new(
|
|
||||||
ff_account,
|
|
||||||
)));
|
|
||||||
cache.save();
|
|
||||||
|
|
||||||
// Only return active asset accounts for linking (existing behavior)
|
|
||||||
let is_active = acc.attributes.active.unwrap_or(true);
|
let is_active = acc.attributes.active.unwrap_or(true);
|
||||||
if is_active && acc.attributes.account_type == "asset" {
|
if is_active {
|
||||||
result.push(Account {
|
result.push(Account {
|
||||||
id: acc.id,
|
id: acc.id,
|
||||||
name: Some(acc.attributes.name),
|
iban: acc.attributes.iban.unwrap_or_default(),
|
||||||
iban: acc.attributes.iban,
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -257,49 +213,4 @@ impl TransactionDestination for FireflyAdapter {
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
|
|
||||||
let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone());
|
|
||||||
let cache =
|
|
||||||
crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption);
|
|
||||||
|
|
||||||
let mut summaries = Vec::new();
|
|
||||||
|
|
||||||
// Use cached account data for display, filter to show only asset and liability accounts
|
|
||||||
for (account_id, cached_account) in &cache.accounts {
|
|
||||||
if let crate::core::cache::CachedAccount::Firefly(ff_account) = cached_account {
|
|
||||||
if ff_account.account_type == "asset" || ff_account.account_type == "liability" {
|
|
||||||
let summary = AccountSummary {
|
|
||||||
id: account_id.clone(),
|
|
||||||
name: Some(ff_account.name.clone()),
|
|
||||||
iban: ff_account.iban.clone().unwrap_or_else(|| "".to_string()),
|
|
||||||
currency: ff_account
|
|
||||||
.currency_code
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "EUR".to_string()),
|
|
||||||
};
|
|
||||||
summaries.push(summary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(summaries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FireflyAdapter {
|
|
||||||
fn find_account_by_iban(&self, iban: &str) -> Option<String> {
|
|
||||||
let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone());
|
|
||||||
let cache = AccountCache::load(self.config.cache.directory.clone(), encryption);
|
|
||||||
for cached_account in cache.accounts.values() {
|
|
||||||
if let CachedAccount::Firefly(ff_account) = cached_account {
|
|
||||||
if ff_account.iban.as_ref() == Some(&iban.to_string())
|
|
||||||
&& ff_account.active.unwrap_or(true)
|
|
||||||
{
|
|
||||||
return Some(ff_account.id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
71
banks2ff/src/adapters/gocardless/cache.rs
Normal file
71
banks2ff/src/adapters/gocardless/cache.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use crate::adapters::gocardless::encryption::Encryption;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||||
|
pub struct AccountCache {
|
||||||
|
/// Map of Account ID -> IBAN
|
||||||
|
pub accounts: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountCache {
|
||||||
|
fn get_path() -> String {
|
||||||
|
let cache_dir =
|
||||||
|
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||||
|
format!("{}/accounts.enc", cache_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let path = Self::get_path();
|
||||||
|
if Path::new(&path).exists() {
|
||||||
|
match fs::read(&path) {
|
||||||
|
Ok(encrypted_data) => match Encryption::decrypt(&encrypted_data) {
|
||||||
|
Ok(json_data) => match serde_json::from_slice(&json_data) {
|
||||||
|
Ok(cache) => return cache,
|
||||||
|
Err(e) => warn!("Failed to parse cache file: {}", e),
|
||||||
|
},
|
||||||
|
Err(e) => warn!("Failed to decrypt cache file: {}", e),
|
||||||
|
},
|
||||||
|
Err(e) => warn!("Failed to read cache file: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let path = Self::get_path();
|
||||||
|
|
||||||
|
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||||
|
warn!(
|
||||||
|
"Failed to create cache folder '{}': {}",
|
||||||
|
parent.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::to_vec(self) {
|
||||||
|
Ok(json_data) => match Encryption::encrypt(&json_data) {
|
||||||
|
Ok(encrypted_data) => {
|
||||||
|
if let Err(e) = fs::write(&path, encrypted_data) {
|
||||||
|
warn!("Failed to write cache file: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!("Failed to encrypt cache: {}", e),
|
||||||
|
},
|
||||||
|
Err(e) => warn!("Failed to serialize cache: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_iban(&self, account_id: &str) -> Option<String> {
|
||||||
|
self.accounts.get(account_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, account_id: String, iban: String) {
|
||||||
|
self.accounts.insert(account_id, iban);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
|
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::cache::{AccountCache, CachedAccount, GoCardlessAccount};
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::encryption::Encryption;
|
|
||||||
use crate::core::models::{
|
use crate::core::models::{
|
||||||
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
|
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
|
||||||
};
|
};
|
||||||
@@ -13,7 +11,7 @@ use chrono::NaiveDate;
|
|||||||
use gocardless_client::client::GoCardlessClient;
|
use gocardless_client::client::GoCardlessClient;
|
||||||
use tracing::{debug, info, instrument, warn};
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -21,22 +19,14 @@ pub struct GoCardlessAdapter {
|
|||||||
client: Arc<Mutex<GoCardlessClient>>,
|
client: Arc<Mutex<GoCardlessClient>>,
|
||||||
cache: Arc<Mutex<AccountCache>>,
|
cache: Arc<Mutex<AccountCache>>,
|
||||||
transaction_caches: Arc<Mutex<HashMap<String, AccountTransactionCache>>>,
|
transaction_caches: Arc<Mutex<HashMap<String, AccountTransactionCache>>>,
|
||||||
config: Config,
|
|
||||||
encryption: Encryption,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GoCardlessAdapter {
|
impl GoCardlessAdapter {
|
||||||
pub fn new(client: GoCardlessClient, config: Config) -> Self {
|
pub fn new(client: GoCardlessClient) -> Self {
|
||||||
let encryption = Encryption::new(config.cache.key.clone());
|
|
||||||
Self {
|
Self {
|
||||||
client: Arc::new(Mutex::new(client)),
|
client: Arc::new(Mutex::new(client)),
|
||||||
cache: Arc::new(Mutex::new(AccountCache::load(
|
cache: Arc::new(Mutex::new(AccountCache::load())),
|
||||||
config.cache.directory.clone(),
|
|
||||||
encryption.clone(),
|
|
||||||
))),
|
|
||||||
transaction_caches: Arc::new(Mutex::new(HashMap::new())),
|
transaction_caches: Arc::new(Mutex::new(HashMap::new())),
|
||||||
config,
|
|
||||||
encryption,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +48,7 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
let wanted_set = wanted_ibans.map(|list| {
|
let wanted_set = wanted_ibans.map(|list| {
|
||||||
list.into_iter()
|
list.into_iter()
|
||||||
.map(|i| i.replace(" ", ""))
|
.map(|i| i.replace(" ", ""))
|
||||||
.collect::<HashSet<_>>()
|
.collect::<std::collections::HashSet<_>>()
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut found_count = 0;
|
let mut found_count = 0;
|
||||||
@@ -95,56 +85,29 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
|
|
||||||
if let Some(req_accounts) = req.accounts {
|
if let Some(req_accounts) = req.accounts {
|
||||||
for acc_id in req_accounts {
|
for acc_id in req_accounts {
|
||||||
// Always fetch fresh account data during sync
|
// 1. Check Cache
|
||||||
match client.get_account(&acc_id).await {
|
let mut iban_opt = cache.get_iban(&acc_id);
|
||||||
Ok(basic_account) => {
|
|
||||||
// Also try to fetch account details
|
|
||||||
let details_result = client.get_account_details(&acc_id).await;
|
|
||||||
|
|
||||||
let gc_account = GoCardlessAccount {
|
// 2. Fetch if missing
|
||||||
id: basic_account.id.clone(),
|
if iban_opt.is_none() {
|
||||||
iban: basic_account.iban,
|
match client.get_account(&acc_id).await {
|
||||||
owner_name: basic_account.owner_name,
|
Ok(details) => {
|
||||||
status: basic_account.status,
|
let new_iban = details.iban.unwrap_or_default();
|
||||||
institution_id: basic_account.institution_id,
|
cache.insert(acc_id.clone(), new_iban.clone());
|
||||||
created: basic_account.created,
|
cache.save();
|
||||||
last_accessed: basic_account.last_accessed,
|
iban_opt = Some(new_iban);
|
||||||
// Include details if available
|
}
|
||||||
name: details_result
|
Err(e) => {
|
||||||
.as_ref()
|
// If rate limit hit here, we might want to skip this account and continue?
|
||||||
.ok()
|
// But get_account is critical to identify the account.
|
||||||
.and_then(|d| d.account.name.clone()),
|
// If we fail here, we can't match.
|
||||||
display_name: details_result
|
warn!("Failed to fetch details for account {}: {}", acc_id, e);
|
||||||
.as_ref()
|
continue;
|
||||||
.ok()
|
}
|
||||||
.and_then(|d| d.account.display_name.clone()),
|
|
||||||
product: details_result
|
|
||||||
.as_ref()
|
|
||||||
.ok()
|
|
||||||
.and_then(|d| d.account.product.clone()),
|
|
||||||
cash_account_type: details_result
|
|
||||||
.as_ref()
|
|
||||||
.ok()
|
|
||||||
.and_then(|d| d.account.cash_account_type.clone()),
|
|
||||||
};
|
|
||||||
|
|
||||||
cache.insert(CachedAccount::GoCardless(Box::new(gc_account)));
|
|
||||||
cache.save();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// If rate limit hit here, we might want to skip this account and continue?
|
|
||||||
// But get_account is critical to identify the account.
|
|
||||||
// If we fail here, we can't match.
|
|
||||||
warn!("Failed to fetch details for account {}: {}", acc_id, e);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let iban = cache
|
let iban = iban_opt.unwrap_or_default();
|
||||||
.get_account_data(&acc_id)
|
|
||||||
.and_then(|acc| acc.iban())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let mut keep = true;
|
let mut keep = true;
|
||||||
if let Some(ref wanted) = wanted_set {
|
if let Some(ref wanted) = wanted_set {
|
||||||
@@ -156,16 +119,9 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if keep {
|
if keep {
|
||||||
// Try to get account name from cache if available
|
|
||||||
let name = cache.get_account(&acc_id).and_then(|acc| match acc {
|
|
||||||
CachedAccount::GoCardless(gc_acc) => gc_acc.name.clone(),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
|
|
||||||
accounts.push(Account {
|
accounts.push(Account {
|
||||||
id: acc_id,
|
id: acc_id,
|
||||||
name,
|
iban,
|
||||||
iban: Some(iban),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -199,12 +155,10 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
// Load or get transaction cache
|
// Load or get transaction cache
|
||||||
let mut caches = self.transaction_caches.lock().await;
|
let mut caches = self.transaction_caches.lock().await;
|
||||||
let cache = caches.entry(account_id.to_string()).or_insert_with(|| {
|
let cache = caches.entry(account_id.to_string()).or_insert_with(|| {
|
||||||
let encryption = self.encryption.clone();
|
AccountTransactionCache::load(account_id).unwrap_or_else(|_| AccountTransactionCache {
|
||||||
let cache_dir = self.config.cache.directory.clone();
|
account_id: account_id.to_string(),
|
||||||
AccountTransactionCache::load(account_id, cache_dir.clone(), encryption.clone())
|
ranges: Vec::new(),
|
||||||
.unwrap_or_else(|_| {
|
})
|
||||||
AccountTransactionCache::new(account_id.to_string(), cache_dir, encryption)
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get cached transactions
|
// Get cached transactions
|
||||||
@@ -283,20 +237,48 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
|
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
|
||||||
let cache = self.cache.lock().await;
|
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();
|
let mut summaries = Vec::new();
|
||||||
|
|
||||||
// Use cached account data for display - only GoCardless accounts
|
for req in requisitions.results {
|
||||||
for (account_id, cached_account) in &cache.accounts {
|
if req.status != "LN" {
|
||||||
if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account {
|
continue;
|
||||||
if let Some(account_data) = cache.get_account_data(account_id) {
|
}
|
||||||
let summary = AccountSummary {
|
|
||||||
id: account_id.clone(),
|
if let Some(agreement_id) = &req.agreement {
|
||||||
name: account_data.display_name(),
|
if client.is_agreement_expired(agreement_id).await? {
|
||||||
iban: account_data.iban().unwrap_or("").to_string(),
|
continue;
|
||||||
currency: "EUR".to_string(), // GoCardless primarily uses EUR
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(summary);
|
|
||||||
|
summaries.push(AccountSummary {
|
||||||
|
id: acc_id,
|
||||||
|
iban,
|
||||||
|
currency: "EUR".to_string(), // Assuming EUR for now
|
||||||
|
status: "linked".to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,56 +288,31 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
|
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
|
||||||
let account_cache = self.cache.lock().await;
|
let caches = self.transaction_caches.lock().await;
|
||||||
let mut statuses = Vec::new();
|
let mut statuses = Vec::new();
|
||||||
|
|
||||||
// Iterate through cached GoCardless accounts
|
for (account_id, cache) in caches.iter() {
|
||||||
for (account_id, cached_account) in &account_cache.accounts {
|
let iban = self
|
||||||
if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account {
|
.cache
|
||||||
// Try to load the transaction cache for this account
|
.lock()
|
||||||
let transaction_cache = AccountTransactionCache::load(
|
.await
|
||||||
account_id,
|
.get_iban(account_id)
|
||||||
self.config.cache.directory.clone(),
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
self.encryption.clone(),
|
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();
|
||||||
|
|
||||||
let iban = account_cache
|
statuses.push(AccountStatus {
|
||||||
.get_account_data(account_id)
|
account_id: account_id.clone(),
|
||||||
.and_then(|acc| acc.iban())
|
iban,
|
||||||
.unwrap_or("Unknown")
|
last_sync_date,
|
||||||
.to_string();
|
transaction_count,
|
||||||
|
status: if transaction_count > 0 {
|
||||||
match transaction_cache {
|
"synced"
|
||||||
Ok(cache) => {
|
} else {
|
||||||
let transaction_count =
|
"pending"
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// No transaction cache found for this account
|
|
||||||
statuses.push(AccountStatus {
|
|
||||||
account_id: account_id.clone(),
|
|
||||||
iban,
|
|
||||||
last_sync_date: None,
|
|
||||||
transaction_count: 0,
|
|
||||||
status: "pending".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(statuses)
|
Ok(statuses)
|
||||||
@@ -363,7 +320,6 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
|
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
|
||||||
// First check in-memory cache
|
|
||||||
let caches = self.transaction_caches.lock().await;
|
let caches = self.transaction_caches.lock().await;
|
||||||
if let Some(cache) = caches.get(account_id) {
|
if let Some(cache) = caches.get(account_id) {
|
||||||
let total_count = cache.ranges.iter().map(|r| r.transactions.len()).sum();
|
let total_count = cache.ranges.iter().map(|r| r.transactions.len()).sum();
|
||||||
@@ -383,40 +339,12 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
last_updated,
|
last_updated,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Load from disk if not in memory
|
Ok(TransactionInfo {
|
||||||
drop(caches); // Release lock before loading from disk
|
account_id: account_id.to_string(),
|
||||||
let transaction_cache = AccountTransactionCache::load(
|
total_count: 0,
|
||||||
account_id,
|
date_range: None,
|
||||||
self.config.cache.directory.clone(),
|
last_updated: None,
|
||||||
self.encryption.clone(),
|
})
|
||||||
);
|
|
||||||
|
|
||||||
match transaction_cache {
|
|
||||||
Ok(cache) => {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(_) => Ok(TransactionInfo {
|
|
||||||
account_id: account_id.to_string(),
|
|
||||||
total_count: 0,
|
|
||||||
date_range: None,
|
|
||||||
last_updated: None,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,336 +362,23 @@ impl TransactionSource for GoCardlessAdapter {
|
|||||||
last_updated: None, // Not tracking
|
last_updated: None, // Not tracking
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transaction caches (in-memory)
|
// Transaction caches
|
||||||
let transaction_caches = self.transaction_caches.lock().await;
|
let transaction_caches = self.transaction_caches.lock().await;
|
||||||
let mut processed_account_ids = HashSet::new();
|
|
||||||
|
|
||||||
for (account_id, cache) in transaction_caches.iter() {
|
for (account_id, cache) in transaction_caches.iter() {
|
||||||
processed_account_ids.insert(account_id.clone());
|
|
||||||
let total_transactions = cache.ranges.iter().map(|r| r.transactions.len()).sum();
|
|
||||||
infos.push(CacheInfo {
|
infos.push(CacheInfo {
|
||||||
account_id: Some(account_id.clone()),
|
account_id: Some(account_id.clone()),
|
||||||
cache_type: "transaction".to_string(),
|
cache_type: "transaction".to_string(),
|
||||||
entry_count: total_transactions,
|
entry_count: cache.ranges.len(),
|
||||||
total_size_bytes: 0, // Not tracking
|
total_size_bytes: 0, // Not tracking
|
||||||
last_updated: cache.ranges.iter().map(|r| r.end_date).max(),
|
last_updated: cache.ranges.iter().map(|r| r.end_date).max(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load transaction caches from disk for discovered accounts not in memory
|
|
||||||
// Get all GoCardless account IDs from the account cache
|
|
||||||
let gocardless_account_ids: Vec<String> = account_cache
|
|
||||||
.accounts
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(id, cached_acc)| match cached_acc {
|
|
||||||
crate::core::cache::CachedAccount::GoCardless(_) => Some(id.clone()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Drop the account_cache lock before loading from disk
|
|
||||||
drop(account_cache);
|
|
||||||
|
|
||||||
for account_id in gocardless_account_ids {
|
|
||||||
// Skip if we already processed this account from in-memory cache
|
|
||||||
if processed_account_ids.contains(&account_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load from disk (same pattern as get_transaction_info)
|
|
||||||
match AccountTransactionCache::load(
|
|
||||||
&account_id,
|
|
||||||
self.config.cache.directory.clone(),
|
|
||||||
self.encryption.clone(),
|
|
||||||
) {
|
|
||||||
Ok(cache) => {
|
|
||||||
let total_transactions =
|
|
||||||
cache.ranges.iter().map(|r| r.transactions.len()).sum();
|
|
||||||
infos.push(CacheInfo {
|
|
||||||
account_id: Some(account_id),
|
|
||||||
cache_type: "transaction".to_string(),
|
|
||||||
entry_count: total_transactions,
|
|
||||||
total_size_bytes: 0, // Not tracking
|
|
||||||
last_updated: cache.ranges.iter().map(|r| r.end_date).max(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Account has no cache file yet - skip silently
|
|
||||||
// This matches get_transaction_info behavior
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(infos)
|
Ok(infos)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
async fn get_cached_transactions(
|
|
||||||
&self,
|
|
||||||
account_id: &str,
|
|
||||||
start: NaiveDate,
|
|
||||||
end: NaiveDate,
|
|
||||||
) -> Result<Vec<BankTransaction>> {
|
|
||||||
// Load or get transaction cache
|
|
||||||
let mut caches = self.transaction_caches.lock().await;
|
|
||||||
let cache = caches.entry(account_id.to_string()).or_insert_with(|| {
|
|
||||||
let encryption = self.encryption.clone();
|
|
||||||
let cache_dir = self.config.cache.directory.clone();
|
|
||||||
AccountTransactionCache::load(account_id, cache_dir.clone(), encryption.clone())
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
AccountTransactionCache::new(account_id.to_string(), cache_dir, encryption)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get cached transactions
|
|
||||||
let raw_transactions = cache.get_cached_transactions(start, end);
|
|
||||||
|
|
||||||
// Map to BankTransaction
|
|
||||||
let mut transactions = Vec::new();
|
|
||||||
for tx in raw_transactions {
|
|
||||||
match map_transaction(tx) {
|
|
||||||
Ok(t) => transactions.push(t),
|
|
||||||
Err(e) => tracing::error!("Failed to map cached transaction: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(transactions)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
||||||
self.get_accounts(None).await
|
self.get_accounts(None).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::core::cache::{CachedAccount, GoCardlessAccount};
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use gocardless_client::models::Transaction;
|
|
||||||
|
|
||||||
fn create_test_config() -> Config {
|
|
||||||
create_test_config_with_suffix("")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_test_config_with_suffix(suffix: &str) -> Config {
|
|
||||||
Config {
|
|
||||||
gocardless: crate::core::config::GoCardlessConfig {
|
|
||||||
url: "https://test.com".to_string(),
|
|
||||||
secret_id: "test".to_string(),
|
|
||||||
secret_key: "test".to_string(),
|
|
||||||
},
|
|
||||||
firefly: crate::core::config::FireflyConfig {
|
|
||||||
url: "https://test.com".to_string(),
|
|
||||||
api_key: "test".to_string(),
|
|
||||||
},
|
|
||||||
cache: crate::core::config::CacheConfig {
|
|
||||||
directory: format!("tmp/test-cache-status{}", suffix),
|
|
||||||
key: "test-key-for-status".to_string(),
|
|
||||||
},
|
|
||||||
logging: crate::core::config::LoggingConfig {
|
|
||||||
level: "info".to_string(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_test_gc_account(id: &str, iban: &str) -> GoCardlessAccount {
|
|
||||||
GoCardlessAccount {
|
|
||||||
id: id.to_string(),
|
|
||||||
iban: Some(iban.to_string()),
|
|
||||||
owner_name: Some("Test Owner".to_string()),
|
|
||||||
status: Some("READY".to_string()),
|
|
||||||
institution_id: Some("TEST_BANK".to_string()),
|
|
||||||
created: Some("2024-01-01T00:00:00Z".to_string()),
|
|
||||||
last_accessed: Some("2024-01-01T00:00:00Z".to_string()),
|
|
||||||
name: Some("Test Account".to_string()),
|
|
||||||
display_name: Some("Test Display Name".to_string()),
|
|
||||||
product: Some("Test Product".to_string()),
|
|
||||||
cash_account_type: Some("CACC".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_test_transaction(id: &str, date: &str) -> Transaction {
|
|
||||||
Transaction {
|
|
||||||
transaction_id: Some(id.to_string()),
|
|
||||||
entry_reference: None,
|
|
||||||
end_to_end_id: None,
|
|
||||||
mandate_id: None,
|
|
||||||
check_id: None,
|
|
||||||
creditor_id: None,
|
|
||||||
booking_date: Some(date.to_string()),
|
|
||||||
value_date: None,
|
|
||||||
booking_date_time: None,
|
|
||||||
value_date_time: None,
|
|
||||||
transaction_amount: gocardless_client::models::TransactionAmount {
|
|
||||||
amount: "100.00".to_string(),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
},
|
|
||||||
currency_exchange: None,
|
|
||||||
creditor_name: Some("Test Creditor".to_string()),
|
|
||||||
creditor_account: None,
|
|
||||||
ultimate_creditor: None,
|
|
||||||
debtor_name: None,
|
|
||||||
debtor_account: None,
|
|
||||||
ultimate_debtor: None,
|
|
||||||
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,
|
|
||||||
internal_transaction_id: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_account_status_with_data() {
|
|
||||||
// Setup
|
|
||||||
let config = create_test_config_with_suffix("-with-data");
|
|
||||||
let _ = std::fs::remove_dir_all(&config.cache.directory); // Clean up any existing test data
|
|
||||||
|
|
||||||
// Create a mock client (we won't actually use it for this test)
|
|
||||||
let client =
|
|
||||||
gocardless_client::client::GoCardlessClient::new("https://test.com", "test", "test")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let adapter = GoCardlessAdapter::new(client, config.clone());
|
|
||||||
|
|
||||||
// Add test accounts to the cache
|
|
||||||
let mut account_cache = adapter.cache.lock().await;
|
|
||||||
account_cache.insert(CachedAccount::GoCardless(Box::new(create_test_gc_account(
|
|
||||||
"acc1",
|
|
||||||
"DE12345678901234567890",
|
|
||||||
))));
|
|
||||||
account_cache.insert(CachedAccount::GoCardless(Box::new(create_test_gc_account(
|
|
||||||
"acc2",
|
|
||||||
"DE09876543210987654321",
|
|
||||||
))));
|
|
||||||
account_cache.save();
|
|
||||||
|
|
||||||
// Create transaction caches with data
|
|
||||||
let encryption = Encryption::new(config.cache.key.clone());
|
|
||||||
|
|
||||||
let mut cache1 = AccountTransactionCache::new(
|
|
||||||
"acc1".to_string(),
|
|
||||||
config.cache.directory.clone(),
|
|
||||||
encryption.clone(),
|
|
||||||
);
|
|
||||||
cache1.store_transactions(
|
|
||||||
chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
|
|
||||||
chrono::NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
|
||||||
vec![create_test_transaction("tx1", "2024-01-15")],
|
|
||||||
);
|
|
||||||
cache1.save().unwrap();
|
|
||||||
|
|
||||||
let mut cache2 = AccountTransactionCache::new(
|
|
||||||
"acc2".to_string(),
|
|
||||||
config.cache.directory.clone(),
|
|
||||||
encryption.clone(),
|
|
||||||
);
|
|
||||||
cache2.store_transactions(
|
|
||||||
chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
|
|
||||||
chrono::NaiveDate::from_ymd_opt(2024, 2, 28).unwrap(),
|
|
||||||
vec![
|
|
||||||
create_test_transaction("tx2", "2024-02-10"),
|
|
||||||
create_test_transaction("tx3", "2024-02-20"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
cache2.save().unwrap();
|
|
||||||
|
|
||||||
drop(account_cache); // Release the lock
|
|
||||||
|
|
||||||
// Test
|
|
||||||
let statuses = adapter.get_account_status().await.unwrap();
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
assert_eq!(statuses.len(), 2);
|
|
||||||
|
|
||||||
// Find status for acc1
|
|
||||||
let status1 = statuses.iter().find(|s| s.account_id == "acc1").unwrap();
|
|
||||||
assert_eq!(status1.iban, "DE12345678901234567890");
|
|
||||||
assert_eq!(status1.transaction_count, 1);
|
|
||||||
assert_eq!(status1.status, "synced");
|
|
||||||
assert_eq!(
|
|
||||||
status1.last_sync_date,
|
|
||||||
Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 31).unwrap())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find status for acc2
|
|
||||||
let status2 = statuses.iter().find(|s| s.account_id == "acc2").unwrap();
|
|
||||||
assert_eq!(status2.iban, "DE09876543210987654321");
|
|
||||||
assert_eq!(status2.transaction_count, 2);
|
|
||||||
assert_eq!(status2.status, "synced");
|
|
||||||
assert_eq!(
|
|
||||||
status2.last_sync_date,
|
|
||||||
Some(chrono::NaiveDate::from_ymd_opt(2024, 2, 28).unwrap())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&config.cache.directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_account_status_no_transaction_cache() {
|
|
||||||
// Setup
|
|
||||||
let config = create_test_config();
|
|
||||||
let _ = std::fs::remove_dir_all(&config.cache.directory);
|
|
||||||
|
|
||||||
let client =
|
|
||||||
gocardless_client::client::GoCardlessClient::new("https://test.com", "test", "test")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let adapter = GoCardlessAdapter::new(client, config.clone());
|
|
||||||
|
|
||||||
// Add test account to the cache but don't create transaction cache
|
|
||||||
let mut account_cache = adapter.cache.lock().await;
|
|
||||||
account_cache.insert(CachedAccount::GoCardless(Box::new(create_test_gc_account(
|
|
||||||
"acc_no_cache",
|
|
||||||
"DE11111111111111111111",
|
|
||||||
))));
|
|
||||||
account_cache.save();
|
|
||||||
drop(account_cache);
|
|
||||||
|
|
||||||
// Test
|
|
||||||
let statuses = adapter.get_account_status().await.unwrap();
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
assert_eq!(statuses.len(), 1);
|
|
||||||
let status = &statuses[0];
|
|
||||||
assert_eq!(status.account_id, "acc_no_cache");
|
|
||||||
assert_eq!(status.iban, "DE11111111111111111111");
|
|
||||||
assert_eq!(status.transaction_count, 0);
|
|
||||||
assert_eq!(status.status, "pending");
|
|
||||||
assert_eq!(status.last_sync_date, None);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&config.cache.directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_account_status_empty() {
|
|
||||||
// Setup
|
|
||||||
let config = create_test_config_with_suffix("-empty");
|
|
||||||
let _ = std::fs::remove_dir_all(&config.cache.directory);
|
|
||||||
|
|
||||||
let client =
|
|
||||||
gocardless_client::client::GoCardlessClient::new("https://test.com", "test", "test")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let adapter = GoCardlessAdapter::new(client, config.clone());
|
|
||||||
|
|
||||||
// Don't add any accounts to cache
|
|
||||||
|
|
||||||
// Test
|
|
||||||
let statuses = adapter.get_account_status().await.unwrap();
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
assert_eq!(statuses.len(), 0);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&config.cache.directory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
175
banks2ff/src/adapters/gocardless/encryption.rs
Normal file
175
banks2ff/src/adapters/gocardless/encryption.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! # Encryption Module
|
||||||
|
//!
|
||||||
|
//! Provides AES-GCM encryption for sensitive cache data using PBKDF2 key derivation.
|
||||||
|
//!
|
||||||
|
//! ## Security Considerations
|
||||||
|
//!
|
||||||
|
//! - **Algorithm**: AES-GCM (Authenticated Encryption) with 256-bit keys
|
||||||
|
//! - **Key Derivation**: PBKDF2 with 200,000 iterations for brute-force resistance
|
||||||
|
//! - **Salt**: Random 16-byte salt per encryption (prepended to ciphertext)
|
||||||
|
//! - **Nonce**: Random 96-bit nonce per encryption (prepended to ciphertext)
|
||||||
|
//! - **Key Source**: Environment variable `BANKS2FF_CACHE_KEY`
|
||||||
|
//!
|
||||||
|
//! ## Data Format
|
||||||
|
//!
|
||||||
|
//! Encrypted data format: `[salt(16)][nonce(12)][ciphertext]`
|
||||||
|
//!
|
||||||
|
//! ## Security Guarantees
|
||||||
|
//!
|
||||||
|
//! - **Confidentiality**: AES-GCM encryption protects data at rest
|
||||||
|
//! - **Integrity**: GCM authentication prevents tampering
|
||||||
|
//! - **Forward Security**: Unique salt/nonce per encryption prevents rainbow tables
|
||||||
|
//! - **Key Security**: PBKDF2 slows brute-force attacks
|
||||||
|
//!
|
||||||
|
//! ## Performance
|
||||||
|
//!
|
||||||
|
//! - Encryption: ~10-50μs for typical cache payloads
|
||||||
|
//! - Key derivation: ~50-100ms (computed once per operation)
|
||||||
|
//! - Memory: Minimal additional overhead
|
||||||
|
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use pbkdf2::pbkdf2_hmac;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
const KEY_LEN: usize = 32; // 256-bit key
|
||||||
|
const NONCE_LEN: usize = 12; // 96-bit nonce for AES-GCM
|
||||||
|
const SALT_LEN: usize = 16; // 128-bit salt for PBKDF2
|
||||||
|
|
||||||
|
pub struct Encryption;
|
||||||
|
|
||||||
|
impl Encryption {
|
||||||
|
/// Derive encryption key from environment variable and salt
|
||||||
|
pub fn derive_key(password: &str, salt: &[u8]) -> Key<Aes256Gcm> {
|
||||||
|
let mut key = [0u8; KEY_LEN];
|
||||||
|
pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, 200_000, &mut key);
|
||||||
|
key.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get password from environment variable
|
||||||
|
fn get_password() -> Result<String> {
|
||||||
|
env::var("BANKS2FF_CACHE_KEY")
|
||||||
|
.map_err(|_| anyhow!("BANKS2FF_CACHE_KEY environment variable not set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt data using AES-GCM
|
||||||
|
pub fn encrypt(data: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let password = Self::get_password()?;
|
||||||
|
|
||||||
|
// Generate random salt
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
rand::thread_rng().fill_bytes(&mut salt);
|
||||||
|
|
||||||
|
let key = Self::derive_key(&password, &salt);
|
||||||
|
let cipher = Aes256Gcm::new(&key);
|
||||||
|
|
||||||
|
// Generate random nonce
|
||||||
|
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, data)
|
||||||
|
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
|
// Prepend salt and nonce to ciphertext: [salt(16)][nonce(12)][ciphertext]
|
||||||
|
let mut result = salt.to_vec();
|
||||||
|
result.extend(nonce_bytes);
|
||||||
|
result.extend(ciphertext);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt data using AES-GCM
|
||||||
|
pub fn decrypt(encrypted_data: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let min_len = SALT_LEN + NONCE_LEN;
|
||||||
|
if encrypted_data.len() < min_len {
|
||||||
|
return Err(anyhow!("Encrypted data too short"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = Self::get_password()?;
|
||||||
|
|
||||||
|
// Extract salt, nonce and ciphertext: [salt(16)][nonce(12)][ciphertext]
|
||||||
|
let salt = &encrypted_data[..SALT_LEN];
|
||||||
|
let nonce = Nonce::from_slice(&encrypted_data[SALT_LEN..min_len]);
|
||||||
|
let ciphertext = &encrypted_data[min_len..];
|
||||||
|
|
||||||
|
let key = Self::derive_key(&password, salt);
|
||||||
|
let cipher = Aes256Gcm::new(&key);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|e| anyhow!("Decryption failed: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encrypt_decrypt_round_trip() {
|
||||||
|
// Set test environment variable
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-key-for-encryption");
|
||||||
|
|
||||||
|
let original_data = b"Hello, World! This is test data.";
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
let encrypted = Encryption::encrypt(original_data).expect("Encryption should succeed");
|
||||||
|
|
||||||
|
// Ensure env var is still set for decryption
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-key-for-encryption");
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let decrypted = Encryption::decrypt(&encrypted).expect("Decryption should succeed");
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert_eq!(original_data.to_vec(), decrypted);
|
||||||
|
assert_ne!(original_data.to_vec(), encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encrypt_decrypt_different_keys() {
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "key1");
|
||||||
|
let data = b"Test data";
|
||||||
|
let encrypted = Encryption::encrypt(data).unwrap();
|
||||||
|
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "key2");
|
||||||
|
let result = Encryption::decrypt(&encrypted);
|
||||||
|
assert!(result.is_err(), "Should fail with different key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_missing_env_var() {
|
||||||
|
// Save current value and restore after test
|
||||||
|
let original_value = env::var("BANKS2FF_CACHE_KEY").ok();
|
||||||
|
env::remove_var("BANKS2FF_CACHE_KEY");
|
||||||
|
|
||||||
|
let result = Encryption::get_password();
|
||||||
|
assert!(result.is_err(), "Should fail without env var");
|
||||||
|
|
||||||
|
// Restore original value
|
||||||
|
if let Some(val) = original_value {
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_small_data() {
|
||||||
|
// Set env var multiple times to ensure it's available
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-key");
|
||||||
|
let data = b"{}"; // Minimal JSON object
|
||||||
|
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-key");
|
||||||
|
let encrypted = Encryption::encrypt(data).unwrap();
|
||||||
|
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-key");
|
||||||
|
let decrypted = Encryption::decrypt(&encrypted).unwrap();
|
||||||
|
assert_eq!(data.to_vec(), decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ use std::str::FromStr;
|
|||||||
pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
||||||
let internal_id = tx
|
let internal_id = tx
|
||||||
.transaction_id
|
.transaction_id
|
||||||
.or(tx.internal_transaction_id)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Transaction ID missing"))?;
|
.ok_or_else(|| anyhow::anyhow!("Transaction ID missing"))?;
|
||||||
|
|
||||||
let date_str = tx
|
let date_str = tx
|
||||||
@@ -27,31 +26,14 @@ pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
|
|||||||
|
|
||||||
if let Some(exchanges) = tx.currency_exchange {
|
if let Some(exchanges) = tx.currency_exchange {
|
||||||
if let Some(exchange) = exchanges.first() {
|
if let Some(exchange) = exchanges.first() {
|
||||||
if let (Some(source_curr), Some(target_curr), Some(rate_str)) = (
|
if let (Some(source_curr), Some(rate_str)) =
|
||||||
&exchange.source_currency,
|
(&exchange.source_currency, &exchange.exchange_rate)
|
||||||
&exchange.target_currency,
|
{
|
||||||
&exchange.exchange_rate,
|
foreign_currency = Some(source_curr.clone());
|
||||||
) {
|
|
||||||
if let Ok(rate) = Decimal::from_str(rate_str) {
|
if let Ok(rate) = Decimal::from_str(rate_str) {
|
||||||
if !rate.is_zero() {
|
let calc = amount.abs() * rate;
|
||||||
let (foreign_curr, calc) = if currency == *target_curr {
|
let sign = amount.signum();
|
||||||
// Transaction is in target currency, foreign is source
|
foreign_amount = Some(calc * sign);
|
||||||
(source_curr.clone(), amount.abs() / rate)
|
|
||||||
} else if currency == *source_curr {
|
|
||||||
// Transaction is in source currency, foreign is target
|
|
||||||
(target_curr.clone(), amount.abs() * rate)
|
|
||||||
} else {
|
|
||||||
// Unexpected currency configuration, skip
|
|
||||||
tracing::warn!("Transaction currency '{}' does not match exchange source '{}' or target '{}', skipping foreign amount calculation",
|
|
||||||
currency, source_curr, target_curr);
|
|
||||||
(String::new(), Decimal::ZERO) // dummy values, will be skipped
|
|
||||||
};
|
|
||||||
if !foreign_curr.is_empty() {
|
|
||||||
foreign_currency = Some(foreign_curr);
|
|
||||||
let sign = amount.signum();
|
|
||||||
foreign_amount = Some(calc * sign);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,15 +108,8 @@ 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(),
|
||||||
@@ -142,19 +117,10 @@ 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();
|
||||||
@@ -166,43 +132,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_multicurrency_transaction_target_to_source() {
|
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(),
|
||||||
},
|
},
|
||||||
currency_exchange: Some(vec![CurrencyExchange {
|
currency_exchange: Some(vec![CurrencyExchange {
|
||||||
source_currency: Some("USD".into()),
|
source_currency: Some("USD".into()),
|
||||||
exchange_rate: Some("2.0".into()),
|
exchange_rate: Some("1.10".into()),
|
||||||
unit_currency: None,
|
unit_currency: None,
|
||||||
target_currency: Some("EUR".into()),
|
target_currency: Some("EUR".into()),
|
||||||
}]),
|
}]),
|
||||||
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();
|
||||||
@@ -210,65 +160,13 @@ mod tests {
|
|||||||
assert_eq!(res.amount, Decimal::new(-1000, 2));
|
assert_eq!(res.amount, Decimal::new(-1000, 2));
|
||||||
assert_eq!(res.foreign_currency, Some("USD".to_string()));
|
assert_eq!(res.foreign_currency, Some("USD".to_string()));
|
||||||
|
|
||||||
// Transaction in target (EUR), foreign in source (USD): 10.00 / 2.0 = 5.00, sign preserved (-5.00)
|
// 10.00 * 1.10 = 11.00. Sign should be preserved (-11.00)
|
||||||
assert_eq!(res.foreign_amount, Some(Decimal::new(-500, 2)));
|
assert_eq!(res.foreign_amount, Some(Decimal::new(-1100, 2)));
|
||||||
|
|
||||||
// Description fallback to creditor name
|
// Description fallback to creditor name
|
||||||
assert_eq!(res.description, "US Shop");
|
assert_eq!(res.description, "US Shop");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_multicurrency_transaction_source_to_target() {
|
|
||||||
let t = Transaction {
|
|
||||||
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()),
|
|
||||||
value_date: None,
|
|
||||||
booking_date_time: None,
|
|
||||||
value_date_time: None,
|
|
||||||
transaction_amount: TransactionAmount {
|
|
||||||
amount: "-10.00".into(),
|
|
||||||
currency: "USD".into(),
|
|
||||||
},
|
|
||||||
currency_exchange: Some(vec![CurrencyExchange {
|
|
||||||
source_currency: Some("USD".into()),
|
|
||||||
exchange_rate: Some("2.0".into()),
|
|
||||||
unit_currency: None,
|
|
||||||
target_currency: Some("EUR".into()),
|
|
||||||
}]),
|
|
||||||
creditor_name: Some("EU Shop".into()),
|
|
||||||
creditor_account: None,
|
|
||||||
ultimate_creditor: None,
|
|
||||||
debtor_name: None,
|
|
||||||
debtor_account: None,
|
|
||||||
ultimate_debtor: 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,
|
|
||||||
internal_transaction_id: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = map_transaction(t).unwrap();
|
|
||||||
assert_eq!(res.internal_id, "125");
|
|
||||||
assert_eq!(res.amount, Decimal::new(-1000, 2));
|
|
||||||
assert_eq!(res.foreign_currency, Some("EUR".to_string()));
|
|
||||||
|
|
||||||
// Transaction in source (USD), foreign in target (EUR): 10.00 * 2.0 = 20.00, sign preserved (-20.00)
|
|
||||||
assert_eq!(res.foreign_amount, Some(Decimal::new(-2000, 2)));
|
|
||||||
|
|
||||||
// Description fallback to creditor name
|
|
||||||
assert_eq!(res.description, "EU Shop");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_amount_zero() {
|
fn test_validate_amount_zero() {
|
||||||
let amount = Decimal::ZERO;
|
let amount = Decimal::ZERO;
|
||||||
@@ -303,15 +201,8 @@ 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(),
|
||||||
@@ -319,19 +210,10 @@ 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());
|
||||||
@@ -341,15 +223,8 @@ 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(),
|
||||||
@@ -357,81 +232,48 @@ 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_transaction_invalid_exchange_rate() {
|
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(),
|
||||||
},
|
},
|
||||||
currency_exchange: Some(vec![CurrencyExchange {
|
currency_exchange: Some(vec![CurrencyExchange {
|
||||||
source_currency: Some("USD".into()),
|
source_currency: Some("USD".into()),
|
||||||
exchange_rate: Some("0".into()), // Invalid rate is handled by not setting foreign_amount
|
exchange_rate: Some("0".into()), // This will make foreign_amount zero
|
||||||
unit_currency: None,
|
unit_currency: None,
|
||||||
target_currency: Some("EUR".into()),
|
target_currency: Some("EUR".into()),
|
||||||
}]),
|
}]),
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = map_transaction(t).unwrap();
|
assert!(map_transaction(t).is_err());
|
||||||
assert_eq!(res.foreign_amount, None); // Invalid rate results in no foreign_amount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
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(),
|
||||||
@@ -444,19 +286,10 @@ 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());
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod cache;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod encryption;
|
||||||
pub mod mapper;
|
pub mod mapper;
|
||||||
pub mod transaction_cache;
|
pub mod transaction_cache;
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
use crate::core::encryption::Encryption;
|
use crate::adapters::gocardless::encryption::Encryption;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::{Days, NaiveDate};
|
use chrono::{Days, NaiveDate};
|
||||||
use gocardless_client::models::Transaction;
|
use gocardless_client::models::Transaction;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct AccountTransactionCache {
|
pub struct AccountTransactionCache {
|
||||||
pub account_id: String,
|
pub account_id: String,
|
||||||
pub ranges: Vec<CachedRange>,
|
pub ranges: Vec<CachedRange>,
|
||||||
cache_dir: String,
|
|
||||||
encryption: Encryption,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
@@ -20,65 +18,45 @@ pub struct CachedRange {
|
|||||||
pub transactions: Vec<Transaction>,
|
pub transactions: Vec<Transaction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
||||||
pub struct AccountTransactionCacheData {
|
|
||||||
pub account_id: String,
|
|
||||||
pub ranges: Vec<CachedRange>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountTransactionCache {
|
impl AccountTransactionCache {
|
||||||
/// Create new cache with directory and encryption
|
|
||||||
pub fn new(account_id: String, cache_dir: String, encryption: Encryption) -> Self {
|
|
||||||
Self {
|
|
||||||
account_id,
|
|
||||||
cache_dir,
|
|
||||||
encryption,
|
|
||||||
ranges: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get cache file path for an account
|
/// Get cache file path for an account
|
||||||
fn get_cache_path(&self, account_id: &str) -> String {
|
fn get_cache_path(account_id: &str) -> String {
|
||||||
format!("{}/transactions/{}.enc", self.cache_dir, account_id)
|
let cache_dir =
|
||||||
|
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||||
|
format!("{}/transactions/{}.enc", cache_dir, account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load cache from disk
|
/// Load cache from disk
|
||||||
pub fn load(account_id: &str, cache_dir: String, encryption: Encryption) -> Result<Self> {
|
pub fn load(account_id: &str) -> Result<Self> {
|
||||||
let path = format!("{}/transactions/{}.enc", cache_dir, account_id);
|
let path = Self::get_cache_path(account_id);
|
||||||
|
|
||||||
if !Path::new(&path).exists() {
|
if !Path::new(&path).exists() {
|
||||||
// Return empty cache if file doesn't exist
|
// Return empty cache if file doesn't exist
|
||||||
return Ok(Self::new(account_id.to_string(), cache_dir, encryption));
|
return Ok(Self {
|
||||||
|
account_id: account_id.to_string(),
|
||||||
|
ranges: Vec::new(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read encrypted data
|
// Read encrypted data
|
||||||
let encrypted_data = std::fs::read(&path)?;
|
let encrypted_data = std::fs::read(&path)?;
|
||||||
let json_data = encryption.decrypt(&encrypted_data)?;
|
let json_data = Encryption::decrypt(&encrypted_data)?;
|
||||||
|
|
||||||
// Deserialize
|
// Deserialize
|
||||||
let cache_data: AccountTransactionCacheData = serde_json::from_slice(&json_data)?;
|
let cache: Self = serde_json::from_slice(&json_data)?;
|
||||||
Ok(Self {
|
Ok(cache)
|
||||||
account_id: cache_data.account_id,
|
|
||||||
ranges: cache_data.ranges,
|
|
||||||
cache_dir,
|
|
||||||
encryption,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save cache to disk
|
/// Save cache to disk
|
||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
// Serialize to JSON (only the data fields)
|
// Serialize to JSON
|
||||||
let cache_data = AccountTransactionCacheData {
|
let json_data = serde_json::to_vec(self)?;
|
||||||
account_id: self.account_id.clone(),
|
|
||||||
ranges: self.ranges.clone(),
|
|
||||||
};
|
|
||||||
let json_data = serde_json::to_vec(&cache_data)?;
|
|
||||||
|
|
||||||
// Encrypt
|
// Encrypt
|
||||||
let encrypted_data = self.encryption.encrypt(&json_data)?;
|
let encrypted_data = Encryption::encrypt(&json_data)?;
|
||||||
|
|
||||||
// Write to file (create directory if needed)
|
// Write to file (create directory if needed)
|
||||||
let path = self.get_cache_path(&self.account_id);
|
let path = Self::get_cache_path(&self.account_id);
|
||||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
@@ -283,12 +261,10 @@ impl AccountTransactionCache {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
fn create_unique_key(prefix: &str) -> String {
|
fn setup_test_env(test_name: &str) -> String {
|
||||||
format!("{}-{}", prefix, rand::random::<u64>())
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_test_dir(test_name: &str) -> String {
|
|
||||||
// Use a unique cache directory for each test to avoid interference
|
// Use a unique cache directory for each test to avoid interference
|
||||||
// Include random component and timestamp for true parallelism safety
|
// Include random component and timestamp for true parallelism safety
|
||||||
let random_suffix = rand::random::<u64>();
|
let random_suffix = rand::random::<u64>();
|
||||||
@@ -296,10 +272,12 @@ mod tests {
|
|||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
format!(
|
let cache_dir = format!(
|
||||||
"tmp/test-cache-{}-{}-{}",
|
"tmp/test-cache-{}-{}-{}",
|
||||||
test_name, random_suffix, timestamp
|
test_name, random_suffix, timestamp
|
||||||
)
|
);
|
||||||
|
env::set_var("BANKS2FF_CACHE_DIR", cache_dir.clone());
|
||||||
|
cache_dir
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup_test_dir(cache_dir: &str) {
|
fn cleanup_test_dir(cache_dir: &str) {
|
||||||
@@ -321,10 +299,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_nonexistent_cache() {
|
fn test_load_nonexistent_cache() {
|
||||||
let cache_dir = setup_test_dir("nonexistent");
|
let cache_dir = setup_test_env("nonexistent");
|
||||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
let cache = AccountTransactionCache::load("nonexistent").unwrap();
|
||||||
let cache =
|
|
||||||
AccountTransactionCache::load("nonexistent", cache_dir.clone(), encryption).unwrap();
|
|
||||||
assert_eq!(cache.account_id, "nonexistent");
|
assert_eq!(cache.account_id, "nonexistent");
|
||||||
assert!(cache.ranges.is_empty());
|
assert!(cache.ranges.is_empty());
|
||||||
cleanup_test_dir(&cache_dir);
|
cleanup_test_dir(&cache_dir);
|
||||||
@@ -332,24 +308,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_save_and_load_empty_cache() {
|
fn test_save_and_load_empty_cache() {
|
||||||
let cache_dir = setup_test_dir("empty");
|
let cache_dir = setup_test_env("empty");
|
||||||
let encryption_key = create_unique_key("test-key");
|
|
||||||
let encryption = Encryption::new(encryption_key.clone());
|
|
||||||
|
|
||||||
let cache = AccountTransactionCache::new(
|
let cache = AccountTransactionCache {
|
||||||
"test_account_empty".to_string(),
|
account_id: "test_account_empty".to_string(),
|
||||||
cache_dir.clone(),
|
ranges: Vec::new(),
|
||||||
encryption,
|
};
|
||||||
);
|
|
||||||
|
|
||||||
|
// Ensure env vars are set before save
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
|
// Ensure env vars are set before save
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
// Save
|
// Save
|
||||||
cache.save().expect("Save should succeed");
|
cache.save().expect("Save should succeed");
|
||||||
|
|
||||||
|
// Ensure env vars are set before load
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
// Load
|
// Load
|
||||||
let encryption = Encryption::new(encryption_key);
|
|
||||||
let loaded =
|
let loaded =
|
||||||
AccountTransactionCache::load("test_account_empty", cache_dir.clone(), encryption)
|
AccountTransactionCache::load("test_account_empty").expect("Load should succeed");
|
||||||
.expect("Load should succeed");
|
|
||||||
|
|
||||||
assert_eq!(loaded.account_id, "test_account_empty");
|
assert_eq!(loaded.account_id, "test_account_empty");
|
||||||
assert!(loaded.ranges.is_empty());
|
assert!(loaded.ranges.is_empty());
|
||||||
@@ -359,20 +336,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_save_and_load_with_data() {
|
fn test_save_and_load_with_data() {
|
||||||
let cache_dir = setup_test_dir("data");
|
let cache_dir = setup_test_env("data");
|
||||||
let encryption_key = create_unique_key("test-key");
|
|
||||||
|
|
||||||
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(),
|
||||||
@@ -380,19 +349,10 @@ 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 {
|
||||||
@@ -401,22 +361,21 @@ mod tests {
|
|||||||
transactions: vec![transaction],
|
transactions: vec![transaction],
|
||||||
};
|
};
|
||||||
|
|
||||||
let encryption = Encryption::new(encryption_key.clone());
|
let cache = AccountTransactionCache {
|
||||||
let mut cache = AccountTransactionCache::new(
|
account_id: "test_account_data".to_string(),
|
||||||
"test_account_data".to_string(),
|
ranges: vec![range],
|
||||||
cache_dir.clone(),
|
};
|
||||||
encryption,
|
|
||||||
);
|
|
||||||
cache.ranges = vec![range];
|
|
||||||
|
|
||||||
|
// Ensure env vars are set before save
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
// Save
|
// Save
|
||||||
cache.save().expect("Save should succeed");
|
cache.save().expect("Save should succeed");
|
||||||
|
|
||||||
|
// Ensure env vars are set before load
|
||||||
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
// Load
|
// Load
|
||||||
let encryption = Encryption::new(encryption_key);
|
|
||||||
let loaded =
|
let loaded =
|
||||||
AccountTransactionCache::load("test_account_data", cache_dir.clone(), encryption)
|
AccountTransactionCache::load("test_account_data").expect("Load should succeed");
|
||||||
.expect("Load should succeed");
|
|
||||||
|
|
||||||
assert_eq!(loaded.account_id, "test_account_data");
|
assert_eq!(loaded.account_id, "test_account_data");
|
||||||
assert_eq!(loaded.ranges.len(), 1);
|
assert_eq!(loaded.ranges.len(), 1);
|
||||||
@@ -431,32 +390,32 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_save_load_different_accounts() {
|
fn test_save_load_different_accounts() {
|
||||||
let cache_dir = setup_test_dir("different_accounts");
|
let cache_dir = setup_test_env("different_accounts");
|
||||||
let encryption_key_a = create_unique_key("test-key-a");
|
|
||||||
let encryption_key_b = create_unique_key("test-key-b");
|
|
||||||
|
|
||||||
// Save cache for account A
|
// Save cache for account A
|
||||||
let encryption_a = Encryption::new(encryption_key_a.clone());
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
let cache_a =
|
let cache_a = AccountTransactionCache {
|
||||||
AccountTransactionCache::new("account_a".to_string(), cache_dir.clone(), encryption_a);
|
account_id: "account_a".to_string(),
|
||||||
|
ranges: Vec::new(),
|
||||||
|
};
|
||||||
cache_a.save().unwrap();
|
cache_a.save().unwrap();
|
||||||
|
|
||||||
// Save cache for account B
|
// Save cache for account B
|
||||||
let encryption_b = Encryption::new(encryption_key_b.clone());
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
let cache_b =
|
let cache_b = AccountTransactionCache {
|
||||||
AccountTransactionCache::new("account_b".to_string(), cache_dir.clone(), encryption_b);
|
account_id: "account_b".to_string(),
|
||||||
|
ranges: Vec::new(),
|
||||||
|
};
|
||||||
cache_b.save().unwrap();
|
cache_b.save().unwrap();
|
||||||
|
|
||||||
// Load account A
|
// Load account A
|
||||||
let encryption_a = Encryption::new(encryption_key_a);
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
let loaded_a =
|
let loaded_a = AccountTransactionCache::load("account_a").unwrap();
|
||||||
AccountTransactionCache::load("account_a", cache_dir.clone(), encryption_a).unwrap();
|
|
||||||
assert_eq!(loaded_a.account_id, "account_a");
|
assert_eq!(loaded_a.account_id, "account_a");
|
||||||
|
|
||||||
// Load account B
|
// Load account B
|
||||||
let encryption_b = Encryption::new(encryption_key_b);
|
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||||
let loaded_b =
|
let loaded_b = AccountTransactionCache::load("account_b").unwrap();
|
||||||
AccountTransactionCache::load("account_b", cache_dir.clone(), encryption_b).unwrap();
|
|
||||||
assert_eq!(loaded_b.account_id, "account_b");
|
assert_eq!(loaded_b.account_id, "account_b");
|
||||||
|
|
||||||
cleanup_test_dir(&cache_dir);
|
cleanup_test_dir(&cache_dir);
|
||||||
@@ -464,9 +423,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_uncovered_ranges_no_cache() {
|
fn test_get_uncovered_ranges_no_cache() {
|
||||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
let cache = AccountTransactionCache {
|
||||||
let cache =
|
account_id: "test".to_string(),
|
||||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
ranges: Vec::new(),
|
||||||
|
};
|
||||||
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, 31).unwrap();
|
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
|
||||||
let uncovered = cache.get_uncovered_ranges(start, end);
|
let uncovered = cache.get_uncovered_ranges(start, end);
|
||||||
@@ -480,10 +440,10 @@ mod tests {
|
|||||||
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
||||||
transactions: Vec::new(),
|
transactions: Vec::new(),
|
||||||
};
|
};
|
||||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
let cache = AccountTransactionCache {
|
||||||
let mut cache =
|
account_id: "test".to_string(),
|
||||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
ranges: vec![range],
|
||||||
cache.ranges = vec![range];
|
};
|
||||||
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, 31).unwrap();
|
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
|
||||||
let uncovered = cache.get_uncovered_ranges(start, end);
|
let uncovered = cache.get_uncovered_ranges(start, end);
|
||||||
@@ -497,10 +457,10 @@ mod tests {
|
|||||||
end_date: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
|
end_date: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
|
||||||
transactions: Vec::new(),
|
transactions: Vec::new(),
|
||||||
};
|
};
|
||||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
let cache = AccountTransactionCache {
|
||||||
let mut cache =
|
account_id: "test".to_string(),
|
||||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
ranges: vec![range],
|
||||||
cache.ranges = vec![range];
|
};
|
||||||
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, 31).unwrap();
|
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
|
||||||
let uncovered = cache.get_uncovered_ranges(start, end);
|
let uncovered = cache.get_uncovered_ranges(start, end);
|
||||||
@@ -523,22 +483,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_store_transactions_and_merge() {
|
fn test_store_transactions_and_merge() {
|
||||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
let mut cache = AccountTransactionCache {
|
||||||
let mut cache =
|
account_id: "test".to_string(),
|
||||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
ranges: Vec::new(),
|
||||||
|
};
|
||||||
let start1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
let start1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||||
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(),
|
||||||
@@ -546,19 +500,10 @@ 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]);
|
||||||
|
|
||||||
@@ -572,15 +517,8 @@ 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(),
|
||||||
@@ -588,19 +526,10 @@ 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]);
|
||||||
|
|
||||||
@@ -613,22 +542,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_transaction_deduplication() {
|
fn test_transaction_deduplication() {
|
||||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
let mut cache = AccountTransactionCache {
|
||||||
let mut cache =
|
account_id: "test".to_string(),
|
||||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
ranges: Vec::new(),
|
||||||
|
};
|
||||||
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("tx1".to_string()),
|
transaction_id: Some("dup".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(),
|
||||||
@@ -636,19 +559,10 @@ 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]);
|
||||||
@@ -660,15 +574,8 @@ 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(),
|
||||||
@@ -676,29 +583,20 @@ 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(),
|
||||||
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
||||||
transactions: vec![tx1],
|
transactions: vec![tx1],
|
||||||
};
|
};
|
||||||
let encryption = Encryption::new(create_unique_key("test-key"));
|
let cache = AccountTransactionCache {
|
||||||
let mut cache =
|
account_id: "test".to_string(),
|
||||||
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
|
ranges: vec![range],
|
||||||
cache.ranges = vec![range];
|
};
|
||||||
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 cached = cache.get_cached_transactions(start, end);
|
let cached = cache.get_cached_transactions(start, end);
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
use crate::core::cache::AccountCache;
|
use crate::core::models::{AccountStatus, AccountSummary, CacheInfo, TransactionInfo};
|
||||||
use crate::core::models::{
|
|
||||||
AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
|
|
||||||
};
|
|
||||||
use comfy_table::{presets::UTF8_FULL, Table};
|
use comfy_table::{presets::UTF8_FULL, Table};
|
||||||
|
|
||||||
pub enum OutputFormat {
|
pub enum OutputFormat {
|
||||||
@@ -9,14 +6,10 @@ pub enum OutputFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait Formattable {
|
pub trait Formattable {
|
||||||
fn to_table(&self, account_cache: Option<&AccountCache>) -> Table;
|
fn to_table(&self) -> Table;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_list_output<T: Formattable>(
|
pub fn print_list_output<T: Formattable>(data: Vec<T>, format: &OutputFormat) {
|
||||||
data: Vec<T>,
|
|
||||||
format: &OutputFormat,
|
|
||||||
account_cache: Option<&AccountCache>,
|
|
||||||
) {
|
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
println!("No data available");
|
println!("No data available");
|
||||||
return;
|
return;
|
||||||
@@ -25,7 +18,7 @@ pub fn print_list_output<T: Formattable>(
|
|||||||
match format {
|
match format {
|
||||||
OutputFormat::Table => {
|
OutputFormat::Table => {
|
||||||
for item in data {
|
for item in data {
|
||||||
println!("{}", item.to_table(account_cache));
|
println!("{}", item.to_table());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,42 +26,33 @@ pub fn print_list_output<T: Formattable>(
|
|||||||
|
|
||||||
// Implement Formattable for the model structs
|
// Implement Formattable for the model structs
|
||||||
impl Formattable for AccountSummary {
|
impl Formattable for AccountSummary {
|
||||||
fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table {
|
fn to_table(&self) -> Table {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.load_preset(UTF8_FULL);
|
table.load_preset(UTF8_FULL);
|
||||||
table.set_header(vec!["Name", "IBAN", "Currency"]);
|
table.set_header(vec!["ID", "IBAN", "Currency", "Status"]);
|
||||||
let name = self.name.as_deref().unwrap_or("");
|
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
name.to_string(),
|
self.id.clone(),
|
||||||
mask_iban(&self.iban),
|
mask_iban(&self.iban),
|
||||||
self.currency.clone(),
|
self.currency.clone(),
|
||||||
|
self.status.clone(),
|
||||||
]);
|
]);
|
||||||
table
|
table
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Formattable for AccountStatus {
|
impl Formattable for AccountStatus {
|
||||||
fn to_table(&self, account_cache: Option<&AccountCache>) -> Table {
|
fn to_table(&self) -> Table {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.load_preset(UTF8_FULL);
|
table.load_preset(UTF8_FULL);
|
||||||
table.set_header(vec![
|
table.set_header(vec![
|
||||||
"Account",
|
"Account ID",
|
||||||
"IBAN",
|
"IBAN",
|
||||||
"Last Sync",
|
"Last Sync",
|
||||||
"Transaction Count",
|
"Transaction Count",
|
||||||
"Status",
|
"Status",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let display_name = if let Some(cache) = account_cache {
|
|
||||||
cache
|
|
||||||
.get_display_name(&self.account_id)
|
|
||||||
.unwrap_or_else(|| self.account_id.clone())
|
|
||||||
} else {
|
|
||||||
self.account_id.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
display_name,
|
self.account_id.clone(),
|
||||||
mask_iban(&self.iban),
|
mask_iban(&self.iban),
|
||||||
self.last_sync_date
|
self.last_sync_date
|
||||||
.map(|d| d.to_string())
|
.map(|d| d.to_string())
|
||||||
@@ -81,7 +65,7 @@ impl Formattable for AccountStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Formattable for TransactionInfo {
|
impl Formattable for TransactionInfo {
|
||||||
fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table {
|
fn to_table(&self) -> Table {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.load_preset(UTF8_FULL);
|
table.load_preset(UTF8_FULL);
|
||||||
table.set_header(vec![
|
table.set_header(vec![
|
||||||
@@ -107,31 +91,18 @@ impl Formattable for TransactionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Formattable for CacheInfo {
|
impl Formattable for CacheInfo {
|
||||||
fn to_table(&self, account_cache: Option<&AccountCache>) -> Table {
|
fn to_table(&self) -> Table {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.load_preset(UTF8_FULL);
|
table.load_preset(UTF8_FULL);
|
||||||
table.set_header(vec![
|
table.set_header(vec![
|
||||||
"Account",
|
"Account ID",
|
||||||
"Cache Type",
|
"Cache Type",
|
||||||
"Entry Count",
|
"Entry Count",
|
||||||
"Size (bytes)",
|
"Size (bytes)",
|
||||||
"Last Updated",
|
"Last Updated",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let account_display = if let Some(account_id) = &self.account_id {
|
|
||||||
if let Some(cache) = account_cache {
|
|
||||||
cache
|
|
||||||
.get_display_name(account_id)
|
|
||||||
.unwrap_or_else(|| account_id.clone())
|
|
||||||
} else {
|
|
||||||
account_id.clone()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"Global".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
account_display,
|
self.account_id.as_deref().unwrap_or("Global").to_string(),
|
||||||
self.cache_type.clone(),
|
self.cache_type.clone(),
|
||||||
self.entry_count.to_string(),
|
self.entry_count.to_string(),
|
||||||
self.total_size_bytes.to_string(),
|
self.total_size_bytes.to_string(),
|
||||||
@@ -143,67 +114,10 @@ impl Formattable for CacheInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Formattable for BankTransaction {
|
fn mask_iban(iban: &str) -> String {
|
||||||
fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec!["Date", "Amount", "Description", "Counterparty"]);
|
|
||||||
table.add_row(vec![
|
|
||||||
self.date.to_string(),
|
|
||||||
format!(
|
|
||||||
"{} {}",
|
|
||||||
mask_amount(&self.amount.to_string()),
|
|
||||||
self.currency
|
|
||||||
),
|
|
||||||
mask_description(&self.description),
|
|
||||||
self.counterparty_name
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("Unknown")
|
|
||||||
.to_string(),
|
|
||||||
]);
|
|
||||||
table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask_amount(amount: &str) -> String {
|
|
||||||
// Show only asterisks for amount, keep the sign and decimal places structure
|
|
||||||
if amount.starts_with('-') {
|
|
||||||
format!("-{}", "*".repeat(amount.len() - 1))
|
|
||||||
} else {
|
|
||||||
"*".repeat(amount.len())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask_description(description: &str) -> String {
|
|
||||||
if description.len() <= 10 {
|
|
||||||
description.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{}...", &description[..10])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mask_iban(iban: &str) -> String {
|
|
||||||
if iban.len() <= 4 {
|
if iban.len() <= 4 {
|
||||||
iban.to_string()
|
iban.to_string()
|
||||||
} else {
|
} else {
|
||||||
let country_code = &iban[0..2];
|
format!("{}{}", "*".repeat(iban.len() - 4), &iban[iban.len() - 4..])
|
||||||
let last_four = &iban[iban.len() - 4..];
|
|
||||||
|
|
||||||
if country_code == "NL" && iban.len() >= 12 {
|
|
||||||
// NL: show first 2 (CC) + next 6 + mask + last 4
|
|
||||||
let next_six = &iban[2..8];
|
|
||||||
let mask_length = iban.len() - 12; // 2 + 6 + 4 = 12, so mask_length = total - 12
|
|
||||||
format!(
|
|
||||||
"{}{}{}{}",
|
|
||||||
country_code,
|
|
||||||
next_six,
|
|
||||||
"*".repeat(mask_length),
|
|
||||||
last_four
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Other countries: show first 2 + mask + last 4
|
|
||||||
let mask_length = iban.len() - 6; // 2 + 4 = 6
|
|
||||||
format!("{}{}{}", country_code, "*".repeat(mask_length), last_four)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
pub mod formatters;
|
pub mod formatters;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod tables;
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use crate::adapters::firefly::client::FireflyAdapter;
|
use crate::adapters::firefly::client::FireflyAdapter;
|
||||||
|
|
||||||
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
use crate::adapters::gocardless::client::GoCardlessAdapter;
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::debug::DebugLogger;
|
use crate::debug::DebugLogger;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use firefly_client::client::FireflyClient;
|
use firefly_client::client::FireflyClient;
|
||||||
use gocardless_client::client::GoCardlessClient;
|
use gocardless_client::client::GoCardlessClient;
|
||||||
use reqwest_middleware::ClientBuilder;
|
use reqwest_middleware::ClientBuilder;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
pub struct AppContext {
|
pub struct AppContext {
|
||||||
pub source: GoCardlessAdapter,
|
pub source: GoCardlessAdapter,
|
||||||
@@ -14,38 +13,38 @@ pub struct AppContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppContext {
|
impl AppContext {
|
||||||
pub async fn new(config: Config, debug: bool) -> Result<Self> {
|
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
|
// Clients
|
||||||
let gc_client = if debug {
|
let gc_client = if debug {
|
||||||
let client = ClientBuilder::new(reqwest::Client::new())
|
let client = ClientBuilder::new(reqwest::Client::new())
|
||||||
.with(DebugLogger::new("gocardless"))
|
.with(DebugLogger::new("gocardless"))
|
||||||
.build();
|
.build();
|
||||||
GoCardlessClient::with_client(
|
GoCardlessClient::with_client(&gc_url, &gc_id, &gc_key, Some(client))?
|
||||||
&config.gocardless.url,
|
|
||||||
&config.gocardless.secret_id,
|
|
||||||
&config.gocardless.secret_key,
|
|
||||||
Some(client),
|
|
||||||
)?
|
|
||||||
} else {
|
} else {
|
||||||
GoCardlessClient::new(
|
GoCardlessClient::new(&gc_url, &gc_id, &gc_key)?
|
||||||
&config.gocardless.url,
|
|
||||||
&config.gocardless.secret_id,
|
|
||||||
&config.gocardless.secret_key,
|
|
||||||
)?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let ff_client = if debug {
|
let ff_client = if debug {
|
||||||
let client = ClientBuilder::new(reqwest::Client::new())
|
let client = ClientBuilder::new(reqwest::Client::new())
|
||||||
.with(DebugLogger::new("firefly"))
|
.with(DebugLogger::new("firefly"))
|
||||||
.build();
|
.build();
|
||||||
FireflyClient::with_client(&config.firefly.url, &config.firefly.api_key, Some(client))?
|
FireflyClient::with_client(&ff_url, &ff_key, Some(client))?
|
||||||
} else {
|
} else {
|
||||||
FireflyClient::new(&config.firefly.url, &config.firefly.api_key)?
|
FireflyClient::new(&ff_url, &ff_key)?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Adapters
|
// Adapters
|
||||||
let source = GoCardlessAdapter::new(gc_client, config.clone());
|
let source = GoCardlessAdapter::new(gc_client);
|
||||||
let destination = FireflyAdapter::new(ff_client, config);
|
let destination = FireflyAdapter::new(ff_client);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
use crate::core::cache::AccountCache;
|
|
||||||
use crate::core::models::{AccountStatus, AccountSummary};
|
|
||||||
use comfy_table::{presets::UTF8_FULL, Table};
|
|
||||||
|
|
||||||
pub fn print_accounts_table(accounts: &[AccountSummary]) {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec!["Name", "IBAN", "Currency"]);
|
|
||||||
|
|
||||||
for account in accounts {
|
|
||||||
let name = account.name.as_deref().unwrap_or("");
|
|
||||||
table.add_row(vec![
|
|
||||||
name.to_string(),
|
|
||||||
mask_iban(&account.iban),
|
|
||||||
account.currency.clone(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", table);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_links_table(
|
|
||||||
links: &[crate::core::linking::AccountLink],
|
|
||||||
account_cache: &crate::core::cache::AccountCache,
|
|
||||||
) {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec!["Source Account", "Destination Account", "Auto-Linked"]);
|
|
||||||
|
|
||||||
for link in links {
|
|
||||||
let source_name = account_cache
|
|
||||||
.get_display_name(&link.source_account_id)
|
|
||||||
.unwrap_or_else(|| format!("Account {}", &link.source_account_id));
|
|
||||||
let dest_name = account_cache
|
|
||||||
.get_display_name(&link.dest_account_id)
|
|
||||||
.unwrap_or_else(|| format!("Account {}", &link.dest_account_id));
|
|
||||||
let auto_linked = if link.auto_linked { "Yes" } else { "No" };
|
|
||||||
|
|
||||||
table.add_row(vec![source_name, dest_name, auto_linked.to_string()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", table);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_account_status_table(statuses: &[AccountStatus], account_cache: &AccountCache) {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec![
|
|
||||||
"Account",
|
|
||||||
"IBAN",
|
|
||||||
"Last Sync",
|
|
||||||
"Transaction Count",
|
|
||||||
"Status",
|
|
||||||
]);
|
|
||||||
|
|
||||||
for status in statuses {
|
|
||||||
let display_name = account_cache
|
|
||||||
.get_display_name(&status.account_id)
|
|
||||||
.unwrap_or_else(|| status.account_id.clone());
|
|
||||||
table.add_row(vec![
|
|
||||||
display_name,
|
|
||||||
mask_iban(&status.iban),
|
|
||||||
status
|
|
||||||
.last_sync_date
|
|
||||||
.map(|d| d.to_string())
|
|
||||||
.unwrap_or_else(|| "Never".to_string()),
|
|
||||||
status.transaction_count.to_string(),
|
|
||||||
status.status.clone(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", table);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mask_iban(iban: &str) -> String {
|
|
||||||
if iban.len() <= 4 {
|
|
||||||
iban.to_string()
|
|
||||||
} else {
|
|
||||||
let country_code = &iban[0..2];
|
|
||||||
let last_four = &iban[iban.len() - 4..];
|
|
||||||
|
|
||||||
if country_code == "NL" && iban.len() >= 12 {
|
|
||||||
// NL: show first 2 (CC) + next 6 + mask + last 4
|
|
||||||
let next_six = &iban[2..8];
|
|
||||||
let mask_length = iban.len() - 12; // 2 + 6 + 4 = 12, so mask_length = total - 12
|
|
||||||
format!(
|
|
||||||
"{}{}{}{}",
|
|
||||||
country_code,
|
|
||||||
next_six,
|
|
||||||
"*".repeat(mask_length),
|
|
||||||
last_four
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Other countries: show first 2 + mask + last 4
|
|
||||||
let mask_length = iban.len() - 6; // 2 + 4 = 6
|
|
||||||
format!("{}{}{}", country_code, "*".repeat(mask_length), last_four)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
use crate::core::cache::{AccountCache, CachedAccount};
|
|
||||||
use crate::core::linking::LinkStore;
|
|
||||||
use crate::core::models::{Account, AccountData};
|
|
||||||
use clap::Subcommand;
|
|
||||||
|
|
||||||
pub fn handle_interactive_link_creation(
|
|
||||||
link_store: &mut LinkStore,
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
// Get unlinked GoCardless accounts
|
|
||||||
let gocardless_accounts = get_gocardless_accounts(account_cache);
|
|
||||||
let unlinked_sources: Vec<_> = gocardless_accounts
|
|
||||||
.iter()
|
|
||||||
.filter(|acc| {
|
|
||||||
!link_store
|
|
||||||
.find_links_by_source(acc.id())
|
|
||||||
.iter()
|
|
||||||
.any(|link| link.dest_adapter_type == "firefly")
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if unlinked_sources.is_empty() {
|
|
||||||
println!("No unlinked source accounts found. All GoCardless accounts are already linked to Firefly III.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create selection items for dialoguer
|
|
||||||
let source_items: Vec<String> = unlinked_sources
|
|
||||||
.iter()
|
|
||||||
.map(|account| {
|
|
||||||
let display_name = account
|
|
||||||
.display_name()
|
|
||||||
.unwrap_or_else(|| account.id().to_string());
|
|
||||||
display_name.to_string()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Add cancel option
|
|
||||||
let mut items = source_items.clone();
|
|
||||||
items.push("Cancel".to_string());
|
|
||||||
|
|
||||||
// Prompt user to select source account
|
|
||||||
let source_selection =
|
|
||||||
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
|
||||||
.with_prompt("Select a source account to link")
|
|
||||||
.items(&items)
|
|
||||||
.default(0)
|
|
||||||
.interact()
|
|
||||||
{
|
|
||||||
Ok(selection) => selection,
|
|
||||||
Err(_) => {
|
|
||||||
// Non-interactive environment (e.g., tests, scripts)
|
|
||||||
println!("Interactive mode not available in this environment.");
|
|
||||||
println!("Use: banks2ff accounts link create <source> <destination>");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if source_selection == items.len() - 1 {
|
|
||||||
// User selected "Cancel"
|
|
||||||
println!("Operation cancelled.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected_source = &unlinked_sources[source_selection];
|
|
||||||
handle_source_selection(link_store, account_cache, selected_source.id().to_string())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_single_arg_link_creation(
|
|
||||||
link_store: &mut LinkStore,
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
arg: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
// Try to find account by ID, name, or IBAN
|
|
||||||
let matched_account = find_account_by_identifier(account_cache, arg);
|
|
||||||
|
|
||||||
match matched_account {
|
|
||||||
Some((account_id, adapter_type)) => {
|
|
||||||
if adapter_type == "gocardless" {
|
|
||||||
// It's a source account - show available destinations
|
|
||||||
handle_source_selection(link_store, account_cache, account_id)
|
|
||||||
} else {
|
|
||||||
// It's a destination account - show available sources
|
|
||||||
handle_destination_selection(link_store, account_cache, account_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("No account found matching '{}'.", arg);
|
|
||||||
println!("Try using an account ID, name, or IBAN pattern.");
|
|
||||||
println!("Run 'banks2ff accounts list' to see available accounts.");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_direct_link_creation(
|
|
||||||
link_store: &mut LinkStore,
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
source_arg: &str,
|
|
||||||
dest_arg: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let source_match = find_account_by_identifier(account_cache, source_arg);
|
|
||||||
let dest_match = find_account_by_identifier(account_cache, dest_arg);
|
|
||||||
|
|
||||||
match (source_match, dest_match) {
|
|
||||||
(Some((source_id, source_adapter)), Some((dest_id, dest_adapter))) => {
|
|
||||||
if source_adapter != "gocardless" {
|
|
||||||
println!(
|
|
||||||
"Error: Source must be a GoCardless account, got {} account.",
|
|
||||||
source_adapter
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if dest_adapter != "firefly" {
|
|
||||||
println!(
|
|
||||||
"Error: Destination must be a Firefly III account, got {} account.",
|
|
||||||
dest_adapter
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
create_link(
|
|
||||||
link_store,
|
|
||||||
account_cache,
|
|
||||||
&source_id,
|
|
||||||
&dest_id,
|
|
||||||
&dest_adapter,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
(None, _) => {
|
|
||||||
println!("Source account '{}' not found.", source_arg);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
(_, None) => {
|
|
||||||
println!("Destination account '{}' not found.", dest_arg);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_account_by_identifier(
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
identifier: &str,
|
|
||||||
) -> Option<(String, String)> {
|
|
||||||
// First try exact ID match
|
|
||||||
if let Some(adapter_type) = account_cache.get_adapter_type(identifier) {
|
|
||||||
return Some((identifier.to_string(), adapter_type.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then try name/IBAN matching
|
|
||||||
for (id, account) in &account_cache.accounts {
|
|
||||||
if let Some(display_name) = account.display_name() {
|
|
||||||
if display_name
|
|
||||||
.to_lowercase()
|
|
||||||
.contains(&identifier.to_lowercase())
|
|
||||||
{
|
|
||||||
let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) {
|
|
||||||
"gocardless"
|
|
||||||
} else {
|
|
||||||
"firefly"
|
|
||||||
};
|
|
||||||
return Some((id.clone(), adapter_type.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(iban) = account.iban() {
|
|
||||||
if iban.contains(identifier) {
|
|
||||||
let adapter_type = if matches!(account, CachedAccount::GoCardless(_)) {
|
|
||||||
"gocardless"
|
|
||||||
} else {
|
|
||||||
"firefly"
|
|
||||||
};
|
|
||||||
return Some((id.clone(), adapter_type.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_source_selection(
|
|
||||||
link_store: &mut LinkStore,
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
source_id: String,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
// Check if source is already linked to firefly
|
|
||||||
if let Some(existing_link) = link_store.find_link_by_source_and_dest_type(&source_id, "firefly")
|
|
||||||
{
|
|
||||||
let dest_name = account_cache
|
|
||||||
.get_display_name(&existing_link.dest_account_id)
|
|
||||||
.unwrap_or_else(|| existing_link.dest_account_id.clone());
|
|
||||||
println!(
|
|
||||||
"Source account '{}' is already linked to '{}'.",
|
|
||||||
account_cache
|
|
||||||
.get_display_name(&source_id)
|
|
||||||
.unwrap_or_else(|| source_id.clone()),
|
|
||||||
dest_name
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get available Firefly destinations
|
|
||||||
let firefly_accounts = get_firefly_accounts(account_cache);
|
|
||||||
|
|
||||||
if firefly_accounts.is_empty() {
|
|
||||||
println!("No Firefly III accounts found. Run sync first.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create selection items for dialoguer
|
|
||||||
let dest_items: Vec<String> = firefly_accounts
|
|
||||||
.iter()
|
|
||||||
.map(|account| {
|
|
||||||
let display_name = account
|
|
||||||
.display_name()
|
|
||||||
.unwrap_or_else(|| account.id().to_string());
|
|
||||||
display_name.to_string()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Add cancel option
|
|
||||||
let mut items = dest_items.clone();
|
|
||||||
items.push("Cancel".to_string());
|
|
||||||
|
|
||||||
// Prompt user to select destination account
|
|
||||||
let source_name = account_cache
|
|
||||||
.get_display_name(&source_id)
|
|
||||||
.unwrap_or_else(|| source_id.clone());
|
|
||||||
let dest_selection =
|
|
||||||
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
|
||||||
.with_prompt(format!(
|
|
||||||
"Select a destination account for '{}'",
|
|
||||||
source_name
|
|
||||||
))
|
|
||||||
.items(&items)
|
|
||||||
.default(0)
|
|
||||||
.interact()
|
|
||||||
{
|
|
||||||
Ok(selection) => selection,
|
|
||||||
Err(_) => {
|
|
||||||
// Non-interactive environment (e.g., tests, scripts)
|
|
||||||
println!("Interactive mode not available in this environment.");
|
|
||||||
println!("Use: banks2ff accounts link create <source> <destination>");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if dest_selection == items.len() - 1 {
|
|
||||||
// User selected "Cancel"
|
|
||||||
println!("Operation cancelled.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected_dest = &firefly_accounts[dest_selection];
|
|
||||||
create_link(
|
|
||||||
link_store,
|
|
||||||
account_cache,
|
|
||||||
&source_id,
|
|
||||||
selected_dest.id(),
|
|
||||||
"firefly",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_destination_selection(
|
|
||||||
link_store: &mut LinkStore,
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
dest_id: String,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
// Get available GoCardless sources that aren't already linked to this destination
|
|
||||||
let gocardless_accounts = get_gocardless_accounts(account_cache);
|
|
||||||
let available_sources: Vec<_> = gocardless_accounts
|
|
||||||
.iter()
|
|
||||||
.filter(|acc| {
|
|
||||||
!link_store
|
|
||||||
.find_links_by_source(acc.id())
|
|
||||||
.iter()
|
|
||||||
.any(|link| link.dest_account_id == dest_id)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if available_sources.is_empty() {
|
|
||||||
println!(
|
|
||||||
"No available source accounts found that can link to '{}'.",
|
|
||||||
account_cache
|
|
||||||
.get_display_name(&dest_id)
|
|
||||||
.unwrap_or_else(|| dest_id.clone())
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create selection items for dialoguer
|
|
||||||
let source_items: Vec<String> = available_sources
|
|
||||||
.iter()
|
|
||||||
.map(|account| {
|
|
||||||
let display_name = account
|
|
||||||
.display_name()
|
|
||||||
.unwrap_or_else(|| account.id().to_string());
|
|
||||||
display_name.to_string()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Add cancel option
|
|
||||||
let mut items = source_items.clone();
|
|
||||||
items.push("Cancel".to_string());
|
|
||||||
|
|
||||||
// Prompt user to select source account
|
|
||||||
let dest_name = account_cache
|
|
||||||
.get_display_name(&dest_id)
|
|
||||||
.unwrap_or_else(|| dest_id.clone());
|
|
||||||
let source_selection =
|
|
||||||
match dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default())
|
|
||||||
.with_prompt(format!(
|
|
||||||
"Select a source account to link to '{}'",
|
|
||||||
dest_name
|
|
||||||
))
|
|
||||||
.items(&items)
|
|
||||||
.default(0)
|
|
||||||
.interact()
|
|
||||||
{
|
|
||||||
Ok(selection) => selection,
|
|
||||||
Err(_) => {
|
|
||||||
// Non-interactive environment (e.g., tests, scripts)
|
|
||||||
println!("Interactive mode not available in this environment.");
|
|
||||||
println!("Use: banks2ff accounts link create <source> <destination>");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if source_selection == items.len() - 1 {
|
|
||||||
// User selected "Cancel"
|
|
||||||
println!("Operation cancelled.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected_source = &available_sources[source_selection];
|
|
||||||
create_link(
|
|
||||||
link_store,
|
|
||||||
account_cache,
|
|
||||||
selected_source.id(),
|
|
||||||
&dest_id,
|
|
||||||
"firefly",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_link(
|
|
||||||
link_store: &mut LinkStore,
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
source_id: &str,
|
|
||||||
dest_id: &str,
|
|
||||||
dest_adapter_type: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let source_acc = account_cache.get_account(source_id);
|
|
||||||
let dest_acc = account_cache.get_account(dest_id);
|
|
||||||
|
|
||||||
if let (Some(src), Some(dst)) = (source_acc, dest_acc) {
|
|
||||||
let src_minimal = Account {
|
|
||||||
id: src.id().to_string(),
|
|
||||||
name: Some(src.id().to_string()),
|
|
||||||
iban: src.iban().map(|s| s.to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
};
|
|
||||||
let dst_minimal = Account {
|
|
||||||
id: dst.id().to_string(),
|
|
||||||
name: Some(dst.id().to_string()),
|
|
||||||
iban: dst.iban().map(|s| s.to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match link_store.add_link(
|
|
||||||
&src_minimal,
|
|
||||||
&dst_minimal,
|
|
||||||
"gocardless",
|
|
||||||
dest_adapter_type,
|
|
||||||
false,
|
|
||||||
) {
|
|
||||||
Ok(true) => {
|
|
||||||
link_store.save()?;
|
|
||||||
let src_display = account_cache
|
|
||||||
.get_display_name(source_id)
|
|
||||||
.unwrap_or_else(|| source_id.to_string());
|
|
||||||
let dst_display = account_cache
|
|
||||||
.get_display_name(dest_id)
|
|
||||||
.unwrap_or_else(|| dest_id.to_string());
|
|
||||||
println!("Created link between {} and {}", src_display, dst_display);
|
|
||||||
}
|
|
||||||
Ok(false) => {
|
|
||||||
let src_display = account_cache
|
|
||||||
.get_display_name(source_id)
|
|
||||||
.unwrap_or_else(|| source_id.to_string());
|
|
||||||
let dst_display = account_cache
|
|
||||||
.get_display_name(dest_id)
|
|
||||||
.unwrap_or_else(|| dest_id.to_string());
|
|
||||||
println!(
|
|
||||||
"Link between {} and {} already exists",
|
|
||||||
src_display, dst_display
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("Cannot create link: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!("Account not found in cache. Run sync first.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_gocardless_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> {
|
|
||||||
account_cache
|
|
||||||
.accounts
|
|
||||||
.values()
|
|
||||||
.filter_map(|acc| match acc {
|
|
||||||
CachedAccount::GoCardless(gc_acc) => Some(gc_acc.as_ref() as &dyn AccountData),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_firefly_accounts(account_cache: &AccountCache) -> Vec<&dyn AccountData> {
|
|
||||||
account_cache
|
|
||||||
.accounts
|
|
||||||
.values()
|
|
||||||
.filter_map(|acc| match acc {
|
|
||||||
CachedAccount::Firefly(ff_acc) => Some(ff_acc.as_ref() as &dyn AccountData),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command handlers
|
|
||||||
use crate::cli::tables::print_links_table;
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::encryption::Encryption;
|
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
pub enum LinkCommands {
|
|
||||||
/// List all account links
|
|
||||||
List,
|
|
||||||
/// Create a new account link
|
|
||||||
Create {
|
|
||||||
/// Source account identifier (ID, IBAN, or name). If omitted, interactive mode is used.
|
|
||||||
source_account: Option<String>,
|
|
||||||
/// Destination account identifier (ID, IBAN, or name). Required if source is provided.
|
|
||||||
dest_account: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_link_commands(config: Config, subcommand: LinkCommands) -> anyhow::Result<()> {
|
|
||||||
match subcommand {
|
|
||||||
LinkCommands::List => {
|
|
||||||
handle_link_list(config).await?;
|
|
||||||
}
|
|
||||||
LinkCommands::Create {
|
|
||||||
source_account,
|
|
||||||
dest_account,
|
|
||||||
} => {
|
|
||||||
handle_link_create(config, source_account, dest_account).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_link_list(config: Config) -> anyhow::Result<()> {
|
|
||||||
let encryption = Encryption::new(config.cache.key.clone());
|
|
||||||
let link_store = LinkStore::load(config.cache.directory.clone());
|
|
||||||
let account_cache =
|
|
||||||
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
|
|
||||||
|
|
||||||
if link_store.links.is_empty() {
|
|
||||||
println!("No account links found.");
|
|
||||||
} else {
|
|
||||||
print_links_table(&link_store.links, &account_cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_link_create(
|
|
||||||
config: Config,
|
|
||||||
source_account: Option<String>,
|
|
||||||
dest_account: Option<String>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let encryption = Encryption::new(config.cache.key.clone());
|
|
||||||
let mut link_store = LinkStore::load(config.cache.directory.clone());
|
|
||||||
let account_cache =
|
|
||||||
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
|
|
||||||
|
|
||||||
match (source_account, dest_account) {
|
|
||||||
(None, None) => {
|
|
||||||
// Interactive mode
|
|
||||||
handle_interactive_link_creation(&mut link_store, &account_cache)?;
|
|
||||||
}
|
|
||||||
(Some(source), None) => {
|
|
||||||
// Single argument - try to resolve as source or destination
|
|
||||||
handle_single_arg_link_creation(&mut link_store, &account_cache, &source)?;
|
|
||||||
}
|
|
||||||
(Some(source), Some(dest)) => {
|
|
||||||
// Two arguments - direct linking
|
|
||||||
handle_direct_link_creation(&mut link_store, &account_cache, &source, &dest)?;
|
|
||||||
}
|
|
||||||
(None, Some(_)) => {
|
|
||||||
println!("Error: Cannot specify destination without source. Use 'banks2ff accounts link create <source> <destination>' or interactive mode.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
use crate::cli::setup::AppContext;
|
|
||||||
use crate::cli::tables::print_accounts_table;
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::ports::{TransactionDestination, TransactionSource};
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
pub async fn handle_list(config: Config, filter: Option<String>) -> anyhow::Result<()> {
|
|
||||||
let context = AppContext::new(config, false).await?;
|
|
||||||
|
|
||||||
// Validate filter parameter
|
|
||||||
let show_gocardless = match filter.as_deref() {
|
|
||||||
Some("gocardless") => true,
|
|
||||||
Some("firefly") => false,
|
|
||||||
None => true, // Show both by default
|
|
||||||
Some(invalid) => {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Invalid filter '{}'. Use 'gocardless', 'firefly', or omit for all.",
|
|
||||||
invalid
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let show_firefly = match filter.as_deref() {
|
|
||||||
Some("gocardless") => false,
|
|
||||||
Some("firefly") => true,
|
|
||||||
None => true, // Show both by default
|
|
||||||
Some(_) => unreachable!(), // Already validated above
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get GoCardless accounts if needed
|
|
||||||
let gocardless_accounts = if show_gocardless {
|
|
||||||
match context.source.list_accounts().await {
|
|
||||||
Ok(mut accounts) => {
|
|
||||||
accounts.sort_by(|a, b| {
|
|
||||||
a.name
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.cmp(b.name.as_deref().unwrap_or(""))
|
|
||||||
});
|
|
||||||
accounts
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to list GoCardless accounts: {}", e);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get Firefly III accounts if needed
|
|
||||||
let firefly_accounts = if show_firefly {
|
|
||||||
match context.destination.list_accounts().await {
|
|
||||||
Ok(mut accounts) => {
|
|
||||||
accounts.sort_by(|a, b| {
|
|
||||||
a.name
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.cmp(b.name.as_deref().unwrap_or(""))
|
|
||||||
});
|
|
||||||
accounts
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to list Firefly III accounts: {}", e);
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
if gocardless_accounts.is_empty() && firefly_accounts.is_empty() {
|
|
||||||
println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
|
|
||||||
} else {
|
|
||||||
// Print GoCardless accounts
|
|
||||||
if !gocardless_accounts.is_empty() {
|
|
||||||
println!("GoCardless Accounts ({}):", gocardless_accounts.len());
|
|
||||||
print_accounts_table(&gocardless_accounts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print Firefly III accounts
|
|
||||||
if !firefly_accounts.is_empty() {
|
|
||||||
if !gocardless_accounts.is_empty() {
|
|
||||||
println!(); // Add spacing between tables
|
|
||||||
}
|
|
||||||
println!("Firefly III Accounts ({}):", firefly_accounts.len());
|
|
||||||
print_accounts_table(&firefly_accounts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
pub mod link;
|
|
||||||
pub mod list;
|
|
||||||
pub mod status;
|
|
||||||
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use clap::Subcommand;
|
|
||||||
|
|
||||||
use link::handle_link_commands;
|
|
||||||
|
|
||||||
use link::LinkCommands;
|
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
pub enum AccountCommands {
|
|
||||||
/// Manage account links between sources and destinations
|
|
||||||
Link {
|
|
||||||
#[command(subcommand)]
|
|
||||||
subcommand: LinkCommands,
|
|
||||||
},
|
|
||||||
/// List all accounts
|
|
||||||
List {
|
|
||||||
/// Filter by adapter type: 'gocardless' or 'firefly', or omit for all
|
|
||||||
filter: Option<String>,
|
|
||||||
},
|
|
||||||
/// Show account status
|
|
||||||
Status,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> {
|
|
||||||
match subcommand {
|
|
||||||
AccountCommands::Link {
|
|
||||||
subcommand: link_sub,
|
|
||||||
} => {
|
|
||||||
handle_link_commands(config.clone(), link_sub).await?;
|
|
||||||
}
|
|
||||||
AccountCommands::List { filter } => {
|
|
||||||
list::handle_list(config, filter).await?;
|
|
||||||
}
|
|
||||||
AccountCommands::Status => {
|
|
||||||
status::handle_status(config).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use crate::cli::setup::AppContext;
|
|
||||||
use crate::cli::tables::print_account_status_table;
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::encryption::Encryption;
|
|
||||||
use crate::core::ports::TransactionSource;
|
|
||||||
|
|
||||||
pub async fn handle_status(config: Config) -> anyhow::Result<()> {
|
|
||||||
let context = AppContext::new(config.clone(), false).await?;
|
|
||||||
let encryption = Encryption::new(config.cache.key.clone());
|
|
||||||
let account_cache =
|
|
||||||
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
|
|
||||||
|
|
||||||
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_account_status_table(&status, &account_cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
use crate::core::adapters::{get_available_destinations, get_available_sources};
|
|
||||||
|
|
||||||
pub async fn handle_sources() -> anyhow::Result<()> {
|
|
||||||
println!("Available sources:");
|
|
||||||
for source in get_available_sources() {
|
|
||||||
println!(" {} - {}", source.id, source.description);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_destinations() -> anyhow::Result<()> {
|
|
||||||
println!("Available destinations:");
|
|
||||||
for destination in get_available_destinations() {
|
|
||||||
println!(" {} - {}", destination.id, destination.description);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod accounts;
|
|
||||||
pub mod list;
|
|
||||||
pub mod sync;
|
|
||||||
pub mod transactions;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
use crate::cli::setup::AppContext;
|
|
||||||
use crate::core::adapters::{
|
|
||||||
get_available_destinations, get_available_sources, is_valid_destination, is_valid_source,
|
|
||||||
};
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::sync::run_sync;
|
|
||||||
use chrono::NaiveDate;
|
|
||||||
use tracing::{error, info};
|
|
||||||
|
|
||||||
pub async fn handle_sync(
|
|
||||||
config: Config,
|
|
||||||
debug: bool,
|
|
||||||
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(config.clone(), debug).await?;
|
|
||||||
|
|
||||||
// Run sync
|
|
||||||
match run_sync(
|
|
||||||
context.source,
|
|
||||||
context.destination,
|
|
||||||
config,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
dry_run,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => {
|
|
||||||
info!("Sync completed successfully.");
|
|
||||||
info!(
|
|
||||||
"Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
|
|
||||||
result.accounts_processed,
|
|
||||||
result.accounts_skipped_expired,
|
|
||||||
result.accounts_skipped_errors
|
|
||||||
);
|
|
||||||
info!(
|
|
||||||
"Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
|
||||||
result.ingest.created,
|
|
||||||
result.ingest.healed,
|
|
||||||
result.ingest.duplicates,
|
|
||||||
result.ingest.errors
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => error!("Sync failed: {}", e),
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use crate::cli::setup::AppContext;
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::encryption::Encryption;
|
|
||||||
use crate::core::ports::TransactionSource;
|
|
||||||
use comfy_table::{presets::UTF8_FULL, Table};
|
|
||||||
|
|
||||||
pub async fn handle_cache_status(config: Config) -> anyhow::Result<()> {
|
|
||||||
let context = AppContext::new(config.clone(), false).await?;
|
|
||||||
|
|
||||||
// Load account cache for display name resolution
|
|
||||||
let encryption = Encryption::new(config.cache.key.clone());
|
|
||||||
let account_cache =
|
|
||||||
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
|
|
||||||
|
|
||||||
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.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate cache info into account and transaction caches
|
|
||||||
let mut account_caches = Vec::new();
|
|
||||||
let mut transaction_caches = Vec::new();
|
|
||||||
|
|
||||||
for info in cache_info {
|
|
||||||
if info.cache_type == "account" {
|
|
||||||
account_caches.push(info);
|
|
||||||
} else if info.cache_type == "transaction" {
|
|
||||||
transaction_caches.push(info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print account cache table
|
|
||||||
if !account_caches.is_empty() {
|
|
||||||
println!("Account Cache:");
|
|
||||||
print_cache_table(&account_caches, &account_cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print transaction caches table
|
|
||||||
if !transaction_caches.is_empty() {
|
|
||||||
if !account_caches.is_empty() {
|
|
||||||
println!(); // Add spacing between tables
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
"Transaction Caches ({} accounts):",
|
|
||||||
transaction_caches.len()
|
|
||||||
);
|
|
||||||
print_cache_table(&transaction_caches, &account_cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_cache_table(
|
|
||||||
cache_info: &[crate::core::models::CacheInfo],
|
|
||||||
account_cache: &crate::core::cache::AccountCache,
|
|
||||||
) {
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec!["Account", "Cache Type", "Entry Count", "Last Updated"]);
|
|
||||||
|
|
||||||
for info in cache_info {
|
|
||||||
let account_display = if let Some(account_id) = &info.account_id {
|
|
||||||
account_cache
|
|
||||||
.get_display_name(account_id)
|
|
||||||
.unwrap_or_else(|| account_id.clone())
|
|
||||||
} else {
|
|
||||||
"Global".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
table.add_row(vec![
|
|
||||||
account_display,
|
|
||||||
info.cache_type.clone(),
|
|
||||||
info.entry_count.to_string(),
|
|
||||||
info.last_updated
|
|
||||||
.map(|d| d.to_string())
|
|
||||||
.unwrap_or_else(|| "Never".to_string()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", table);
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
use crate::cli::formatters::{print_list_output, OutputFormat};
|
|
||||||
use crate::cli::setup::AppContext;
|
|
||||||
use crate::commands::accounts::link::get_gocardless_accounts;
|
|
||||||
use crate::core::cache::AccountCache;
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::encryption::Encryption;
|
|
||||||
use crate::core::ports::TransactionSource;
|
|
||||||
use chrono::Days;
|
|
||||||
use dialoguer::{theme::ColorfulTheme, Select};
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
|
|
||||||
pub async fn handle_list(
|
|
||||||
config: Config,
|
|
||||||
account: Option<String>,
|
|
||||||
details: bool,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let context = AppContext::new(config.clone(), false).await?;
|
|
||||||
|
|
||||||
// Load account cache for display name resolution
|
|
||||||
let encryption = Encryption::new(config.cache.key.clone());
|
|
||||||
let account_cache =
|
|
||||||
crate::core::cache::AccountCache::load(config.cache.directory.clone(), encryption);
|
|
||||||
|
|
||||||
let account_id = match account {
|
|
||||||
Some(identifier) => {
|
|
||||||
// Try to resolve the identifier
|
|
||||||
match find_transaction_account(&account_cache, &identifier) {
|
|
||||||
Some(id) => id,
|
|
||||||
None => {
|
|
||||||
println!("No account found matching '{}'.", identifier);
|
|
||||||
println!("Try using an account ID, name, or IBAN pattern.");
|
|
||||||
println!("Run 'banks2ff transactions list' for interactive selection.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Interactive mode
|
|
||||||
match select_account_interactive(&account_cache, &context.source).await? {
|
|
||||||
Some(id) => id,
|
|
||||||
None => {
|
|
||||||
println!("Operation cancelled.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if details {
|
|
||||||
show_transaction_details(&context.source, &account_id, limit).await?;
|
|
||||||
} else {
|
|
||||||
show_transaction_summary(&context.source, &account_id, &account_cache).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_transaction_account(account_cache: &AccountCache, identifier: &str) -> Option<String> {
|
|
||||||
// First try exact ID match for GoCardless accounts
|
|
||||||
if let Some(adapter_type) = account_cache.get_adapter_type(identifier) {
|
|
||||||
if adapter_type == "gocardless" {
|
|
||||||
return Some(identifier.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then try name/IBAN matching for GoCardless accounts
|
|
||||||
let gocardless_accounts = get_gocardless_accounts(account_cache);
|
|
||||||
for account in gocardless_accounts {
|
|
||||||
if let Some(display_name) = account.display_name() {
|
|
||||||
if display_name
|
|
||||||
.to_lowercase()
|
|
||||||
.contains(&identifier.to_lowercase())
|
|
||||||
{
|
|
||||||
return Some(account.id().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(iban) = account.iban() {
|
|
||||||
if iban.contains(identifier) {
|
|
||||||
return Some(account.id().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn select_account_interactive(
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
source: &dyn TransactionSource,
|
|
||||||
) -> anyhow::Result<Option<String>> {
|
|
||||||
let gocardless_accounts = get_gocardless_accounts(account_cache);
|
|
||||||
|
|
||||||
// Filter to accounts that have transactions
|
|
||||||
let mut accounts_with_data = Vec::new();
|
|
||||||
for account in gocardless_accounts {
|
|
||||||
let info = source.get_transaction_info(account.id()).await?;
|
|
||||||
if info.total_count > 0 {
|
|
||||||
accounts_with_data.push((account, info));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if accounts_with_data.is_empty() {
|
|
||||||
println!("No accounts found with transaction data. Run 'banks2ff sync gocardless firefly' first.");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create selection items
|
|
||||||
let items: Vec<String> = accounts_with_data
|
|
||||||
.iter()
|
|
||||||
.map(|(account, info)| {
|
|
||||||
let display_name = account
|
|
||||||
.display_name()
|
|
||||||
.unwrap_or_else(|| account.id().to_string());
|
|
||||||
let iban = account.iban().unwrap_or("");
|
|
||||||
format!(
|
|
||||||
"{} ({}) - {} transactions",
|
|
||||||
display_name,
|
|
||||||
crate::cli::formatters::mask_iban(iban),
|
|
||||||
info.total_count
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Add cancel option
|
|
||||||
let mut selection_items = items.clone();
|
|
||||||
selection_items.push("Cancel".to_string());
|
|
||||||
|
|
||||||
// Prompt user
|
|
||||||
let selection = Select::with_theme(&ColorfulTheme::default())
|
|
||||||
.with_prompt("Select account to view transactions")
|
|
||||||
.items(&selection_items)
|
|
||||||
.default(0)
|
|
||||||
.interact_opt()?;
|
|
||||||
|
|
||||||
match selection {
|
|
||||||
Some(index) if index < accounts_with_data.len() => {
|
|
||||||
Ok(Some(accounts_with_data[index].0.id().to_string()))
|
|
||||||
}
|
|
||||||
_ => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn show_transaction_summary(
|
|
||||||
source: &dyn TransactionSource,
|
|
||||||
account_id: &str,
|
|
||||||
account_cache: &AccountCache,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let info = source.get_transaction_info(account_id).await?;
|
|
||||||
if info.total_count == 0 {
|
|
||||||
let display_name = account_cache
|
|
||||||
.get_display_name(account_id)
|
|
||||||
.unwrap_or_else(|| account_id.to_string());
|
|
||||||
println!("No transaction data found for account {}. Run 'banks2ff sync gocardless firefly' first to sync transactions.", display_name);
|
|
||||||
} else {
|
|
||||||
let format = OutputFormat::Table;
|
|
||||||
print_list_output(vec![info], &format, Some(account_cache));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn show_transaction_details(
|
|
||||||
source: &dyn TransactionSource,
|
|
||||||
account_id: &str,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
// Get recent transactions from cache (last 90 days to ensure we have enough)
|
|
||||||
let end_date = chrono::Utc::now().date_naive();
|
|
||||||
let start_date = end_date - Days::new(90);
|
|
||||||
|
|
||||||
let transactions = source
|
|
||||||
.get_cached_transactions(account_id, start_date, end_date)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if transactions.is_empty() {
|
|
||||||
println!("No transactions found in the recent period.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by date descending and take the limit
|
|
||||||
let mut sorted_transactions = transactions.clone();
|
|
||||||
sorted_transactions.sort_by(|a, b| b.date.cmp(&a.date));
|
|
||||||
let to_show = sorted_transactions
|
|
||||||
.into_iter()
|
|
||||||
.take(limit)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
// Display as table with proper column constraints
|
|
||||||
use comfy_table::{presets::UTF8_FULL, ColumnConstraint::*, Table, Width::*};
|
|
||||||
let mut table = Table::new();
|
|
||||||
table.load_preset(UTF8_FULL);
|
|
||||||
table.set_header(vec!["Date", "Amount", "Description", "Counterparty"]);
|
|
||||||
|
|
||||||
// Set column constraints for proper width control
|
|
||||||
table.set_constraints(vec![
|
|
||||||
UpperBoundary(Fixed(12)), // Date - fixed width
|
|
||||||
UpperBoundary(Fixed(22)), // Amount - fixed width
|
|
||||||
UpperBoundary(Fixed(60)), // Description - wider fixed width
|
|
||||||
UpperBoundary(Fixed(25)), // Counterparty - fixed width
|
|
||||||
]);
|
|
||||||
|
|
||||||
for tx in &to_show {
|
|
||||||
table.add_row(vec![
|
|
||||||
tx.date.to_string(),
|
|
||||||
format_amount(
|
|
||||||
&tx.amount,
|
|
||||||
&tx.currency,
|
|
||||||
tx.foreign_amount.as_ref(),
|
|
||||||
tx.foreign_currency.as_deref(),
|
|
||||||
),
|
|
||||||
mask_description(&tx.description),
|
|
||||||
tx.counterparty_name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "Unknown".to_string()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", table);
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"\nShowing {} of {} transactions",
|
|
||||||
to_show.len(),
|
|
||||||
transactions.len()
|
|
||||||
);
|
|
||||||
println!("Date range: {} to {}", start_date, end_date);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask_description(description: &str) -> String {
|
|
||||||
// Truncate very long descriptions to keep table readable, but allow reasonable length
|
|
||||||
if description.len() <= 50 {
|
|
||||||
description.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{}...", &description[..47])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_amount(
|
|
||||||
amount: &Decimal,
|
|
||||||
currency: &str,
|
|
||||||
foreign_amount: Option<&Decimal>,
|
|
||||||
foreign_currency: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let primary = format!("{:.2} {}", amount, currency_symbol(currency));
|
|
||||||
|
|
||||||
if let (Some(fx_amount), Some(fx_currency)) = (foreign_amount, foreign_currency) {
|
|
||||||
format!(
|
|
||||||
"{} ({:.2} {})",
|
|
||||||
primary,
|
|
||||||
fx_amount,
|
|
||||||
currency_symbol(fx_currency)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
primary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn currency_symbol(currency: &str) -> String {
|
|
||||||
match currency {
|
|
||||||
"EUR" => "€".to_string(),
|
|
||||||
"GBP" => "£".to_string(),
|
|
||||||
"USD" => "$".to_string(),
|
|
||||||
_ => currency.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
pub mod cache;
|
|
||||||
pub mod list;
|
|
||||||
|
|
||||||
use crate::core::config::Config;
|
|
||||||
use clap::Subcommand;
|
|
||||||
|
|
||||||
use self::cache::handle_cache_status;
|
|
||||||
use self::list::handle_list as handle_transaction_list;
|
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
|
||||||
pub enum TransactionCommands {
|
|
||||||
/// List transactions for an account
|
|
||||||
List {
|
|
||||||
/// Account identifier (ID, IBAN, or name). If omitted, interactive mode is used.
|
|
||||||
account: Option<String>,
|
|
||||||
/// Show actual transactions instead of summary
|
|
||||||
#[arg(long)]
|
|
||||||
details: bool,
|
|
||||||
/// Number of recent transactions to show (default: 20)
|
|
||||||
#[arg(long, default_value = "20")]
|
|
||||||
limit: usize,
|
|
||||||
},
|
|
||||||
/// Show cache status
|
|
||||||
CacheStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_transactions(
|
|
||||||
config: Config,
|
|
||||||
subcommand: TransactionCommands,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
match subcommand {
|
|
||||||
TransactionCommands::List {
|
|
||||||
account,
|
|
||||||
details,
|
|
||||||
limit,
|
|
||||||
} => {
|
|
||||||
handle_transaction_list(config, account, details, limit).await?;
|
|
||||||
}
|
|
||||||
TransactionCommands::CacheStatus => {
|
|
||||||
handle_cache_status(config).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
use crate::core::encryption::Encryption;
|
|
||||||
use crate::core::models::AccountData;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub enum CachedAccount {
|
|
||||||
GoCardless(Box<GoCardlessAccount>),
|
|
||||||
Firefly(Box<FireflyAccount>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct GoCardlessAccount {
|
|
||||||
pub id: String,
|
|
||||||
pub iban: Option<String>,
|
|
||||||
pub name: Option<String>, // From AccountDetail.name
|
|
||||||
pub display_name: Option<String>, // From AccountDetail.displayName
|
|
||||||
pub owner_name: Option<String>, // From Account.owner_name
|
|
||||||
pub status: Option<String>, // From Account.status
|
|
||||||
pub institution_id: Option<String>, // From Account.institution_id
|
|
||||||
pub created: Option<String>, // From Account.created
|
|
||||||
pub last_accessed: Option<String>, // From Account.last_accessed
|
|
||||||
pub product: Option<String>, // From AccountDetail.product
|
|
||||||
pub cash_account_type: Option<String>, // From AccountDetail.cashAccountType
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
||||||
pub struct FireflyAccount {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String, // From Account.name
|
|
||||||
pub account_type: String, // From Account.type
|
|
||||||
pub iban: Option<String>, // From Account.iban
|
|
||||||
pub active: Option<bool>, // From Account.active
|
|
||||||
pub order: Option<i32>, // From Account.order
|
|
||||||
pub created_at: Option<String>, // From Account.created_at
|
|
||||||
pub account_role: Option<String>, // From Account.account_role
|
|
||||||
pub object_group_id: Option<String>, // From Account.object_group_id
|
|
||||||
pub object_group_title: Option<String>, // From Account.object_group_title
|
|
||||||
pub object_group_order: Option<i32>, // From Account.object_group_order
|
|
||||||
pub currency_id: Option<String>, // From Account.currency_id
|
|
||||||
pub currency_name: Option<String>, // From Account.currency_name
|
|
||||||
pub currency_code: Option<String>, // From Account.currency_code
|
|
||||||
pub currency_symbol: Option<String>, // From Account.currency_symbol
|
|
||||||
pub currency_decimal_places: Option<i32>, // From Account.currency_decimal_places
|
|
||||||
pub primary_currency_id: Option<String>, // From Account.primary_currency_id
|
|
||||||
pub primary_currency_name: Option<String>, // From Account.primary_currency_name
|
|
||||||
pub primary_currency_code: Option<String>, // From Account.primary_currency_code
|
|
||||||
pub primary_currency_symbol: Option<String>, // From Account.primary_currency_symbol
|
|
||||||
pub primary_currency_decimal_places: Option<i32>, // From Account.primary_currency_decimal_places
|
|
||||||
pub opening_balance: Option<String>, // From Account.opening_balance
|
|
||||||
pub pc_opening_balance: Option<String>, // From Account.pc_opening_balance
|
|
||||||
pub debt_amount: Option<String>, // From Account.debt_amount
|
|
||||||
pub pc_debt_amount: Option<String>, // From Account.pc_debt_amount
|
|
||||||
pub notes: Option<String>, // From Account.notes
|
|
||||||
pub monthly_payment_date: Option<String>, // From Account.monthly_payment_date
|
|
||||||
pub credit_card_type: Option<String>, // From Account.credit_card_type
|
|
||||||
pub account_number: Option<String>, // From Account.account_number
|
|
||||||
pub bic: Option<String>, // From Account.bic
|
|
||||||
pub opening_balance_date: Option<String>, // From Account.opening_balance_date
|
|
||||||
pub liability_type: Option<String>, // From Account.liability_type
|
|
||||||
pub liability_direction: Option<String>, // From Account.liability_direction
|
|
||||||
pub interest: Option<String>, // From Account.interest
|
|
||||||
pub interest_period: Option<String>, // From Account.interest_period
|
|
||||||
pub include_net_worth: Option<bool>, // From Account.include_net_worth
|
|
||||||
pub longitude: Option<f64>, // From Account.longitude
|
|
||||||
pub latitude: Option<f64>, // From Account.latitude
|
|
||||||
pub zoom_level: Option<i32>, // From Account.zoom_level
|
|
||||||
pub last_activity: Option<String>, // From Account.last_activity
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::core::models::AccountData for CachedAccount {
|
|
||||||
fn id(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
CachedAccount::GoCardless(acc) => &acc.id,
|
|
||||||
CachedAccount::Firefly(acc) => &acc.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn iban(&self) -> Option<&str> {
|
|
||||||
match self {
|
|
||||||
CachedAccount::GoCardless(acc) => acc.iban.as_deref(),
|
|
||||||
CachedAccount::Firefly(acc) => acc.iban.as_deref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_name(&self) -> Option<String> {
|
|
||||||
match self {
|
|
||||||
CachedAccount::GoCardless(acc) => acc
|
|
||||||
.display_name
|
|
||||||
.clone()
|
|
||||||
.or_else(|| acc.name.clone())
|
|
||||||
.or_else(|| {
|
|
||||||
acc.owner_name
|
|
||||||
.as_ref()
|
|
||||||
.map(|owner| format!("{} Account", owner))
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
acc.iban.as_ref().map(|iban| {
|
|
||||||
if iban.len() > 4 {
|
|
||||||
format!("{}****{}", &iban[..4], &iban[iban.len() - 4..])
|
|
||||||
} else {
|
|
||||||
iban.to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
CachedAccount::Firefly(acc) => Some(acc.name.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountData for GoCardlessAccount {
|
|
||||||
fn id(&self) -> &str {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn iban(&self) -> Option<&str> {
|
|
||||||
self.iban.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_name(&self) -> Option<String> {
|
|
||||||
// Priority: display_name > name > owner_name > masked IBAN
|
|
||||||
let base_name = self
|
|
||||||
.display_name
|
|
||||||
.clone()
|
|
||||||
.or_else(|| self.name.clone())
|
|
||||||
.or_else(|| {
|
|
||||||
self.owner_name
|
|
||||||
.as_ref()
|
|
||||||
.map(|owner| format!("{} Account", owner))
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
self.iban.as_ref().map(|iban| {
|
|
||||||
if iban.len() > 4 {
|
|
||||||
format!("{}****{}", &iban[..4], &iban[iban.len() - 4..])
|
|
||||||
} else {
|
|
||||||
iban.to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// For GoCardless accounts, append institution if available
|
|
||||||
if let (Some(name), Some(institution_id)) = (&base_name, &self.institution_id) {
|
|
||||||
Some(format!("{} ({})", name, institution_id))
|
|
||||||
} else {
|
|
||||||
base_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountData for FireflyAccount {
|
|
||||||
fn id(&self) -> &str {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn iban(&self) -> Option<&str> {
|
|
||||||
self.iban.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_name(&self) -> Option<String> {
|
|
||||||
// Priority: name > iban > None (will fallback to "Account <id>")
|
|
||||||
if !self.name.is_empty() {
|
|
||||||
Some(self.name.clone())
|
|
||||||
} else {
|
|
||||||
self.iban.as_ref().map(|iban| {
|
|
||||||
if iban.len() > 4 {
|
|
||||||
format!("{}****{}", &iban[..4], &iban[iban.len() - 4..])
|
|
||||||
} else {
|
|
||||||
iban.to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AccountCache {
|
|
||||||
/// Map of Account ID -> Full Account Data
|
|
||||||
pub accounts: HashMap<String, CachedAccount>,
|
|
||||||
/// Cache directory path
|
|
||||||
cache_dir: String,
|
|
||||||
/// Encryption instance
|
|
||||||
encryption: Encryption,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
|
||||||
pub struct AccountCacheData {
|
|
||||||
/// Map of Account ID -> Full Account Data
|
|
||||||
pub accounts: HashMap<String, CachedAccount>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AccountCache {
|
|
||||||
fn default() -> Self {
|
|
||||||
// This should not be used in practice, but provide a dummy implementation
|
|
||||||
Self {
|
|
||||||
accounts: HashMap::new(),
|
|
||||||
cache_dir: String::new(),
|
|
||||||
encryption: Encryption::new(String::new()), // Dummy key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountCache {
|
|
||||||
/// Create new AccountCache with directory and encryption
|
|
||||||
pub fn new(cache_dir: String, encryption: Encryption) -> Self {
|
|
||||||
Self {
|
|
||||||
accounts: HashMap::new(),
|
|
||||||
cache_dir,
|
|
||||||
encryption,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_path(&self) -> String {
|
|
||||||
format!("{}/accounts.enc", self.cache_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(cache_dir: String, encryption: Encryption) -> Self {
|
|
||||||
let path = format!("{}/accounts.enc", cache_dir);
|
|
||||||
if Path::new(&path).exists() {
|
|
||||||
match fs::read(&path) {
|
|
||||||
Ok(encrypted_data) => match encryption.decrypt(&encrypted_data) {
|
|
||||||
Ok(json_data) => match serde_json::from_slice::<AccountCacheData>(&json_data) {
|
|
||||||
Ok(cache_data) => {
|
|
||||||
return Self {
|
|
||||||
accounts: cache_data.accounts,
|
|
||||||
cache_dir,
|
|
||||||
encryption,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to parse cache file: {}", e),
|
|
||||||
},
|
|
||||||
Err(e) => warn!("Failed to decrypt cache file: {}", e),
|
|
||||||
},
|
|
||||||
Err(e) => warn!("Failed to read cache file: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self::new(cache_dir, encryption)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) {
|
|
||||||
let path = self.get_path();
|
|
||||||
|
|
||||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
||||||
warn!(
|
|
||||||
"Failed to create cache folder '{}': {}",
|
|
||||||
parent.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match serde_json::to_vec(&AccountCacheData {
|
|
||||||
accounts: self.accounts.clone(),
|
|
||||||
}) {
|
|
||||||
Ok(json_data) => match self.encryption.encrypt(&json_data) {
|
|
||||||
Ok(encrypted_data) => {
|
|
||||||
if let Err(e) = fs::write(&path, encrypted_data) {
|
|
||||||
warn!("Failed to write cache file: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to encrypt cache: {}", e),
|
|
||||||
},
|
|
||||||
Err(e) => warn!("Failed to serialize cache: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_account(&self, account_id: &str) -> Option<&CachedAccount> {
|
|
||||||
self.accounts.get(account_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_account_data(&self, account_id: &str) -> Option<&dyn AccountData> {
|
|
||||||
match self.accounts.get(account_id)? {
|
|
||||||
CachedAccount::GoCardless(acc) => Some(acc.as_ref() as &dyn AccountData),
|
|
||||||
CachedAccount::Firefly(acc) => Some(acc.as_ref() as &dyn AccountData),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_display_name(&self, account_id: &str) -> Option<String> {
|
|
||||||
self.get_account_data(account_id)?.display_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_adapter_type(&self, account_id: &str) -> Option<&str> {
|
|
||||||
match self.accounts.get(account_id)? {
|
|
||||||
CachedAccount::GoCardless(_) => Some("gocardless"),
|
|
||||||
CachedAccount::Firefly(_) => Some("firefly"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert(&mut self, account: CachedAccount) {
|
|
||||||
let account_id = account.id().to_string();
|
|
||||||
self.accounts.insert(account_id, account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
//! Configuration management for banks2ff
|
|
||||||
//!
|
|
||||||
//! Provides centralized configuration loading from environment variables
|
|
||||||
//! with type-safe configuration structures.
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
/// Main application configuration
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Config {
|
|
||||||
pub gocardless: GoCardlessConfig,
|
|
||||||
pub firefly: FireflyConfig,
|
|
||||||
pub cache: CacheConfig,
|
|
||||||
pub logging: LoggingConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GoCardless API configuration
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct GoCardlessConfig {
|
|
||||||
pub url: String,
|
|
||||||
pub secret_id: String,
|
|
||||||
pub secret_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Firefly III API configuration
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct FireflyConfig {
|
|
||||||
pub url: String,
|
|
||||||
pub api_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cache configuration
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct CacheConfig {
|
|
||||||
pub key: String,
|
|
||||||
pub directory: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Logging configuration
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct LoggingConfig {
|
|
||||||
pub level: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Load configuration from environment variables
|
|
||||||
pub fn from_env() -> Result<Self> {
|
|
||||||
let gocardless = GoCardlessConfig::from_env()?;
|
|
||||||
let firefly = FireflyConfig::from_env()?;
|
|
||||||
let cache = CacheConfig::from_env()?;
|
|
||||||
let logging = LoggingConfig::from_env()?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
gocardless,
|
|
||||||
firefly,
|
|
||||||
cache,
|
|
||||||
logging,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GoCardlessConfig {
|
|
||||||
pub fn from_env() -> Result<Self> {
|
|
||||||
let url = env::var("GOCARDLESS_URL")
|
|
||||||
.unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string());
|
|
||||||
let secret_id = env::var("GOCARDLESS_ID")
|
|
||||||
.map_err(|_| anyhow!("GOCARDLESS_ID environment variable not set"))?;
|
|
||||||
let secret_key = env::var("GOCARDLESS_KEY")
|
|
||||||
.map_err(|_| anyhow!("GOCARDLESS_KEY environment variable not set"))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
url,
|
|
||||||
secret_id,
|
|
||||||
secret_key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FireflyConfig {
|
|
||||||
pub fn from_env() -> Result<Self> {
|
|
||||||
let url = env::var("FIREFLY_III_URL")
|
|
||||||
.map_err(|_| anyhow!("FIREFLY_III_URL environment variable not set"))?;
|
|
||||||
let api_key = env::var("FIREFLY_III_API_KEY")
|
|
||||||
.map_err(|_| anyhow!("FIREFLY_III_API_KEY environment variable not set"))?;
|
|
||||||
|
|
||||||
Ok(Self { url, api_key })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CacheConfig {
|
|
||||||
pub fn from_env() -> Result<Self> {
|
|
||||||
let key = env::var("BANKS2FF_CACHE_KEY")
|
|
||||||
.map_err(|_| anyhow!("BANKS2FF_CACHE_KEY environment variable not set"))?;
|
|
||||||
let directory = env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
|
||||||
|
|
||||||
Ok(Self { key, directory })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoggingConfig {
|
|
||||||
pub fn from_env() -> Result<Self> {
|
|
||||||
let level = env::var("RUST_LOG").unwrap_or_else(|_| "warn".to_string());
|
|
||||||
Ok(Self { level })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use temp_env::with_vars;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_gocardless_config_from_env() {
|
|
||||||
with_vars(
|
|
||||||
[
|
|
||||||
("GOCARDLESS_ID", Some("test-id")),
|
|
||||||
("GOCARDLESS_KEY", Some("test-key")),
|
|
||||||
("GOCARDLESS_URL", Some("https://test.example.com")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config = GoCardlessConfig::from_env().unwrap();
|
|
||||||
assert_eq!(config.secret_id, "test-id");
|
|
||||||
assert_eq!(config.secret_key, "test-key");
|
|
||||||
assert_eq!(config.url, "https://test.example.com");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_gocardless_config_default_url() {
|
|
||||||
with_vars(
|
|
||||||
[
|
|
||||||
("GOCARDLESS_ID", Some("test-id")),
|
|
||||||
("GOCARDLESS_KEY", Some("test-key")),
|
|
||||||
("GOCARDLESS_URL", None),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config = GoCardlessConfig::from_env().unwrap();
|
|
||||||
assert_eq!(config.url, "https://bankaccountdata.gocardless.com");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_gocardless_config_missing_id() {
|
|
||||||
with_vars(
|
|
||||||
[
|
|
||||||
("GOCARDLESS_ID", None),
|
|
||||||
("GOCARDLESS_KEY", Some("test-key")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let result = GoCardlessConfig::from_env();
|
|
||||||
assert!(result.is_err());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_firefly_config_from_env() {
|
|
||||||
with_vars(
|
|
||||||
[
|
|
||||||
("FIREFLY_III_URL", Some("https://firefly.test.com")),
|
|
||||||
("FIREFLY_III_API_KEY", Some("test-api-key")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config = FireflyConfig::from_env().unwrap();
|
|
||||||
assert_eq!(config.url, "https://firefly.test.com");
|
|
||||||
assert_eq!(config.api_key, "test-api-key");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cache_config_from_env() {
|
|
||||||
with_vars(
|
|
||||||
[
|
|
||||||
("BANKS2FF_CACHE_KEY", Some("test-cache-key")),
|
|
||||||
("BANKS2FF_CACHE_DIR", Some("/tmp/test-cache")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config = CacheConfig::from_env().unwrap();
|
|
||||||
assert_eq!(config.key, "test-cache-key");
|
|
||||||
assert_eq!(config.directory, "/tmp/test-cache");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cache_config_default_directory() {
|
|
||||||
with_vars(
|
|
||||||
[
|
|
||||||
("BANKS2FF_CACHE_DIR", None),
|
|
||||||
("BANKS2FF_CACHE_KEY", Some("test-cache-key")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config = CacheConfig::from_env().unwrap();
|
|
||||||
assert_eq!(config.directory, "data/cache");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_logging_config_from_env() {
|
|
||||||
with_vars([("RUST_LOG", Some("debug"))], || {
|
|
||||||
let config = LoggingConfig::from_env().unwrap();
|
|
||||||
assert_eq!(config.level, "debug");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_logging_config_default() {
|
|
||||||
with_vars([("RUST_LOG", None::<&str>)], || {
|
|
||||||
let config = LoggingConfig::from_env().unwrap();
|
|
||||||
assert_eq!(config.level, "warn");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_full_config_from_env() {
|
|
||||||
with_vars(
|
|
||||||
[
|
|
||||||
("GOCARDLESS_ID", Some("test-id")),
|
|
||||||
("GOCARDLESS_KEY", Some("test-key")),
|
|
||||||
("FIREFLY_III_URL", Some("https://firefly.test.com")),
|
|
||||||
("FIREFLY_III_API_KEY", Some("test-api-key")),
|
|
||||||
("BANKS2FF_CACHE_KEY", Some("test-cache-key")),
|
|
||||||
("RUST_LOG", Some("info")),
|
|
||||||
],
|
|
||||||
|| {
|
|
||||||
let config = Config::from_env().unwrap();
|
|
||||||
assert_eq!(config.gocardless.secret_id, "test-id");
|
|
||||||
assert_eq!(config.gocardless.secret_key, "test-key");
|
|
||||||
assert_eq!(config.firefly.url, "https://firefly.test.com");
|
|
||||||
assert_eq!(config.firefly.api_key, "test-api-key");
|
|
||||||
assert_eq!(config.cache.key, "test-cache-key");
|
|
||||||
assert_eq!(config.logging.level, "info");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
//! # Encryption Module
|
|
||||||
//!
|
|
||||||
//! Provides AES-GCM encryption for sensitive cache data using hybrid key derivation.
|
|
||||||
//!
|
|
||||||
//! ## Security Considerations
|
|
||||||
//!
|
|
||||||
//! - **Algorithm**: AES-GCM (Authenticated Encryption) with 256-bit keys
|
|
||||||
//! - **Key Derivation**: PBKDF2 (50k iterations) for master key + HKDF for per-operation keys
|
|
||||||
//! - **Salt**: Random 16-byte salt per encryption (prepended to ciphertext)
|
|
||||||
//! - **Nonce**: Random 96-bit nonce per encryption (prepended to ciphertext)
|
|
||||||
//! - **Key Source**: Environment variable `BANKS2FF_CACHE_KEY`
|
|
||||||
//!
|
|
||||||
//! ## Data Format (Version 1)
|
|
||||||
//!
|
|
||||||
//! Encrypted data format: `[magic(4:"B2FF")][version(1)][salt(16)][nonce(12)][ciphertext]`
|
|
||||||
//!
|
|
||||||
//! ## Security Guarantees
|
|
||||||
//!
|
|
||||||
//! - **Confidentiality**: AES-GCM encryption protects data at rest
|
|
||||||
//! - **Integrity**: GCM authentication prevents tampering
|
|
||||||
//! - **Forward Security**: Unique salt/nonce per encryption prevents rainbow tables
|
|
||||||
//! - **Key Security**: PBKDF2 + HKDF provides strong key derivation
|
|
||||||
//!
|
|
||||||
//! ## Performance
|
|
||||||
//!
|
|
||||||
//! - Encryption: ~10-50μs for typical cache payloads
|
|
||||||
//! - Key derivation: ~5-10ms master key (once per session) + ~1μs per operation
|
|
||||||
//! - Memory: Minimal additional overhead
|
|
||||||
|
|
||||||
use aes_gcm::aead::{Aead, KeyInit};
|
|
||||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use hkdf::Hkdf;
|
|
||||||
use pbkdf2::pbkdf2_hmac;
|
|
||||||
use rand::RngCore;
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
const MAGIC: &[u8] = b"B2FF";
|
|
||||||
const VERSION_1: u8 = 1;
|
|
||||||
|
|
||||||
const KEY_LEN: usize = 32; // 256-bit key
|
|
||||||
const NONCE_LEN: usize = 12; // 96-bit nonce for AES-GCM
|
|
||||||
const SALT_LEN: usize = 16; // 128-bit salt for HKDF
|
|
||||||
const MASTER_SALT: &[u8] = b"Banks2FF_MasterSalt"; // Fixed salt for master key
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Encryption {
|
|
||||||
master_key: Key<Aes256Gcm>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Encryption {
|
|
||||||
/// Create new Encryption instance with cache key
|
|
||||||
pub fn new(cache_key: String) -> Self {
|
|
||||||
let master_key = Self::derive_master_key(&cache_key);
|
|
||||||
Self { master_key }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive master key from password using PBKDF2
|
|
||||||
fn derive_master_key(password: &str) -> Key<Aes256Gcm> {
|
|
||||||
let mut key = [0u8; KEY_LEN];
|
|
||||||
pbkdf2_hmac::<Sha256>(password.as_bytes(), MASTER_SALT, 50_000, &mut key);
|
|
||||||
key.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive operation key from master key and salt using HKDF
|
|
||||||
fn derive_operation_key(master_key: &Key<Aes256Gcm>, salt: &[u8]) -> Key<Aes256Gcm> {
|
|
||||||
let hkdf = Hkdf::<Sha256>::new(Some(salt), master_key);
|
|
||||||
let mut okm = [0u8; KEY_LEN];
|
|
||||||
hkdf.expand(b"banks2ff-operation-key", &mut okm)
|
|
||||||
.expect("HKDF expand failed");
|
|
||||||
okm.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypt data using AES-GCM (Version 1 format)
|
|
||||||
pub fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
// Generate random operation salt
|
|
||||||
let mut salt = [0u8; SALT_LEN];
|
|
||||||
rand::thread_rng().fill_bytes(&mut salt);
|
|
||||||
|
|
||||||
let key = Self::derive_operation_key(&self.master_key, &salt);
|
|
||||||
let cipher = Aes256Gcm::new(&key);
|
|
||||||
|
|
||||||
// Generate random nonce
|
|
||||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
// Encrypt
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(nonce, data)
|
|
||||||
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
|
|
||||||
|
|
||||||
// Format: [magic(4)][version(1)][salt(16)][nonce(12)][ciphertext]
|
|
||||||
let mut result = MAGIC.to_vec();
|
|
||||||
result.push(VERSION_1);
|
|
||||||
result.extend(salt);
|
|
||||||
result.extend(nonce_bytes);
|
|
||||||
result.extend(ciphertext);
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt data using AES-GCM (Version 1 format)
|
|
||||||
pub fn decrypt(&self, encrypted_data: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
let header_len = MAGIC.len() + 1 + SALT_LEN + NONCE_LEN;
|
|
||||||
if encrypted_data.len() < header_len {
|
|
||||||
return Err(anyhow!("Encrypted data too short"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify magic and version: [magic(4)][version(1)][salt(16)][nonce(12)][ciphertext]
|
|
||||||
if &encrypted_data[0..MAGIC.len()] != MAGIC {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Invalid encrypted data format - missing magic bytes"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if encrypted_data[MAGIC.len()] != VERSION_1 {
|
|
||||||
return Err(anyhow!("Unsupported encryption version"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let salt_start = MAGIC.len() + 1;
|
|
||||||
let nonce_start = salt_start + SALT_LEN;
|
|
||||||
let ciphertext_start = nonce_start + NONCE_LEN;
|
|
||||||
|
|
||||||
let salt = &encrypted_data[salt_start..nonce_start];
|
|
||||||
let nonce = Nonce::from_slice(&encrypted_data[nonce_start..ciphertext_start]);
|
|
||||||
let ciphertext = &encrypted_data[ciphertext_start..];
|
|
||||||
|
|
||||||
let key = Self::derive_operation_key(&self.master_key, salt);
|
|
||||||
let cipher = Aes256Gcm::new(&key);
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
cipher
|
|
||||||
.decrypt(nonce, ciphertext)
|
|
||||||
.map_err(|e| anyhow!("Decryption failed: {}", e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encrypt_decrypt_round_trip() {
|
|
||||||
let encryption = Encryption::new("test-key-for-encryption".to_string());
|
|
||||||
let original_data = b"Hello, World! This is test data.";
|
|
||||||
|
|
||||||
// Encrypt
|
|
||||||
let encrypted = encryption
|
|
||||||
.encrypt(original_data)
|
|
||||||
.expect("Encryption should succeed");
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
let decrypted = encryption
|
|
||||||
.decrypt(&encrypted)
|
|
||||||
.expect("Decryption should succeed");
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
assert_eq!(original_data.to_vec(), decrypted);
|
|
||||||
assert_ne!(original_data.to_vec(), encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encrypt_decrypt_different_keys() {
|
|
||||||
let encryption1 = Encryption::new("key1".to_string());
|
|
||||||
let encryption2 = Encryption::new("key2".to_string());
|
|
||||||
let data = b"Test data";
|
|
||||||
let encrypted = encryption1.encrypt(data).unwrap();
|
|
||||||
|
|
||||||
let result = encryption2.decrypt(&encrypted);
|
|
||||||
assert!(result.is_err(), "Should fail with different key");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encryption_creation() {
|
|
||||||
let encryption = Encryption::new("test-key".to_string());
|
|
||||||
// Encryption now stores master_key, not password
|
|
||||||
// Test that it can encrypt/decrypt
|
|
||||||
let data = b"test";
|
|
||||||
let encrypted = encryption.encrypt(data).unwrap();
|
|
||||||
let decrypted = encryption.decrypt(&encrypted).unwrap();
|
|
||||||
assert_eq!(data.to_vec(), decrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_small_data() {
|
|
||||||
let encryption = Encryption::new("test-key".to_string());
|
|
||||||
let data = b"{}"; // Minimal JSON object
|
|
||||||
|
|
||||||
let encrypted = encryption.encrypt(data).unwrap();
|
|
||||||
let decrypted = encryption.decrypt(&encrypted).unwrap();
|
|
||||||
assert_eq!(data.to_vec(), decrypted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +1,51 @@
|
|||||||
use crate::core::models::Account;
|
use crate::core::models::Account;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AccountLink {
|
pub struct AccountLink {
|
||||||
|
pub id: String,
|
||||||
pub source_account_id: String,
|
pub source_account_id: String,
|
||||||
pub dest_account_id: String,
|
pub dest_account_id: String,
|
||||||
#[serde(default = "default_source_adapter_type")]
|
pub alias: Option<String>,
|
||||||
pub source_adapter_type: String, // e.g., "gocardless", "other_source"
|
|
||||||
#[serde(default = "default_dest_adapter_type")]
|
|
||||||
pub dest_adapter_type: String, // e.g., "firefly", "other_destination"
|
|
||||||
pub auto_linked: bool,
|
pub auto_linked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_source_adapter_type() -> String {
|
|
||||||
"gocardless".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_dest_adapter_type() -> String {
|
|
||||||
"firefly".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||||
pub struct LinkStore {
|
pub struct LinkStore {
|
||||||
pub links: Vec<AccountLink>,
|
pub links: Vec<AccountLink>,
|
||||||
#[serde(skip)]
|
pub source_accounts: HashMap<String, HashMap<String, Account>>, // outer key: source type, inner: account id
|
||||||
cache_dir: String,
|
pub dest_accounts: HashMap<String, HashMap<String, Account>>, // outer key: dest type, inner: account id
|
||||||
|
next_id: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LinkStore {
|
impl LinkStore {
|
||||||
/// Create new LinkStore with cache directory
|
fn get_path() -> String {
|
||||||
pub fn new(cache_dir: String) -> Self {
|
let cache_dir =
|
||||||
Self {
|
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||||
links: Vec::new(),
|
format!("{}/links.json", cache_dir)
|
||||||
cache_dir,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_path(&self) -> String {
|
pub fn load() -> Self {
|
||||||
format!("{}/links.json", self.cache_dir)
|
let path = Self::get_path();
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(cache_dir: String) -> Self {
|
|
||||||
let path = format!("{}/links.json", cache_dir);
|
|
||||||
if Path::new(&path).exists() {
|
if Path::new(&path).exists() {
|
||||||
match fs::read_to_string(&path) {
|
match fs::read_to_string(&path) {
|
||||||
Ok(content) => match serde_json::from_str::<LinkStore>(&content) {
|
Ok(content) => match serde_json::from_str(&content) {
|
||||||
Ok(mut store) => {
|
Ok(store) => return store,
|
||||||
store.cache_dir = cache_dir;
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to parse link store: {}", e),
|
Err(e) => warn!("Failed to parse link store: {}", e),
|
||||||
},
|
},
|
||||||
Err(e) => warn!("Failed to read link store: {}", e),
|
Err(e) => warn!("Failed to read link store: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::new(cache_dir)
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
let path = self.get_path();
|
let path = Self::get_path();
|
||||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
@@ -75,59 +58,54 @@ impl LinkStore {
|
|||||||
&mut self,
|
&mut self,
|
||||||
source_account: &Account,
|
source_account: &Account,
|
||||||
dest_account: &Account,
|
dest_account: &Account,
|
||||||
source_adapter_type: &str,
|
|
||||||
dest_adapter_type: &str,
|
|
||||||
auto_linked: bool,
|
auto_linked: bool,
|
||||||
) -> Result<bool, String> {
|
) -> String {
|
||||||
// Check if link already exists (exact same source-dest pair)
|
let id = format!("link_{}", self.next_id);
|
||||||
if self.links.iter().any(|l| {
|
self.next_id += 1;
|
||||||
l.source_account_id == source_account.id && l.dest_account_id == dest_account.id
|
|
||||||
}) {
|
|
||||||
return Ok(false); // Link already exists
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if source account is already linked to a DIFFERENT destination of this adapter type
|
|
||||||
if let Some(existing_link) = self.links.iter().find(|l| {
|
|
||||||
l.source_account_id == source_account.id
|
|
||||||
&& l.dest_adapter_type == dest_adapter_type
|
|
||||||
&& l.dest_account_id != dest_account.id
|
|
||||||
}) {
|
|
||||||
return Err(format!(
|
|
||||||
"Source account '{}' is already linked to destination '{}' of type '{}'. Unlink first to create a new link.",
|
|
||||||
source_account.id, existing_link.dest_account_id, dest_adapter_type
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let link = AccountLink {
|
let link = AccountLink {
|
||||||
|
id: id.clone(),
|
||||||
source_account_id: source_account.id.clone(),
|
source_account_id: source_account.id.clone(),
|
||||||
dest_account_id: dest_account.id.clone(),
|
dest_account_id: dest_account.id.clone(),
|
||||||
source_adapter_type: source_adapter_type.to_string(),
|
alias: None,
|
||||||
dest_adapter_type: dest_adapter_type.to_string(),
|
|
||||||
auto_linked,
|
auto_linked,
|
||||||
};
|
};
|
||||||
self.links.push(link);
|
self.links.push(link);
|
||||||
Ok(true)
|
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> {
|
pub fn find_link_by_source(&self, source_id: &str) -> Option<&AccountLink> {
|
||||||
self.links.iter().find(|l| l.source_account_id == source_id)
|
self.links.iter().find(|l| l.source_account_id == source_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_links_by_source(&self, source_id: &str) -> Vec<&AccountLink> {
|
pub fn update_source_accounts(&mut self, source_type: &str, accounts: Vec<Account>) {
|
||||||
self.links
|
let type_map = self
|
||||||
.iter()
|
.source_accounts
|
||||||
.filter(|l| l.source_account_id == source_id)
|
.entry(source_type.to_string())
|
||||||
.collect()
|
.or_default();
|
||||||
|
for account in accounts {
|
||||||
|
type_map.insert(account.id.clone(), account);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_link_by_source_and_dest_type(
|
pub fn update_dest_accounts(&mut self, dest_type: &str, accounts: Vec<Account>) {
|
||||||
&self,
|
let type_map = self.dest_accounts.entry(dest_type.to_string()).or_default();
|
||||||
source_id: &str,
|
for account in accounts {
|
||||||
dest_adapter_type: &str,
|
type_map.insert(account.id.clone(), account);
|
||||||
) -> Option<&AccountLink> {
|
}
|
||||||
self.links
|
|
||||||
.iter()
|
|
||||||
.find(|l| l.source_account_id == source_id && l.dest_adapter_type == dest_adapter_type)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +116,7 @@ pub fn auto_link_accounts(
|
|||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
for (i, source) in source_accounts.iter().enumerate() {
|
for (i, source) in source_accounts.iter().enumerate() {
|
||||||
for (j, dest) in dest_accounts.iter().enumerate() {
|
for (j, dest) in dest_accounts.iter().enumerate() {
|
||||||
if source.iban == dest.iban
|
if source.iban == dest.iban && !source.iban.is_empty() {
|
||||||
&& source.iban.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
|
|
||||||
{
|
|
||||||
links.push((i, j));
|
links.push((i, j));
|
||||||
break; // First match
|
break; // First match
|
||||||
}
|
}
|
||||||
@@ -149,147 +125,3 @@ pub fn auto_link_accounts(
|
|||||||
// Could add name similarity matching here
|
// Could add name similarity matching here
|
||||||
links
|
links
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_link_prevents_duplicates() {
|
|
||||||
let mut store = LinkStore::default();
|
|
||||||
let src = Account {
|
|
||||||
id: "src1".to_string(),
|
|
||||||
name: Some("Source Account".to_string()),
|
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
};
|
|
||||||
let dest = Account {
|
|
||||||
id: "dest1".to_string(),
|
|
||||||
name: Some("Destination Account".to_string()),
|
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// First call should create a link
|
|
||||||
let first_result = store.add_link(&src, &dest, "gocardless", "firefly", true);
|
|
||||||
assert!(first_result.is_ok());
|
|
||||||
assert!(first_result.unwrap());
|
|
||||||
|
|
||||||
// Second call should not create a duplicate
|
|
||||||
let second_result = store.add_link(&src, &dest, "gocardless", "firefly", true);
|
|
||||||
assert!(second_result.is_ok());
|
|
||||||
assert!(!second_result.unwrap());
|
|
||||||
assert_eq!(store.links.len(), 1); // Still only one link
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_link_allows_different_accounts() {
|
|
||||||
let mut store = LinkStore::default();
|
|
||||||
let src1 = Account {
|
|
||||||
id: "src1".to_string(),
|
|
||||||
name: Some("Source Account 1".to_string()),
|
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
};
|
|
||||||
let dest1 = Account {
|
|
||||||
id: "dest1".to_string(),
|
|
||||||
name: Some("Destination Account 1".to_string()),
|
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
};
|
|
||||||
let dest2 = Account {
|
|
||||||
id: "dest2".to_string(),
|
|
||||||
name: Some("Destination Account 2".to_string()),
|
|
||||||
iban: Some("NL02".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Link src1 to dest1 (firefly)
|
|
||||||
let result1 = store.add_link(&src1, &dest1, "gocardless", "firefly", false);
|
|
||||||
assert!(result1.is_ok());
|
|
||||||
assert!(result1.unwrap());
|
|
||||||
|
|
||||||
// Try to link src1 to dest2 (same adapter type) - should fail
|
|
||||||
let result2 = store.add_link(&src1, &dest2, "gocardless", "firefly", false);
|
|
||||||
assert!(result2.is_err());
|
|
||||||
assert!(result2.unwrap_err().contains("already linked"));
|
|
||||||
assert_eq!(store.links.len(), 1); // Still only one link
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_test_dir(test_name: &str) -> String {
|
|
||||||
// Use a unique cache directory for each test to avoid interference
|
|
||||||
// Include random component and timestamp for true parallelism safety
|
|
||||||
let random_suffix = rand::random::<u64>();
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
format!(
|
|
||||||
"tmp/test-links-{}-{}-{}",
|
|
||||||
test_name, random_suffix, timestamp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup_test_dir(cache_dir: &str) {
|
|
||||||
// Wait a bit longer to ensure all file operations are complete
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
||||||
|
|
||||||
// Try multiple times in case of temporary file locks
|
|
||||||
for _ in 0..5 {
|
|
||||||
if std::path::Path::new(cache_dir).exists() {
|
|
||||||
if std::fs::remove_dir_all(cache_dir).is_ok() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break; // Directory already gone
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_links_store() {
|
|
||||||
let cache_dir = setup_test_dir("load");
|
|
||||||
|
|
||||||
// Create JSON in the correct format
|
|
||||||
let json_content = r#"{
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"source_account_id": "src1",
|
|
||||||
"dest_account_id": "dest1",
|
|
||||||
"source_adapter_type": "gocardless",
|
|
||||||
"dest_adapter_type": "firefly",
|
|
||||||
"auto_linked": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source_account_id": "src2",
|
|
||||||
"dest_account_id": "dest2",
|
|
||||||
"source_adapter_type": "gocardless",
|
|
||||||
"dest_adapter_type": "firefly",
|
|
||||||
"auto_linked": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
// Create directory and write file
|
|
||||||
std::fs::create_dir_all(&cache_dir).unwrap();
|
|
||||||
std::fs::write(format!("{}/links.json", cache_dir), json_content).unwrap();
|
|
||||||
|
|
||||||
// Load should work and set cache_dir
|
|
||||||
let store = LinkStore::load(cache_dir.clone());
|
|
||||||
assert_eq!(store.links.len(), 2);
|
|
||||||
assert_eq!(store.links[0].source_account_id, "src1");
|
|
||||||
assert_eq!(store.links[0].dest_account_id, "dest1");
|
|
||||||
assert_eq!(store.links[0].source_adapter_type, "gocardless");
|
|
||||||
assert_eq!(store.links[0].dest_adapter_type, "firefly");
|
|
||||||
assert!(store.links[0].auto_linked);
|
|
||||||
assert_eq!(store.links[1].source_account_id, "src2");
|
|
||||||
assert_eq!(store.links[1].dest_account_id, "dest2");
|
|
||||||
assert_eq!(store.links[1].source_adapter_type, "gocardless");
|
|
||||||
assert_eq!(store.links[1].dest_adapter_type, "firefly");
|
|
||||||
assert!(!store.links[1].auto_linked);
|
|
||||||
assert_eq!(store.cache_dir, cache_dir);
|
|
||||||
|
|
||||||
cleanup_test_dir(&cache_dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
pub mod adapters;
|
pub mod adapters;
|
||||||
pub mod cache;
|
|
||||||
pub mod config;
|
|
||||||
pub mod encryption;
|
|
||||||
pub mod linking;
|
pub mod linking;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ impl fmt::Debug for BankTransaction {
|
|||||||
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: Option<String>, // Account display name
|
pub iban: String,
|
||||||
pub iban: Option<String>, // IBAN may not be available for all accounts
|
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +68,6 @@ impl fmt::Debug for Account {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common interface for account data from different sources
|
|
||||||
pub trait AccountData {
|
|
||||||
fn id(&self) -> &str;
|
|
||||||
fn iban(&self) -> Option<&str>;
|
|
||||||
fn display_name(&self) -> Option<String>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -112,8 +104,7 @@ mod tests {
|
|||||||
fn test_account_debug_masks_iban() {
|
fn test_account_debug_masks_iban() {
|
||||||
let account = Account {
|
let account = Account {
|
||||||
id: "123".to_string(),
|
id: "123".to_string(),
|
||||||
name: Some("Test Account".to_string()),
|
iban: "DE1234567890".to_string(),
|
||||||
iban: Some("DE1234567890".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,9 +119,9 @@ mod tests {
|
|||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct AccountSummary {
|
pub struct AccountSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: Option<String>,
|
|
||||||
pub iban: String,
|
pub iban: String,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
|
pub status: String, // e.g., "active", "expired", "linked"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
|||||||
@@ -32,12 +32,6 @@ pub trait TransactionSource: Send + Sync {
|
|||||||
async fn get_account_status(&self) -> Result<Vec<AccountStatus>>;
|
async fn get_account_status(&self) -> Result<Vec<AccountStatus>>;
|
||||||
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo>;
|
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo>;
|
||||||
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>>;
|
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>>;
|
||||||
async fn get_cached_transactions(
|
|
||||||
&self,
|
|
||||||
account_id: &str,
|
|
||||||
start: NaiveDate,
|
|
||||||
end: NaiveDate,
|
|
||||||
) -> Result<Vec<BankTransaction>>;
|
|
||||||
|
|
||||||
/// Account discovery for linking
|
/// Account discovery for linking
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>>;
|
async fn discover_accounts(&self) -> Result<Vec<Account>>;
|
||||||
@@ -75,17 +69,6 @@ impl<T: TransactionSource> TransactionSource for &T {
|
|||||||
(**self).get_cache_info().await
|
(**self).get_cache_info().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_cached_transactions(
|
|
||||||
&self,
|
|
||||||
account_id: &str,
|
|
||||||
start: NaiveDate,
|
|
||||||
end: NaiveDate,
|
|
||||||
) -> Result<Vec<BankTransaction>> {
|
|
||||||
(**self)
|
|
||||||
.get_cached_transactions(account_id, start, end)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
||||||
(**self).discover_accounts().await
|
(**self).discover_accounts().await
|
||||||
}
|
}
|
||||||
@@ -100,6 +83,9 @@ 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 {
|
||||||
|
/// Get list of all active asset account IBANs to drive the sync
|
||||||
|
async fn get_active_account_ibans(&self) -> Result<Vec<String>>;
|
||||||
|
|
||||||
// New granular methods for Healer Logic
|
// New granular methods for Healer Logic
|
||||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>>;
|
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>>;
|
||||||
async fn find_transaction(
|
async fn find_transaction(
|
||||||
@@ -112,14 +98,15 @@ pub trait TransactionDestination: Send + Sync {
|
|||||||
|
|
||||||
/// Account discovery for linking
|
/// Account discovery for linking
|
||||||
async fn discover_accounts(&self) -> Result<Vec<Account>>;
|
async fn discover_accounts(&self) -> Result<Vec<Account>>;
|
||||||
|
|
||||||
/// Inspection methods for CLI
|
|
||||||
async fn list_accounts(&self) -> Result<Vec<AccountSummary>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 get_active_account_ibans(&self) -> Result<Vec<String>> {
|
||||||
|
(**self).get_active_account_ibans().await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
|
||||||
(**self).get_last_transaction_date(account_id).await
|
(**self).get_last_transaction_date(account_id).await
|
||||||
}
|
}
|
||||||
@@ -145,8 +132,4 @@ impl<T: TransactionDestination> TransactionDestination for &T {
|
|||||||
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
async fn discover_accounts(&self) -> Result<Vec<Account>> {
|
||||||
(**self).discover_accounts().await
|
(**self).discover_accounts().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
|
|
||||||
(**self).list_accounts().await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::core::config::Config;
|
|
||||||
use crate::core::linking::{auto_link_accounts, LinkStore};
|
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};
|
||||||
@@ -14,18 +13,33 @@ pub struct SyncResult {
|
|||||||
pub accounts_skipped_errors: usize,
|
pub accounts_skipped_errors: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(source, destination, config))]
|
#[instrument(skip(source, destination))]
|
||||||
pub async fn run_sync(
|
pub async fn run_sync(
|
||||||
source: impl TransactionSource,
|
source: impl TransactionSource,
|
||||||
destination: impl TransactionDestination,
|
destination: impl TransactionDestination,
|
||||||
config: Config,
|
|
||||||
cli_start_date: Option<NaiveDate>,
|
cli_start_date: Option<NaiveDate>,
|
||||||
cli_end_date: Option<NaiveDate>,
|
cli_end_date: Option<NaiveDate>,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<SyncResult> {
|
) -> Result<SyncResult> {
|
||||||
info!("Starting synchronization...");
|
info!("Starting synchronization...");
|
||||||
|
|
||||||
// Discover all accounts from both source and destination
|
// Optimization: Get active Firefly IBANs first
|
||||||
|
let wanted_ibans = destination
|
||||||
|
.get_active_account_ibans()
|
||||||
|
.await
|
||||||
|
.map_err(SyncError::DestinationError)?;
|
||||||
|
info!(
|
||||||
|
"Syncing {} active accounts from Firefly III",
|
||||||
|
wanted_ibans.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let accounts = source
|
||||||
|
.get_accounts(Some(wanted_ibans))
|
||||||
|
.await
|
||||||
|
.map_err(SyncError::SourceError)?;
|
||||||
|
info!("Found {} accounts from source", accounts.len());
|
||||||
|
|
||||||
|
// Discover all accounts and update linking
|
||||||
let all_source_accounts = source
|
let all_source_accounts = source
|
||||||
.discover_accounts()
|
.discover_accounts()
|
||||||
.await
|
.await
|
||||||
@@ -34,54 +48,27 @@ pub async fn run_sync(
|
|||||||
.discover_accounts()
|
.discover_accounts()
|
||||||
.await
|
.await
|
||||||
.map_err(SyncError::DestinationError)?;
|
.map_err(SyncError::DestinationError)?;
|
||||||
info!(
|
|
||||||
"Discovered {} source accounts and {} destination accounts",
|
|
||||||
all_source_accounts.len(),
|
|
||||||
all_dest_accounts.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Accounts are cached by their respective adapters during discover_accounts
|
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());
|
||||||
|
|
||||||
let mut link_store = LinkStore::load(config.cache.directory.clone());
|
// Auto-link accounts
|
||||||
|
|
||||||
// Auto-link accounts based on IBAN
|
|
||||||
let links = auto_link_accounts(&all_source_accounts, &all_dest_accounts);
|
let links = auto_link_accounts(&all_source_accounts, &all_dest_accounts);
|
||||||
for (src_idx, dest_idx) in links {
|
for (src_idx, dest_idx) in links {
|
||||||
let src = &all_source_accounts[src_idx];
|
let src = &all_source_accounts[src_idx];
|
||||||
let dest = &all_dest_accounts[dest_idx];
|
let dest = &all_dest_accounts[dest_idx];
|
||||||
match link_store.add_link(src, dest, "gocardless", "firefly", true) {
|
link_store.add_link(src, dest, true);
|
||||||
Ok(true) => {
|
|
||||||
info!("Created new account link: {} -> {}", src.id, dest.id);
|
|
||||||
}
|
|
||||||
Ok(false) => {
|
|
||||||
info!("Account link already exists: {} -> {}", src.id, dest.id);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to create account link: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
link_store.save().map_err(SyncError::SourceError)?;
|
link_store.save().map_err(SyncError::SourceError)?;
|
||||||
|
|
||||||
// Get all matched accounts (those with existing links)
|
|
||||||
let mut accounts_to_sync = Vec::new();
|
|
||||||
for source_account in &all_source_accounts {
|
|
||||||
if link_store.find_link_by_source(&source_account.id).is_some() {
|
|
||||||
accounts_to_sync.push(source_account.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
"Found {} accounts with existing links to sync",
|
|
||||||
accounts_to_sync.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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));
|
||||||
|
|
||||||
let mut result = SyncResult::default();
|
let mut result = SyncResult::default();
|
||||||
|
|
||||||
for account in accounts_to_sync {
|
for account in accounts {
|
||||||
let span = tracing::info_span!("sync_account", account_id = %account.id);
|
let span = tracing::info_span!("sync_account", account_id = %account.id);
|
||||||
let _enter = span.enter();
|
let _enter = span.enter();
|
||||||
|
|
||||||
@@ -280,39 +267,11 @@ async fn process_single_account(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::core::config::{
|
|
||||||
CacheConfig, Config, FireflyConfig, GoCardlessConfig, LoggingConfig,
|
|
||||||
};
|
|
||||||
use crate::core::models::{Account, BankTransaction};
|
use crate::core::models::{Account, BankTransaction};
|
||||||
use crate::core::ports::{MockTransactionDestination, MockTransactionSource, TransactionMatch};
|
use crate::core::ports::{MockTransactionDestination, MockTransactionSource, TransactionMatch};
|
||||||
use mockall::predicate::*;
|
use mockall::predicate::*;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
fn create_unique_key(prefix: &str) -> String {
|
|
||||||
format!("{}-{}", prefix, rand::random::<u64>())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_test_config(temp_dir: &str) -> Config {
|
|
||||||
Config {
|
|
||||||
gocardless: GoCardlessConfig {
|
|
||||||
url: "https://bankaccountdata.gocardless.com".to_string(),
|
|
||||||
secret_id: create_unique_key("gocardless-id"),
|
|
||||||
secret_key: create_unique_key("gocardless-key"),
|
|
||||||
},
|
|
||||||
firefly: FireflyConfig {
|
|
||||||
url: "https://firefly.test.com".to_string(),
|
|
||||||
api_key: create_unique_key("firefly-api-key"),
|
|
||||||
},
|
|
||||||
cache: CacheConfig {
|
|
||||||
key: create_unique_key("cache-key"),
|
|
||||||
directory: temp_dir.to_string(),
|
|
||||||
},
|
|
||||||
logging: LoggingConfig {
|
|
||||||
level: "warn".to_string(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_sync_flow_create_new() {
|
async fn test_sync_flow_create_new() {
|
||||||
let mut source = MockTransactionSource::new();
|
let mut source = MockTransactionSource::new();
|
||||||
@@ -325,8 +284,7 @@ mod tests {
|
|||||||
.returning(|_| {
|
.returning(|_| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
name: Some("Test Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -334,8 +292,7 @@ mod tests {
|
|||||||
source.expect_discover_accounts().returning(|| {
|
source.expect_discover_accounts().returning(|| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
name: Some("Test Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -358,11 +315,13 @@ mod tests {
|
|||||||
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
.returning(move |_, _, _| Ok(vec![tx.clone()]));
|
||||||
|
|
||||||
// Destination setup
|
// Destination setup
|
||||||
|
dest.expect_get_active_account_ibans()
|
||||||
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
dest.expect_discover_accounts().returning(|| {
|
dest.expect_discover_accounts().returning(|| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "dest_1".to_string(),
|
id: "dest_1".to_string(),
|
||||||
name: Some("Savings Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -382,13 +341,8 @@ mod tests {
|
|||||||
.returning(|_, _| Ok(()));
|
.returning(|_, _| Ok(()));
|
||||||
|
|
||||||
// Execution
|
// Execution
|
||||||
let temp_dir = format!("tmp/test-sync-{}", rand::random::<u64>());
|
let res = run_sync(&source, &dest, None, None, false).await;
|
||||||
let config = create_test_config(&temp_dir);
|
|
||||||
let res = run_sync(&source, &dest, config, None, None, false).await;
|
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -396,11 +350,13 @@ mod tests {
|
|||||||
let mut source = MockTransactionSource::new();
|
let mut source = MockTransactionSource::new();
|
||||||
let mut dest = MockTransactionDestination::new();
|
let mut dest = MockTransactionDestination::new();
|
||||||
|
|
||||||
|
dest.expect_get_active_account_ibans()
|
||||||
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
dest.expect_discover_accounts().returning(|| {
|
dest.expect_discover_accounts().returning(|| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "dest_1".to_string(),
|
id: "dest_1".to_string(),
|
||||||
name: Some("Savings Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -408,8 +364,7 @@ mod tests {
|
|||||||
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(),
|
||||||
name: Some("Test Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -417,8 +372,7 @@ mod tests {
|
|||||||
source.expect_discover_accounts().returning(|| {
|
source.expect_discover_accounts().returning(|| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
name: Some("Test Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -454,13 +408,8 @@ mod tests {
|
|||||||
.times(1)
|
.times(1)
|
||||||
.returning(|_, _| Ok(()));
|
.returning(|_, _| Ok(()));
|
||||||
|
|
||||||
let temp_dir = format!("tmp/test-sync-heal-{}", rand::random::<u64>());
|
let res = run_sync(&source, &dest, None, None, false).await;
|
||||||
let config = create_test_config(&temp_dir);
|
|
||||||
let res = run_sync(&source, &dest, config, None, None, false).await;
|
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -468,11 +417,13 @@ mod tests {
|
|||||||
let mut source = MockTransactionSource::new();
|
let mut source = MockTransactionSource::new();
|
||||||
let mut dest = MockTransactionDestination::new();
|
let mut dest = MockTransactionDestination::new();
|
||||||
|
|
||||||
|
dest.expect_get_active_account_ibans()
|
||||||
|
.returning(|| Ok(vec!["NL01".to_string()]));
|
||||||
|
|
||||||
dest.expect_discover_accounts().returning(|| {
|
dest.expect_discover_accounts().returning(|| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "dest_1".to_string(),
|
id: "dest_1".to_string(),
|
||||||
name: Some("Savings Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -480,8 +431,7 @@ mod tests {
|
|||||||
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(),
|
||||||
name: Some("Test Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -489,8 +439,7 @@ mod tests {
|
|||||||
source.expect_discover_accounts().returning(|| {
|
source.expect_discover_accounts().returning(|| {
|
||||||
Ok(vec![Account {
|
Ok(vec![Account {
|
||||||
id: "src_1".to_string(),
|
id: "src_1".to_string(),
|
||||||
name: Some("Test Account".to_string()),
|
iban: "NL01".to_string(),
|
||||||
iban: Some("NL01".to_string()),
|
|
||||||
currency: "EUR".to_string(),
|
currency: "EUR".to_string(),
|
||||||
}])
|
}])
|
||||||
});
|
});
|
||||||
@@ -521,12 +470,7 @@ mod tests {
|
|||||||
dest.expect_create_transaction().never();
|
dest.expect_create_transaction().never();
|
||||||
dest.expect_update_transaction_external_id().never();
|
dest.expect_update_transaction_external_id().never();
|
||||||
|
|
||||||
let temp_dir = format!("tmp/test-sync-dry-run-{}", rand::random::<u64>());
|
let res = run_sync(source, dest, None, None, true).await;
|
||||||
let config = create_test_config(&temp_dir);
|
|
||||||
let res = run_sync(source, dest, config, None, None, true).await;
|
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ impl Middleware for DebugLogger {
|
|||||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||||
let filename = format!("{}_{}_{}.txt", timestamp, request_id, self.service_name);
|
let filename = format!("{}_{}_{}.txt", timestamp, request_id, self.service_name);
|
||||||
|
|
||||||
let date = Utc::now().format("%Y-%m-%d").to_string();
|
let dir = format!("./debug_logs/{}", self.service_name);
|
||||||
let dir = format!("./debug_logs/{}/{}", date, self.service_name);
|
|
||||||
fs::create_dir_all(&dir).unwrap_or_else(|e| {
|
fs::create_dir_all(&dir).unwrap_or_else(|e| {
|
||||||
eprintln!("Failed to create debug log directory: {}", e);
|
eprintln!("Failed to create debug log directory: {}", e);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
mod adapters;
|
mod adapters;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod commands;
|
|
||||||
mod core;
|
mod core;
|
||||||
mod debug;
|
mod debug;
|
||||||
|
|
||||||
use crate::commands::accounts::AccountCommands;
|
use crate::cli::formatters::{print_list_output, OutputFormat};
|
||||||
use crate::commands::sync::handle_sync;
|
use crate::cli::setup::AppContext;
|
||||||
use crate::commands::transactions::TransactionCommands;
|
use crate::core::adapters::{
|
||||||
use crate::core::config::Config;
|
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 chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use tracing::info;
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
@@ -65,14 +68,62 @@ enum Commands {
|
|||||||
Destinations,
|
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
|
// Load environment variables first
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
let config = Config::from_env()?;
|
|
||||||
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
// Initialize logging based on command type
|
// Initialize logging based on command type
|
||||||
@@ -83,11 +134,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
_ => "warn",
|
_ => "warn",
|
||||||
};
|
};
|
||||||
|
|
||||||
let log_level = config
|
let log_level = std::env::var("RUST_LOG")
|
||||||
.logging
|
.map(|s| {
|
||||||
.level
|
s.parse()
|
||||||
.parse()
|
.unwrap_or(tracing_subscriber::EnvFilter::new(default_level))
|
||||||
.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();
|
tracing_subscriber::fmt().with_env_filter(log_level).init();
|
||||||
|
|
||||||
@@ -103,53 +155,252 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
} => {
|
} => {
|
||||||
handle_sync(
|
handle_sync(args.debug, source, destination, start, end, args.dry_run).await?;
|
||||||
config,
|
|
||||||
args.debug,
|
|
||||||
source,
|
|
||||||
destination,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
args.dry_run,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Commands::Sources => {
|
Commands::Sources => {
|
||||||
commands::list::handle_sources().await?;
|
handle_sources().await?;
|
||||||
}
|
}
|
||||||
Commands::Destinations => {
|
Commands::Destinations => {
|
||||||
commands::list::handle_destinations().await?;
|
handle_destinations().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Commands::Accounts { subcommand } => {
|
Commands::Accounts { subcommand } => {
|
||||||
commands::accounts::handle_accounts(config, subcommand).await?;
|
handle_accounts(subcommand).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Commands::Transactions { subcommand } => {
|
Commands::Transactions { subcommand } => {
|
||||||
commands::transactions::handle_transactions(config, subcommand).await?;
|
handle_transactions(subcommand).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
async fn handle_sync(
|
||||||
mod tests {
|
debug: bool,
|
||||||
use crate::cli::tables::mask_iban;
|
source: String,
|
||||||
|
destination: String,
|
||||||
#[test]
|
start: Option<NaiveDate>,
|
||||||
fn test_mask_iban_short() {
|
end: Option<NaiveDate>,
|
||||||
assert_eq!(mask_iban("123"), "123");
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// Validate destination
|
||||||
fn test_mask_iban_long() {
|
if !is_valid_destination(&destination) {
|
||||||
assert_eq!(mask_iban("NL12ABCD1234567890"), "NL12ABCD******7890");
|
let available = get_available_destinations()
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.id)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
anyhow::bail!(
|
||||||
|
"Unknown destination '{}'. Available destinations: {}",
|
||||||
|
destination,
|
||||||
|
available
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
// For now, only support gocardless -> firefly
|
||||||
fn test_mask_iban_other_country() {
|
if source != "gocardless" {
|
||||||
assert_eq!(mask_iban("DE1234567890123456"), "DE************3456");
|
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) => {
|
||||||
|
info!("Sync completed successfully.");
|
||||||
|
info!(
|
||||||
|
"Accounts processed: {}, skipped (expired): {}, skipped (errors): {}",
|
||||||
|
result.accounts_processed,
|
||||||
|
result.accounts_skipped_expired,
|
||||||
|
result.accounts_skipped_errors
|
||||||
|
);
|
||||||
|
info!(
|
||||||
|
"Transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
|
||||||
|
result.ingest.created,
|
||||||
|
result.ingest.healed,
|
||||||
|
result.ingest.duplicates,
|
||||||
|
result.ingest.errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => error!("Sync failed: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
@@ -10,19 +10,9 @@ Banks2FF implements a **Hexagonal (Ports & Adapters) Architecture** to synchroni
|
|||||||
banks2ff/
|
banks2ff/
|
||||||
├── banks2ff/ # Main CLI application
|
├── banks2ff/ # Main CLI application
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── commands/ # Command handlers
|
|
||||||
│ │ ├── accounts/ # Account management commands
|
|
||||||
│ │ │ ├── mod.rs # Account commands dispatch
|
|
||||||
│ │ │ ├── link.rs # Account linking logic
|
|
||||||
│ │ │ ├── list.rs # Account listing functionality
|
|
||||||
│ │ │ └── status.rs # Account status functionality
|
|
||||||
│ │ ├── transactions/ # Transaction management commands
|
|
||||||
│ │ ├── list.rs # Source/destination listing
|
|
||||||
│ │ └── sync.rs # Sync command handler
|
|
||||||
│ ├── cli/ # CLI utilities and formatting
|
|
||||||
│ ├── core/ # Domain logic and models
|
│ ├── core/ # Domain logic and models
|
||||||
│ ├── adapters/ # External service integrations
|
│ ├── adapters/ # External service integrations
|
||||||
│ └── main.rs # CLI entry point and command dispatch
|
│ └── main.rs # CLI entry point
|
||||||
├── firefly-client/ # Firefly III API client library
|
├── firefly-client/ # Firefly III API client library
|
||||||
├── gocardless-client/ # GoCardless API client library
|
├── gocardless-client/ # GoCardless API client library
|
||||||
└── docs/ # Architecture documentation
|
└── docs/ # Architecture documentation
|
||||||
@@ -59,15 +49,7 @@ banks2ff/
|
|||||||
- `client.rs`: Wrapper for Firefly client for transaction storage
|
- `client.rs`: Wrapper for Firefly client for transaction storage
|
||||||
- Maps domain models to Firefly API format
|
- Maps domain models to Firefly API format
|
||||||
|
|
||||||
### 3. Command Handlers (`banks2ff/src/commands/`)
|
### 3. API Clients
|
||||||
|
|
||||||
The CLI commands are organized into focused modules:
|
|
||||||
- **sync.rs**: Handles transaction synchronization between sources and destinations
|
|
||||||
- **accounts/**: Account management including linking, listing, and status
|
|
||||||
- **transactions/**: Transaction inspection, caching, and cache management
|
|
||||||
- **list.rs**: Simple listing of available sources and destinations
|
|
||||||
|
|
||||||
### 4. API Clients
|
|
||||||
|
|
||||||
Both clients are hand-crafted using `reqwest`:
|
Both clients are hand-crafted using `reqwest`:
|
||||||
- Strongly-typed DTOs for compile-time safety
|
- Strongly-typed DTOs for compile-time safety
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ impl FireflyClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_accounts(&self) -> Result<AccountArray, FireflyError> {
|
pub async fn get_accounts(&self, _iban: &str) -> Result<AccountArray, FireflyError> {
|
||||||
let url = self.base_url.join("/api/v1/accounts")?;
|
let mut url = self.base_url.join("/api/v1/accounts")?;
|
||||||
|
url.query_pairs_mut().append_pair("type", "asset");
|
||||||
|
|
||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,42 +13,6 @@ pub struct Account {
|
|||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub account_type: String,
|
pub account_type: String,
|
||||||
pub active: Option<bool>,
|
pub active: Option<bool>,
|
||||||
pub order: Option<i32>,
|
|
||||||
pub created_at: Option<String>,
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
pub account_role: Option<String>,
|
|
||||||
pub object_group_id: Option<String>,
|
|
||||||
pub object_group_title: Option<String>,
|
|
||||||
pub object_group_order: Option<i32>,
|
|
||||||
pub currency_id: Option<String>,
|
|
||||||
pub currency_name: Option<String>,
|
|
||||||
pub currency_code: Option<String>,
|
|
||||||
pub currency_symbol: Option<String>,
|
|
||||||
pub currency_decimal_places: Option<i32>,
|
|
||||||
pub primary_currency_id: Option<String>,
|
|
||||||
pub primary_currency_name: Option<String>,
|
|
||||||
pub primary_currency_code: Option<String>,
|
|
||||||
pub primary_currency_symbol: Option<String>,
|
|
||||||
pub primary_currency_decimal_places: Option<i32>,
|
|
||||||
pub opening_balance: Option<String>,
|
|
||||||
pub pc_opening_balance: Option<String>,
|
|
||||||
pub debt_amount: Option<String>,
|
|
||||||
pub pc_debt_amount: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub monthly_payment_date: Option<String>,
|
|
||||||
pub credit_card_type: Option<String>,
|
|
||||||
pub account_number: Option<String>,
|
|
||||||
pub bic: Option<String>,
|
|
||||||
pub opening_balance_date: Option<String>,
|
|
||||||
pub liability_type: Option<String>,
|
|
||||||
pub liability_direction: Option<String>,
|
|
||||||
pub interest: Option<String>,
|
|
||||||
pub interest_period: Option<String>,
|
|
||||||
pub include_net_worth: Option<bool>,
|
|
||||||
pub longitude: Option<f64>,
|
|
||||||
pub latitude: Option<f64>,
|
|
||||||
pub zoom_level: Option<i32>,
|
|
||||||
pub last_activity: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
Account, AccountDetail, EndUserAgreement, PaginatedResponse, Requisition, TokenResponse,
|
Account, EndUserAgreement, PaginatedResponse, Requisition, TokenResponse, TransactionsResponse,
|
||||||
TransactionsResponse,
|
|
||||||
};
|
};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use reqwest_middleware::ClientWithMiddleware;
|
use reqwest_middleware::ClientWithMiddleware;
|
||||||
@@ -148,14 +147,6 @@ impl GoCardlessClient {
|
|||||||
self.get_authenticated(url).await
|
self.get_authenticated(url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn get_account_details(&self, id: &str) -> Result<AccountDetail, GoCardlessError> {
|
|
||||||
let url = self
|
|
||||||
.base_url
|
|
||||||
.join(&format!("/api/v2/accounts/{}/details/", id))?;
|
|
||||||
self.get_authenticated(url).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub async fn get_transactions(
|
pub async fn get_transactions(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -42,33 +42,6 @@ pub struct Account {
|
|||||||
pub iban: Option<String>,
|
pub iban: Option<String>,
|
||||||
pub institution_id: Option<String>,
|
pub institution_id: Option<String>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
pub owner_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AccountDetail {
|
|
||||||
pub account: DetailSchema,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DetailSchema {
|
|
||||||
pub resource_id: Option<String>,
|
|
||||||
pub iban: Option<String>,
|
|
||||||
pub bban: Option<String>,
|
|
||||||
pub pan: Option<String>,
|
|
||||||
pub masked_pan: Option<String>,
|
|
||||||
pub msisdn: Option<String>,
|
|
||||||
pub currency: Option<String>,
|
|
||||||
pub owner_name: Option<String>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub product: Option<String>,
|
|
||||||
pub cash_account_type: Option<String>,
|
|
||||||
pub status: Option<String>,
|
|
||||||
pub bic: Option<String>,
|
|
||||||
pub linked_accounts: Option<String>,
|
|
||||||
pub usage: Option<String>,
|
|
||||||
pub details: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -86,24 +59,10 @@ 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")]
|
||||||
@@ -112,32 +71,14 @@ 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)]
|
||||||
@@ -161,10 +102,4 @@ 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>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,6 @@
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Current Status Summary
|
|
||||||
|
|
||||||
- **Completed Phases**: 1, 2, 3, 4, 4.5, 5, 9.5 (7/10 phases complete)
|
|
||||||
- **Remaining Phases**: 6, 7, 8, 10 (4 phases pending)
|
|
||||||
- **Core Functionality**: CLI structure, account linking, transaction inspection all working
|
|
||||||
- **Next Priority**: Fix cache-status to scan disk for all transaction caches
|
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- **Decouple CLI Architecture**: Separate CLI logic from core business logic to enable multiple entry points (CLI, web API)
|
- **Decouple CLI Architecture**: Separate CLI logic from core business logic to enable multiple entry points (CLI, web API)
|
||||||
@@ -49,8 +42,6 @@ COMMANDS:
|
|||||||
|
|
||||||
**Objective**: Establish new subcommand architecture while preserving existing sync functionality.
|
**Objective**: Establish new subcommand architecture while preserving existing sync functionality.
|
||||||
|
|
||||||
**Completion Date**: Early implementation
|
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
1. ✅ Refactor `main.rs` to use `clap::Subcommand` with nested enums for commands and subcommands
|
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
|
2. ✅ Extract environment loading and client initialization into a `cli::setup` module
|
||||||
@@ -151,31 +142,9 @@ COMMANDS:
|
|||||||
4. ✅ Ensure sensitive data masking in all outputs
|
4. ✅ Ensure sensitive data masking in all outputs
|
||||||
5. Add progress indicators for long-running operations (pending)
|
5. Add progress indicators for long-running operations (pending)
|
||||||
6. ✅ Implement `accounts` command with `list` and `status` subcommands
|
6. ✅ Implement `accounts` command with `list` and `status` subcommands
|
||||||
7. ✅ Implement `transactions` command with `list` and `cache-status` subcommands
|
7. ✅ Implement `transactions` command with `list`, `cache-status`, and `clear-cache` subcommands
|
||||||
8. ✅ Add account and transaction inspection methods to adapter traits
|
8. ✅ Add account and transaction inspection methods to adapter traits
|
||||||
|
|
||||||
### Phase 4.5: Enhanced Transaction List UX ✅ COMPLETED
|
|
||||||
|
|
||||||
**Objective**: Improve the transactions list command to match account linking UX patterns and fix functional bugs.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. ✅ Fix `get_transaction_info` bug in GoCardlessAdapter to load cache from disk when not in memory
|
|
||||||
2. ✅ Update `transactions list` command to accept optional account identifier (ID, IBAN, or name)
|
|
||||||
3. ✅ Add interactive account selection when no identifier provided, showing transaction counts
|
|
||||||
4. ✅ Implement flexible account resolution using same logic as account linking
|
|
||||||
5. ✅ Add `--details` flag to show actual transactions instead of summary
|
|
||||||
6. ✅ Add `--limit` flag to control number of transactions displayed (default: 20)
|
|
||||||
7. ✅ Create `Formattable` implementation for `BankTransaction` with proper masking
|
|
||||||
8. ✅ Update CLI help text and error messages for better user guidance
|
|
||||||
|
|
||||||
**Implementation Details:**
|
|
||||||
- Fixed critical bug where transaction counts always showed 0 due to cache not being loaded from disk
|
|
||||||
- Made account parameter optional with interactive fallback, matching account linking UX
|
|
||||||
- Added transaction details view with recent transaction display and proper financial data masking
|
|
||||||
- Maintained security by masking amounts, descriptions, and counterparties in output
|
|
||||||
- Used same account resolution patterns as linking for consistency
|
|
||||||
- All code formatted, linted, and tested; backward compatibility maintained
|
|
||||||
|
|
||||||
**Testing:**
|
**Testing:**
|
||||||
- Unit tests for formatter functions
|
- Unit tests for formatter functions
|
||||||
- Integration tests for CLI output with sample data
|
- Integration tests for CLI output with sample data
|
||||||
@@ -191,21 +160,15 @@ COMMANDS:
|
|||||||
- Added `print_list_output` function for displaying collections of data
|
- Added `print_list_output` function for displaying collections of data
|
||||||
- All code formatted with `cargo fmt` and linted with `cargo clippy`
|
- All code formatted with `cargo fmt` and linted with `cargo clippy`
|
||||||
|
|
||||||
### Phase 5: Status and Cache Management ✅ COMPLETED
|
### Phase 5: Status and Cache Management
|
||||||
|
|
||||||
**Objective**: Implement status overview and cache inspection commands.
|
**Objective**: Implement status overview and cache management commands.
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
1. ✅ Implement `accounts status` command aggregating account data from adapters
|
1. Implement `status` command aggregating data from all adapters
|
||||||
2. ✅ Add cache inspection functionality to `transactions cache-status` (shows in-memory caches only)
|
2. Add cache inspection and clearing functionality to `transactions cache-status` and `transactions clear-cache`
|
||||||
3. ✅ Fix `transactions cache-status` to scan disk for all transaction caches (currently missing disk-based caches)
|
3. Create status models for sync health metrics
|
||||||
4. ❌ Create status models for sync health metrics (deferred - current AccountStatus sufficient)
|
4. Integrate with existing debug logging infrastructure
|
||||||
5. ❌ Integrate with existing debug logging infrastructure (deferred - tracing instrumentation adequate)
|
|
||||||
|
|
||||||
**Current Status:**
|
|
||||||
- `accounts status` works and shows account sync status
|
|
||||||
- `transactions cache-status` now shows comprehensive cache information including disk-based caches
|
|
||||||
- Removed unused `transactions clear-cache` command
|
|
||||||
|
|
||||||
**Testing:**
|
**Testing:**
|
||||||
- Unit tests for status aggregation logic
|
- Unit tests for status aggregation logic
|
||||||
@@ -260,46 +223,19 @@ COMMANDS:
|
|||||||
- Security audits for data handling
|
- Security audits for data handling
|
||||||
- Compatibility tests with existing configurations
|
- Compatibility tests with existing configurations
|
||||||
|
|
||||||
### Phase 9.5: Command Handler Extraction ✅ COMPLETED
|
### Phase 9: File-Based Source Adapters
|
||||||
|
|
||||||
**Objective**: Extract command handling logic from main.rs into dedicated modules for better maintainability and separation of concerns.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. ✅ Create `commands/` module structure with submodules for each command group
|
|
||||||
2. ✅ Extract table printing utilities to `cli/tables.rs`
|
|
||||||
3. ✅ Move command handlers to appropriate modules:
|
|
||||||
- `commands/sync.rs`: Sync command logic
|
|
||||||
- `commands/accounts/`: Account management (link, list, status)
|
|
||||||
- `commands/transactions/`: Transaction operations (list, cache, clear)
|
|
||||||
- `commands/list.rs`: Source/destination listing
|
|
||||||
4. ✅ Update main.rs to dispatch to new command modules
|
|
||||||
5. ✅ Remove extracted functions from main.rs, reducing it from 1049 to ~150 lines
|
|
||||||
6. ✅ Update documentation to reflect new structure
|
|
||||||
|
|
||||||
**Implementation Details:**
|
|
||||||
- Created hierarchical module structure with focused responsibilities
|
|
||||||
- Maintained all existing functionality and CLI interface
|
|
||||||
- Improved code organization and testability
|
|
||||||
- Updated architecture documentation with new module structure
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- All existing tests pass
|
|
||||||
- CLI functionality preserved
|
|
||||||
- Code formatting and linting applied
|
|
||||||
|
|
||||||
### Phase 10: File-Based Source Adapters
|
|
||||||
|
|
||||||
**Objective**: Implement adapters for file-based transaction sources.
|
**Objective**: Implement adapters for file-based transaction sources.
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
1. Create `adapters::csv` module implementing `TransactionSource`
|
1. Create `adapters::csv` module implementing `TransactionSource`
|
||||||
- Parse CSV files with configurable column mappings
|
- Parse CSV files with configurable column mappings
|
||||||
- Implement caching similar to GoCardless adapter
|
- Implement caching similar to GoCardless adapter
|
||||||
- Add inspection methods for file status and transaction counts
|
- Add inspection methods for file status and transaction counts
|
||||||
2. Create `adapters::camt053` and `adapters::mt940` modules
|
2. Create `adapters::camt053` and `adapters::mt940` modules
|
||||||
- Parse respective financial file formats
|
- Parse respective financial file formats
|
||||||
- Implement transaction mapping and validation
|
- Implement transaction mapping and validation
|
||||||
- Add format-specific caching and inspection
|
- Add format-specific caching and inspection
|
||||||
3. Update `adapter_factory` to instantiate file adapters with file paths
|
3. Update `adapter_factory` to instantiate file adapters with file paths
|
||||||
|
|
||||||
**Testing:**
|
**Testing:**
|
||||||
@@ -328,18 +264,10 @@ COMMANDS:
|
|||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- All existing sync functionality preserved ✅
|
- All existing sync functionality preserved
|
||||||
- New commands work with all supported sources/destinations ✅
|
- New commands work with all supported sources/destinations
|
||||||
- Core logic remains adapter-agnostic ✅
|
- Core logic remains adapter-agnostic
|
||||||
- Comprehensive test coverage maintained ✅
|
- Comprehensive test coverage maintained
|
||||||
- Performance meets or exceeds current benchmarks ✅
|
- Performance meets or exceeds current benchmarks
|
||||||
- Architecture supports future web API development ✅
|
- Architecture supports future web API development</content>
|
||||||
- Cache status reporting is comprehensive ✅
|
|
||||||
|
|
||||||
## Completion Notes
|
|
||||||
|
|
||||||
- **Phase 5 Fix Required**: ✅ COMPLETED - Updated `GoCardlessAdapter::get_cache_info()` to load all transaction caches from discovered accounts (both in-memory and disk-based)
|
|
||||||
- **Remove Cache Clearing**: ✅ COMPLETED - Removed `transactions clear-cache` command from CLI as it's not useful
|
|
||||||
- **Status Metrics**: Deferred - current AccountStatus provides adequate sync health information
|
|
||||||
- **Multi-Source Ready**: Architecture supports adding CSV, CAMT.053, MT940 adapters when needed</content>
|
|
||||||
<parameter name="filePath">specs/cli-refactor-plan.md
|
<parameter name="filePath">specs/cli-refactor-plan.md
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
# Encrypted Transaction Caching Implementation Plan
|
# Encrypted Transaction Caching Implementation Plan
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
High-performance encrypted caching for GoCardless transactions to minimize API calls against rate limits (4 reqs/day per account). Uses optimized hybrid encryption with PBKDF2 master key derivation and HKDF per-operation keys.
|
Implement encrypted caching for GoCardless transactions to minimize API calls against the extremely low rate limits (4 reqs/day per account). Cache raw transaction data with automatic range merging and deduplication.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- **Location**: `banks2ff/src/adapters/gocardless/`
|
- **Location**: `banks2ff/src/adapters/gocardless/`
|
||||||
- **Storage**: `data/cache/` directory
|
- **Storage**: `data/cache/` directory
|
||||||
- **Encryption**: AES-GCM with hybrid key derivation (PBKDF2 + HKDF)
|
- **Encryption**: AES-GCM for disk storage only
|
||||||
- **Performance**: Single PBKDF2 derivation per adapter instance
|
|
||||||
- **No API Client Changes**: All caching logic in adapter layer
|
- **No API Client Changes**: All caching logic in adapter layer
|
||||||
|
|
||||||
## Components to Create
|
## Components to Create
|
||||||
@@ -123,9 +122,8 @@ struct CachedRange {
|
|||||||
|
|
||||||
### Encryption Scope
|
### Encryption Scope
|
||||||
- **In Memory**: Plain structs (no performance overhead)
|
- **In Memory**: Plain structs (no performance overhead)
|
||||||
- **On Disk**: Full AES-GCM encryption with hybrid key derivation
|
- **On Disk**: Full AES-GCM encryption
|
||||||
- **Key Source**: Environment variable `BANKS2FF_CACHE_KEY`
|
- **Key Source**: Environment variable `BANKS2FF_CACHE_KEY`
|
||||||
- **Performance**: Single PBKDF2 derivation per adapter instance
|
|
||||||
|
|
||||||
### Range Merging Strategy
|
### Range Merging Strategy
|
||||||
- **Overlap Detection**: Check date range intersections
|
- **Overlap Detection**: Check date range intersections
|
||||||
@@ -140,17 +138,15 @@ struct CachedRange {
|
|||||||
|
|
||||||
## Dependencies to Add
|
## Dependencies to Add
|
||||||
- `aes-gcm`: For encryption
|
- `aes-gcm`: For encryption
|
||||||
- `pbkdf2`: For master key derivation
|
- `pbkdf2`: For key derivation
|
||||||
- `hkdf`: For per-operation key derivation
|
|
||||||
- `rand`: For encryption nonces
|
- `rand`: For encryption nonces
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
- **Encryption**: AES-GCM with 256-bit keys and hybrid derivation (PBKDF2 50k + HKDF)
|
- **Encryption**: AES-GCM with 256-bit keys and PBKDF2 (200,000 iterations)
|
||||||
- **Salt Security**: Fixed master salt + random operation salts
|
- **Salt Security**: Random 16-byte salt per encryption (prepended to ciphertext)
|
||||||
- **Key Management**: Environment variable `BANKS2FF_CACHE_KEY` required
|
- **Key Management**: Environment variable `BANKS2FF_CACHE_KEY` required
|
||||||
- **Data Protection**: Financial data encrypted at rest, no sensitive data in logs
|
- **Data Protection**: Financial data encrypted at rest, no sensitive data in logs
|
||||||
- **Authentication**: GCM provides integrity protection against tampering
|
- **Authentication**: GCM provides integrity protection against tampering
|
||||||
- **Performance**: ~10-50μs per cache operation vs 50-100ms previously
|
|
||||||
- **Forward Security**: Unique salt/nonce prevents rainbow table attacks
|
- **Forward Security**: Unique salt/nonce prevents rainbow table attacks
|
||||||
|
|
||||||
## Performance Expectations
|
## Performance Expectations
|
||||||
@@ -266,12 +262,13 @@ struct CachedRange {
|
|||||||
- **Disk I/O**: Encrypted storage with minimal overhead for persistence
|
- **Disk I/O**: Encrypted storage with minimal overhead for persistence
|
||||||
|
|
||||||
### Security Validation
|
### Security Validation
|
||||||
- **Encryption**: All cache operations use AES-GCM with hybrid PBKDF2+HKDF key derivation
|
- **Encryption**: All cache operations use AES-GCM with PBKDF2 key derivation
|
||||||
- **Data Integrity**: GCM authentication prevents tampering detection
|
- **Data Integrity**: GCM authentication prevents tampering detection
|
||||||
- **Key Security**: 50k iteration PBKDF2 master key + HKDF per-operation keys
|
- **Key Security**: 200,000 iteration PBKDF2 with random salt per operation
|
||||||
- **No Sensitive Data**: Financial amounts masked in logs, secure at-rest storage
|
- **No Sensitive Data**: Financial amounts masked in logs, secure at-rest storage
|
||||||
|
|
||||||
### Final Status
|
### Final Status
|
||||||
- **All Phases Completed**: Core infrastructure, range management, adapter integration, and testing
|
- **All Phases Completed**: Core infrastructure, range management, adapter integration, and testing
|
||||||
- **Production Ready**: High-performance encrypted caching reduces API calls by 99%
|
- **Production Ready**: Encrypted caching reduces API calls by 99% while maintaining security
|
||||||
- **Maintainable**: Clean architecture with comprehensive test coverage
|
- **Maintainable**: Clean architecture with comprehensive test coverage
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user