130 lines
5.7 KiB
Markdown
130 lines
5.7 KiB
Markdown
|
|
# 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:
|
||
|
|
1. **Core (Domain)**: Pure Rust, no external API dependencies. Defines the `BankTransaction` model and the `Ports` (traits) for interacting with the world.
|
||
|
|
2. **Adapters**: Implementations of the Ports.
|
||
|
|
- `GoCardlessAdapter`: Implements `TransactionSource`.
|
||
|
|
- `FireflyAdapter`: Implements `TransactionDestination`.
|
||
|
|
3. **Application**: Configuration, CLI parsing, and wiring (`main.rs`).
|
||
|
|
|
||
|
|
### Directory Structure
|
||
|
|
```text
|
||
|
|
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.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
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.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
#[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
|
||
|
|
1. **Setup**: Create the directory structure.
|
||
|
|
2. **Dependencies**: Add `anyhow`, `thiserror`, `tracing`, `tracing-subscriber`, `async-trait`, `rust_decimal`, `chrono`.
|
||
|
|
3. **Core**: Implement `models.rs` and `ports.rs`.
|
||
|
|
|
||
|
|
### Phase 2: GoCardless Adapter (Source)
|
||
|
|
1. **Token Management**: Create a wrapper struct that holds the `gocardless_bankaccount_data_api` configuration and manages the Access/Refresh token lifecycle. It should check validity before every request.
|
||
|
|
2. **Mapping Logic**:
|
||
|
|
- Map `transactionAmount.amount` -> `BankTransaction.amount`.
|
||
|
|
- **Multi-Currency**: Inspect `currencyExchange`.
|
||
|
|
- If present, map `sourceCurrency` -> `foreign_currency`.
|
||
|
|
- Calculate `foreign_amount` using `exchangeRate` if `instructedAmount` is missing.
|
||
|
|
- Map `transactionId` -> `internal_id`.
|
||
|
|
3. **Trait Implementation**: Implement `TransactionSource`.
|
||
|
|
|
||
|
|
### Phase 3: Firefly Adapter (Destination)
|
||
|
|
1. **Client Wrapper**: Initialize `firefly_iii_api` client with API Key.
|
||
|
|
2. **Resolution**: Implement `resolve_account_id` by querying Firefly accounts by IBAN (using the search/list endpoint).
|
||
|
|
3. **Ingestion**:
|
||
|
|
- Map `BankTransaction` to `TransactionSplitStore`.
|
||
|
|
- **Crucial**: Set `external_id` = `BankTransaction.internal_id`.
|
||
|
|
- Handle Credits vs Debits (Swap Source/Destination logic).
|
||
|
|
- Call `store_transaction` endpoint.
|
||
|
|
- Handle 422/409 errors gracefully (log duplicates).
|
||
|
|
|
||
|
|
### Phase 4: Synchronization Engine
|
||
|
|
1. Implement `src/core/sync.rs`.
|
||
|
|
2. 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
|
||
|
|
1. Refactor `main.rs`.
|
||
|
|
2. Initialize `tracing` for structured logging.
|
||
|
|
3. Load Config (Env variables).
|
||
|
|
4. Instantiate Adapters.
|
||
|
|
5. Run the Sync Engine.
|
||
|
|
|
||
|
|
## 5. Multi-Currency Handling Specifics
|
||
|
|
- **GoCardless Spec**: The `currencyExchange` array contains the details.
|
||
|
|
- **Logic**:
|
||
|
|
- If `currencyExchange` is not null/empty:
|
||
|
|
- `foreign_currency` = `currencyExchange[0].sourceCurrency`
|
||
|
|
- `foreign_amount` = `amount` * `currencyExchange[0].exchangeRate` (Validation: check if `unitCurrency` affects this).
|
||
|
|
- Ensure `Decimal` precision is handled correctly.
|
||
|
|
|
||
|
|
## 6. Testability & Observability
|
||
|
|
- **Tests**: Write a unit test for `core/sync.rs` using Mock structs for Source/Destination to verify the flow logic.
|
||
|
|
- **Logs**: Use `tracing::info!` for high-level progress ("Synced Account X") and `tracing::debug!` for details ("Transaction Y mapped to Z").
|