Files
banks2ff/specs/planning.md
2025-11-21 23:00:09 +01:00

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:

  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

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

  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").