5.7 KiB
5.7 KiB
Implementation Plan: Bank2FF Refactoring
1. Objective
Refactor the bank2ff application from a prototype script into a robust, testable, and observable production-grade CLI tool. The application must synchronize bank transactions from GoCardless to Firefly III, ensuring:
- Clean Architecture: Decoupling of business logic from API clients.
- Testability: Ability to unit test core logic without external dependencies.
- Multi-Currency Support: Accurate capturing of foreign amounts and currencies.
- Idempotency: Preventing duplicate transactions in Firefly III.
2. Architecture: Hexagonal (Ports & Adapters)
The application will be structured into three distinct layers:
- Core (Domain): Pure Rust, no external API dependencies. Defines the
BankTransactionmodel and thePorts(traits) for interacting with the world. - Adapters: Implementations of the Ports.
GoCardlessAdapter: ImplementsTransactionSource.FireflyAdapter: ImplementsTransactionDestination.
- Application: Configuration, CLI parsing, and wiring (
main.rs).
Directory Structure
bank2ff/src/
├── core/
│ ├── mod.rs
│ ├── models.rs # Domain entities (BankTransaction, Account)
│ ├── ports.rs # Traits (TransactionSource, TransactionDestination)
│ └── sync.rs # Core business logic (The "Use Case")
├── adapters/
│ ├── mod.rs
│ ├── gocardless/
│ │ ├── mod.rs
│ │ ├── client.rs # Wrapper around generated client (Auth/RateLimits)
│ │ └── mapper.rs # Logic to map API response -> Domain Model
│ └── firefly/
│ │ ├── mod.rs
│ │ └── client.rs # Implementation of TransactionDestination
└── main.rs # Entry point, wiring, and config loading
3. Core definitions
src/core/models.rs
The domain model must support multi-currency data.
pub struct BankTransaction {
pub internal_id: String, // Source ID (GoCardless transactionId)
pub date: NaiveDate, // Booking date
pub amount: Decimal, // Amount in account currency
pub currency: String, // Account currency code (e.g., EUR)
pub foreign_amount: Option<Decimal>, // Original amount (if currency exchange occurred)
pub foreign_currency: Option<String>,// Original currency code
pub description: String,
pub counterparty_name: Option<String>,
pub counterparty_iban: Option<String>,
}
src/core/ports.rs
Traits to decouple the architecture.
#[async_trait]
pub trait TransactionSource: Send + Sync {
async fn get_accounts(&self) -> Result<Vec<Account>>;
async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result<Vec<BankTransaction>>;
}
#[async_trait]
pub trait TransactionDestination: Send + Sync {
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>>;
async fn ingest_transactions(&self, account_id: &str, transactions: Vec<BankTransaction>) -> Result<IngestResult>;
}
4. Implementation Steps
Phase 1: Infrastructure & Core
- Setup: Create the directory structure.
- Dependencies: Add
anyhow,thiserror,tracing,tracing-subscriber,async-trait,rust_decimal,chrono. - Core: Implement
models.rsandports.rs.
Phase 2: GoCardless Adapter (Source)
- Token Management: Create a wrapper struct that holds the
gocardless_bankaccount_data_apiconfiguration and manages the Access/Refresh token lifecycle. It should check validity before every request. - Mapping Logic:
- Map
transactionAmount.amount->BankTransaction.amount. - Multi-Currency: Inspect
currencyExchange.- If present, map
sourceCurrency->foreign_currency. - Calculate
foreign_amountusingexchangeRateifinstructedAmountis missing.
- If present, map
- Map
transactionId->internal_id.
- Map
- Trait Implementation: Implement
TransactionSource.
Phase 3: Firefly Adapter (Destination)
- Client Wrapper: Initialize
firefly_iii_apiclient with API Key. - Resolution: Implement
resolve_account_idby querying Firefly accounts by IBAN (using the search/list endpoint). - Ingestion:
- Map
BankTransactiontoTransactionSplitStore. - Crucial: Set
external_id=BankTransaction.internal_id. - Handle Credits vs Debits (Swap Source/Destination logic).
- Call
store_transactionendpoint. - Handle 422/409 errors gracefully (log duplicates).
- Map
Phase 4: Synchronization Engine
- Implement
src/core/sync.rs. - Logic:
- Fetch accounts from Source.
- For each account, find Destination ID.
- Fetch transactions (default: last 30 days).
- Filter/Process (optional).
- Ingest to Destination.
- Log statistics using
tracing.
Phase 5: Wiring
- Refactor
main.rs. - Initialize
tracingfor structured logging. - Load Config (Env variables).
- Instantiate Adapters.
- Run the Sync Engine.
5. Multi-Currency Handling Specifics
- GoCardless Spec: The
currencyExchangearray contains the details. - Logic:
- If
currencyExchangeis not null/empty:foreign_currency=currencyExchange[0].sourceCurrencyforeign_amount=amount*currencyExchange[0].exchangeRate(Validation: check ifunitCurrencyaffects this).
- Ensure
Decimalprecision is handled correctly.
- If
6. Testability & Observability
- Tests: Write a unit test for
core/sync.rsusing Mock structs for Source/Destination to verify the flow logic. - Logs: Use
tracing::info!for high-level progress ("Synced Account X") andtracing::debug!for details ("Transaction Y mapped to Z").