Compare commits

...

38 Commits

Author SHA1 Message Date
d9a3ea4e94 feat: Implement IBAN-based account linking for Firefly III transactions
This enhancement improves transaction synchronization by
automatically linking counterparty IBANs from GoCardless to
existing Firefly III accounts, ensuring more accurate
reconciliation and better data integrity. Users benefit from
reduced manual effort in matching transactions, fewer duplicate
payees, and cleaner financial records that reflect real-world
banking relationships. The implementation caches all account
types during discovery, adds IBAN lookup logic with fallback to
payee creation, and filters CLI account lists to focus on
user-managed asset and liability accounts while maintaining
full backward compatibility.
2025-12-08 00:01:26 +01:00
82197d414d feat(cache-status): remove irrelevant size column and add disk cache scanning
The cache-status command now provides a cleaner, more focused display by removing the irrelevant 'Size (bytes)' column that always showed zero. Additionally, it now scans disk for all transaction caches, ensuring comprehensive visibility into both in-memory and persisted cache data. Users can now quickly assess their cache state without distraction from meaningless data.
2025-12-06 18:49:01 +01:00
7034799926 fix(cli): Remove unwanted and unimplemented clear-cache 2025-12-06 18:49:01 +01:00
758a16bd73 feat(transactions-list): add interactive selection and details view
The transactions list command now supports interactive account selection when no account is specified, allowing users to easily choose from accounts with transaction data. Added a --details flag to show recent transactions with amounts, descriptions, and counterparties, while maintaining security through proper data masking. Users can now flexibly inspect their transaction data without needing to know exact account IDs.
2025-12-06 18:49:01 +01:00
9ebc370e67 fix(agent): Add section on workspace structure
The agent used to trip itself up when looking for code. With this
section, that doesn't happen any more.
2025-12-06 17:56:53 +01:00
58b6994372 fix(mapper): Make currency exchange more robust
Instead of having hard-coded logic, which was inverted to boot, the
currency exchane logic now uses the information about source and target
currencies as received from GoCardless.
2025-12-06 16:48:49 +01:00
31bd02f974 Move debug logs to directory per day
This makes it easier to find stuff.
2025-12-06 16:48:15 +01:00
5f54124015 chore: Move all command handlers to their own files
This makes the code much easier to follow and shortens main.rs from
>1000 lines to around 150.
2025-11-29 01:22:49 +01:00
095e15cd5f feat: speed up cache operations with optimized encryption
Cache load/save operations now complete in milliseconds instead of
hundreds of milliseconds, making transaction syncs noticeably faster
while maintaining full AES-GCM security.
2025-11-29 00:37:05 +01:00
a53449d463 feat: Enhanced account management and UX
This commit significantly improves the account linking and display experience
throughout the application, making it much more user-friendly while
maintaining security and reliability.

Key User-Facing Improvements:

Interactive Account Linking
- Added professional interactive prompts using dialoguer for account linking
- Users can now select accounts by name instead of cryptic IDs
- Smart argument resolution automatically detects source/destination accounts
- Graceful error handling for non-interactive environments

Enhanced Account Display
- GoCardless accounts now show institution information (e.g., "Checking (BANK123)")
- Removed confusing ID columns from account lists and status displays
- Consistent display names across all account-related commands

Improved IBAN Security
- Smart IBAN masking shows country codes and more context for Dutch accounts
- NL accounts display first 8 characters + last 4 for better identification
- Other countries show country code + last 4 digits for verification

Technical Enhancements:
- Enforced data integrity constraints preventing invalid account links
- Fixed trait implementation dispatch issues for consistent display logic
- Added comprehensive test coverage for all new functionality
- Maintained backward compatibility with existing scripts and workflows

Security & Reliability:
- All financial data masking remains robust and secure
- Comprehensive test suite ensures no regressions (55 tests passing)
- Error handling prevents crashes in edge cases
- Clean separation of interactive vs automated usage patterns

Users can now intuitively manage their account connections with clear visual
feedback, while automated scripts continue to work seamlessly with the
enhanced validation and error handling.
2025-11-28 23:09:26 +01:00
52791e39f9 chore: testing fixes and cargo cleanup
This change makes sure all dependencies are now in the workspace-level
Cargo.toml.

Uses the `temp-env` crate to make the config tests less flaky.
2025-11-28 21:21:46 +01:00
0ab978fa87 feat: Add the account status command
This shows the account status from gocardless (for now), based on the
sync results.
2025-11-28 20:20:55 +01:00
c0453ce093 fix: Be more explicit about coding quality
By making the AGENTS.md file more explicit about the code quality
standards, it will hopefully result in cleaner code.

There was a tendency to mask unused variables by prefixing them with a _
instead of removing them. That is not what I want: if it's unused, I
want it gone.
2025-11-28 20:18:51 +01:00
c3d74fa6ae feat: Improve accounts list
The accounts lists are now shown per source and/or destination, and
include the name. Furthermore they are sorted alphabetically by name,
because that is how humans think.
2025-11-28 19:58:29 +01:00
8518bb33f5 feat: Introduce configuration structs
Instead of relying on the environment variables everywhere, this is now
abstracted to a config struct.

Furthermore, all tests now also use this struct and are made fully
independent of each other. That is because now they don't rely on the
set_env() / get_env() calls any more, which are not thread safe.
2025-11-28 19:49:35 +01:00
a384a9cfcd feat: Improve sync algorithm
Now the synchronisation works differently:

1. Discover and cache all accounts in source and destination
2. Auto-link unlinked accounts
3. Sync all linked accounts, including previously linked ones

In order to cache all accounts, the accounts cache (and encryptor) are
therefore moved to the core types, instead of being part of the
Gocardless adapter.
2025-11-28 16:57:39 +01:00
53be083dc0 feat: Speed up account syncing with enhanced caching
Reduce API calls and improve sync performance by caching complete account data from GoCardless and Firefly III. Display account names for clearer identification in the CLI. Separate account and link storage for better data organization and maintainability.
2025-11-27 23:28:13 +01:00
ef0c483ee7 feat: Add full account representations
Having all details available is useful. For Firefly III some fields like
current balance are left out, because they are not strictly account
metadata, but are a result of the transactions in the account.
2025-11-27 22:59:30 +01:00
334f6ce277 fix: Prevent duplicate account links
Before, for each sync run, accounts were re-linked together. That is
incorrect, they should only be linked once.

Furthermore, the account status for Gocardless was incorrect: it always
defaulted to "linked". That is now fixed: it now defaults to "active".
2025-11-27 22:59:29 +01:00
21ef49ee38 fix: Correctly map transactions without counterparty
Before this change, transactions that did not have a counterparty were
not correctly mapped and could not be insterted into Firefly III. That
is not correct: _all_ transactions must be added to Firefly III.

Furthermore, Firefly III already has logic to deal with this, and rules
that can solve it manually as well.
2025-11-27 22:59:29 +01:00
baac50c36a feat: Add CLI table formatting and remove unused inspection methods
- Enhanced CLI output with table formatting for better readability of account and transaction data
- Added new commands to list accounts and view their sync status
- Added new commands to inspect transaction information and cache status
- Cleaned up internal code by removing unused trait methods and implementations
- Updated documentation with examples of new CLI commands

This improves the user experience with clearer CLI output and new inspection capabilities while maintaining code quality.
2025-11-27 22:59:26 +01:00
b85c366176 feat: implement account linking and management system
Add comprehensive account linking functionality to automatically match bank accounts to Firefly III accounts, with manual override options. This includes:

- New LinkStore module for persistent storage of account links with auto-linking based on IBAN matching
- Extended adapter traits with inspection methods (list_accounts, get_account_status, etc.) and discover_accounts for account discovery
- Integration of linking into sync logic to automatically discover and link accounts before syncing transactions
- CLI commands for managing account links (list, create, etc.)
- Updated README with new features and usage examples

This enables users to easily manage account mappings between sources and destinations, reducing manual configuration and improving sync reliability.
2025-11-27 21:59:29 +01:00
c8c07af9a1 feat: refactor CLI to subcommands and add dynamic adapter discovery
Introduce structured subcommand architecture for better CLI organization and extensibility.

Implement dynamic adapter discovery and validation system in core module for pluggable sources and destinations.

Extract client initialization logic into dedicated CLI setup module for cleaner separation of concerns.

Update README documentation to reflect new CLI structure and available commands.

Add comprehensive tests for adapter validation and discovery functionality.

Maintain backward compatibility for existing sync command usage.
2025-11-27 21:59:25 +01:00
3d4ace793d Refine development guidelines for improved code quality
Update the development guide to emphasize best practices including updating specifications during work, mandatory code formatting and linting, README updates for user-visible changes, and cleanup of unused code. This fosters consistent, high-quality contributions that enhance the project's reliability and maintainability.
2025-11-27 21:28:09 +01:00
93c1c8d861 Formatting fixes
The result of `cargo fmt`.
2025-11-27 21:24:52 +01:00
508975a086 Fix clippy warnings 2025-11-27 21:24:51 +01:00
53087fa900 Implement encrypted transaction caching for GoCardless adapter
- Reduces GoCardless API calls by up to 99% through intelligent caching of transaction data
- Secure AES-GCM encryption with PBKDF2 key derivation (200k iterations) for at-rest storage
- Automatic range merging and transaction deduplication to minimize storage and API usage
- Cache-first approach with automatic fetching of uncovered date ranges
- Comprehensive test suite with 30 unit tests covering all cache operations and edge cases
- Thread-safe implementation with in-memory caching and encrypted disk persistence

Cache everything Gocardless sends back
2025-11-27 21:24:30 +01:00
1dd251c379 Add input validation for transaction amounts and currencies
- Validate amounts are non-zero and within reasonable bounds (≤1B)
- Validate currency codes are 3 uppercase ASCII letters
- Apply validation to main and foreign amounts/currencies
- Add comprehensive tests for validation logic
- Maintain graceful error handling for invalid data
2025-11-22 15:04:04 +00:00
edacda0d01 Add JJ version control requirement to AGENTS.md 2025-11-22 15:04:04 +00:00
0b2549ddb4 Handle expired agreements and rewrite README
- Implement robust End User Agreement expiry detection and handling
- Add graceful error recovery for failed accounts
- Rewrite README.md to focus on user benefits
- Add documentation guidelines to AGENTS.md
2025-11-22 15:04:04 +00:00
aeb8e9bdcb Mask details in debug traces. 2025-11-22 15:04:04 +00:00
cd000061b4 Implemented debug logging to debug_logs/ 2025-11-22 15:04:04 +00:00
5eba24cb87 Add specs for debug logging. 2025-11-22 15:04:04 +00:00
45c0788a4b Differentiate between human and LLM debugging 2025-11-22 15:04:04 +00:00
9a5c6d0f68 Implement logic 2025-11-22 15:04:04 +00:00
ab81c729c7 Add high-level planning 2025-11-22 15:04:04 +00:00
7d9d3f4501 Re-add Firefly III and GoCardless specs 2025-11-22 15:03:48 +00:00
dfd1918723 Remove old stuff 2025-11-21 23:00:09 +01:00
636 changed files with 35900 additions and 38378 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@
**/target/
**/*.rs.bk
.env
/debug_logs/
/data/

255
AGENTS.md Normal file
View File

@@ -0,0 +1,255 @@
# Banks2FF Development Guide
## Project Purpose
Banks2FF is a Rust CLI tool that synchronizes bank transactions from GoCardless Bank Account Data API to Firefly III personal finance manager. It implements a hexagonal architecture for clean separation of concerns and comprehensive testing.
## 🚨 CRITICAL: Financial Data Security
### **Financial Data Masking Requirements**
**FOR LLM/AI INTERACTIONS ONLY**: When interacting with coding agents, LLMs, or AI assistants:
- **NEVER** expose, log, or display raw financial information including:
- Transaction amounts
- Account balances
- IBANs or account numbers
- Transaction descriptions
- Personal identifiers
- API keys or tokens
**FOR DEBUG LOGGING**: When using `RUST_LOG=debug`:
- **STRUCTURED LOGGING** shows HTTP requests, responses, and errors
- **NO SENSITIVE DATA** is logged (financial amounts, personal info, tokens)
- **REQUEST TRACING** includes method, URL, status codes, and error details
### **Compliance Protocol for AI Agent Debugging**
When debugging financial data issues with AI agents:
1. **Create Anonymized Test Scripts**: Write small, focused scripts that extract only the necessary data structure information
2. **Use Mock Data**: Replace real financial values with placeholder data
3. **Validate Structure, Not Values**: Focus on data structure integrity, not actual financial content
4. **Sanitize All Outputs**: Ensure any debugging output masks sensitive information
### **Debug Logging**
The application uses structured logging with the `tracing` crate:
- **Normal operation**: Uses INFO level logging for key operations
- **Debug mode**: Set `RUST_LOG=debug` to see detailed HTTP request/response logging
- **No sensitive data**: Financial amounts and personal information are never logged
- **Request tracing**: HTTP method, URL, status codes, and error responses are logged
```rust
// ✅ GOOD: Structure validation with mock data
fn validate_transaction_structure() {
let mock_tx = BankTransaction {
amount: Decimal::new(12345, 2), // Mock amount
currency: "EUR".to_string(),
// ... other fields with mock data
};
// Validate structure only
}
// ❌ BAD: Exposing real financial data
fn debug_real_transactions(transactions: Vec<BankTransaction>) {
for tx in transactions {
println!("Real amount: {}", tx.amount); // SECURITY VIOLATION
}
}
```
## Rust Development Best Practices
### Error Handling
- **Use `thiserror`** for domain-specific error types in core modules
- **Use `anyhow`** for application-level error context and propagation
- **Never use `panic!`** in production code - handle errors gracefully
- **Implement `From` traits** for error type conversions
```rust
// Core domain errors
#[derive(Error, Debug)]
pub enum SyncError {
#[error("Failed to fetch transactions from source: {0}")]
SourceError(#[from] anyhow::Error),
#[error("Failed to store transaction: {0}")]
DestinationError(#[from] anyhow::Error),
}
```
### Async Programming
- **Use `tokio`** as the async runtime (workspace dependency)
- **Prefer `async-trait`** for trait methods that need to be async
- **Handle cancellation** properly with `select!` or `tokio::time::timeout`
- **Use `?` operator** for error propagation in async functions
### Testing Strategy
- **Unit Tests**: Test pure functions and business logic in isolation
- **Integration Tests**: Test adapter implementations with `wiremock`
- **Mock External Dependencies**: Use `mockall` for trait-based testing
- **Test Fixtures**: Store sample JSON responses in `tests/fixtures/`
```rust
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
#[tokio::test]
async fn test_sync_with_mock_source() {
let mut mock_source = MockTransactionSource::new();
// Setup mock expectations
// Test core logic
}
}
```
### Code Organization
- **Workspace Dependencies**: Define common dependencies in root `Cargo.toml`
- **Feature Flags**: Use features for optional functionality
- **Module Structure**: Keep modules focused and single-responsibility
- **Public API**: Minimize public surface area; prefer internal modules
### Dependencies and Patterns
**Key Workspace Dependencies:**
- `tokio`: Async runtime with full features
- `reqwest`: HTTP client with JSON support
- `serde`/`serde_json`: Serialization/deserialization
- `chrono`: Date/time handling with serde support
- `rust_decimal`: Precise decimal arithmetic for financial data
- `tracing`/`tracing-subscriber`: Structured logging
- `clap`: CLI argument parsing with derive macros
- `anyhow`/`thiserror`: Error handling
- `async-trait`: Async trait support
- `wiremock`: HTTP mocking for tests
- `mockall`: Runtime mocking for tests
## Development Workflow
### 1. Code Development
- Write code in appropriate modules following the hexagonal architecture
- Keep core business logic separate from external integrations
- Use workspace dependencies consistently
- 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
- Write tests alongside code in `#[cfg(test)]` modules
- Test both happy path and error conditions
- Use mock objects for external dependencies
- Ensure all tests pass: `cargo test --workspace`
### 3. Code Quality
- Follow Rust idioms and conventions
- Use `cargo fmt` for formatting
- Use `cargo clippy` for linting
- Ensure documentation for public APIs
- **MANDATORY: ALWAYS format and lint after making ANY change, and fix ALL linting errors and warnings**
- 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!
#### 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
- *Always* ensure the workspace compiles: `cargo build --workspace`
- Commit both code and tests together
- Write clear, descriptive commit messages, focusing on user benefits over technical details. Use prose over bullet points
### Version Control
- **Use JJ (Jujutsu)** as the primary tool for all source control operations due to its concurrency and conflict-free design. Use a specialized agent if available
- **Git fallback**: Only for complex operations unsupported by JJ (e.g., interactive rebasing)
## 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/`)
- **models.rs**: Domain entities (BankTransaction, Account)
- **ports.rs**: Trait definitions (TransactionSource, TransactionDestination)
- **sync.rs**: Business logic orchestration
### Adapters Module (`banks2ff/src/adapters/`)
- **gocardless/**: GoCardless API integration
- **firefly/**: Firefly III API integration
- 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
- **gocardless-client/**: Standalone GoCardless API wrapper
- **firefly-client/**: Standalone Firefly III API wrapper
- Both use `reqwest` for HTTP communication
## Security Considerations
- **Never log sensitive data**: Use tracing filters to exclude financial information
- **Environment variables**: Store credentials in `.env` file (never in code)
- **Input validation**: Validate all external data before processing
- **Error messages**: Don't expose sensitive information in error messages
## Performance Considerations
- **Caching**: Use caching to reduce API calls (see GoCardlessAdapter)
- **Rate Limiting**: Handle 429 responses gracefully
- **Batch Processing**: Process transactions in reasonable batches
- **Async Concurrency**: Use `tokio` for concurrent operations where appropriate
## Observability
- **Structured Logging**: Use `tracing` with spans for operations
- **Error Context**: Provide context in error messages for debugging
- **Metrics**: Consider adding metrics for sync operations
- **Log Levels**: Use appropriate log levels (debug, info, warn, error)
## Documentation Guidelines
### README.md
- **Keep High-Level**: Focus on user benefits and key features, not technical implementation details
- **User-Centric**: Describe what the tool does and why users would want it
- **Skip Implementation Details**: Avoid technical jargon, architecture specifics, or internal implementation that users don't need to know
- **Feature Descriptions**: Use concise, benefit-focused language (e.g., "Robust Error Handling" rather than "Implements EUA expiry detection with multiple requisition fallback")
### Technical Documentation
- **docs/architecture.md**: Detailed technical specifications, implementation details, and developer-focused content
- **specs/**: Implementation planning, API specifications, and historical context
- **Code Comments**: Use sparingly for implementation details. *Do* explain complex logic

2704
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,45 @@
[workspace]
members = [
'bank2ff',
'firefly-iii-api',
'gocardless-bankaccount-data-api',
"banks2ff",
"firefly-client",
"gocardless-client",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
[workspace.dependencies]
tokio = { version = "1", features = ['full'] }
tokio-macros = "2.4.0"
tokio = { version = "1.34", features = ["full"] }
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1.33", features = ["serde-float"] }
async-trait = "0.1"
dotenvy = "0.15"
clap = { version = "4.4", features = ["derive", "env"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
url = "2.5"
wiremock = "0.5"
tokio-test = "0.4"
mockall = "0.11"
reqwest-middleware = "0.2"
hyper = { version = "0.14", features = ["full"] }
bytes = "1.0"
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"

185
README.md
View File

@@ -1,30 +1,183 @@
# Bank2FF
# Banks2FF
Bank2FF is a tool that can retrieve bank transactions from Gocardless and
add them to Firefly III.
A robust command-line tool to synchronize bank transactions between various sources and destinations. Currently supports GoCardless (formerly Nordigen) to Firefly III, with extensible architecture for additional sources and destinations.
It contains autogenerated APIs for both Firefly III and for the
Gocardless Bank Account Data API.
## ✨ Key Benefits
## Usage
- **Automatic Transaction Sync**: Keep your Firefly III finances up-to-date with your bank accounts
- **Intelligent Caching**: Reduces GoCardless API calls by up to 99% through encrypted local storage
- **Multi-Currency Support**: Handles international transactions and foreign currencies correctly
- **Smart Duplicate Detection**: Avoids double-counting transactions automatically
- **Reliable Operation**: Continues working even when some accounts need attention
- **Safe Preview Mode**: Test changes before applying them to your finances
- **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
TBD
## 🚀 Quick Start
### Prerequisites
## Generating the API clients
- Rust (latest stable)
- GoCardless Bank Account Data account
- Running Firefly III instance
These API clients are generated with the OpenAPI Generators for Rust.
### Setup
These need Podman installed, and assume this command is run from the same
directory where this README.md file is located.
1. Copy environment template: `cp env.example .env`
2. Fill in your credentials in `.env`:
- `GOCARDLESS_ID`: Your GoCardless Secret ID
- `GOCARDLESS_KEY`: Your GoCardless Secret Key
- `FIREFLY_III_URL`: Your Firefly instance URL
- `FIREFLY_III_API_KEY`: Your Personal Access Token
- `BANKS2FF_CACHE_KEY`: Required encryption key for secure transaction caching
For Gocardless:
### Usage
`podman run --rm -v ${PWD}:/local openapitools/openapi-generator-cli generate -g rust -o /local/gocardless-bankaccount-data-api -i 'https://bankaccountdata.gocardless.com/api/v2/swagger.json' --additional-properties=library=reqwest,packageName=gocardless-bankaccount-data-api,packageVersion=2.0.0,supportMiddleware=true,avoidBoxedModels=true`
```bash
# Sync all accounts (automatic date range)
cargo run -p banks2ff -- sync gocardless firefly
# Preview changes without saving
cargo run -p banks2ff -- --dry-run sync gocardless firefly
For Firefly III:
# Sync specific date range
cargo run -p banks2ff -- sync gocardless firefly --start 2023-01-01 --end 2023-01-31
If necessary, change the URL to the definition. If that is a new version, then also change the `packageVersion` parameter.
# List available sources and destinations
cargo run -p banks2ff -- sources
cargo run -p banks2ff -- destinations
`podman run --rm -v ${PWD}:/local openapitools/openapi-generator-cli generate -g rust -o /local/firefly-iii-api -i 'https://api-docs.firefly-iii.org/firefly-iii-2.1.0-v1.yaml' --additional-properties=library=reqwest,packageName=firefly-iii-api,packageVersion=2.1.0,supportMiddleware=true,avoidBoxedModels=true`
# Inspect accounts
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
# Manage account links
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 "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
cargo run -p banks2ff -- transactions list # Interactive account selection
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
```
## 🖥️ CLI Structure
Banks2FF uses a structured command-line interface with the following commands:
- `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination
- `sources` - List all available source types
- `destinations` - List all available destination types
- `accounts list [gocardless|firefly]` - List all discovered accounts (optionally filter by adapter type)
- `accounts status` - Show sync status for all accounts
- `accounts link` - Manage account links between sources and destinations (with interactive and smart modes)
- `transactions list [account] [--details] [--limit N]` - Show transaction summary or details for an account (interactive selection if no account specified)
- `transactions cache-status` - Display cache status and statistics
Use `cargo run -p banks2ff -- --help` for detailed command information.
## 📋 What It Does
Banks2FF automatically:
1. Connects to your bank accounts via GoCardless
2. Discovers accounts and provides intelligent linking between GoCardless and Firefly III
3. Downloads new transactions since your last sync
4. Adds them to Firefly III (avoiding duplicates)
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
Banks2FF automatically caches your transaction data to make future syncs much faster:
- **Faster Syncs**: Reuses previously downloaded data instead of re-fetching from the bank
- **API Efficiency**: Dramatically reduces the number of calls made to GoCardless
- **Secure Storage**: Your financial data is safely encrypted on your local machine
- **Automatic Management**: The cache works transparently in the background
The cache requires `BANKS2FF_CACHE_KEY` to be set in your `.env` file for secure encryption (see `env.example` for key generation instructions).
## 🔧 Troubleshooting
- **Unknown source/destination?** Use `sources` and `destinations` commands to see what's available
- **Account not syncing?** Check that the IBAN matches between GoCardless and Firefly III, or use `accounts link create` for interactive linking
- **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
- **Rate limited?** The tool automatically handles API limits and retries appropriately
---
*For technical details, see [docs/architecture.md](docs/architecture.md)*

View File

@@ -1,6 +0,0 @@
FIREFLY_III_URL=
FIREFLY_III_API_KEY=
FIREFLY_III_CLIENT_ID=
GOCARDLESS_KEY=
GOCARDLESS_ID=

2
bank2ff/.gitignore vendored
View File

@@ -1,2 +0,0 @@
/target
../.env

View File

@@ -1,11 +0,0 @@
[package]
name = "bank2ff"
version = "0.1.0"
edition = "2021"
[dependencies]
firefly-iii-api = { path = "../firefly-iii-api", version = "2.1.0" }
gocardless-bankaccount-data-api = { path = '../gocardless-bankaccount-data-api', version = "2.0.0" }
dotenv = "0.15.0"
serde = { version = "1.0.210", features = ["derive"] }
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] }

View File

@@ -1,44 +0,0 @@
{
"version": 3,
"source": "ff3-importer-1.5.2",
"created_at": "2024-09-07T10:07:05+02:00",
"date": "",
"default_account": 1142,
"delimiter": "comma",
"headers": false,
"rules": true,
"skip_form": false,
"add_import_tag": true,
"roles": [],
"do_mapping": [],
"mapping": [],
"duplicate_detection_method": "classic",
"ignore_duplicate_lines": false,
"unique_column_index": 0,
"unique_column_type": "external-id",
"flow": "nordigen",
"content_type": "unknown",
"custom_tag": "",
"identifier": "0",
"connection": "0",
"ignore_spectre_categories": false,
"grouped_transaction_handling": "",
"use_entire_opposing_address": false,
"map_all_data": false,
"accounts": {
"4cda1369-178c-485b-b3d8-1892afdbfb6c": 1142
},
"date_range": "range",
"date_range_number": 30,
"date_range_unit": "d",
"date_not_before": "2024-06-29",
"date_not_after": "2024-09-06",
"nordigen_country": "NL",
"nordigen_bank": "ASN_BANK_ASNBNL21",
"nordigen_requisitions": {
"b2e6fd94-fc45-484c-abc1-5f410a58a220": "c5006758-135b-4770-8715-2a047a426973"
},
"nordigen_max_days": "90",
"conversion": false,
"ignore_duplicate_transactions": true
}

View File

@@ -1,29 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(super) struct AppConfiguration {
pub(crate) firefly_iii_url: String,
pub(crate) firefly_iii_api_key: String,
pub(crate) go_cardless_key: String,
pub(crate) go_cardless_id: String,
}
impl AppConfiguration {
pub(super) fn from_env() -> Result<Self, dotenv::Error> {
use dotenv::var;
let firefly_iii_url = var("FIREFLY_III_URL")?;
let firefly_iii_api_key = var("FIREFLY_III_API_KEY")?;
let go_cardless_key = var("GOCARDLESS_KEY")?;
let go_cardless_id = var("GOCARDLESS_ID")?;
Ok(Self {
firefly_iii_url,
firefly_iii_api_key,
go_cardless_key,
go_cardless_id,
})
}
}

View File

@@ -1,108 +0,0 @@
use firefly_iii_api::apis::configuration::Configuration;
use firefly_iii_api::apis::{accounts_api, transactions_api};
use firefly_iii_api::models::{AccountRead, TransactionRead};
pub(super) async fn load_all_transactions(ff: &mut FFTransactions) -> Result<(), ()> {
let mut has_more = true;
let mut page = None;
while has_more {
match transactions_api::list_transaction(
&ff.config,
None,
Some(500),
page,
None,
None,
None,
)
.await
{
Ok(transactions) => {
has_more = transactions.links.next.is_some();
let pagination = transactions.meta.pagination.clone().unwrap();
let next = pagination.current_page.unwrap() + 1;
page = Some(next);
println!(
"Page {} of {}",
pagination.current_page.unwrap(),
pagination.total_pages.unwrap()
);
transactions
}
Err(e) => {
dbg!(e);
return Ok(());
}
}
.data
.iter()
.for_each(|tx| {
ff.transactions.push(tx.to_owned());
});
}
Ok(())
}
pub(super) async fn load_all_accounts(ff: &mut FFTransactions) -> Result<(), ()> {
let mut has_more = true;
let mut page = None;
while has_more {
match accounts_api::list_account(&ff.config, None, Some(500), page, None, None).await {
Ok(accounts) => {
let pagination = accounts.meta.pagination.clone().unwrap();
has_more = pagination.current_page < pagination.total_pages;
let next = pagination.current_page.unwrap() + 1;
page = Some(next);
println!(
"Page {} of {}",
pagination.current_page.unwrap(),
pagination.total_pages.unwrap()
);
accounts.data
}
Err(e) => {
dbg!(e);
return Ok(());
}
}
.iter()
.for_each(|a| ff.accounts.push(a.to_owned()));
}
Ok(())
}
pub(super) struct FFTransactions {
accounts: Vec<AccountRead>,
transactions: Vec<TransactionRead>,
config: Configuration,
}
impl FFTransactions {
pub(super) fn new(config: Configuration) -> Self {
Self {
accounts: Vec::with_capacity(1000),
transactions: Vec::with_capacity(10000),
config,
}
}
pub(super) fn accounts(&self) -> &Vec<AccountRead> {
&self.accounts
}
pub(super) fn transactions(&self) -> &Vec<TransactionRead> {
&self.transactions
}
pub fn find_account_by_iban(&self, iban: &str) -> Option<&AccountRead> {
let to_check = Some(Some(iban.to_owned()));
self.accounts.iter().find(|a| a.attributes.iban == to_check)
}
}

View File

@@ -1,98 +0,0 @@
mod config;
mod firefly;
use crate::config::AppConfiguration;
use crate::firefly::{load_all_accounts, load_all_transactions, FFTransactions};
use firefly_iii_api::apis::configuration;
use tokio::io::AsyncReadExt;
use gocardless_bankaccount_data_api::models::{JwtObtainPairRequest, StatusEnum};
#[tokio::main]
async fn main() -> Result<(), dotenv::Error> {
dotenv::dotenv().ok();
let config = AppConfiguration::from_env()?;
// let mut ff = FFTransactions::new(configuration::Configuration {
// base_path: config.firefly_iii_url,
// user_agent: None,
// client: Default::default(),
// basic_auth: None,
// oauth_access_token: None,
// bearer_access_token: Some(config.firefly_iii_api_key),
// api_key: None,
// });
//
// let _ = load_all_accounts(&mut ff).await;
// println!("#Accounts:\t{}", ff.accounts().len());
// let _ = load_all_transactions(&mut ff).await;
// println!("#Transactions:\t{}", ff.transactions().len());
let mut gocardless_config = gocardless_bankaccount_data_api::apis::configuration::Configuration::new();
let gc_token_pair = gocardless_bankaccount_data_api::apis::token_api::obtain_new_access_slash_refresh_token_pair(
&gocardless_config,JwtObtainPairRequest::new(config.go_cardless_id, config.go_cardless_key)).await.unwrap();
gocardless_config.bearer_access_token = gc_token_pair.access;
// let institutions = gocardless_bankaccount_data_api::apis::institutions_api::retrieve_all_supported_institutions_in_a_given_country(
// &gocardless_config,
// None,
// None,
// None,
// None,
// None,
// Some("NL"),
// None,
// None,
// None,
// None,
// None,
// None,
// None
// ).await.unwrap();
let gc_reqs = gocardless_bankaccount_data_api::apis::requisitions_api::retrieve_all_requisitions(&gocardless_config, None, None)
.await
.unwrap();
dbg!("# of requisitions:{} ", &gc_reqs.results.len());
let active_reqs = gc_reqs
.results.iter()
.filter(|req| req.status == Some(StatusEnum::Ln) && req.institution_id != "BUNQ_BUNQNL2A").collect::<Vec<_>>();
dbg!("# of active requisitions:{} ", &active_reqs.len());
for req in active_reqs {
if req.accounts.is_none() {
dbg!("No active accounts for requisition {}", req.id);
continue;
}
let accounts = req.accounts.as_ref().unwrap();
for account in accounts {
let transactions_resp = gocardless_bankaccount_data_api::apis::accounts_api::retrieve_account_transactions(
&gocardless_config,
&account.to_string(),
Some("2024-09-01".to_string()),
Some("2024-09-30".to_string())
).await;
if transactions_resp.is_err() {
dbg!("{:?}", transactions_resp.unwrap_err());
// TODO: Do something smarter here, if possible
continue;
}
let transactions = transactions_resp.unwrap();
dbg!(&transactions);
}
}
let _ = tokio::io::stdin().read_u8().await;
Ok(())
}

48
banks2ff/Cargo.toml Normal file
View File

@@ -0,0 +1,48 @@
[package]
name = "banks2ff"
version.workspace = true
edition.workspace = true
authors.workspace = true
[dependencies]
tokio = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
rust_decimal = { workspace = true }
dotenvy = { workspace = true }
clap = { workspace = true }
reqwest = { workspace = true }
# Core logic dependencies
async-trait = { workspace = true }
# API Client dependencies
firefly-client = { path = "../firefly-client" }
gocardless-client = { path = "../gocardless-client" }
# Debug logging dependencies
reqwest-middleware = { workspace = true }
hyper = { workspace = true }
bytes = { workspace = true }
http = { workspace = true }
task-local-extensions = { workspace = true }
# Encryption dependencies
aes-gcm = { workspace = true }
pbkdf2 = { workspace = true }
hkdf = { workspace = true }
rand = { workspace = true }
sha2 = { workspace = true }
# CLI formatting dependencies
comfy-table = { workspace = true }
dialoguer = { workspace = true }
[dev-dependencies]
mockall = { workspace = true }
temp-env = { workspace = true }

View File

@@ -0,0 +1,305 @@
use crate::core::cache::{AccountCache, CachedAccount};
use crate::core::config::Config;
use crate::core::models::{Account, AccountSummary, BankTransaction};
use crate::core::ports::{TransactionDestination, TransactionMatch};
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDate;
use firefly_client::client::FireflyClient;
use firefly_client::models::{
TransactionSplitStore, TransactionSplitUpdate, TransactionStore, TransactionUpdate,
};
use rust_decimal::Decimal;
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::instrument;
pub struct FireflyAdapter {
client: Arc<Mutex<FireflyClient>>,
config: Config,
}
impl FireflyAdapter {
pub fn new(client: FireflyClient, config: Config) -> Self {
Self {
client: Arc::new(Mutex::new(client)),
config,
}
}
}
#[async_trait]
impl TransactionDestination for FireflyAdapter {
#[instrument(skip(self))]
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
let client = self.client.lock().await;
// Fetch latest 1 transaction
let tx_list = client
.list_account_transactions(account_id, None, None)
.await?;
if let Some(first) = tx_list.data.first() {
if let Some(split) = first.attributes.transactions.first() {
// Format is usually YYYY-MM-DDT... or YYYY-MM-DD
let date_str = split.date.split('T').next().unwrap_or(&split.date);
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
return Ok(Some(date));
}
}
}
Ok(None)
}
#[instrument(skip(self))]
async fn find_transaction(
&self,
account_id: &str,
tx: &BankTransaction,
) -> Result<Option<TransactionMatch>> {
let client = self.client.lock().await;
// Search window: +/- 3 days
let start_date = tx.date - chrono::Duration::days(3);
let end_date = tx.date + chrono::Duration::days(3);
let tx_list = client
.list_account_transactions(
account_id,
Some(&start_date.format("%Y-%m-%d").to_string()),
Some(&end_date.format("%Y-%m-%d").to_string()),
)
.await?;
// Filter logic
for existing_tx in tx_list.data {
for split in existing_tx.attributes.transactions {
// 1. Check Amount (exact match absolute value)
if let Ok(amount) = Decimal::from_str(&split.amount) {
if amount.abs() == tx.amount.abs() {
// 2. Check External ID
if let Some(ref ext_id) = split.external_id {
if ext_id == &tx.internal_id {
return Ok(Some(TransactionMatch {
id: existing_tx.id.clone(),
has_external_id: true,
}));
}
} else {
// 3. "Naked" transaction match (Heuristic)
// If currency matches
if let Some(ref code) = split.currency_code {
if code != &tx.currency {
continue;
}
}
return Ok(Some(TransactionMatch {
id: existing_tx.id.clone(),
has_external_id: false,
}));
}
}
}
}
}
Ok(None)
}
#[instrument(skip(self))]
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()> {
let client = self.client.lock().await;
// Map to Firefly Transaction
let is_credit = tx.amount.is_sign_positive();
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 {
transaction_type: transaction_type.to_string(),
date: tx.date.format("%Y-%m-%d").to_string(),
amount: tx.amount.abs().to_string(),
description: tx.description.clone(),
source_id,
source_name,
destination_id,
destination_name,
currency_code: Some(tx.currency.clone()),
foreign_amount: tx.foreign_amount.map(|d| d.abs().to_string()),
foreign_currency_code: tx.foreign_currency.clone(),
external_id: Some(tx.internal_id.clone()),
};
let store = TransactionStore {
transactions: vec![split],
apply_rules: Some(true),
fire_webhooks: Some(true),
error_if_duplicate_hash: Some(true),
};
client.store_transaction(store).await.map_err(|e| e.into())
}
#[instrument(skip(self))]
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()> {
let client = self.client.lock().await;
let update = TransactionUpdate {
transactions: vec![TransactionSplitUpdate {
external_id: Some(external_id.to_string()),
}],
};
client
.update_transaction(id, update)
.await
.map_err(|e| e.into())
}
#[instrument(skip(self))]
async fn discover_accounts(&self) -> Result<Vec<Account>> {
let client = self.client.lock().await;
let accounts = client.get_accounts().await?;
let mut result = Vec::new();
// 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 {
// 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);
if is_active && acc.attributes.account_type == "asset" {
result.push(Account {
id: acc.id,
name: Some(acc.attributes.name),
iban: acc.attributes.iban,
currency: "EUR".to_string(),
});
}
}
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
}
}

View File

@@ -0,0 +1 @@
pub mod client;

View File

@@ -0,0 +1,769 @@
use crate::adapters::gocardless::mapper::map_transaction;
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::{
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
};
use crate::core::ports::TransactionSource;
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDate;
use gocardless_client::client::GoCardlessClient;
use tracing::{debug, info, instrument, warn};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct GoCardlessAdapter {
client: Arc<Mutex<GoCardlessClient>>,
cache: Arc<Mutex<AccountCache>>,
transaction_caches: Arc<Mutex<HashMap<String, AccountTransactionCache>>>,
config: Config,
encryption: Encryption,
}
impl GoCardlessAdapter {
pub fn new(client: GoCardlessClient, config: Config) -> Self {
let encryption = Encryption::new(config.cache.key.clone());
Self {
client: Arc::new(Mutex::new(client)),
cache: Arc::new(Mutex::new(AccountCache::load(
config.cache.directory.clone(),
encryption.clone(),
))),
transaction_caches: Arc::new(Mutex::new(HashMap::new())),
config,
encryption,
}
}
}
#[async_trait]
impl TransactionSource for GoCardlessAdapter {
#[instrument(skip(self))]
async fn get_accounts(&self, wanted_ibans: Option<Vec<String>>) -> Result<Vec<Account>> {
let mut client = self.client.lock().await;
let mut cache = self.cache.lock().await;
// Ensure token
client.obtain_access_token().await?;
let requisitions = client.get_requisitions().await?;
let mut accounts = Vec::new();
// Build a hashset of wanted IBANs if provided, for faster lookup
let wanted_set = wanted_ibans.map(|list| {
list.into_iter()
.map(|i| i.replace(" ", ""))
.collect::<HashSet<_>>()
});
let mut found_count = 0;
let target_count = wanted_set.as_ref().map(|s| s.len()).unwrap_or(0);
for req in requisitions.results {
// Optimization: Only process Linked requisitions to avoid 401/403 on expired ones
if req.status != "LN" {
continue;
}
// Check if agreement is expired
if let Some(agreement_id) = &req.agreement {
match client.is_agreement_expired(agreement_id).await {
Ok(true) => {
debug!(
"Skipping requisition {} - agreement {} has expired",
req.id, agreement_id
);
continue;
}
Ok(false) => {
// Agreement is valid, proceed
}
Err(e) => {
warn!(
"Failed to check agreement {} expiry: {}. Skipping requisition.",
agreement_id, e
);
continue;
}
}
}
if let Some(req_accounts) = req.accounts {
for acc_id in req_accounts {
// Always fetch fresh account data during sync
match client.get_account(&acc_id).await {
Ok(basic_account) => {
// Also try to fetch account details
let details_result = client.get_account_details(&acc_id).await;
let gc_account = GoCardlessAccount {
id: basic_account.id.clone(),
iban: basic_account.iban,
owner_name: basic_account.owner_name,
status: basic_account.status,
institution_id: basic_account.institution_id,
created: basic_account.created,
last_accessed: basic_account.last_accessed,
// Include details if available
name: details_result
.as_ref()
.ok()
.and_then(|d| d.account.name.clone()),
display_name: details_result
.as_ref()
.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
.get_account_data(&acc_id)
.and_then(|acc| acc.iban())
.unwrap_or("")
.to_string();
let mut keep = true;
if let Some(ref wanted) = wanted_set {
if !wanted.contains(&iban.replace(" ", "")) {
keep = false;
} else {
found_count += 1;
}
}
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 {
id: acc_id,
name,
iban: Some(iban),
currency: "EUR".to_string(),
});
}
// Optimization: Stop if we found all wanted accounts
if wanted_set.is_some() && found_count >= target_count && target_count > 0 {
info!(
"Found all {} wanted accounts. Stopping search.",
target_count
);
return Ok(accounts);
}
}
}
}
info!("Found {} matching accounts in GoCardless", accounts.len());
Ok(accounts)
}
#[instrument(skip(self))]
async fn get_transactions(
&self,
account_id: &str,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<BankTransaction>> {
let mut client = self.client.lock().await;
client.obtain_access_token().await?;
// 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 mut raw_transactions = cache.get_cached_transactions(start, end);
// Get uncovered ranges
let uncovered_ranges = cache.get_uncovered_ranges(start, end);
// Fetch missing ranges
for (range_start, range_end) in uncovered_ranges {
let response_result = client
.get_transactions(
account_id,
Some(&range_start.to_string()),
Some(&range_end.to_string()),
)
.await;
match response_result {
Ok(response) => {
let raw_txs = response.transactions.booked.clone();
raw_transactions.extend(raw_txs.clone());
cache.store_transactions(range_start, range_end, raw_txs);
info!(
"Fetched {} transactions for account {} in range {}-{}",
response.transactions.booked.len(),
account_id,
range_start,
range_end
);
}
Err(e) => {
let err_str = e.to_string();
if err_str.contains("429") {
warn!(
"Rate limit reached for account {} in range {}-{}. Skipping.",
account_id, range_start, range_end
);
continue;
}
if err_str.contains("401")
&& (err_str.contains("expired") || err_str.contains("EUA"))
{
debug!(
"EUA expired for account {} in range {}-{}. Skipping.",
account_id, range_start, range_end
);
continue;
}
return Err(e.into());
}
}
}
// Save cache
cache.save()?;
// 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 transaction: {}", e),
}
}
info!(
"Total {} transactions for account {} in range {}-{}",
transactions.len(),
account_id,
start,
end
);
Ok(transactions)
}
#[instrument(skip(self))]
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
let cache = self.cache.lock().await;
let mut summaries = Vec::new();
// Use cached account data for display - only GoCardless accounts
for (account_id, cached_account) in &cache.accounts {
if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account {
if let Some(account_data) = cache.get_account_data(account_id) {
let summary = AccountSummary {
id: account_id.clone(),
name: account_data.display_name(),
iban: account_data.iban().unwrap_or("").to_string(),
currency: "EUR".to_string(), // GoCardless primarily uses EUR
};
summaries.push(summary);
}
}
}
Ok(summaries)
}
#[instrument(skip(self))]
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
let account_cache = self.cache.lock().await;
let mut statuses = Vec::new();
// Iterate through cached GoCardless accounts
for (account_id, cached_account) in &account_cache.accounts {
if let crate::core::cache::CachedAccount::GoCardless(_) = cached_account {
// Try to load the transaction cache for this account
let transaction_cache = AccountTransactionCache::load(
account_id,
self.config.cache.directory.clone(),
self.encryption.clone(),
);
let iban = account_cache
.get_account_data(account_id)
.and_then(|acc| acc.iban())
.unwrap_or("Unknown")
.to_string();
match transaction_cache {
Ok(cache) => {
let transaction_count =
cache.ranges.iter().map(|r| r.transactions.len()).sum();
let last_sync_date = cache.ranges.iter().map(|r| r.end_date).max();
statuses.push(AccountStatus {
account_id: account_id.clone(),
iban,
last_sync_date,
transaction_count,
status: if transaction_count > 0 {
"synced"
} else {
"pending"
}
.to_string(),
});
}
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(),
});
}
}
}
}
Ok(statuses)
}
#[instrument(skip(self))]
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
// First check in-memory cache
let caches = self.transaction_caches.lock().await;
if let Some(cache) = caches.get(account_id) {
let total_count = cache.ranges.iter().map(|r| r.transactions.len()).sum();
let date_range = if cache.ranges.is_empty() {
None
} else {
let min_date = cache.ranges.iter().map(|r| r.start_date).min();
let max_date = cache.ranges.iter().map(|r| r.end_date).max();
min_date.and_then(|min| max_date.map(|max| (min, max)))
};
let last_updated = cache.ranges.iter().map(|r| r.end_date).max();
Ok(TransactionInfo {
account_id: account_id.to_string(),
total_count,
date_range,
last_updated,
})
} else {
// Load from disk if not in memory
drop(caches); // Release lock before loading from disk
let transaction_cache = AccountTransactionCache::load(
account_id,
self.config.cache.directory.clone(),
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,
}),
}
}
}
#[instrument(skip(self))]
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>> {
let mut infos = Vec::new();
// Account cache
let account_cache = self.cache.lock().await;
infos.push(CacheInfo {
account_id: None,
cache_type: "account".to_string(),
entry_count: account_cache.accounts.len(),
total_size_bytes: 0, // Not tracking size
last_updated: None, // Not tracking
});
// Transaction caches (in-memory)
let transaction_caches = self.transaction_caches.lock().await;
let mut processed_account_ids = HashSet::new();
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 {
account_id: Some(account_id.clone()),
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(),
});
}
// 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)
}
#[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))]
async fn discover_accounts(&self) -> Result<Vec<Account>> {
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);
}
}

View File

@@ -0,0 +1,464 @@
use crate::core::models::BankTransaction;
use anyhow::Result;
use gocardless_client::models::Transaction;
use rust_decimal::prelude::Signed;
use rust_decimal::Decimal;
use std::str::FromStr;
pub fn map_transaction(tx: Transaction) -> Result<BankTransaction> {
let internal_id = tx
.transaction_id
.or(tx.internal_transaction_id)
.ok_or_else(|| anyhow::anyhow!("Transaction ID missing"))?;
let date_str = tx
.booking_date
.or(tx.value_date)
.ok_or_else(|| anyhow::anyhow!("Transaction date missing"))?;
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
let amount = Decimal::from_str(&tx.transaction_amount.amount)?;
validate_amount(&amount)?;
let currency = tx.transaction_amount.currency;
validate_currency(&currency)?;
let mut foreign_amount = None;
let mut foreign_currency = None;
if let Some(exchanges) = tx.currency_exchange {
if let Some(exchange) = exchanges.first() {
if let (Some(source_curr), Some(target_curr), Some(rate_str)) = (
&exchange.source_currency,
&exchange.target_currency,
&exchange.exchange_rate,
) {
if let Ok(rate) = Decimal::from_str(rate_str) {
if !rate.is_zero() {
let (foreign_curr, calc) = if currency == *target_curr {
// Transaction is in target currency, foreign is source
(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);
}
}
}
}
}
}
if let Some(ref fa) = foreign_amount {
validate_amount(fa)?;
}
if let Some(ref fc) = foreign_currency {
validate_currency(fc)?;
}
// Fallback for description: Remittance Unstructured -> Debtor/Creditor Name -> "Unknown"
let description = tx
.remittance_information_unstructured
.or(tx.creditor_name.clone())
.or(tx.debtor_name.clone())
.unwrap_or_else(|| "Unknown Transaction".to_string());
Ok(BankTransaction {
internal_id,
date,
amount,
currency,
foreign_amount,
foreign_currency,
description,
counterparty_name: tx.creditor_name.or(tx.debtor_name),
counterparty_iban: tx
.creditor_account
.and_then(|a| a.iban)
.or(tx.debtor_account.and_then(|a| a.iban)),
})
}
fn validate_amount(amount: &Decimal) -> Result<()> {
let abs = amount.abs();
if abs > Decimal::new(1_000_000_000, 0) {
return Err(anyhow::anyhow!(
"Amount exceeds reasonable bounds: {}",
amount
));
}
if abs == Decimal::ZERO {
return Err(anyhow::anyhow!("Amount cannot be zero"));
}
Ok(())
}
fn validate_currency(currency: &str) -> Result<()> {
if currency.len() != 3 {
return Err(anyhow::anyhow!(
"Invalid currency code length: {}",
currency
));
}
if !currency.chars().all(|c| c.is_ascii_uppercase()) {
return Err(anyhow::anyhow!(
"Invalid currency code format: {}",
currency
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use gocardless_client::models::{CurrencyExchange, TransactionAmount};
#[test]
fn test_map_normal_transaction() {
let t = Transaction {
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()),
value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount {
amount: "100.50".into(),
currency: "EUR".into(),
},
currency_exchange: None,
creditor_name: Some("Shop".into()),
creditor_account: None,
ultimate_creditor: None,
debtor_name: None,
debtor_account: None,
ultimate_debtor: None,
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,
internal_transaction_id: None,
};
let res = map_transaction(t).unwrap();
assert_eq!(res.internal_id, "123");
assert_eq!(res.amount, Decimal::new(10050, 2));
assert_eq!(res.currency, "EUR");
assert_eq!(res.foreign_amount, None);
assert_eq!(res.description, "Groceries");
}
#[test]
fn test_map_multicurrency_transaction_target_to_source() {
let t = Transaction {
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()),
value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount {
amount: "-10.00".into(),
currency: "EUR".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("US 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, "124");
assert_eq!(res.amount, Decimal::new(-1000, 2));
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)
assert_eq!(res.foreign_amount, Some(Decimal::new(-500, 2)));
// Description fallback to creditor name
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]
fn test_validate_amount_zero() {
let amount = Decimal::ZERO;
assert!(validate_amount(&amount).is_err());
}
#[test]
fn test_validate_amount_too_large() {
let amount = Decimal::new(2_000_000_000, 0);
assert!(validate_amount(&amount).is_err());
}
#[test]
fn test_validate_currency_invalid_length() {
assert!(validate_currency("EU").is_err());
assert!(validate_currency("EURO").is_err());
}
#[test]
fn test_validate_currency_not_uppercase() {
assert!(validate_currency("eur").is_err());
assert!(validate_currency("EuR").is_err());
}
#[test]
fn test_validate_currency_valid() {
assert!(validate_currency("EUR").is_ok());
assert!(validate_currency("USD").is_ok());
}
#[test]
fn test_map_transaction_invalid_amount() {
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: "0.00".into(),
currency: "EUR".into(),
},
currency_exchange: None,
creditor_name: Some("Test".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,
};
assert!(map_transaction(t).is_err());
}
#[test]
fn test_map_transaction_invalid_currency() {
let t = Transaction {
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()),
value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount {
amount: "100.00".into(),
currency: "euro".into(),
},
currency_exchange: None,
creditor_name: Some("Test".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,
};
assert!(map_transaction(t).is_err());
}
#[test]
fn test_map_transaction_invalid_exchange_rate() {
let t = Transaction {
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()),
value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount {
amount: "-10.00".into(),
currency: "EUR".into(),
},
currency_exchange: Some(vec![CurrencyExchange {
source_currency: Some("USD".into()),
exchange_rate: Some("0".into()), // Invalid rate is handled by not setting foreign_amount
unit_currency: None,
target_currency: Some("EUR".into()),
}]),
creditor_name: Some("Test".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.foreign_amount, None); // Invalid rate results in no foreign_amount
}
#[test]
fn test_map_transaction_invalid_foreign_currency() {
let t = Transaction {
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()),
value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount {
amount: "-10.00".into(),
currency: "EUR".into(),
},
currency_exchange: Some(vec![CurrencyExchange {
source_currency: Some("usd".into()), // lowercase
exchange_rate: Some("1.10".into()),
unit_currency: None,
target_currency: Some("EUR".into()),
}]),
creditor_name: Some("Test".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,
};
assert!(map_transaction(t).is_err());
}
}

View File

@@ -0,0 +1,3 @@
pub mod client;
pub mod mapper;
pub mod transaction_cache;

View File

@@ -0,0 +1,708 @@
use crate::core::encryption::Encryption;
use anyhow::Result;
use chrono::{Days, NaiveDate};
use gocardless_client::models::Transaction;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AccountTransactionCache {
pub account_id: String,
pub ranges: Vec<CachedRange>,
cache_dir: String,
encryption: Encryption,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CachedRange {
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub transactions: Vec<Transaction>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AccountTransactionCacheData {
pub account_id: String,
pub ranges: Vec<CachedRange>,
}
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
fn get_cache_path(&self, account_id: &str) -> String {
format!("{}/transactions/{}.enc", self.cache_dir, account_id)
}
/// Load cache from disk
pub fn load(account_id: &str, cache_dir: String, encryption: Encryption) -> Result<Self> {
let path = format!("{}/transactions/{}.enc", cache_dir, account_id);
if !Path::new(&path).exists() {
// Return empty cache if file doesn't exist
return Ok(Self::new(account_id.to_string(), cache_dir, encryption));
}
// Read encrypted data
let encrypted_data = std::fs::read(&path)?;
let json_data = encryption.decrypt(&encrypted_data)?;
// Deserialize
let cache_data: AccountTransactionCacheData = serde_json::from_slice(&json_data)?;
Ok(Self {
account_id: cache_data.account_id,
ranges: cache_data.ranges,
cache_dir,
encryption,
})
}
/// Save cache to disk
pub fn save(&self) -> Result<()> {
// Serialize to JSON (only the data fields)
let cache_data = AccountTransactionCacheData {
account_id: self.account_id.clone(),
ranges: self.ranges.clone(),
};
let json_data = serde_json::to_vec(&cache_data)?;
// Encrypt
let encrypted_data = self.encryption.encrypt(&json_data)?;
// Write to file (create directory if needed)
let path = self.get_cache_path(&self.account_id);
if let Some(parent) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, encrypted_data)?;
Ok(())
}
/// Get cached transactions within date range
pub fn get_cached_transactions(&self, start: NaiveDate, end: NaiveDate) -> Vec<Transaction> {
let mut result = Vec::new();
for range in &self.ranges {
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
for tx in &range.transactions {
if let Some(booking_date_str) = &tx.booking_date {
if let Ok(booking_date) =
NaiveDate::parse_from_str(booking_date_str, "%Y-%m-%d")
{
if booking_date >= start && booking_date <= end {
result.push(tx.clone());
}
}
}
}
}
}
result
}
/// Get uncovered date ranges within requested period
pub fn get_uncovered_ranges(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Vec<(NaiveDate, NaiveDate)> {
let mut covered_periods: Vec<(NaiveDate, NaiveDate)> = self
.ranges
.iter()
.filter_map(|range| {
if Self::ranges_overlap(range.start_date, range.end_date, start, end) {
let overlap_start = range.start_date.max(start);
let overlap_end = range.end_date.min(end);
if overlap_start <= overlap_end {
Some((overlap_start, overlap_end))
} else {
None
}
} else {
None
}
})
.collect();
covered_periods.sort_by_key(|&(s, _)| s);
// Merge overlapping covered periods
let mut merged_covered: Vec<(NaiveDate, NaiveDate)> = Vec::new();
for period in covered_periods {
if let Some(last) = merged_covered.last_mut() {
if last.1 >= period.0 {
last.1 = last.1.max(period.1);
} else {
merged_covered.push(period);
}
} else {
merged_covered.push(period);
}
}
// Find gaps
let mut uncovered = Vec::new();
let mut current_start = start;
for (cov_start, cov_end) in merged_covered {
if current_start < cov_start {
uncovered.push((current_start, cov_start - Days::new(1)));
}
current_start = cov_end + Days::new(1);
}
if current_start <= end {
uncovered.push((current_start, end));
}
uncovered
}
/// Store transactions for a date range, merging with existing cache
pub fn store_transactions(
&mut self,
start: NaiveDate,
end: NaiveDate,
mut transactions: Vec<Transaction>,
) {
Self::deduplicate_transactions(&mut transactions);
let new_range = CachedRange {
start_date: start,
end_date: end,
transactions,
};
self.merge_ranges(new_range);
}
/// Merge a new range into existing ranges
pub fn merge_ranges(&mut self, new_range: CachedRange) {
// Find overlapping or adjacent ranges
let mut to_merge = Vec::new();
let mut remaining = Vec::new();
for range in &self.ranges {
if Self::ranges_overlap_or_adjacent(
range.start_date,
range.end_date,
new_range.start_date,
new_range.end_date,
) {
to_merge.push(range.clone());
} else {
remaining.push(range.clone());
}
}
// Merge all overlapping/adjacent ranges including the new one
to_merge.push(new_range);
let merged = Self::merge_range_list(to_merge);
// Update ranges
self.ranges = remaining;
self.ranges.extend(merged);
}
/// Check if two date ranges overlap
fn ranges_overlap(
start1: NaiveDate,
end1: NaiveDate,
start2: NaiveDate,
end2: NaiveDate,
) -> bool {
start1 <= end2 && start2 <= end1
}
/// Check if two date ranges overlap or are adjacent
fn ranges_overlap_or_adjacent(
start1: NaiveDate,
end1: NaiveDate,
start2: NaiveDate,
end2: NaiveDate,
) -> bool {
Self::ranges_overlap(start1, end1, start2, end2)
|| (end1 + Days::new(1)) == start2
|| (end2 + Days::new(1)) == start1
}
/// Merge a list of ranges into minimal set
fn merge_range_list(ranges: Vec<CachedRange>) -> Vec<CachedRange> {
if ranges.is_empty() {
return Vec::new();
}
// Sort by start date
let mut sorted = ranges;
sorted.sort_by_key(|r| r.start_date);
let mut merged = Vec::new();
let mut current = sorted[0].clone();
for range in sorted.into_iter().skip(1) {
if Self::ranges_overlap_or_adjacent(
current.start_date,
current.end_date,
range.start_date,
range.end_date,
) {
// Merge
current.start_date = current.start_date.min(range.start_date);
current.end_date = current.end_date.max(range.end_date);
// Deduplicate transactions
current.transactions.extend(range.transactions);
Self::deduplicate_transactions(&mut current.transactions);
} else {
merged.push(current);
current = range;
}
}
merged.push(current);
merged
}
/// Deduplicate transactions by transaction_id
fn deduplicate_transactions(transactions: &mut Vec<Transaction>) {
let mut seen = std::collections::HashSet::new();
transactions.retain(|tx| {
if let Some(id) = &tx.transaction_id {
seen.insert(id.clone())
} else {
true // Keep if no id
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn create_unique_key(prefix: &str) -> String {
format!("{}-{}", prefix, rand::random::<u64>())
}
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-cache-{}-{}-{}",
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_nonexistent_cache() {
let cache_dir = setup_test_dir("nonexistent");
let encryption = Encryption::new(create_unique_key("test-key"));
let cache =
AccountTransactionCache::load("nonexistent", cache_dir.clone(), encryption).unwrap();
assert_eq!(cache.account_id, "nonexistent");
assert!(cache.ranges.is_empty());
cleanup_test_dir(&cache_dir);
}
#[test]
fn test_save_and_load_empty_cache() {
let cache_dir = setup_test_dir("empty");
let encryption_key = create_unique_key("test-key");
let encryption = Encryption::new(encryption_key.clone());
let cache = AccountTransactionCache::new(
"test_account_empty".to_string(),
cache_dir.clone(),
encryption,
);
// Save
cache.save().expect("Save should succeed");
// Load
let encryption = Encryption::new(encryption_key);
let loaded =
AccountTransactionCache::load("test_account_empty", cache_dir.clone(), encryption)
.expect("Load should succeed");
assert_eq!(loaded.account_id, "test_account_empty");
assert!(loaded.ranges.is_empty());
cleanup_test_dir(&cache_dir);
}
#[test]
fn test_save_and_load_with_data() {
let cache_dir = setup_test_dir("data");
let encryption_key = create_unique_key("test-key");
let transaction = Transaction {
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()),
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,
};
let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
transactions: vec![transaction],
};
let encryption = Encryption::new(encryption_key.clone());
let mut cache = AccountTransactionCache::new(
"test_account_data".to_string(),
cache_dir.clone(),
encryption,
);
cache.ranges = vec![range];
// Save
cache.save().expect("Save should succeed");
// Load
let encryption = Encryption::new(encryption_key);
let loaded =
AccountTransactionCache::load("test_account_data", cache_dir.clone(), encryption)
.expect("Load should succeed");
assert_eq!(loaded.account_id, "test_account_data");
assert_eq!(loaded.ranges.len(), 1);
assert_eq!(loaded.ranges[0].transactions.len(), 1);
assert_eq!(
loaded.ranges[0].transactions[0].transaction_id,
Some("test-tx-1".to_string())
);
cleanup_test_dir(&cache_dir);
}
#[test]
fn test_save_load_different_accounts() {
let cache_dir = setup_test_dir("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
let encryption_a = Encryption::new(encryption_key_a.clone());
let cache_a =
AccountTransactionCache::new("account_a".to_string(), cache_dir.clone(), encryption_a);
cache_a.save().unwrap();
// Save cache for account B
let encryption_b = Encryption::new(encryption_key_b.clone());
let cache_b =
AccountTransactionCache::new("account_b".to_string(), cache_dir.clone(), encryption_b);
cache_b.save().unwrap();
// Load account A
let encryption_a = Encryption::new(encryption_key_a);
let loaded_a =
AccountTransactionCache::load("account_a", cache_dir.clone(), encryption_a).unwrap();
assert_eq!(loaded_a.account_id, "account_a");
// Load account B
let encryption_b = Encryption::new(encryption_key_b);
let loaded_b =
AccountTransactionCache::load("account_b", cache_dir.clone(), encryption_b).unwrap();
assert_eq!(loaded_b.account_id, "account_b");
cleanup_test_dir(&cache_dir);
}
#[test]
fn test_get_uncovered_ranges_no_cache() {
let encryption = Encryption::new(create_unique_key("test-key"));
let cache =
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let uncovered = cache.get_uncovered_ranges(start, end);
assert_eq!(uncovered, vec![(start, end)]);
}
#[test]
fn test_get_uncovered_ranges_full_coverage() {
let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
transactions: Vec::new(),
};
let encryption = Encryption::new(create_unique_key("test-key"));
let mut cache =
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
cache.ranges = vec![range];
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let uncovered = cache.get_uncovered_ranges(start, end);
assert!(uncovered.is_empty());
}
#[test]
fn test_get_uncovered_ranges_partial_coverage() {
let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
transactions: Vec::new(),
};
let encryption = Encryption::new(create_unique_key("test-key"));
let mut cache =
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
cache.ranges = vec![range];
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let uncovered = cache.get_uncovered_ranges(start, end);
assert_eq!(uncovered.len(), 2);
assert_eq!(
uncovered[0],
(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()
)
);
assert_eq!(
uncovered[1],
(
NaiveDate::from_ymd_opt(2024, 1, 21).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 31).unwrap()
)
);
}
#[test]
fn test_store_transactions_and_merge() {
let encryption = Encryption::new(create_unique_key("test-key"));
let mut cache =
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
let start1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let tx1 = Transaction {
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()),
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("Creditor".to_string()),
creditor_account: None,
ultimate_creditor: None,
debtor_name: None,
debtor_account: None,
ultimate_debtor: None,
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,
internal_transaction_id: None,
};
cache.store_transactions(start1, end1, vec![tx1]);
assert_eq!(cache.ranges.len(), 1);
assert_eq!(cache.ranges[0].start_date, start1);
assert_eq!(cache.ranges[0].end_date, end1);
assert_eq!(cache.ranges[0].transactions.len(), 1);
// Add overlapping range
let start2 = NaiveDate::from_ymd_opt(2024, 1, 5).unwrap();
let end2 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let tx2 = Transaction {
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()),
value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: gocardless_client::models::TransactionAmount {
amount: "200.00".to_string(),
currency: "EUR".to_string(),
},
currency_exchange: None,
creditor_name: Some("Creditor2".to_string()),
creditor_account: None,
ultimate_creditor: None,
debtor_name: None,
debtor_account: None,
ultimate_debtor: None,
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,
internal_transaction_id: None,
};
cache.store_transactions(start2, end2, vec![tx2]);
// Should merge into one range
assert_eq!(cache.ranges.len(), 1);
assert_eq!(cache.ranges[0].start_date, start1);
assert_eq!(cache.ranges[0].end_date, end2);
assert_eq!(cache.ranges[0].transactions.len(), 2);
}
#[test]
fn test_transaction_deduplication() {
let encryption = Encryption::new(create_unique_key("test-key"));
let mut cache =
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let tx1 = Transaction {
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()),
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("Creditor".to_string()),
creditor_account: None,
ultimate_creditor: None,
debtor_name: None,
debtor_account: None,
ultimate_debtor: None,
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,
internal_transaction_id: None,
};
let tx2 = tx1.clone(); // Duplicate
cache.store_transactions(start, end, vec![tx1, tx2]);
assert_eq!(cache.ranges[0].transactions.len(), 1);
}
#[test]
fn test_get_cached_transactions() {
let tx1 = Transaction {
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()),
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("Creditor".to_string()),
creditor_account: None,
ultimate_creditor: None,
debtor_name: None,
debtor_account: None,
ultimate_debtor: None,
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,
internal_transaction_id: None,
};
let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
transactions: vec![tx1],
};
let encryption = Encryption::new(create_unique_key("test-key"));
let mut cache =
AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption);
cache.ranges = vec![range];
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let cached = cache.get_cached_transactions(start, end);
assert_eq!(cached.len(), 1);
assert_eq!(cached[0].transaction_id, Some("tx1".to_string()));
}
}

View File

@@ -0,0 +1,2 @@
pub mod firefly;
pub mod gocardless;

View File

@@ -0,0 +1,209 @@
use crate::core::cache::AccountCache;
use crate::core::models::{
AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
};
use comfy_table::{presets::UTF8_FULL, Table};
pub enum OutputFormat {
Table,
}
pub trait Formattable {
fn to_table(&self, account_cache: Option<&AccountCache>) -> Table;
}
pub fn print_list_output<T: Formattable>(
data: Vec<T>,
format: &OutputFormat,
account_cache: Option<&AccountCache>,
) {
if data.is_empty() {
println!("No data available");
return;
}
match format {
OutputFormat::Table => {
for item in data {
println!("{}", item.to_table(account_cache));
}
}
}
}
// Implement Formattable for the model structs
impl Formattable for AccountSummary {
fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Name", "IBAN", "Currency"]);
let name = self.name.as_deref().unwrap_or("");
table.add_row(vec![
name.to_string(),
mask_iban(&self.iban),
self.currency.clone(),
]);
table
}
}
impl Formattable for AccountStatus {
fn to_table(&self, account_cache: Option<&AccountCache>) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account",
"IBAN",
"Last Sync",
"Transaction Count",
"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![
display_name,
mask_iban(&self.iban),
self.last_sync_date
.map(|d| d.to_string())
.unwrap_or_else(|| "Never".to_string()),
self.transaction_count.to_string(),
self.status.clone(),
]);
table
}
}
impl Formattable for TransactionInfo {
fn to_table(&self, _account_cache: Option<&AccountCache>) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account ID",
"Total Transactions",
"Date Range",
"Last Updated",
]);
let date_range = self
.date_range
.map(|(start, end)| format!("{} to {}", start, end))
.unwrap_or_else(|| "N/A".to_string());
table.add_row(vec![
self.account_id.clone(),
self.total_count.to_string(),
date_range,
self.last_updated
.map(|d| d.to_string())
.unwrap_or_else(|| "Never".to_string()),
]);
table
}
}
impl Formattable for CacheInfo {
fn to_table(&self, account_cache: Option<&AccountCache>) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account",
"Cache Type",
"Entry Count",
"Size (bytes)",
"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![
account_display,
self.cache_type.clone(),
self.entry_count.to_string(),
self.total_size_bytes.to_string(),
self.last_updated
.map(|d| d.to_string())
.unwrap_or_else(|| "Never".to_string()),
]);
table
}
}
impl Formattable for BankTransaction {
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 {
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)
}
}
}

3
banks2ff/src/cli/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod formatters;
pub mod setup;
pub mod tables;

55
banks2ff/src/cli/setup.rs Normal file
View File

@@ -0,0 +1,55 @@
use crate::adapters::firefly::client::FireflyAdapter;
use crate::adapters::gocardless::client::GoCardlessAdapter;
use crate::core::config::Config;
use crate::debug::DebugLogger;
use anyhow::Result;
use firefly_client::client::FireflyClient;
use gocardless_client::client::GoCardlessClient;
use reqwest_middleware::ClientBuilder;
pub struct AppContext {
pub source: GoCardlessAdapter,
pub destination: FireflyAdapter,
}
impl AppContext {
pub async fn new(config: Config, debug: bool) -> Result<Self> {
// Clients
let gc_client = if debug {
let client = ClientBuilder::new(reqwest::Client::new())
.with(DebugLogger::new("gocardless"))
.build();
GoCardlessClient::with_client(
&config.gocardless.url,
&config.gocardless.secret_id,
&config.gocardless.secret_key,
Some(client),
)?
} else {
GoCardlessClient::new(
&config.gocardless.url,
&config.gocardless.secret_id,
&config.gocardless.secret_key,
)?
};
let ff_client = if debug {
let client = ClientBuilder::new(reqwest::Client::new())
.with(DebugLogger::new("firefly"))
.build();
FireflyClient::with_client(&config.firefly.url, &config.firefly.api_key, Some(client))?
} else {
FireflyClient::new(&config.firefly.url, &config.firefly.api_key)?
};
// Adapters
let source = GoCardlessAdapter::new(gc_client, config.clone());
let destination = FireflyAdapter::new(ff_client, config);
Ok(Self {
source,
destination,
})
}
}

View File

@@ -0,0 +1,99 @@
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)
}
}
}

View File

@@ -0,0 +1,513 @@
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(())
}

View File

@@ -0,0 +1,91 @@
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(())
}

View File

@@ -0,0 +1,43 @@
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(())
}

View File

@@ -0,0 +1,21 @@
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(())
}

View File

@@ -0,0 +1,17 @@
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(())
}

View File

@@ -0,0 +1,4 @@
pub mod accounts;
pub mod list;
pub mod sync;
pub mod transactions;

View File

@@ -0,0 +1,88 @@
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(())
}

View File

@@ -0,0 +1,82 @@
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);
}

View File

@@ -0,0 +1,266 @@
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(),
}
}

View File

@@ -0,0 +1,44 @@
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(())
}

View File

@@ -0,0 +1,70 @@
#[derive(Debug, Clone)]
pub struct AdapterInfo {
pub id: &'static str,
pub description: &'static str,
}
pub fn get_available_sources() -> Vec<AdapterInfo> {
vec![AdapterInfo {
id: "gocardless",
description: "GoCardless Bank Account Data API",
}]
}
pub fn get_available_destinations() -> Vec<AdapterInfo> {
vec![AdapterInfo {
id: "firefly",
description: "Firefly III personal finance manager",
}]
}
pub fn is_valid_source(source: &str) -> bool {
get_available_sources().iter().any(|s| s.id == source)
}
pub fn is_valid_destination(destination: &str) -> bool {
get_available_destinations()
.iter()
.any(|d| d.id == destination)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_available_sources() {
let sources = get_available_sources();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].id, "gocardless");
assert_eq!(sources[0].description, "GoCardless Bank Account Data API");
}
#[test]
fn test_get_available_destinations() {
let destinations = get_available_destinations();
assert_eq!(destinations.len(), 1);
assert_eq!(destinations[0].id, "firefly");
assert_eq!(
destinations[0].description,
"Firefly III personal finance manager"
);
}
#[test]
fn test_is_valid_source() {
assert!(is_valid_source("gocardless"));
assert!(!is_valid_source("csv")); // Not implemented yet
assert!(!is_valid_source("camt053")); // Not implemented yet
assert!(!is_valid_source("mt940")); // Not implemented yet
assert!(!is_valid_source("invalid"));
assert!(!is_valid_source(""));
}
#[test]
fn test_is_valid_destination() {
assert!(is_valid_destination("firefly"));
assert!(!is_valid_destination("invalid"));
assert!(!is_valid_destination("gocardless"));
}
}

296
banks2ff/src/core/cache.rs Normal file
View File

@@ -0,0 +1,296 @@
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);
}
}

241
banks2ff/src/core/config.rs Normal file
View File

@@ -0,0 +1,241 @@
//! 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");
},
);
}
}

View File

@@ -0,0 +1,192 @@
//! # 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);
}
}

View File

@@ -0,0 +1,295 @@
use crate::core::models::Account;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountLink {
pub source_account_id: String,
pub dest_account_id: String,
#[serde(default = "default_source_adapter_type")]
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,
}
fn default_source_adapter_type() -> String {
"gocardless".to_string()
}
fn default_dest_adapter_type() -> String {
"firefly".to_string()
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct LinkStore {
pub links: Vec<AccountLink>,
#[serde(skip)]
cache_dir: String,
}
impl LinkStore {
/// Create new LinkStore with cache directory
pub fn new(cache_dir: String) -> Self {
Self {
links: Vec::new(),
cache_dir,
}
}
fn get_path(&self) -> String {
format!("{}/links.json", self.cache_dir)
}
pub fn load(cache_dir: String) -> Self {
let path = format!("{}/links.json", cache_dir);
if Path::new(&path).exists() {
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<LinkStore>(&content) {
Ok(mut store) => {
store.cache_dir = cache_dir;
return store;
}
Err(e) => warn!("Failed to parse link store: {}", e),
},
Err(e) => warn!("Failed to read link store: {}", e),
}
}
Self::new(cache_dir)
}
pub fn save(&self) -> Result<()> {
let path = self.get_path();
if let Some(parent) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
fs::write(path, content)?;
Ok(())
}
pub fn add_link(
&mut self,
source_account: &Account,
dest_account: &Account,
source_adapter_type: &str,
dest_adapter_type: &str,
auto_linked: bool,
) -> Result<bool, String> {
// Check if link already exists (exact same source-dest pair)
if self.links.iter().any(|l| {
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 {
source_account_id: source_account.id.clone(),
dest_account_id: dest_account.id.clone(),
source_adapter_type: source_adapter_type.to_string(),
dest_adapter_type: dest_adapter_type.to_string(),
auto_linked,
};
self.links.push(link);
Ok(true)
}
pub fn find_link_by_source(&self, source_id: &str) -> Option<&AccountLink> {
self.links.iter().find(|l| l.source_account_id == source_id)
}
pub fn find_links_by_source(&self, source_id: &str) -> Vec<&AccountLink> {
self.links
.iter()
.filter(|l| l.source_account_id == source_id)
.collect()
}
pub fn find_link_by_source_and_dest_type(
&self,
source_id: &str,
dest_adapter_type: &str,
) -> Option<&AccountLink> {
self.links
.iter()
.find(|l| l.source_account_id == source_id && l.dest_adapter_type == dest_adapter_type)
}
}
pub fn auto_link_accounts(
source_accounts: &[Account],
dest_accounts: &[Account],
) -> Vec<(usize, usize)> {
let mut links = Vec::new();
for (i, source) in source_accounts.iter().enumerate() {
for (j, dest) in dest_accounts.iter().enumerate() {
if source.iban == dest.iban
&& source.iban.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
{
links.push((i, j));
break; // First match
}
}
}
// Could add name similarity matching here
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);
}
}

8
banks2ff/src/core/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod adapters;
pub mod cache;
pub mod config;
pub mod encryption;
pub mod linking;
pub mod models;
pub mod ports;
pub mod sync;

172
banks2ff/src/core/models.rs Normal file
View File

@@ -0,0 +1,172 @@
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Serialize;
use std::fmt;
use thiserror::Error;
#[derive(Clone, PartialEq)]
pub struct BankTransaction {
/// Source ID (GoCardless transactionId)
pub internal_id: String,
/// Booking date
pub date: NaiveDate,
/// Amount in account currency
pub amount: Decimal,
/// Account currency code (e.g., EUR)
pub currency: String,
/// Original amount (if currency exchange occurred)
pub foreign_amount: Option<Decimal>,
/// Original currency code
pub foreign_currency: Option<String>,
/// Remittance info or description
pub description: String,
/// Counterparty name
pub counterparty_name: Option<String>,
/// Counterparty IBAN
pub counterparty_iban: Option<String>,
}
impl fmt::Debug for BankTransaction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BankTransaction")
.field("internal_id", &self.internal_id)
.field("date", &self.date)
.field("amount", &"[REDACTED]")
.field("currency", &self.currency)
.field(
"foreign_amount",
&self.foreign_amount.as_ref().map(|_| "[REDACTED]"),
)
.field("foreign_currency", &self.foreign_currency)
.field("description", &"[REDACTED]")
.field(
"counterparty_name",
&self.counterparty_name.as_ref().map(|_| "[REDACTED]"),
)
.field(
"counterparty_iban",
&self.counterparty_iban.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Account {
pub id: String,
pub name: Option<String>, // Account display name
pub iban: Option<String>, // IBAN may not be available for all accounts
pub currency: String,
}
impl fmt::Debug for Account {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Account")
.field("id", &self.id)
.field("iban", &"[REDACTED]")
.field("currency", &self.currency)
.finish()
}
}
/// 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)]
mod tests {
use super::*;
use rust_decimal::Decimal;
#[test]
fn test_bank_transaction_debug_masks_sensitive_data() {
let tx = BankTransaction {
internal_id: "test-id".to_string(),
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
amount: Decimal::new(12345, 2), // 123.45
currency: "EUR".to_string(),
foreign_amount: Some(Decimal::new(67890, 2)), // 678.90
foreign_currency: Some("USD".to_string()),
description: "Test transaction".to_string(),
counterparty_name: Some("Test Counterparty".to_string()),
counterparty_iban: Some("DE1234567890".to_string()),
};
let debug_str = format!("{:?}", tx);
assert!(debug_str.contains("internal_id"));
assert!(debug_str.contains("date"));
assert!(debug_str.contains("currency"));
assert!(debug_str.contains("foreign_currency"));
assert!(debug_str.contains("[REDACTED]"));
assert!(!debug_str.contains("123.45"));
assert!(!debug_str.contains("678.90"));
assert!(!debug_str.contains("Test transaction"));
assert!(!debug_str.contains("Test Counterparty"));
assert!(!debug_str.contains("DE1234567890"));
}
#[test]
fn test_account_debug_masks_iban() {
let account = Account {
id: "123".to_string(),
name: Some("Test Account".to_string()),
iban: Some("DE1234567890".to_string()),
currency: "EUR".to_string(),
};
let debug_str = format!("{:?}", account);
assert!(debug_str.contains("id"));
assert!(debug_str.contains("currency"));
assert!(debug_str.contains("[REDACTED]"));
assert!(!debug_str.contains("DE1234567890"));
}
}
#[derive(Clone, Debug, Serialize)]
pub struct AccountSummary {
pub id: String,
pub name: Option<String>,
pub iban: String,
pub currency: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AccountStatus {
pub account_id: String,
pub iban: String,
pub last_sync_date: Option<NaiveDate>,
pub transaction_count: usize,
pub status: String, // e.g., "synced", "pending", "error"
}
#[derive(Clone, Debug, Serialize)]
pub struct TransactionInfo {
pub account_id: String,
pub total_count: usize,
pub date_range: Option<(NaiveDate, NaiveDate)>,
pub last_updated: Option<NaiveDate>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CacheInfo {
pub account_id: Option<String>, // None for global, Some for per-account
pub cache_type: String, // e.g., "account", "transaction"
pub entry_count: usize,
pub total_size_bytes: usize,
pub last_updated: Option<NaiveDate>,
}
#[derive(Error, Debug)]
pub enum SyncError {
#[error("End User Agreement {agreement_id} has expired")]
AgreementExpired { agreement_id: String },
#[error("Account {account_id} skipped: {reason}")]
AccountSkipped { account_id: String, reason: String },
#[error("Source error: {0}")]
SourceError(anyhow::Error),
#[error("Destination error: {0}")]
DestinationError(anyhow::Error),
}

152
banks2ff/src/core/ports.rs Normal file
View File

@@ -0,0 +1,152 @@
use crate::core::models::{
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
};
use anyhow::Result;
use async_trait::async_trait;
use chrono::NaiveDate;
#[cfg(test)]
use mockall::automock;
#[derive(Debug, Default)]
pub struct IngestResult {
pub created: usize,
pub duplicates: usize,
pub errors: usize,
pub healed: usize,
}
#[cfg_attr(test, automock)]
#[async_trait]
pub trait TransactionSource: Send + Sync {
/// Fetch accounts. Optionally filter by a list of wanted IBANs to save requests.
async fn get_accounts(&self, wanted_ibans: Option<Vec<String>>) -> Result<Vec<Account>>;
async fn get_transactions(
&self,
account_id: &str,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<BankTransaction>>;
/// Inspection methods for CLI
async fn list_accounts(&self) -> Result<Vec<AccountSummary>>;
async fn get_account_status(&self) -> Result<Vec<AccountStatus>>;
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo>;
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>>;
async fn get_cached_transactions(
&self,
account_id: &str,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<BankTransaction>>;
/// Account discovery for linking
async fn discover_accounts(&self) -> Result<Vec<Account>>;
}
// Blanket implementation for references
#[async_trait]
impl<T: TransactionSource> TransactionSource for &T {
async fn get_accounts(&self, wanted_ibans: Option<Vec<String>>) -> Result<Vec<Account>> {
(**self).get_accounts(wanted_ibans).await
}
async fn get_transactions(
&self,
account_id: &str,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<BankTransaction>> {
(**self).get_transactions(account_id, start, end).await
}
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
(**self).list_accounts().await
}
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
(**self).get_account_status().await
}
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
(**self).get_transaction_info(account_id).await
}
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>> {
(**self).get_cache_info().await
}
async fn 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>> {
(**self).discover_accounts().await
}
}
#[derive(Debug, Clone)]
pub struct TransactionMatch {
pub id: String,
pub has_external_id: bool,
}
#[cfg_attr(test, automock)]
#[async_trait]
pub trait TransactionDestination: Send + Sync {
// New granular methods for Healer Logic
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>>;
async fn find_transaction(
&self,
account_id: &str,
transaction: &BankTransaction,
) -> Result<Option<TransactionMatch>>;
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>;
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>;
/// Account discovery for linking
async fn discover_accounts(&self) -> Result<Vec<Account>>;
/// Inspection methods for CLI
async fn list_accounts(&self) -> Result<Vec<AccountSummary>>;
}
// Blanket implementation for references
#[async_trait]
impl<T: TransactionDestination> TransactionDestination for &T {
async fn get_last_transaction_date(&self, account_id: &str) -> Result<Option<NaiveDate>> {
(**self).get_last_transaction_date(account_id).await
}
async fn find_transaction(
&self,
account_id: &str,
transaction: &BankTransaction,
) -> Result<Option<TransactionMatch>> {
(**self).find_transaction(account_id, transaction).await
}
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()> {
(**self).create_transaction(account_id, tx).await
}
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()> {
(**self)
.update_transaction_external_id(id, external_id)
.await
}
async fn discover_accounts(&self) -> Result<Vec<Account>> {
(**self).discover_accounts().await
}
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
(**self).list_accounts().await
}
}

532
banks2ff/src/core/sync.rs Normal file
View File

@@ -0,0 +1,532 @@
use crate::core::config::Config;
use crate::core::linking::{auto_link_accounts, LinkStore};
use crate::core::models::{Account, SyncError};
use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource};
use anyhow::Result;
use chrono::{Local, NaiveDate};
use tracing::{info, instrument, warn};
#[derive(Debug, Default)]
pub struct SyncResult {
pub ingest: IngestResult,
pub accounts_processed: usize,
pub accounts_skipped_expired: usize,
pub accounts_skipped_errors: usize,
}
#[instrument(skip(source, destination, config))]
pub async fn run_sync(
source: impl TransactionSource,
destination: impl TransactionDestination,
config: Config,
cli_start_date: Option<NaiveDate>,
cli_end_date: Option<NaiveDate>,
dry_run: bool,
) -> Result<SyncResult> {
info!("Starting synchronization...");
// Discover all accounts from both source and destination
let all_source_accounts = source
.discover_accounts()
.await
.map_err(SyncError::SourceError)?;
let all_dest_accounts = destination
.discover_accounts()
.await
.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(config.cache.directory.clone());
// Auto-link accounts based on IBAN
let links = auto_link_accounts(&all_source_accounts, &all_dest_accounts);
for (src_idx, dest_idx) in links {
let src = &all_source_accounts[src_idx];
let dest = &all_dest_accounts[dest_idx];
match link_store.add_link(src, dest, "gocardless", "firefly", 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)?;
// 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
let end_date =
cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1));
let mut result = SyncResult::default();
for account in accounts_to_sync {
let span = tracing::info_span!("sync_account", account_id = %account.id);
let _enter = span.enter();
info!("Processing account...");
// Process account with error handling
match process_single_account(
&source,
&destination,
&account,
&link_store,
cli_start_date,
end_date,
dry_run,
)
.await
{
Ok(stats) => {
result.accounts_processed += 1;
result.ingest.created += stats.created;
result.ingest.healed += stats.healed;
result.ingest.duplicates += stats.duplicates;
result.ingest.errors += stats.errors;
info!(
"Account {} sync complete. Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
account.id, stats.created, stats.healed, stats.duplicates, stats.errors
);
}
Err(SyncError::AgreementExpired { agreement_id }) => {
result.accounts_skipped_expired += 1;
warn!(
"Account {} skipped - associated agreement {} has expired",
account.id, agreement_id
);
}
Err(SyncError::AccountSkipped { account_id, reason }) => {
result.accounts_skipped_errors += 1;
warn!("Account {} skipped: {}", account_id, reason);
}
Err(e) => {
result.accounts_skipped_errors += 1;
warn!("Account {} failed with error: {}", account.id, e);
}
}
}
info!(
"Synchronization finished. Processed: {}, Skipped (expired): {}, Skipped (errors): {}",
result.accounts_processed, result.accounts_skipped_expired, result.accounts_skipped_errors
);
info!(
"Total transactions - Created: {}, Healed: {}, Duplicates: {}, Errors: {}",
result.ingest.created, result.ingest.healed, result.ingest.duplicates, result.ingest.errors
);
Ok(result)
}
async fn process_single_account(
source: &impl TransactionSource,
destination: &impl TransactionDestination,
account: &Account,
link_store: &LinkStore,
cli_start_date: Option<NaiveDate>,
end_date: NaiveDate,
dry_run: bool,
) -> Result<IngestResult, SyncError> {
let link_opt = link_store.find_link_by_source(&account.id);
let Some(link) = link_opt else {
return Err(SyncError::AccountSkipped {
account_id: account.id.clone(),
reason: "No link found to destination account".to_string(),
});
};
let dest_id = link.dest_account_id.clone();
info!("Resolved destination ID: {}", dest_id);
// Determine Start Date
let start_date = if let Some(d) = cli_start_date {
d
} else {
// Default: Latest transaction date + 1 day
match destination
.get_last_transaction_date(&dest_id)
.await
.map_err(SyncError::DestinationError)?
{
Some(last_date) => last_date + chrono::Duration::days(1),
None => {
// If no transaction exists in Firefly, we assume this is a fresh sync.
// Default to syncing last 30 days.
end_date - chrono::Duration::days(30)
}
}
};
if start_date > end_date {
info!(
"Start date {} is after end date {}. Nothing to sync.",
start_date, end_date
);
return Ok(IngestResult::default());
}
info!("Syncing interval: {} to {}", start_date, end_date);
let transactions = match source
.get_transactions(&account.id, start_date, end_date)
.await
{
Ok(txns) => txns,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") && (err_str.contains("expired") || err_str.contains("EUA")) {
return Err(SyncError::AgreementExpired {
agreement_id: "unknown".to_string(), // We don't have the agreement ID here
});
}
return Err(SyncError::SourceError(e));
}
};
if transactions.is_empty() {
info!("No transactions found for period.");
return Ok(IngestResult::default());
}
info!("Fetched {} transactions from source.", transactions.len());
let mut stats = IngestResult::default();
// Healer Logic Loop
for tx in transactions {
// 1. Check if it exists
match destination
.find_transaction(&dest_id, &tx)
.await
.map_err(SyncError::DestinationError)?
{
Some(existing) => {
if existing.has_external_id {
// Already synced properly
stats.duplicates += 1;
} else {
// Found "naked" transaction -> Heal it
if dry_run {
info!(
"[DRY RUN] Would heal transaction {} (Firefly ID: {})",
tx.internal_id, existing.id
);
stats.healed += 1;
} else {
info!(
"Healing transaction {} (Firefly ID: {})",
tx.internal_id, existing.id
);
if let Err(e) = destination
.update_transaction_external_id(&existing.id, &tx.internal_id)
.await
{
tracing::error!("Failed to heal transaction: {}", e);
stats.errors += 1;
} else {
stats.healed += 1;
}
}
}
}
None => {
// New transaction
if dry_run {
info!("[DRY RUN] Would create transaction {}", tx.internal_id);
stats.created += 1;
} else if let Err(e) = destination.create_transaction(&dest_id, &tx).await {
// Firefly might still reject it as duplicate if hash matches, even if we didn't find it via heuristic
// (unlikely if heuristic is good, but possible)
let err_str = e.to_string();
if err_str.contains("422") || err_str.contains("Duplicate") {
warn!("Duplicate rejected by Firefly: {}", tx.internal_id);
stats.duplicates += 1;
} else {
tracing::error!("Failed to create transaction: {}", e);
stats.errors += 1;
}
} else {
stats.created += 1;
}
}
}
}
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::{
CacheConfig, Config, FireflyConfig, GoCardlessConfig, LoggingConfig,
};
use crate::core::models::{Account, BankTransaction};
use crate::core::ports::{MockTransactionDestination, MockTransactionSource, TransactionMatch};
use mockall::predicate::*;
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]
async fn test_sync_flow_create_new() {
let mut source = MockTransactionSource::new();
let mut dest = MockTransactionDestination::new();
// Source setup
source
.expect_get_accounts()
.with(always()) // Match any argument
.returning(|_| {
Ok(vec![Account {
id: "src_1".to_string(),
name: Some("Test Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
source.expect_discover_accounts().returning(|| {
Ok(vec![Account {
id: "src_1".to_string(),
name: Some("Test Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
let tx = BankTransaction {
internal_id: "tx1".into(),
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
amount: Decimal::new(100, 0),
currency: "EUR".into(),
foreign_amount: None,
foreign_currency: None,
description: "Test".into(),
counterparty_name: None,
counterparty_iban: None,
};
let tx_clone = tx.clone();
source
.expect_get_transactions()
.returning(move |_, _, _| Ok(vec![tx.clone()]));
// Destination setup
dest.expect_discover_accounts().returning(|| {
Ok(vec![Account {
id: "dest_1".to_string(),
name: Some("Savings Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
dest.expect_get_last_transaction_date()
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
// 1. Find -> None
dest.expect_find_transaction()
.times(1)
.returning(|_, _| Ok(None));
// 2. Create -> Ok
dest.expect_create_transaction()
.with(eq("dest_1"), eq(tx_clone))
.times(1)
.returning(|_, _| Ok(()));
// Execution
let temp_dir = format!("tmp/test-sync-{}", rand::random::<u64>());
let config = create_test_config(&temp_dir);
let res = run_sync(&source, &dest, config, None, None, false).await;
assert!(res.is_ok());
// Cleanup
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_sync_flow_heal_existing() {
let mut source = MockTransactionSource::new();
let mut dest = MockTransactionDestination::new();
dest.expect_discover_accounts().returning(|| {
Ok(vec![Account {
id: "dest_1".to_string(),
name: Some("Savings Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
source.expect_get_accounts().with(always()).returning(|_| {
Ok(vec![Account {
id: "src_1".to_string(),
name: Some("Test Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
source.expect_discover_accounts().returning(|| {
Ok(vec![Account {
id: "src_1".to_string(),
name: Some("Test Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
source.expect_get_transactions().returning(|_, _, _| {
Ok(vec![BankTransaction {
internal_id: "tx1".into(),
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
amount: Decimal::new(100, 0),
currency: "EUR".into(),
foreign_amount: None,
foreign_currency: None,
description: "Test".into(),
counterparty_name: None,
counterparty_iban: None,
}])
});
dest.expect_get_last_transaction_date()
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
// 1. Find -> Some(No External ID)
dest.expect_find_transaction().times(1).returning(|_, _| {
Ok(Some(TransactionMatch {
id: "ff_tx_1".to_string(),
has_external_id: false,
}))
});
// 2. Update -> Ok
dest.expect_update_transaction_external_id()
.with(eq("ff_tx_1"), eq("tx1"))
.times(1)
.returning(|_, _| Ok(()));
let temp_dir = format!("tmp/test-sync-heal-{}", rand::random::<u64>());
let config = create_test_config(&temp_dir);
let res = run_sync(&source, &dest, config, None, None, false).await;
assert!(res.is_ok());
// Cleanup
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_sync_flow_dry_run() {
let mut source = MockTransactionSource::new();
let mut dest = MockTransactionDestination::new();
dest.expect_discover_accounts().returning(|| {
Ok(vec![Account {
id: "dest_1".to_string(),
name: Some("Savings Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
source.expect_get_accounts().with(always()).returning(|_| {
Ok(vec![Account {
id: "src_1".to_string(),
name: Some("Test Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
source.expect_discover_accounts().returning(|| {
Ok(vec![Account {
id: "src_1".to_string(),
name: Some("Test Account".to_string()),
iban: Some("NL01".to_string()),
currency: "EUR".to_string(),
}])
});
let tx = BankTransaction {
internal_id: "tx1".into(),
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
amount: Decimal::new(100, 0),
currency: "EUR".into(),
foreign_amount: None,
foreign_currency: None,
description: "Test".into(),
counterparty_name: None,
counterparty_iban: None,
};
source
.expect_get_transactions()
.returning(move |_, _, _| Ok(vec![tx.clone()]));
dest.expect_get_last_transaction_date()
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
// 1. Find -> None (New transaction)
dest.expect_find_transaction().returning(|_, _| Ok(None));
// 2. Create -> NEVER Called (Dry Run)
dest.expect_create_transaction().never();
dest.expect_update_transaction_external_id().never();
let temp_dir = format!("tmp/test-sync-dry-run-{}", rand::random::<u64>());
let config = create_test_config(&temp_dir);
let res = run_sync(source, dest, config, None, None, true).await;
assert!(res.is_ok());
// Cleanup
let _ = std::fs::remove_dir_all(&temp_dir);
}
}

132
banks2ff/src/debug.rs Normal file
View File

@@ -0,0 +1,132 @@
use chrono::Utc;
use hyper::Body;
use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use task_local_extensions::Extensions;
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct DebugLogger {
service_name: String,
}
impl DebugLogger {
pub fn new(service_name: &str) -> Self {
Self {
service_name: service_name.to_string(),
}
}
}
#[async_trait::async_trait]
impl Middleware for DebugLogger {
async fn handle(
&self,
req: Request,
extensions: &mut Extensions,
next: Next<'_>,
) -> reqwest_middleware::Result<Response> {
let request_id = REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
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/{}/{}", date, self.service_name);
fs::create_dir_all(&dir).unwrap_or_else(|e| {
eprintln!("Failed to create debug log directory: {}", e);
});
let filepath = Path::new(&dir).join(filename);
let mut log_content = String::new();
// Curl command
log_content.push_str("# Curl command:\n");
let curl = build_curl_command(&req);
log_content.push_str(&format!("{}\n\n", curl));
// Request
log_content.push_str("# Request:\n");
log_content.push_str(&format!("{} {} HTTP/1.1\n", req.method(), req.url()));
for (key, value) in req.headers() {
log_content.push_str(&format!(
"{}: {}\n",
key,
value.to_str().unwrap_or("[INVALID]")
));
}
if let Some(body) = req.body() {
if let Some(bytes) = body.as_bytes() {
log_content.push_str(&format!("\n{}", String::from_utf8_lossy(bytes)));
}
}
log_content.push_str("\n\n");
// Send request and get response
let response = next.run(req, extensions).await?;
// Extract parts before consuming body
let status = response.status();
let version = response.version();
let headers = response.headers().clone();
// Response
log_content.push_str("# Response:\n");
log_content.push_str(&format!(
"HTTP/1.1 {} {}\n",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown")
));
for (key, value) in &headers {
log_content.push_str(&format!(
"{}: {}\n",
key,
value.to_str().unwrap_or("[INVALID]")
));
}
// Read body
let body_bytes = response.bytes().await.map_err(|e| {
reqwest_middleware::Error::Middleware(anyhow::anyhow!(
"Failed to read response body: {}",
e
))
})?;
let body_str = String::from_utf8_lossy(&body_bytes);
log_content.push_str(&format!("\n{}", body_str));
// Write to file
if let Err(e) = fs::write(&filepath, log_content) {
eprintln!("Failed to write debug log: {}", e);
}
// Reconstruct response
let mut builder = http::Response::builder().status(status).version(version);
for (key, value) in &headers {
builder = builder.header(key, value);
}
let new_response = builder.body(Body::from(body_bytes)).unwrap();
Ok(Response::from(new_response))
}
}
fn build_curl_command(req: &Request) -> String {
let mut curl = format!("curl -v -X {} '{}'", req.method(), req.url());
for (key, value) in req.headers() {
let value_str = value.to_str().unwrap_or("[INVALID]").replace("'", "\\'");
curl.push_str(&format!(" -H '{}: {}'", key, value_str));
}
if let Some(body) = req.body() {
if let Some(bytes) = body.as_bytes() {
let body_str = String::from_utf8_lossy(bytes).replace("'", "\\'");
curl.push_str(&format!(" -d '{}'", body_str));
}
}
curl
}

155
banks2ff/src/main.rs Normal file
View File

@@ -0,0 +1,155 @@
mod adapters;
mod cli;
mod commands;
mod core;
mod debug;
use crate::commands::accounts::AccountCommands;
use crate::commands::sync::handle_sync;
use crate::commands::transactions::TransactionCommands;
use crate::core::config::Config;
use chrono::NaiveDate;
use clap::{Parser, Subcommand};
use tracing::info;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to configuration file (optional)
#[arg(short, long)]
config: Option<String>,
/// Dry run mode: Do not create or update transactions in Firefly III.
#[arg(long, default_value_t = false)]
dry_run: bool,
/// Enable debug logging of HTTP requests/responses to ./debug_logs/
#[arg(long, default_value_t = false)]
debug: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Synchronize transactions between source and destination
Sync {
/// Source type (gocardless, csv, camt053, mt940)
source: String,
/// Destination type (firefly)
destination: String,
/// Start date for synchronization (YYYY-MM-DD)
#[arg(short, long)]
start: Option<NaiveDate>,
/// End date for synchronization (YYYY-MM-DD)
#[arg(short, long)]
end: Option<NaiveDate>,
},
/// Manage accounts and linking
Accounts {
#[command(subcommand)]
subcommand: AccountCommands,
},
/// Manage transactions and cache
Transactions {
#[command(subcommand)]
subcommand: TransactionCommands,
},
/// List all available source types
Sources,
/// List all available destination types
Destinations,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load environment variables first
dotenvy::dotenv().ok();
// Load configuration
let config = Config::from_env()?;
let args = Args::parse();
// Initialize logging based on command type
// For sync command, show INFO logs by default (but allow RUST_LOG override)
// For other commands, only show warnings/errors by default (but allow RUST_LOG override)
let default_level = match args.command {
Commands::Sync { .. } => "info",
_ => "warn",
};
let log_level = config
.logging
.level
.parse()
.unwrap_or(tracing_subscriber::EnvFilter::new(default_level));
tracing_subscriber::fmt().with_env_filter(log_level).init();
info!("Starting banks2ff...");
if args.dry_run {
info!("DRY RUN MODE ENABLED: No changes will be made to Firefly III.");
}
match args.command {
Commands::Sync {
source,
destination,
start,
end,
} => {
handle_sync(
config,
args.debug,
source,
destination,
start,
end,
args.dry_run,
)
.await?;
}
Commands::Sources => {
commands::list::handle_sources().await?;
}
Commands::Destinations => {
commands::list::handle_destinations().await?;
}
Commands::Accounts { subcommand } => {
commands::accounts::handle_accounts(config, subcommand).await?;
}
Commands::Transactions { subcommand } => {
commands::transactions::handle_transactions(config, subcommand).await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::cli::tables::mask_iban;
#[test]
fn test_mask_iban_short() {
assert_eq!(mask_iban("123"), "123");
}
#[test]
fn test_mask_iban_long() {
assert_eq!(mask_iban("NL12ABCD1234567890"), "NL12ABCD******7890");
}
#[test]
fn test_mask_iban_other_country() {
assert_eq!(mask_iban("DE1234567890123456"), "DE************3456");
}
}

143
docs/architecture.md Normal file
View File

@@ -0,0 +1,143 @@
# Architecture Documentation
## Overview
Banks2FF implements a **Hexagonal (Ports & Adapters) Architecture** to synchronize bank transactions from GoCardless to Firefly III. This architecture separates business logic from external concerns, making the system testable and maintainable.
## Workspace Structure
```
banks2ff/
├── banks2ff/ # Main CLI application
│ └── 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
│ ├── adapters/ # External service integrations
│ └── main.rs # CLI entry point and command dispatch
├── firefly-client/ # Firefly III API client library
├── gocardless-client/ # GoCardless API client library
└── docs/ # Architecture documentation
```
## Core Components
### 1. Domain Core (`banks2ff/src/core/`)
**models.rs**: Defines domain entities
- `BankTransaction`: Core transaction model with multi-currency support
- `Account`: Bank account representation
- Supports `foreign_amount` and `foreign_currency` for international transactions
**ports.rs**: Defines abstraction traits
- `TransactionSource`: Interface for fetching transactions (implemented by GoCardless adapter)
- `TransactionDestination`: Interface for storing transactions (implemented by Firefly adapter)
- Traits are mockable for isolated testing
**sync.rs**: Synchronization engine
- `run_sync()`: Orchestrates the entire sync process
- Implements "Healer" strategy for idempotency
- Smart date range calculation (Last Transaction Date + 1 to Yesterday)
### 2. Adapters (`banks2ff/src/adapters/`)
**gocardless/**: GoCardless integration
- `client.rs`: Wrapper for GoCardless client with token management
- `mapper.rs`: Converts GoCardless API responses to domain models
- `cache.rs`: Caches account mappings to reduce API calls
- Correctly handles multi-currency via `currencyExchange` array parsing
**firefly/**: Firefly III integration
- `client.rs`: Wrapper for Firefly client for transaction storage
- Maps domain models to Firefly API format
### 3. Command Handlers (`banks2ff/src/commands/`)
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`:
- Strongly-typed DTOs for compile-time safety
- Custom error handling with `thiserror`
- Rate limit awareness and graceful degradation
## Synchronization Process
The "Healer" strategy ensures idempotency with robust error handling:
1. **Account Discovery**: Fetch active accounts from GoCardless (filtered by End User Agreement (EUA) validity)
2. **Agreement Validation**: Check EUA expiry status for each account's requisition
3. **Account Matching**: Match GoCardless accounts to Firefly asset accounts by IBAN
4. **Error-Aware Processing**: Continue with valid accounts when some have expired agreements
5. **Date Window**: Calculate sync range (Last Firefly transaction + 1 to Yesterday)
6. **Transaction Processing** (with error recovery):
- **Search**: Look for existing transaction using windowed heuristic (date ± 3 days, exact amount)
- **Heal**: If found without `external_id`, update with GoCardless transaction ID
- **Skip**: If found with matching `external_id`, ignore
- **Create**: If not found, create new transaction in Firefly
- **Error Handling**: Log issues but continue with other transactions/accounts
## Key Features
### Multi-Currency Support
- Parses `currencyExchange` array from GoCardless responses
- Calculates `foreign_amount = amount * exchange_rate`
- Maps to Firefly's `foreign_amount` and `foreign_currency_code` fields
### Rate Limit Management
- **Caching**: Stores `AccountId -> IBAN` mappings to reduce requisition calls
- **Token Reuse**: Maintains tokens until expiry to minimize auth requests
- **Graceful Handling**: Continues sync for other accounts when encountering 429 errors
### Agreement Expiry Handling
- **Proactive Validation**: Checks End User Agreement (EUA) expiry before making API calls to avoid unnecessary requests
- **Reactive Recovery**: Detects expired agreements from API 401 errors and skips affected accounts
- **Continued Operation**: Maintains partial sync success even when some accounts are inaccessible
- **User Feedback**: Provides detailed reporting on account status and re-authorization needs
- **Multiple Requisitions**: Supports accounts linked to multiple requisitions, using the most recent valid one
### Idempotency
- GoCardless `transactionId` → Firefly `external_id` mapping
- Windowed duplicate detection prevents double-creation
- Historical transaction healing for pre-existing data
## Data Flow
```
GoCardless API → GoCardlessAdapter → TransactionSource → SyncEngine → TransactionDestination → FireflyAdapter → Firefly API
```
## Testing Strategy
- **Unit Tests**: Core logic with `mockall` for trait mocking
- **Integration Tests**: API clients with `wiremock` for HTTP mocking
- **Fixture Testing**: Real JSON responses for adapter mapping validation
- **Isolation**: Business logic tested without external dependencies
## Error Handling
- **Custom Errors**: `thiserror` for domain-specific error types including End User Agreement (EUA) expiry (`SyncError::AgreementExpired`)
- **Propagation**: `anyhow` for error context across async boundaries
- **Graceful Degradation**: Rate limits, network issues, and expired agreements don't crash entire sync
- **Partial Success**: Continues processing available accounts when some fail
- **Structured Logging**: `tracing` for observability and debugging with account-level context
## Configuration Management
- Environment variables loaded via `dotenvy`
- Workspace-level dependency management
- Feature flags for optional functionality
- Secure credential handling (no hardcoded secrets)

View File

@@ -4,3 +4,12 @@ FIREFLY_III_CLIENT_ID=
GOCARDLESS_KEY=
GOCARDLESS_ID=
# Required: Generate a secure random key (32+ characters recommended)
# Linux/macOS: tr -dc [:alnum:] < /dev/urandom | head -c 32
# Windows PowerShell: [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))
# Or use any password manager to generate a strong random string
BANKS2FF_CACHE_KEY=
# Optional: Custom cache directory (defaults to data/cache)
# BANKS2FF_CACHE_DIR=

21
firefly-client/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "firefly-client"
version.workspace = true
edition.workspace = true
authors.workspace = true
[dependencies]
reqwest = { workspace = true, default-features = false, features = ["json", "rustls-tls"] }
reqwest-middleware = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
chrono = { workspace = true }
rust_decimal = { workspace = true }
[dev-dependencies]
wiremock = { workspace = true }
tokio = { workspace = true }
tokio-test = { workspace = true }

View File

@@ -0,0 +1,168 @@
use crate::models::{AccountArray, TransactionArray, TransactionStore, TransactionUpdate};
use reqwest::Url;
use reqwest_middleware::ClientWithMiddleware;
use serde::de::DeserializeOwned;
use thiserror::Error;
use tracing::instrument;
#[derive(Error, Debug)]
pub enum FireflyError {
#[error("Request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Middleware error: {0}")]
MiddlewareError(#[from] reqwest_middleware::Error),
#[error("API Error: {0}")]
ApiError(String),
#[error("URL Parse Error: {0}")]
UrlParseError(#[from] url::ParseError),
}
pub struct FireflyClient {
base_url: Url,
client: ClientWithMiddleware,
access_token: String,
}
impl FireflyClient {
pub fn new(base_url: &str, access_token: &str) -> Result<Self, FireflyError> {
Self::with_client(base_url, access_token, None)
}
pub fn with_client(
base_url: &str,
access_token: &str,
client: Option<ClientWithMiddleware>,
) -> Result<Self, FireflyError> {
Ok(Self {
base_url: Url::parse(base_url)?,
client: client.unwrap_or_else(|| {
reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()
}),
access_token: access_token.to_string(),
})
}
#[instrument(skip(self))]
pub async fn get_accounts(&self) -> Result<AccountArray, FireflyError> {
let url = self.base_url.join("/api/v1/accounts")?;
self.get_authenticated(url).await
}
#[instrument(skip(self))]
pub async fn search_accounts(&self, query: &str) -> Result<AccountArray, FireflyError> {
let mut url = self.base_url.join("/api/v1/search/accounts")?;
url.query_pairs_mut()
.append_pair("query", query)
.append_pair("type", "asset")
.append_pair("field", "all");
self.get_authenticated(url).await
}
#[instrument(skip(self, transaction))]
pub async fn store_transaction(
&self,
transaction: TransactionStore,
) -> Result<(), FireflyError> {
let url = self.base_url.join("/api/v1/transactions")?;
let response = self
.client
.post(url)
.bearer_auth(&self.access_token)
.header("accept", "application/json")
.json(&transaction)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await?;
return Err(FireflyError::ApiError(format!(
"Store Transaction Failed {}: {}",
status, text
)));
}
Ok(())
}
#[instrument(skip(self))]
pub async fn list_account_transactions(
&self,
account_id: &str,
start: Option<&str>,
end: Option<&str>,
) -> Result<TransactionArray, FireflyError> {
let mut url = self
.base_url
.join(&format!("/api/v1/accounts/{}/transactions", account_id))?;
{
let mut pairs = url.query_pairs_mut();
if let Some(s) = start {
pairs.append_pair("start", s);
}
if let Some(e) = end {
pairs.append_pair("end", e);
}
// Limit to 50, could be higher but safer to page if needed. For heuristic checks 50 is usually plenty per day range.
pairs.append_pair("limit", "50");
}
self.get_authenticated(url).await
}
#[instrument(skip(self, update))]
pub async fn update_transaction(
&self,
id: &str,
update: TransactionUpdate,
) -> Result<(), FireflyError> {
let url = self
.base_url
.join(&format!("/api/v1/transactions/{}", id))?;
let response = self
.client
.put(url)
.bearer_auth(&self.access_token)
.header("accept", "application/json")
.json(&update)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await?;
return Err(FireflyError::ApiError(format!(
"Update Transaction Failed {}: {}",
status, text
)));
}
Ok(())
}
async fn get_authenticated<T: DeserializeOwned>(&self, url: Url) -> Result<T, FireflyError> {
let response = self
.client
.get(url)
.bearer_auth(&self.access_token)
.header("accept", "application/json")
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await?;
return Err(FireflyError::ApiError(format!(
"API request failed {}: {}",
status, text
)));
}
let data = response.json().await?;
Ok(data)
}
}

View File

@@ -0,0 +1,2 @@
pub mod client;
pub mod models;

View File

@@ -0,0 +1,117 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountRead {
pub id: String,
pub attributes: Account,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub name: String,
pub iban: Option<String>,
#[serde(rename = "type")]
pub account_type: String,
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)]
pub struct AccountArray {
pub data: Vec<AccountRead>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionRead {
pub id: String,
pub attributes: Transaction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
pub transactions: Vec<TransactionSplit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionSplit {
pub date: String,
pub amount: String,
pub description: String,
pub external_id: Option<String>,
pub currency_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionArray {
pub data: Vec<TransactionRead>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionSplitStore {
#[serde(rename = "type")]
pub transaction_type: String,
pub date: String,
pub amount: String,
pub description: String,
pub source_id: Option<String>,
pub source_name: Option<String>,
pub destination_id: Option<String>,
pub destination_name: Option<String>,
pub currency_code: Option<String>,
pub foreign_amount: Option<String>,
pub foreign_currency_code: Option<String>,
pub external_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionStore {
pub transactions: Vec<TransactionSplitStore>,
pub apply_rules: Option<bool>,
pub fire_webhooks: Option<bool>,
pub error_if_duplicate_hash: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionUpdate {
pub transactions: Vec<TransactionSplitUpdate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionSplitUpdate {
pub external_id: Option<String>,
}

View File

@@ -0,0 +1,65 @@
use firefly_client::client::FireflyClient;
use firefly_client::models::{TransactionSplitStore, TransactionStore};
use std::fs;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_search_accounts() {
let mock_server = MockServer::start().await;
let fixture = fs::read_to_string("tests/fixtures/ff_accounts.json").unwrap();
Mock::given(method("GET"))
.and(path("/api/v1/search/accounts"))
.and(header("Authorization", "Bearer my-token"))
.respond_with(ResponseTemplate::new(200).set_body_string(fixture))
.mount(&mock_server)
.await;
let client = FireflyClient::new(&mock_server.uri(), "my-token").unwrap();
let accounts = client.search_accounts("NL01").await.unwrap();
assert_eq!(accounts.data.len(), 1);
assert_eq!(accounts.data[0].attributes.name, "Checking Account");
assert_eq!(
accounts.data[0].attributes.iban.as_deref(),
Some("NL01BANK0123456789")
);
}
#[tokio::test]
async fn test_store_transaction() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/transactions"))
.and(header("Authorization", "Bearer my-token"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let client = FireflyClient::new(&mock_server.uri(), "my-token").unwrap();
let tx = TransactionStore {
transactions: vec![TransactionSplitStore {
transaction_type: "withdrawal".to_string(),
date: "2023-01-01".to_string(),
amount: "10.00".to_string(),
description: "Test".to_string(),
source_id: Some("1".to_string()),
destination_name: Some("Shop".to_string()),
currency_code: None,
foreign_amount: None,
foreign_currency_code: None,
external_id: None,
source_name: None,
destination_id: None,
}],
apply_rules: None,
fire_webhooks: None,
error_if_duplicate_hash: None,
};
let result = client.store_transaction(tx).await;
assert!(result.is_ok());
}

View File

@@ -0,0 +1,22 @@
{
"data": [
{
"type": "accounts",
"id": "2",
"attributes": {
"name": "Checking Account",
"type": "asset",
"iban": "NL01BANK0123456789"
}
}
],
"meta": {
"pagination": {
"total": 1,
"count": 1,
"per_page": 20,
"current_page": 1,
"total_pages": 1
}
}
}

View File

@@ -1,3 +0,0 @@
/target/
**/*.rs.bk
Cargo.lock

8
firefly-iii-api/.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,5 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
</profile>
</component>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectInspectionProfilesVisibleTreeState">
<entry key="Project Default">
<profile-state>
<expanded-state>
<State />
<State>
<id>SQL</id>
</State>
</expanded-state>
<selected-state>
<State>
<id>CSS</id>
</State>
</selected-state>
</profile-state>
</entry>
</component>
</project>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/rust.iml" filepath="$PROJECT_DIR$/.idea/rust.iml" />
</modules>
</component>
</project>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,23 +0,0 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@@ -1,484 +0,0 @@
.gitignore
.openapi-generator-ignore
.travis.yml
Cargo.toml
README.md
docs/AboutApi.md
docs/Account.md
docs/AccountArray.md
docs/AccountRead.md
docs/AccountRoleProperty.md
docs/AccountSearchFieldFilter.md
docs/AccountSingle.md
docs/AccountStore.md
docs/AccountTypeFilter.md
docs/AccountTypeProperty.md
docs/AccountUpdate.md
docs/AccountsApi.md
docs/AttachableType.md
docs/Attachment.md
docs/AttachmentArray.md
docs/AttachmentRead.md
docs/AttachmentSingle.md
docs/AttachmentStore.md
docs/AttachmentUpdate.md
docs/AttachmentsApi.md
docs/AutoBudgetPeriod.md
docs/AutoBudgetType.md
docs/AutocompleteAccount.md
docs/AutocompleteApi.md
docs/AutocompleteBill.md
docs/AutocompleteBudget.md
docs/AutocompleteCategory.md
docs/AutocompleteCurrency.md
docs/AutocompleteCurrencyCode.md
docs/AutocompleteObjectGroup.md
docs/AutocompletePiggy.md
docs/AutocompletePiggyBalance.md
docs/AutocompleteRecurrence.md
docs/AutocompleteRule.md
docs/AutocompleteRuleGroup.md
docs/AutocompleteTag.md
docs/AutocompleteTransaction.md
docs/AutocompleteTransactionId.md
docs/AutocompleteTransactionType.md
docs/AvailableBudget.md
docs/AvailableBudgetArray.md
docs/AvailableBudgetRead.md
docs/AvailableBudgetSingle.md
docs/AvailableBudgetsApi.md
docs/BadRequestResponse.md
docs/BasicSummaryEntry.md
docs/Bill.md
docs/BillArray.md
docs/BillPaidDatesInner.md
docs/BillRead.md
docs/BillRepeatFrequency.md
docs/BillSingle.md
docs/BillStore.md
docs/BillUpdate.md
docs/BillsApi.md
docs/Budget.md
docs/BudgetArray.md
docs/BudgetLimit.md
docs/BudgetLimitArray.md
docs/BudgetLimitRead.md
docs/BudgetLimitSingle.md
docs/BudgetLimitStore.md
docs/BudgetRead.md
docs/BudgetSingle.md
docs/BudgetSpent.md
docs/BudgetStore.md
docs/BudgetUpdate.md
docs/BudgetsApi.md
docs/CategoriesApi.md
docs/Category.md
docs/CategoryArray.md
docs/CategoryEarned.md
docs/CategoryRead.md
docs/CategorySingle.md
docs/CategorySpent.md
docs/CategoryUpdate.md
docs/ChartDataPoint.md
docs/ChartDataSet.md
docs/ChartsApi.md
docs/ConfigValueFilter.md
docs/ConfigValueUpdateFilter.md
docs/Configuration.md
docs/ConfigurationApi.md
docs/ConfigurationSingle.md
docs/ConfigurationUpdate.md
docs/CreditCardTypeProperty.md
docs/CronResult.md
docs/CronResultRow.md
docs/CurrenciesApi.md
docs/Currency.md
docs/CurrencyArray.md
docs/CurrencyRead.md
docs/CurrencySingle.md
docs/CurrencyStore.md
docs/CurrencyUpdate.md
docs/DataApi.md
docs/DataDestroyObject.md
docs/ExportFileFilter.md
docs/InsightApi.md
docs/InsightGroupEntry.md
docs/InsightTotalEntry.md
docs/InsightTransferEntry.md
docs/InterestPeriodProperty.md
docs/InternalExceptionResponse.md
docs/LiabilityDirectionProperty.md
docs/LiabilityTypeProperty.md
docs/LinkType.md
docs/LinkTypeArray.md
docs/LinkTypeRead.md
docs/LinkTypeSingle.md
docs/LinkTypeUpdate.md
docs/LinksApi.md
docs/Meta.md
docs/MetaPagination.md
docs/NotFoundResponse.md
docs/ObjectGroup.md
docs/ObjectGroupArray.md
docs/ObjectGroupRead.md
docs/ObjectGroupSingle.md
docs/ObjectGroupUpdate.md
docs/ObjectGroupsApi.md
docs/ObjectLink.md
docs/ObjectLink0.md
docs/PageLink.md
docs/PiggyBank.md
docs/PiggyBankArray.md
docs/PiggyBankEvent.md
docs/PiggyBankEventArray.md
docs/PiggyBankEventRead.md
docs/PiggyBankRead.md
docs/PiggyBankSingle.md
docs/PiggyBankStore.md
docs/PiggyBankUpdate.md
docs/PiggyBanksApi.md
docs/PolymorphicProperty.md
docs/Preference.md
docs/PreferenceArray.md
docs/PreferenceRead.md
docs/PreferenceSingle.md
docs/PreferenceUpdate.md
docs/PreferencesApi.md
docs/Recurrence.md
docs/RecurrenceArray.md
docs/RecurrenceRead.md
docs/RecurrenceRepetition.md
docs/RecurrenceRepetitionStore.md
docs/RecurrenceRepetitionType.md
docs/RecurrenceRepetitionUpdate.md
docs/RecurrenceSingle.md
docs/RecurrenceStore.md
docs/RecurrenceTransaction.md
docs/RecurrenceTransactionStore.md
docs/RecurrenceTransactionType.md
docs/RecurrenceTransactionUpdate.md
docs/RecurrenceUpdate.md
docs/RecurrencesApi.md
docs/Rule.md
docs/RuleAction.md
docs/RuleActionKeyword.md
docs/RuleActionStore.md
docs/RuleActionUpdate.md
docs/RuleArray.md
docs/RuleGroup.md
docs/RuleGroupArray.md
docs/RuleGroupRead.md
docs/RuleGroupSingle.md
docs/RuleGroupStore.md
docs/RuleGroupUpdate.md
docs/RuleGroupsApi.md
docs/RuleRead.md
docs/RuleSingle.md
docs/RuleStore.md
docs/RuleTrigger.md
docs/RuleTriggerKeyword.md
docs/RuleTriggerStore.md
docs/RuleTriggerType.md
docs/RuleTriggerUpdate.md
docs/RuleUpdate.md
docs/RulesApi.md
docs/SearchApi.md
docs/ShortAccountTypeProperty.md
docs/SummaryApi.md
docs/SystemInfo.md
docs/SystemInfoData.md
docs/TagArray.md
docs/TagModel.md
docs/TagModelStore.md
docs/TagModelUpdate.md
docs/TagRead.md
docs/TagSingle.md
docs/TagsApi.md
docs/Transaction.md
docs/TransactionArray.md
docs/TransactionLink.md
docs/TransactionLinkArray.md
docs/TransactionLinkRead.md
docs/TransactionLinkSingle.md
docs/TransactionLinkStore.md
docs/TransactionLinkUpdate.md
docs/TransactionRead.md
docs/TransactionSingle.md
docs/TransactionSplit.md
docs/TransactionSplitStore.md
docs/TransactionSplitUpdate.md
docs/TransactionStore.md
docs/TransactionTypeFilter.md
docs/TransactionTypeProperty.md
docs/TransactionUpdate.md
docs/TransactionsApi.md
docs/UnauthenticatedResponse.md
docs/User.md
docs/UserArray.md
docs/UserBlockedCodeProperty.md
docs/UserRead.md
docs/UserRoleProperty.md
docs/UserSingle.md
docs/UsersApi.md
docs/ValidationErrorResponse.md
docs/ValidationErrorResponseErrors.md
docs/Webhook.md
docs/WebhookArray.md
docs/WebhookAttempt.md
docs/WebhookAttemptArray.md
docs/WebhookAttemptRead.md
docs/WebhookAttemptSingle.md
docs/WebhookDelivery.md
docs/WebhookMessage.md
docs/WebhookMessageArray.md
docs/WebhookMessageRead.md
docs/WebhookMessageSingle.md
docs/WebhookRead.md
docs/WebhookResponse.md
docs/WebhookSingle.md
docs/WebhookStore.md
docs/WebhookTrigger.md
docs/WebhookUpdate.md
docs/WebhooksApi.md
git_push.sh
src/apis/about_api.rs
src/apis/accounts_api.rs
src/apis/attachments_api.rs
src/apis/autocomplete_api.rs
src/apis/available_budgets_api.rs
src/apis/bills_api.rs
src/apis/budgets_api.rs
src/apis/categories_api.rs
src/apis/charts_api.rs
src/apis/configuration.rs
src/apis/configuration_api.rs
src/apis/currencies_api.rs
src/apis/data_api.rs
src/apis/insight_api.rs
src/apis/links_api.rs
src/apis/mod.rs
src/apis/object_groups_api.rs
src/apis/piggy_banks_api.rs
src/apis/preferences_api.rs
src/apis/recurrences_api.rs
src/apis/rule_groups_api.rs
src/apis/rules_api.rs
src/apis/search_api.rs
src/apis/summary_api.rs
src/apis/tags_api.rs
src/apis/transactions_api.rs
src/apis/users_api.rs
src/apis/webhooks_api.rs
src/lib.rs
src/models/account.rs
src/models/account_array.rs
src/models/account_read.rs
src/models/account_role_property.rs
src/models/account_search_field_filter.rs
src/models/account_single.rs
src/models/account_store.rs
src/models/account_type_filter.rs
src/models/account_type_property.rs
src/models/account_update.rs
src/models/attachable_type.rs
src/models/attachment.rs
src/models/attachment_array.rs
src/models/attachment_read.rs
src/models/attachment_single.rs
src/models/attachment_store.rs
src/models/attachment_update.rs
src/models/auto_budget_period.rs
src/models/auto_budget_type.rs
src/models/autocomplete_account.rs
src/models/autocomplete_bill.rs
src/models/autocomplete_budget.rs
src/models/autocomplete_category.rs
src/models/autocomplete_currency.rs
src/models/autocomplete_currency_code.rs
src/models/autocomplete_object_group.rs
src/models/autocomplete_piggy.rs
src/models/autocomplete_piggy_balance.rs
src/models/autocomplete_recurrence.rs
src/models/autocomplete_rule.rs
src/models/autocomplete_rule_group.rs
src/models/autocomplete_tag.rs
src/models/autocomplete_transaction.rs
src/models/autocomplete_transaction_id.rs
src/models/autocomplete_transaction_type.rs
src/models/available_budget.rs
src/models/available_budget_array.rs
src/models/available_budget_read.rs
src/models/available_budget_single.rs
src/models/bad_request_response.rs
src/models/basic_summary_entry.rs
src/models/bill.rs
src/models/bill_array.rs
src/models/bill_paid_dates_inner.rs
src/models/bill_read.rs
src/models/bill_repeat_frequency.rs
src/models/bill_single.rs
src/models/bill_store.rs
src/models/bill_update.rs
src/models/budget.rs
src/models/budget_array.rs
src/models/budget_limit.rs
src/models/budget_limit_array.rs
src/models/budget_limit_read.rs
src/models/budget_limit_single.rs
src/models/budget_limit_store.rs
src/models/budget_read.rs
src/models/budget_single.rs
src/models/budget_spent.rs
src/models/budget_store.rs
src/models/budget_update.rs
src/models/category.rs
src/models/category_array.rs
src/models/category_earned.rs
src/models/category_read.rs
src/models/category_single.rs
src/models/category_spent.rs
src/models/category_update.rs
src/models/chart_data_point.rs
src/models/chart_data_set.rs
src/models/config_value_filter.rs
src/models/config_value_update_filter.rs
src/models/configuration.rs
src/models/configuration_single.rs
src/models/configuration_update.rs
src/models/credit_card_type_property.rs
src/models/cron_result.rs
src/models/cron_result_row.rs
src/models/currency.rs
src/models/currency_array.rs
src/models/currency_read.rs
src/models/currency_single.rs
src/models/currency_store.rs
src/models/currency_update.rs
src/models/data_destroy_object.rs
src/models/export_file_filter.rs
src/models/insight_group_entry.rs
src/models/insight_total_entry.rs
src/models/insight_transfer_entry.rs
src/models/interest_period_property.rs
src/models/internal_exception_response.rs
src/models/liability_direction_property.rs
src/models/liability_type_property.rs
src/models/link_type.rs
src/models/link_type_array.rs
src/models/link_type_read.rs
src/models/link_type_single.rs
src/models/link_type_update.rs
src/models/meta.rs
src/models/meta_pagination.rs
src/models/mod.rs
src/models/not_found_response.rs
src/models/object_group.rs
src/models/object_group_array.rs
src/models/object_group_read.rs
src/models/object_group_single.rs
src/models/object_group_update.rs
src/models/object_link.rs
src/models/object_link_0.rs
src/models/page_link.rs
src/models/piggy_bank.rs
src/models/piggy_bank_array.rs
src/models/piggy_bank_event.rs
src/models/piggy_bank_event_array.rs
src/models/piggy_bank_event_read.rs
src/models/piggy_bank_read.rs
src/models/piggy_bank_single.rs
src/models/piggy_bank_store.rs
src/models/piggy_bank_update.rs
src/models/polymorphic_property.rs
src/models/preference.rs
src/models/preference_array.rs
src/models/preference_read.rs
src/models/preference_single.rs
src/models/preference_update.rs
src/models/recurrence.rs
src/models/recurrence_array.rs
src/models/recurrence_read.rs
src/models/recurrence_repetition.rs
src/models/recurrence_repetition_store.rs
src/models/recurrence_repetition_type.rs
src/models/recurrence_repetition_update.rs
src/models/recurrence_single.rs
src/models/recurrence_store.rs
src/models/recurrence_transaction.rs
src/models/recurrence_transaction_store.rs
src/models/recurrence_transaction_type.rs
src/models/recurrence_transaction_update.rs
src/models/recurrence_update.rs
src/models/rule.rs
src/models/rule_action.rs
src/models/rule_action_keyword.rs
src/models/rule_action_store.rs
src/models/rule_action_update.rs
src/models/rule_array.rs
src/models/rule_group.rs
src/models/rule_group_array.rs
src/models/rule_group_read.rs
src/models/rule_group_single.rs
src/models/rule_group_store.rs
src/models/rule_group_update.rs
src/models/rule_read.rs
src/models/rule_single.rs
src/models/rule_store.rs
src/models/rule_trigger.rs
src/models/rule_trigger_keyword.rs
src/models/rule_trigger_store.rs
src/models/rule_trigger_type.rs
src/models/rule_trigger_update.rs
src/models/rule_update.rs
src/models/short_account_type_property.rs
src/models/system_info.rs
src/models/system_info_data.rs
src/models/tag_array.rs
src/models/tag_model.rs
src/models/tag_model_store.rs
src/models/tag_model_update.rs
src/models/tag_read.rs
src/models/tag_single.rs
src/models/transaction.rs
src/models/transaction_array.rs
src/models/transaction_link.rs
src/models/transaction_link_array.rs
src/models/transaction_link_read.rs
src/models/transaction_link_single.rs
src/models/transaction_link_store.rs
src/models/transaction_link_update.rs
src/models/transaction_read.rs
src/models/transaction_single.rs
src/models/transaction_split.rs
src/models/transaction_split_store.rs
src/models/transaction_split_update.rs
src/models/transaction_store.rs
src/models/transaction_type_filter.rs
src/models/transaction_type_property.rs
src/models/transaction_update.rs
src/models/unauthenticated_response.rs
src/models/user.rs
src/models/user_array.rs
src/models/user_blocked_code_property.rs
src/models/user_read.rs
src/models/user_role_property.rs
src/models/user_single.rs
src/models/validation_error_response.rs
src/models/validation_error_response_errors.rs
src/models/webhook.rs
src/models/webhook_array.rs
src/models/webhook_attempt.rs
src/models/webhook_attempt_array.rs
src/models/webhook_attempt_read.rs
src/models/webhook_attempt_single.rs
src/models/webhook_delivery.rs
src/models/webhook_message.rs
src/models/webhook_message_array.rs
src/models/webhook_message_read.rs
src/models/webhook_message_single.rs
src/models/webhook_read.rs
src/models/webhook_response.rs
src/models/webhook_single.rs
src/models/webhook_store.rs
src/models/webhook_trigger.rs
src/models/webhook_update.rs

View File

@@ -1 +0,0 @@
7.9.0-SNAPSHOT

View File

@@ -1 +0,0 @@
language: rust

View File

@@ -1,16 +0,0 @@
[package]
name = "firefly-iii-api"
version = "2.1.0"
authors = ["james@firefly-iii.org"]
description = "This is the documentation of the Firefly III API. You can find accompanying documentation on the website of Firefly III itself (see below). Please report any bugs or issues. You may use the \"Authorize\" button to try the API below. This file was last generated on 2024-09-10T05:07:57+00:00 Please keep in mind that the demo site does not accept requests from curl, colly, wget, etc. You must use a browser or a tool like Postman to make requests. Too many script kiddies out there, sorry about that. "
license = "AGPLv3"
edition = "2021"
[dependencies]
serde = { version = "^1.0", features = ["derive"] }
serde_with = { version = "^3.8", default-features = false, features = ["base64", "std", "macros"] }
serde_json = "^1.0"
serde_repr = "^0.1"
url = "^2.5"
uuid = { version = "^1.8", features = ["serde", "v4"] }
reqwest = { version = "^0.12", features = ["json", "multipart"] }

View File

@@ -1,467 +0,0 @@
# Rust API client for openapi
This is the documentation of the Firefly III API. You can find accompanying documentation on the website of Firefly III itself (see below). Please report any bugs or issues. You may use the \"Authorize\" button to try the API below. This file was last generated on 2024-09-10T05:07:57+00:00
Please keep in mind that the demo site does not accept requests from curl, colly, wget, etc. You must use a browser or a tool like Postman to make requests. Too many script kiddies out there, sorry about that.
For more information, please visit [https://firefly-iii.org](https://firefly-iii.org)
## Overview
This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://openapis.org) from a remote server, you can easily generate an API client.
- API version: 2.1.0
- Package version: 2.1.0
- Generator version: 7.9.0-SNAPSHOT
- Build package: `org.openapitools.codegen.languages.RustClientCodegen`
## Installation
Put the package under your project folder in a directory named `openapi` and add the following to `Cargo.toml` under `[dependencies]`:
```
openapi = { path = "./openapi" }
```
## Documentation for API Endpoints
All URIs are relative to *https://demo.firefly-iii.org/api*
Class | Method | HTTP request | Description
------------ | ------------- | ------------- | -------------
*AboutApi* | [**get_about**](docs/AboutApi.md#get_about) | **GET** /v1/about | System information end point.
*AboutApi* | [**get_cron**](docs/AboutApi.md#get_cron) | **GET** /v1/cron/{cliToken} | Cron job endpoint
*AboutApi* | [**get_current_user**](docs/AboutApi.md#get_current_user) | **GET** /v1/about/user | Currently authenticated user endpoint.
*AccountsApi* | [**delete_account**](docs/AccountsApi.md#delete_account) | **DELETE** /v1/accounts/{id} | Permanently delete account.
*AccountsApi* | [**get_account**](docs/AccountsApi.md#get_account) | **GET** /v1/accounts/{id} | Get single account.
*AccountsApi* | [**list_account**](docs/AccountsApi.md#list_account) | **GET** /v1/accounts | List all accounts.
*AccountsApi* | [**list_attachment_by_account**](docs/AccountsApi.md#list_attachment_by_account) | **GET** /v1/accounts/{id}/attachments | Lists all attachments.
*AccountsApi* | [**list_piggy_bank_by_account**](docs/AccountsApi.md#list_piggy_bank_by_account) | **GET** /v1/accounts/{id}/piggy-banks | List all piggy banks related to the account.
*AccountsApi* | [**list_transaction_by_account**](docs/AccountsApi.md#list_transaction_by_account) | **GET** /v1/accounts/{id}/transactions | List all transactions related to the account.
*AccountsApi* | [**store_account**](docs/AccountsApi.md#store_account) | **POST** /v1/accounts | Create new account.
*AccountsApi* | [**update_account**](docs/AccountsApi.md#update_account) | **PUT** /v1/accounts/{id} | Update existing account.
*AttachmentsApi* | [**delete_attachment**](docs/AttachmentsApi.md#delete_attachment) | **DELETE** /v1/attachments/{id} | Delete an attachment.
*AttachmentsApi* | [**download_attachment**](docs/AttachmentsApi.md#download_attachment) | **GET** /v1/attachments/{id}/download | Download a single attachment.
*AttachmentsApi* | [**get_attachment**](docs/AttachmentsApi.md#get_attachment) | **GET** /v1/attachments/{id} | Get a single attachment.
*AttachmentsApi* | [**list_attachment**](docs/AttachmentsApi.md#list_attachment) | **GET** /v1/attachments | List all attachments.
*AttachmentsApi* | [**store_attachment**](docs/AttachmentsApi.md#store_attachment) | **POST** /v1/attachments | Store a new attachment.
*AttachmentsApi* | [**update_attachment**](docs/AttachmentsApi.md#update_attachment) | **PUT** /v1/attachments/{id} | Update existing attachment.
*AttachmentsApi* | [**upload_attachment**](docs/AttachmentsApi.md#upload_attachment) | **POST** /v1/attachments/{id}/upload | Upload an attachment.
*AutocompleteApi* | [**get_accounts_ac**](docs/AutocompleteApi.md#get_accounts_ac) | **GET** /v1/autocomplete/accounts | Returns all accounts of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_bills_ac**](docs/AutocompleteApi.md#get_bills_ac) | **GET** /v1/autocomplete/bills | Returns all bills of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_budgets_ac**](docs/AutocompleteApi.md#get_budgets_ac) | **GET** /v1/autocomplete/budgets | Returns all budgets of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_categories_ac**](docs/AutocompleteApi.md#get_categories_ac) | **GET** /v1/autocomplete/categories | Returns all categories of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_currencies_ac**](docs/AutocompleteApi.md#get_currencies_ac) | **GET** /v1/autocomplete/currencies | Returns all currencies of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_currencies_code_ac**](docs/AutocompleteApi.md#get_currencies_code_ac) | **GET** /v1/autocomplete/currencies-with-code | Returns all currencies of the user returned in a basic auto-complete array. This endpoint is DEPRECATED and I suggest you DO NOT use it.
*AutocompleteApi* | [**get_object_groups_ac**](docs/AutocompleteApi.md#get_object_groups_ac) | **GET** /v1/autocomplete/object-groups | Returns all object groups of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_piggies_ac**](docs/AutocompleteApi.md#get_piggies_ac) | **GET** /v1/autocomplete/piggy-banks | Returns all piggy banks of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_piggies_balance_ac**](docs/AutocompleteApi.md#get_piggies_balance_ac) | **GET** /v1/autocomplete/piggy-banks-with-balance | Returns all piggy banks of the user returned in a basic auto-complete array complemented with balance information.
*AutocompleteApi* | [**get_recurring_ac**](docs/AutocompleteApi.md#get_recurring_ac) | **GET** /v1/autocomplete/recurring | Returns all recurring transactions of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_rule_groups_ac**](docs/AutocompleteApi.md#get_rule_groups_ac) | **GET** /v1/autocomplete/rule-groups | Returns all rule groups of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_rules_ac**](docs/AutocompleteApi.md#get_rules_ac) | **GET** /v1/autocomplete/rules | Returns all rules of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_tag_ac**](docs/AutocompleteApi.md#get_tag_ac) | **GET** /v1/autocomplete/tags | Returns all tags of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_transaction_types_ac**](docs/AutocompleteApi.md#get_transaction_types_ac) | **GET** /v1/autocomplete/transaction-types | Returns all transaction types returned in a basic auto-complete array. English only.
*AutocompleteApi* | [**get_transactions_ac**](docs/AutocompleteApi.md#get_transactions_ac) | **GET** /v1/autocomplete/transactions | Returns all transaction descriptions of the user returned in a basic auto-complete array.
*AutocompleteApi* | [**get_transactions_idac**](docs/AutocompleteApi.md#get_transactions_idac) | **GET** /v1/autocomplete/transactions-with-id | Returns all transactions, complemented with their ID, of the user returned in a basic auto-complete array. This endpoint is DEPRECATED and I suggest you DO NOT use it.
*AvailableBudgetsApi* | [**get_available_budget**](docs/AvailableBudgetsApi.md#get_available_budget) | **GET** /v1/available-budgets/{id} | Get a single available budget.
*AvailableBudgetsApi* | [**list_available_budget**](docs/AvailableBudgetsApi.md#list_available_budget) | **GET** /v1/available-budgets | List all available budget amounts.
*BillsApi* | [**delete_bill**](docs/BillsApi.md#delete_bill) | **DELETE** /v1/bills/{id} | Delete a bill.
*BillsApi* | [**get_bill**](docs/BillsApi.md#get_bill) | **GET** /v1/bills/{id} | Get a single bill.
*BillsApi* | [**list_attachment_by_bill**](docs/BillsApi.md#list_attachment_by_bill) | **GET** /v1/bills/{id}/attachments | List all attachments uploaded to the bill.
*BillsApi* | [**list_bill**](docs/BillsApi.md#list_bill) | **GET** /v1/bills | List all bills.
*BillsApi* | [**list_rule_by_bill**](docs/BillsApi.md#list_rule_by_bill) | **GET** /v1/bills/{id}/rules | List all rules associated with the bill.
*BillsApi* | [**list_transaction_by_bill**](docs/BillsApi.md#list_transaction_by_bill) | **GET** /v1/bills/{id}/transactions | List all transactions associated with the bill.
*BillsApi* | [**store_bill**](docs/BillsApi.md#store_bill) | **POST** /v1/bills | Store a new bill
*BillsApi* | [**update_bill**](docs/BillsApi.md#update_bill) | **PUT** /v1/bills/{id} | Update existing bill.
*BudgetsApi* | [**delete_budget**](docs/BudgetsApi.md#delete_budget) | **DELETE** /v1/budgets/{id} | Delete a budget.
*BudgetsApi* | [**delete_budget_limit**](docs/BudgetsApi.md#delete_budget_limit) | **DELETE** /v1/budgets/{id}/limits/{limitId} | Delete a budget limit.
*BudgetsApi* | [**get_budget**](docs/BudgetsApi.md#get_budget) | **GET** /v1/budgets/{id} | Get a single budget.
*BudgetsApi* | [**get_budget_limit**](docs/BudgetsApi.md#get_budget_limit) | **GET** /v1/budgets/{id}/limits/{limitId} | Get single budget limit.
*BudgetsApi* | [**list_attachment_by_budget**](docs/BudgetsApi.md#list_attachment_by_budget) | **GET** /v1/budgets/{id}/attachments | Lists all attachments of a budget.
*BudgetsApi* | [**list_budget**](docs/BudgetsApi.md#list_budget) | **GET** /v1/budgets | List all budgets.
*BudgetsApi* | [**list_budget_limit**](docs/BudgetsApi.md#list_budget_limit) | **GET** /v1/budget-limits | Get list of budget limits by date
*BudgetsApi* | [**list_budget_limit_by_budget**](docs/BudgetsApi.md#list_budget_limit_by_budget) | **GET** /v1/budgets/{id}/limits | Get all limits for a budget.
*BudgetsApi* | [**list_transaction_by_budget**](docs/BudgetsApi.md#list_transaction_by_budget) | **GET** /v1/budgets/{id}/transactions | All transactions to a budget.
*BudgetsApi* | [**list_transaction_by_budget_limit**](docs/BudgetsApi.md#list_transaction_by_budget_limit) | **GET** /v1/budgets/{id}/limits/{limitId}/transactions | List all transactions by a budget limit ID.
*BudgetsApi* | [**store_budget**](docs/BudgetsApi.md#store_budget) | **POST** /v1/budgets | Store a new budget
*BudgetsApi* | [**store_budget_limit**](docs/BudgetsApi.md#store_budget_limit) | **POST** /v1/budgets/{id}/limits | Store new budget limit.
*BudgetsApi* | [**update_budget**](docs/BudgetsApi.md#update_budget) | **PUT** /v1/budgets/{id} | Update existing budget.
*BudgetsApi* | [**update_budget_limit**](docs/BudgetsApi.md#update_budget_limit) | **PUT** /v1/budgets/{id}/limits/{limitId} | Update existing budget limit.
*CategoriesApi* | [**delete_category**](docs/CategoriesApi.md#delete_category) | **DELETE** /v1/categories/{id} | Delete a category.
*CategoriesApi* | [**get_category**](docs/CategoriesApi.md#get_category) | **GET** /v1/categories/{id} | Get a single category.
*CategoriesApi* | [**list_attachment_by_category**](docs/CategoriesApi.md#list_attachment_by_category) | **GET** /v1/categories/{id}/attachments | Lists all attachments.
*CategoriesApi* | [**list_category**](docs/CategoriesApi.md#list_category) | **GET** /v1/categories | List all categories.
*CategoriesApi* | [**list_transaction_by_category**](docs/CategoriesApi.md#list_transaction_by_category) | **GET** /v1/categories/{id}/transactions | List all transactions in a category.
*CategoriesApi* | [**store_category**](docs/CategoriesApi.md#store_category) | **POST** /v1/categories | Store a new category
*CategoriesApi* | [**update_category**](docs/CategoriesApi.md#update_category) | **PUT** /v1/categories/{id} | Update existing category.
*ChartsApi* | [**get_chart_account_overview**](docs/ChartsApi.md#get_chart_account_overview) | **GET** /v1/chart/account/overview | Dashboard chart with asset account balance information.
*ConfigurationApi* | [**get_configuration**](docs/ConfigurationApi.md#get_configuration) | **GET** /v1/configuration | Get Firefly III system configuration values.
*ConfigurationApi* | [**get_single_configuration**](docs/ConfigurationApi.md#get_single_configuration) | **GET** /v1/configuration/{name} | Get a single Firefly III system configuration value
*ConfigurationApi* | [**set_configuration**](docs/ConfigurationApi.md#set_configuration) | **PUT** /v1/configuration/{name} | Update configuration value
*CurrenciesApi* | [**default_currency**](docs/CurrenciesApi.md#default_currency) | **POST** /v1/currencies/{code}/default | Make currency default currency.
*CurrenciesApi* | [**delete_currency**](docs/CurrenciesApi.md#delete_currency) | **DELETE** /v1/currencies/{code} | Delete a currency.
*CurrenciesApi* | [**disable_currency**](docs/CurrenciesApi.md#disable_currency) | **POST** /v1/currencies/{code}/disable | Disable a currency.
*CurrenciesApi* | [**enable_currency**](docs/CurrenciesApi.md#enable_currency) | **POST** /v1/currencies/{code}/enable | Enable a single currency.
*CurrenciesApi* | [**get_currency**](docs/CurrenciesApi.md#get_currency) | **GET** /v1/currencies/{code} | Get a single currency.
*CurrenciesApi* | [**get_default_currency**](docs/CurrenciesApi.md#get_default_currency) | **GET** /v1/currencies/default | Get the user's default currency.
*CurrenciesApi* | [**list_account_by_currency**](docs/CurrenciesApi.md#list_account_by_currency) | **GET** /v1/currencies/{code}/accounts | List all accounts with this currency.
*CurrenciesApi* | [**list_available_budget_by_currency**](docs/CurrenciesApi.md#list_available_budget_by_currency) | **GET** /v1/currencies/{code}/available-budgets | List all available budgets with this currency.
*CurrenciesApi* | [**list_bill_by_currency**](docs/CurrenciesApi.md#list_bill_by_currency) | **GET** /v1/currencies/{code}/bills | List all bills with this currency.
*CurrenciesApi* | [**list_budget_limit_by_currency**](docs/CurrenciesApi.md#list_budget_limit_by_currency) | **GET** /v1/currencies/{code}/budget_limits | List all budget limits with this currency
*CurrenciesApi* | [**list_currency**](docs/CurrenciesApi.md#list_currency) | **GET** /v1/currencies | List all currencies.
*CurrenciesApi* | [**list_recurrence_by_currency**](docs/CurrenciesApi.md#list_recurrence_by_currency) | **GET** /v1/currencies/{code}/recurrences | List all recurring transactions with this currency.
*CurrenciesApi* | [**list_rule_by_currency**](docs/CurrenciesApi.md#list_rule_by_currency) | **GET** /v1/currencies/{code}/rules | List all rules with this currency.
*CurrenciesApi* | [**list_transaction_by_currency**](docs/CurrenciesApi.md#list_transaction_by_currency) | **GET** /v1/currencies/{code}/transactions | List all transactions with this currency.
*CurrenciesApi* | [**store_currency**](docs/CurrenciesApi.md#store_currency) | **POST** /v1/currencies | Store a new currency
*CurrenciesApi* | [**update_currency**](docs/CurrenciesApi.md#update_currency) | **PUT** /v1/currencies/{code} | Update existing currency.
*DataApi* | [**bulk_update_transactions**](docs/DataApi.md#bulk_update_transactions) | **POST** /v1/data/bulk/transactions | Bulk update transaction properties. For more information, see https://docs.firefly-iii.org/references/firefly-iii/api/specials/
*DataApi* | [**destroy_data**](docs/DataApi.md#destroy_data) | **DELETE** /v1/data/destroy | Endpoint to destroy user data
*DataApi* | [**export_accounts**](docs/DataApi.md#export_accounts) | **GET** /v1/data/export/accounts | Export account data from Firefly III
*DataApi* | [**export_bills**](docs/DataApi.md#export_bills) | **GET** /v1/data/export/bills | Export bills from Firefly III
*DataApi* | [**export_budgets**](docs/DataApi.md#export_budgets) | **GET** /v1/data/export/budgets | Export budgets and budget amount data from Firefly III
*DataApi* | [**export_categories**](docs/DataApi.md#export_categories) | **GET** /v1/data/export/categories | Export category data from Firefly III
*DataApi* | [**export_piggies**](docs/DataApi.md#export_piggies) | **GET** /v1/data/export/piggy-banks | Export piggy banks from Firefly III
*DataApi* | [**export_recurring**](docs/DataApi.md#export_recurring) | **GET** /v1/data/export/recurring | Export recurring transaction data from Firefly III
*DataApi* | [**export_rules**](docs/DataApi.md#export_rules) | **GET** /v1/data/export/rules | Export rule groups and rule data from Firefly III
*DataApi* | [**export_tags**](docs/DataApi.md#export_tags) | **GET** /v1/data/export/tags | Export tag data from Firefly III
*DataApi* | [**export_transactions**](docs/DataApi.md#export_transactions) | **GET** /v1/data/export/transactions | Export transaction data from Firefly III
*DataApi* | [**purge_data**](docs/DataApi.md#purge_data) | **DELETE** /v1/data/purge | Endpoint to purge user data
*InsightApi* | [**insight_expense_asset**](docs/InsightApi.md#insight_expense_asset) | **GET** /v1/insight/expense/asset | Insight into expenses, grouped by asset account.
*InsightApi* | [**insight_expense_bill**](docs/InsightApi.md#insight_expense_bill) | **GET** /v1/insight/expense/bill | Insight into expenses, grouped by bill.
*InsightApi* | [**insight_expense_budget**](docs/InsightApi.md#insight_expense_budget) | **GET** /v1/insight/expense/budget | Insight into expenses, grouped by budget.
*InsightApi* | [**insight_expense_category**](docs/InsightApi.md#insight_expense_category) | **GET** /v1/insight/expense/category | Insight into expenses, grouped by category.
*InsightApi* | [**insight_expense_expense**](docs/InsightApi.md#insight_expense_expense) | **GET** /v1/insight/expense/expense | Insight into expenses, grouped by expense account.
*InsightApi* | [**insight_expense_no_bill**](docs/InsightApi.md#insight_expense_no_bill) | **GET** /v1/insight/expense/no-bill | Insight into expenses, without bill.
*InsightApi* | [**insight_expense_no_budget**](docs/InsightApi.md#insight_expense_no_budget) | **GET** /v1/insight/expense/no-budget | Insight into expenses, without budget.
*InsightApi* | [**insight_expense_no_category**](docs/InsightApi.md#insight_expense_no_category) | **GET** /v1/insight/expense/no-category | Insight into expenses, without category.
*InsightApi* | [**insight_expense_no_tag**](docs/InsightApi.md#insight_expense_no_tag) | **GET** /v1/insight/expense/no-tag | Insight into expenses, without tag.
*InsightApi* | [**insight_expense_tag**](docs/InsightApi.md#insight_expense_tag) | **GET** /v1/insight/expense/tag | Insight into expenses, grouped by tag.
*InsightApi* | [**insight_expense_total**](docs/InsightApi.md#insight_expense_total) | **GET** /v1/insight/expense/total | Insight into total expenses.
*InsightApi* | [**insight_income_asset**](docs/InsightApi.md#insight_income_asset) | **GET** /v1/insight/income/asset | Insight into income, grouped by asset account.
*InsightApi* | [**insight_income_category**](docs/InsightApi.md#insight_income_category) | **GET** /v1/insight/income/category | Insight into income, grouped by category.
*InsightApi* | [**insight_income_no_category**](docs/InsightApi.md#insight_income_no_category) | **GET** /v1/insight/income/no-category | Insight into income, without category.
*InsightApi* | [**insight_income_no_tag**](docs/InsightApi.md#insight_income_no_tag) | **GET** /v1/insight/income/no-tag | Insight into income, without tag.
*InsightApi* | [**insight_income_revenue**](docs/InsightApi.md#insight_income_revenue) | **GET** /v1/insight/income/revenue | Insight into income, grouped by revenue account.
*InsightApi* | [**insight_income_tag**](docs/InsightApi.md#insight_income_tag) | **GET** /v1/insight/income/tag | Insight into income, grouped by tag.
*InsightApi* | [**insight_income_total**](docs/InsightApi.md#insight_income_total) | **GET** /v1/insight/income/total | Insight into total income.
*InsightApi* | [**insight_transfer_category**](docs/InsightApi.md#insight_transfer_category) | **GET** /v1/insight/transfer/category | Insight into transfers, grouped by category.
*InsightApi* | [**insight_transfer_no_category**](docs/InsightApi.md#insight_transfer_no_category) | **GET** /v1/insight/transfer/no-category | Insight into transfers, without category.
*InsightApi* | [**insight_transfer_no_tag**](docs/InsightApi.md#insight_transfer_no_tag) | **GET** /v1/insight/transfer/no-tag | Insight into expenses, without tag.
*InsightApi* | [**insight_transfer_tag**](docs/InsightApi.md#insight_transfer_tag) | **GET** /v1/insight/transfer/tag | Insight into transfers, grouped by tag.
*InsightApi* | [**insight_transfer_total**](docs/InsightApi.md#insight_transfer_total) | **GET** /v1/insight/transfer/total | Insight into total transfers.
*InsightApi* | [**insight_transfers**](docs/InsightApi.md#insight_transfers) | **GET** /v1/insight/transfer/asset | Insight into transfers, grouped by account.
*LinksApi* | [**delete_link_type**](docs/LinksApi.md#delete_link_type) | **DELETE** /v1/link-types/{id} | Permanently delete link type.
*LinksApi* | [**delete_transaction_link**](docs/LinksApi.md#delete_transaction_link) | **DELETE** /v1/transaction-links/{id} | Permanently delete link between transactions.
*LinksApi* | [**get_link_type**](docs/LinksApi.md#get_link_type) | **GET** /v1/link-types/{id} | Get single a link type.
*LinksApi* | [**get_transaction_link**](docs/LinksApi.md#get_transaction_link) | **GET** /v1/transaction-links/{id} | Get a single link.
*LinksApi* | [**list_link_type**](docs/LinksApi.md#list_link_type) | **GET** /v1/link-types | List all types of links.
*LinksApi* | [**list_transaction_by_link_type**](docs/LinksApi.md#list_transaction_by_link_type) | **GET** /v1/link-types/{id}/transactions | List all transactions under this link type.
*LinksApi* | [**list_transaction_link**](docs/LinksApi.md#list_transaction_link) | **GET** /v1/transaction-links | List all transaction links.
*LinksApi* | [**store_link_type**](docs/LinksApi.md#store_link_type) | **POST** /v1/link-types | Create a new link type
*LinksApi* | [**store_transaction_link**](docs/LinksApi.md#store_transaction_link) | **POST** /v1/transaction-links | Create a new link between transactions
*LinksApi* | [**update_link_type**](docs/LinksApi.md#update_link_type) | **PUT** /v1/link-types/{id} | Update existing link type.
*LinksApi* | [**update_transaction_link**](docs/LinksApi.md#update_transaction_link) | **PUT** /v1/transaction-links/{id} | Update an existing link between transactions.
*ObjectGroupsApi* | [**delete_object_group**](docs/ObjectGroupsApi.md#delete_object_group) | **DELETE** /v1/object-groups/{id} | Delete a object group.
*ObjectGroupsApi* | [**get_object_group**](docs/ObjectGroupsApi.md#get_object_group) | **GET** /v1/object-groups/{id} | Get a single object group.
*ObjectGroupsApi* | [**list_bill_by_object_group**](docs/ObjectGroupsApi.md#list_bill_by_object_group) | **GET** /v1/object-groups/{id}/bills | List all bills with this object group.
*ObjectGroupsApi* | [**list_object_groups**](docs/ObjectGroupsApi.md#list_object_groups) | **GET** /v1/object-groups | List all oject groups.
*ObjectGroupsApi* | [**list_piggy_bank_by_object_group**](docs/ObjectGroupsApi.md#list_piggy_bank_by_object_group) | **GET** /v1/object-groups/{id}/piggy-banks | List all piggy banks related to the object group.
*ObjectGroupsApi* | [**update_object_group**](docs/ObjectGroupsApi.md#update_object_group) | **PUT** /v1/object-groups/{id} | Update existing object group.
*PiggyBanksApi* | [**delete_piggy_bank**](docs/PiggyBanksApi.md#delete_piggy_bank) | **DELETE** /v1/piggy-banks/{id} | Delete a piggy bank.
*PiggyBanksApi* | [**get_piggy_bank**](docs/PiggyBanksApi.md#get_piggy_bank) | **GET** /v1/piggy-banks/{id} | Get a single piggy bank.
*PiggyBanksApi* | [**list_attachment_by_piggy_bank**](docs/PiggyBanksApi.md#list_attachment_by_piggy_bank) | **GET** /v1/piggy-banks/{id}/attachments | Lists all attachments.
*PiggyBanksApi* | [**list_event_by_piggy_bank**](docs/PiggyBanksApi.md#list_event_by_piggy_bank) | **GET** /v1/piggy-banks/{id}/events | List all events linked to a piggy bank.
*PiggyBanksApi* | [**list_piggy_bank**](docs/PiggyBanksApi.md#list_piggy_bank) | **GET** /v1/piggy-banks | List all piggy banks.
*PiggyBanksApi* | [**store_piggy_bank**](docs/PiggyBanksApi.md#store_piggy_bank) | **POST** /v1/piggy-banks | Store a new piggy bank
*PiggyBanksApi* | [**update_piggy_bank**](docs/PiggyBanksApi.md#update_piggy_bank) | **PUT** /v1/piggy-banks/{id} | Update existing piggy bank.
*PreferencesApi* | [**get_preference**](docs/PreferencesApi.md#get_preference) | **GET** /v1/preferences/{name} | Return a single preference.
*PreferencesApi* | [**list_preference**](docs/PreferencesApi.md#list_preference) | **GET** /v1/preferences | List all users preferences.
*PreferencesApi* | [**store_preference**](docs/PreferencesApi.md#store_preference) | **POST** /v1/preferences | Store a new preference for this user.
*PreferencesApi* | [**update_preference**](docs/PreferencesApi.md#update_preference) | **PUT** /v1/preferences/{name} | Update preference
*RecurrencesApi* | [**delete_recurrence**](docs/RecurrencesApi.md#delete_recurrence) | **DELETE** /v1/recurrences/{id} | Delete a recurring transaction.
*RecurrencesApi* | [**get_recurrence**](docs/RecurrencesApi.md#get_recurrence) | **GET** /v1/recurrences/{id} | Get a single recurring transaction.
*RecurrencesApi* | [**list_recurrence**](docs/RecurrencesApi.md#list_recurrence) | **GET** /v1/recurrences | List all recurring transactions.
*RecurrencesApi* | [**list_transaction_by_recurrence**](docs/RecurrencesApi.md#list_transaction_by_recurrence) | **GET** /v1/recurrences/{id}/transactions | List all transactions created by a recurring transaction.
*RecurrencesApi* | [**store_recurrence**](docs/RecurrencesApi.md#store_recurrence) | **POST** /v1/recurrences | Store a new recurring transaction
*RecurrencesApi* | [**update_recurrence**](docs/RecurrencesApi.md#update_recurrence) | **PUT** /v1/recurrences/{id} | Update existing recurring transaction.
*RuleGroupsApi* | [**delete_rule_group**](docs/RuleGroupsApi.md#delete_rule_group) | **DELETE** /v1/rule-groups/{id} | Delete a rule group.
*RuleGroupsApi* | [**fire_rule_group**](docs/RuleGroupsApi.md#fire_rule_group) | **POST** /v1/rule-groups/{id}/trigger | Fire the rule group on your transactions.
*RuleGroupsApi* | [**get_rule_group**](docs/RuleGroupsApi.md#get_rule_group) | **GET** /v1/rule-groups/{id} | Get a single rule group.
*RuleGroupsApi* | [**list_rule_by_group**](docs/RuleGroupsApi.md#list_rule_by_group) | **GET** /v1/rule-groups/{id}/rules | List rules in this rule group.
*RuleGroupsApi* | [**list_rule_group**](docs/RuleGroupsApi.md#list_rule_group) | **GET** /v1/rule-groups | List all rule groups.
*RuleGroupsApi* | [**store_rule_group**](docs/RuleGroupsApi.md#store_rule_group) | **POST** /v1/rule-groups | Store a new rule group.
*RuleGroupsApi* | [**test_rule_group**](docs/RuleGroupsApi.md#test_rule_group) | **GET** /v1/rule-groups/{id}/test | Test which transactions would be hit by the rule group. No changes will be made.
*RuleGroupsApi* | [**update_rule_group**](docs/RuleGroupsApi.md#update_rule_group) | **PUT** /v1/rule-groups/{id} | Update existing rule group.
*RulesApi* | [**delete_rule**](docs/RulesApi.md#delete_rule) | **DELETE** /v1/rules/{id} | Delete an rule.
*RulesApi* | [**fire_rule**](docs/RulesApi.md#fire_rule) | **POST** /v1/rules/{id}/trigger | Fire the rule on your transactions.
*RulesApi* | [**get_rule**](docs/RulesApi.md#get_rule) | **GET** /v1/rules/{id} | Get a single rule.
*RulesApi* | [**list_rule**](docs/RulesApi.md#list_rule) | **GET** /v1/rules | List all rules.
*RulesApi* | [**store_rule**](docs/RulesApi.md#store_rule) | **POST** /v1/rules | Store a new rule
*RulesApi* | [**test_rule**](docs/RulesApi.md#test_rule) | **GET** /v1/rules/{id}/test | Test which transactions would be hit by the rule. No changes will be made.
*RulesApi* | [**update_rule**](docs/RulesApi.md#update_rule) | **PUT** /v1/rules/{id} | Update existing rule.
*SearchApi* | [**search_accounts**](docs/SearchApi.md#search_accounts) | **GET** /v1/search/accounts | Search for accounts
*SearchApi* | [**search_transactions**](docs/SearchApi.md#search_transactions) | **GET** /v1/search/transactions | Search for transactions
*SummaryApi* | [**get_basic_summary**](docs/SummaryApi.md#get_basic_summary) | **GET** /v1/summary/basic | Returns basic sums of the users data.
*TagsApi* | [**delete_tag**](docs/TagsApi.md#delete_tag) | **DELETE** /v1/tags/{tag} | Delete an tag.
*TagsApi* | [**get_tag**](docs/TagsApi.md#get_tag) | **GET** /v1/tags/{tag} | Get a single tag.
*TagsApi* | [**list_attachment_by_tag**](docs/TagsApi.md#list_attachment_by_tag) | **GET** /v1/tags/{tag}/attachments | Lists all attachments.
*TagsApi* | [**list_tag**](docs/TagsApi.md#list_tag) | **GET** /v1/tags | List all tags.
*TagsApi* | [**list_transaction_by_tag**](docs/TagsApi.md#list_transaction_by_tag) | **GET** /v1/tags/{tag}/transactions | List all transactions with this tag.
*TagsApi* | [**store_tag**](docs/TagsApi.md#store_tag) | **POST** /v1/tags | Store a new tag
*TagsApi* | [**update_tag**](docs/TagsApi.md#update_tag) | **PUT** /v1/tags/{tag} | Update existing tag.
*TransactionsApi* | [**delete_transaction**](docs/TransactionsApi.md#delete_transaction) | **DELETE** /v1/transactions/{id} | Delete a transaction.
*TransactionsApi* | [**delete_transaction_journal**](docs/TransactionsApi.md#delete_transaction_journal) | **DELETE** /v1/transaction-journals/{id} | Delete split from transaction
*TransactionsApi* | [**get_transaction**](docs/TransactionsApi.md#get_transaction) | **GET** /v1/transactions/{id} | Get a single transaction.
*TransactionsApi* | [**get_transaction_by_journal**](docs/TransactionsApi.md#get_transaction_by_journal) | **GET** /v1/transaction-journals/{id} | Get a single transaction, based on one of the underlying transaction journals (transaction splits).
*TransactionsApi* | [**list_attachment_by_transaction**](docs/TransactionsApi.md#list_attachment_by_transaction) | **GET** /v1/transactions/{id}/attachments | Lists all attachments.
*TransactionsApi* | [**list_event_by_transaction**](docs/TransactionsApi.md#list_event_by_transaction) | **GET** /v1/transactions/{id}/piggy-bank-events | Lists all piggy bank events.
*TransactionsApi* | [**list_links_by_journal**](docs/TransactionsApi.md#list_links_by_journal) | **GET** /v1/transaction-journals/{id}/links | Lists all the transaction links for an individual journal (individual split).
*TransactionsApi* | [**list_transaction**](docs/TransactionsApi.md#list_transaction) | **GET** /v1/transactions | List all the user's transactions.
*TransactionsApi* | [**store_transaction**](docs/TransactionsApi.md#store_transaction) | **POST** /v1/transactions | Store a new transaction
*TransactionsApi* | [**update_transaction**](docs/TransactionsApi.md#update_transaction) | **PUT** /v1/transactions/{id} | Update existing transaction. For more information, see https://docs.firefly-iii.org/references/firefly-iii/api/specials/
*UsersApi* | [**delete_user**](docs/UsersApi.md#delete_user) | **DELETE** /v1/users/{id} | Delete a user.
*UsersApi* | [**get_user**](docs/UsersApi.md#get_user) | **GET** /v1/users/{id} | Get a single user.
*UsersApi* | [**list_user**](docs/UsersApi.md#list_user) | **GET** /v1/users | List all users.
*UsersApi* | [**store_user**](docs/UsersApi.md#store_user) | **POST** /v1/users | Store a new user
*UsersApi* | [**update_user**](docs/UsersApi.md#update_user) | **PUT** /v1/users/{id} | Update an existing user's information.
*WebhooksApi* | [**delete_webhook**](docs/WebhooksApi.md#delete_webhook) | **DELETE** /v1/webhooks/{id} | Delete a webhook.
*WebhooksApi* | [**delete_webhook_message**](docs/WebhooksApi.md#delete_webhook_message) | **DELETE** /v1/webhooks/{id}/messages/{messageId} | Delete a webhook message.
*WebhooksApi* | [**delete_webhook_message_attempt**](docs/WebhooksApi.md#delete_webhook_message_attempt) | **DELETE** /v1/webhooks/{id}/messages/{messageId}/attempts/{attemptId} | Delete a webhook attempt.
*WebhooksApi* | [**get_single_webhook_message**](docs/WebhooksApi.md#get_single_webhook_message) | **GET** /v1/webhooks/{id}/messages/{messageId} | Get a single message from a webhook.
*WebhooksApi* | [**get_single_webhook_message_attempt**](docs/WebhooksApi.md#get_single_webhook_message_attempt) | **GET** /v1/webhooks/{id}/messages/{messageId}/attempts/{attemptId} | Get a single failed attempt from a single webhook message.
*WebhooksApi* | [**get_webhook**](docs/WebhooksApi.md#get_webhook) | **GET** /v1/webhooks/{id} | Get a single webhook.
*WebhooksApi* | [**get_webhook_message_attempts**](docs/WebhooksApi.md#get_webhook_message_attempts) | **GET** /v1/webhooks/{id}/messages/{messageId}/attempts | Get all the failed attempts of a single webhook message.
*WebhooksApi* | [**get_webhook_messages**](docs/WebhooksApi.md#get_webhook_messages) | **GET** /v1/webhooks/{id}/messages | Get all the messages of a single webhook.
*WebhooksApi* | [**list_webhook**](docs/WebhooksApi.md#list_webhook) | **GET** /v1/webhooks | List all webhooks.
*WebhooksApi* | [**store_webhook**](docs/WebhooksApi.md#store_webhook) | **POST** /v1/webhooks | Store a new webhook
*WebhooksApi* | [**submit_webook**](docs/WebhooksApi.md#submit_webook) | **POST** /v1/webhooks/{id}/submit | Submit messages for a webhook.
*WebhooksApi* | [**trigger_transaction_webhook**](docs/WebhooksApi.md#trigger_transaction_webhook) | **POST** /v1/webhooks/{id}/trigger-transaction/{transactionId} | Trigger webhook for a given transaction.
*WebhooksApi* | [**update_webhook**](docs/WebhooksApi.md#update_webhook) | **PUT** /v1/webhooks/{id} | Update existing webhook.
## Documentation For Models
- [Account](docs/Account.md)
- [AccountArray](docs/AccountArray.md)
- [AccountRead](docs/AccountRead.md)
- [AccountRoleProperty](docs/AccountRoleProperty.md)
- [AccountSearchFieldFilter](docs/AccountSearchFieldFilter.md)
- [AccountSingle](docs/AccountSingle.md)
- [AccountStore](docs/AccountStore.md)
- [AccountTypeFilter](docs/AccountTypeFilter.md)
- [AccountTypeProperty](docs/AccountTypeProperty.md)
- [AccountUpdate](docs/AccountUpdate.md)
- [AttachableType](docs/AttachableType.md)
- [Attachment](docs/Attachment.md)
- [AttachmentArray](docs/AttachmentArray.md)
- [AttachmentRead](docs/AttachmentRead.md)
- [AttachmentSingle](docs/AttachmentSingle.md)
- [AttachmentStore](docs/AttachmentStore.md)
- [AttachmentUpdate](docs/AttachmentUpdate.md)
- [AutoBudgetPeriod](docs/AutoBudgetPeriod.md)
- [AutoBudgetType](docs/AutoBudgetType.md)
- [AutocompleteAccount](docs/AutocompleteAccount.md)
- [AutocompleteBill](docs/AutocompleteBill.md)
- [AutocompleteBudget](docs/AutocompleteBudget.md)
- [AutocompleteCategory](docs/AutocompleteCategory.md)
- [AutocompleteCurrency](docs/AutocompleteCurrency.md)
- [AutocompleteCurrencyCode](docs/AutocompleteCurrencyCode.md)
- [AutocompleteObjectGroup](docs/AutocompleteObjectGroup.md)
- [AutocompletePiggy](docs/AutocompletePiggy.md)
- [AutocompletePiggyBalance](docs/AutocompletePiggyBalance.md)
- [AutocompleteRecurrence](docs/AutocompleteRecurrence.md)
- [AutocompleteRule](docs/AutocompleteRule.md)
- [AutocompleteRuleGroup](docs/AutocompleteRuleGroup.md)
- [AutocompleteTag](docs/AutocompleteTag.md)
- [AutocompleteTransaction](docs/AutocompleteTransaction.md)
- [AutocompleteTransactionId](docs/AutocompleteTransactionId.md)
- [AutocompleteTransactionType](docs/AutocompleteTransactionType.md)
- [AvailableBudget](docs/AvailableBudget.md)
- [AvailableBudgetArray](docs/AvailableBudgetArray.md)
- [AvailableBudgetRead](docs/AvailableBudgetRead.md)
- [AvailableBudgetSingle](docs/AvailableBudgetSingle.md)
- [BadRequestResponse](docs/BadRequestResponse.md)
- [BasicSummaryEntry](docs/BasicSummaryEntry.md)
- [Bill](docs/Bill.md)
- [BillArray](docs/BillArray.md)
- [BillPaidDatesInner](docs/BillPaidDatesInner.md)
- [BillRead](docs/BillRead.md)
- [BillRepeatFrequency](docs/BillRepeatFrequency.md)
- [BillSingle](docs/BillSingle.md)
- [BillStore](docs/BillStore.md)
- [BillUpdate](docs/BillUpdate.md)
- [Budget](docs/Budget.md)
- [BudgetArray](docs/BudgetArray.md)
- [BudgetLimit](docs/BudgetLimit.md)
- [BudgetLimitArray](docs/BudgetLimitArray.md)
- [BudgetLimitRead](docs/BudgetLimitRead.md)
- [BudgetLimitSingle](docs/BudgetLimitSingle.md)
- [BudgetLimitStore](docs/BudgetLimitStore.md)
- [BudgetRead](docs/BudgetRead.md)
- [BudgetSingle](docs/BudgetSingle.md)
- [BudgetSpent](docs/BudgetSpent.md)
- [BudgetStore](docs/BudgetStore.md)
- [BudgetUpdate](docs/BudgetUpdate.md)
- [Category](docs/Category.md)
- [CategoryArray](docs/CategoryArray.md)
- [CategoryEarned](docs/CategoryEarned.md)
- [CategoryRead](docs/CategoryRead.md)
- [CategorySingle](docs/CategorySingle.md)
- [CategorySpent](docs/CategorySpent.md)
- [CategoryUpdate](docs/CategoryUpdate.md)
- [ChartDataPoint](docs/ChartDataPoint.md)
- [ChartDataSet](docs/ChartDataSet.md)
- [ConfigValueFilter](docs/ConfigValueFilter.md)
- [ConfigValueUpdateFilter](docs/ConfigValueUpdateFilter.md)
- [Configuration](docs/Configuration.md)
- [ConfigurationSingle](docs/ConfigurationSingle.md)
- [ConfigurationUpdate](docs/ConfigurationUpdate.md)
- [CreditCardTypeProperty](docs/CreditCardTypeProperty.md)
- [CronResult](docs/CronResult.md)
- [CronResultRow](docs/CronResultRow.md)
- [Currency](docs/Currency.md)
- [CurrencyArray](docs/CurrencyArray.md)
- [CurrencyRead](docs/CurrencyRead.md)
- [CurrencySingle](docs/CurrencySingle.md)
- [CurrencyStore](docs/CurrencyStore.md)
- [CurrencyUpdate](docs/CurrencyUpdate.md)
- [DataDestroyObject](docs/DataDestroyObject.md)
- [ExportFileFilter](docs/ExportFileFilter.md)
- [InsightGroupEntry](docs/InsightGroupEntry.md)
- [InsightTotalEntry](docs/InsightTotalEntry.md)
- [InsightTransferEntry](docs/InsightTransferEntry.md)
- [InterestPeriodProperty](docs/InterestPeriodProperty.md)
- [InternalExceptionResponse](docs/InternalExceptionResponse.md)
- [LiabilityDirectionProperty](docs/LiabilityDirectionProperty.md)
- [LiabilityTypeProperty](docs/LiabilityTypeProperty.md)
- [LinkType](docs/LinkType.md)
- [LinkTypeArray](docs/LinkTypeArray.md)
- [LinkTypeRead](docs/LinkTypeRead.md)
- [LinkTypeSingle](docs/LinkTypeSingle.md)
- [LinkTypeUpdate](docs/LinkTypeUpdate.md)
- [Meta](docs/Meta.md)
- [MetaPagination](docs/MetaPagination.md)
- [NotFoundResponse](docs/NotFoundResponse.md)
- [ObjectGroup](docs/ObjectGroup.md)
- [ObjectGroupArray](docs/ObjectGroupArray.md)
- [ObjectGroupRead](docs/ObjectGroupRead.md)
- [ObjectGroupSingle](docs/ObjectGroupSingle.md)
- [ObjectGroupUpdate](docs/ObjectGroupUpdate.md)
- [ObjectLink](docs/ObjectLink.md)
- [ObjectLink0](docs/ObjectLink0.md)
- [PageLink](docs/PageLink.md)
- [PiggyBank](docs/PiggyBank.md)
- [PiggyBankArray](docs/PiggyBankArray.md)
- [PiggyBankEvent](docs/PiggyBankEvent.md)
- [PiggyBankEventArray](docs/PiggyBankEventArray.md)
- [PiggyBankEventRead](docs/PiggyBankEventRead.md)
- [PiggyBankRead](docs/PiggyBankRead.md)
- [PiggyBankSingle](docs/PiggyBankSingle.md)
- [PiggyBankStore](docs/PiggyBankStore.md)
- [PiggyBankUpdate](docs/PiggyBankUpdate.md)
- [PolymorphicProperty](docs/PolymorphicProperty.md)
- [Preference](docs/Preference.md)
- [PreferenceArray](docs/PreferenceArray.md)
- [PreferenceRead](docs/PreferenceRead.md)
- [PreferenceSingle](docs/PreferenceSingle.md)
- [PreferenceUpdate](docs/PreferenceUpdate.md)
- [Recurrence](docs/Recurrence.md)
- [RecurrenceArray](docs/RecurrenceArray.md)
- [RecurrenceRead](docs/RecurrenceRead.md)
- [RecurrenceRepetition](docs/RecurrenceRepetition.md)
- [RecurrenceRepetitionStore](docs/RecurrenceRepetitionStore.md)
- [RecurrenceRepetitionType](docs/RecurrenceRepetitionType.md)
- [RecurrenceRepetitionUpdate](docs/RecurrenceRepetitionUpdate.md)
- [RecurrenceSingle](docs/RecurrenceSingle.md)
- [RecurrenceStore](docs/RecurrenceStore.md)
- [RecurrenceTransaction](docs/RecurrenceTransaction.md)
- [RecurrenceTransactionStore](docs/RecurrenceTransactionStore.md)
- [RecurrenceTransactionType](docs/RecurrenceTransactionType.md)
- [RecurrenceTransactionUpdate](docs/RecurrenceTransactionUpdate.md)
- [RecurrenceUpdate](docs/RecurrenceUpdate.md)
- [Rule](docs/Rule.md)
- [RuleAction](docs/RuleAction.md)
- [RuleActionKeyword](docs/RuleActionKeyword.md)
- [RuleActionStore](docs/RuleActionStore.md)
- [RuleActionUpdate](docs/RuleActionUpdate.md)
- [RuleArray](docs/RuleArray.md)
- [RuleGroup](docs/RuleGroup.md)
- [RuleGroupArray](docs/RuleGroupArray.md)
- [RuleGroupRead](docs/RuleGroupRead.md)
- [RuleGroupSingle](docs/RuleGroupSingle.md)
- [RuleGroupStore](docs/RuleGroupStore.md)
- [RuleGroupUpdate](docs/RuleGroupUpdate.md)
- [RuleRead](docs/RuleRead.md)
- [RuleSingle](docs/RuleSingle.md)
- [RuleStore](docs/RuleStore.md)
- [RuleTrigger](docs/RuleTrigger.md)
- [RuleTriggerKeyword](docs/RuleTriggerKeyword.md)
- [RuleTriggerStore](docs/RuleTriggerStore.md)
- [RuleTriggerType](docs/RuleTriggerType.md)
- [RuleTriggerUpdate](docs/RuleTriggerUpdate.md)
- [RuleUpdate](docs/RuleUpdate.md)
- [ShortAccountTypeProperty](docs/ShortAccountTypeProperty.md)
- [SystemInfo](docs/SystemInfo.md)
- [SystemInfoData](docs/SystemInfoData.md)
- [TagArray](docs/TagArray.md)
- [TagModel](docs/TagModel.md)
- [TagModelStore](docs/TagModelStore.md)
- [TagModelUpdate](docs/TagModelUpdate.md)
- [TagRead](docs/TagRead.md)
- [TagSingle](docs/TagSingle.md)
- [Transaction](docs/Transaction.md)
- [TransactionArray](docs/TransactionArray.md)
- [TransactionLink](docs/TransactionLink.md)
- [TransactionLinkArray](docs/TransactionLinkArray.md)
- [TransactionLinkRead](docs/TransactionLinkRead.md)
- [TransactionLinkSingle](docs/TransactionLinkSingle.md)
- [TransactionLinkStore](docs/TransactionLinkStore.md)
- [TransactionLinkUpdate](docs/TransactionLinkUpdate.md)
- [TransactionRead](docs/TransactionRead.md)
- [TransactionSingle](docs/TransactionSingle.md)
- [TransactionSplit](docs/TransactionSplit.md)
- [TransactionSplitStore](docs/TransactionSplitStore.md)
- [TransactionSplitUpdate](docs/TransactionSplitUpdate.md)
- [TransactionStore](docs/TransactionStore.md)
- [TransactionTypeFilter](docs/TransactionTypeFilter.md)
- [TransactionTypeProperty](docs/TransactionTypeProperty.md)
- [TransactionUpdate](docs/TransactionUpdate.md)
- [UnauthenticatedResponse](docs/UnauthenticatedResponse.md)
- [User](docs/User.md)
- [UserArray](docs/UserArray.md)
- [UserBlockedCodeProperty](docs/UserBlockedCodeProperty.md)
- [UserRead](docs/UserRead.md)
- [UserRoleProperty](docs/UserRoleProperty.md)
- [UserSingle](docs/UserSingle.md)
- [ValidationErrorResponse](docs/ValidationErrorResponse.md)
- [ValidationErrorResponseErrors](docs/ValidationErrorResponseErrors.md)
- [Webhook](docs/Webhook.md)
- [WebhookArray](docs/WebhookArray.md)
- [WebhookAttempt](docs/WebhookAttempt.md)
- [WebhookAttemptArray](docs/WebhookAttemptArray.md)
- [WebhookAttemptRead](docs/WebhookAttemptRead.md)
- [WebhookAttemptSingle](docs/WebhookAttemptSingle.md)
- [WebhookDelivery](docs/WebhookDelivery.md)
- [WebhookMessage](docs/WebhookMessage.md)
- [WebhookMessageArray](docs/WebhookMessageArray.md)
- [WebhookMessageRead](docs/WebhookMessageRead.md)
- [WebhookMessageSingle](docs/WebhookMessageSingle.md)
- [WebhookRead](docs/WebhookRead.md)
- [WebhookResponse](docs/WebhookResponse.md)
- [WebhookSingle](docs/WebhookSingle.md)
- [WebhookStore](docs/WebhookStore.md)
- [WebhookTrigger](docs/WebhookTrigger.md)
- [WebhookUpdate](docs/WebhookUpdate.md)
To get access to the crate's generated documentation, use:
```
cargo doc --open
```
## Author
james@firefly-iii.org

View File

@@ -1,104 +0,0 @@
# \AboutApi
All URIs are relative to *https://demo.firefly-iii.org/api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**get_about**](AboutApi.md#get_about) | **GET** /v1/about | System information end point.
[**get_cron**](AboutApi.md#get_cron) | **GET** /v1/cron/{cliToken} | Cron job endpoint
[**get_current_user**](AboutApi.md#get_current_user) | **GET** /v1/about/user | Currently authenticated user endpoint.
## get_about
> models::SystemInfo get_about(x_trace_id)
System information end point.
Returns general system information and versions of the (supporting) software.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**models::SystemInfo**](SystemInfo.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_cron
> models::CronResult get_cron(cli_token, x_trace_id, date, force)
Cron job endpoint
Firefly III has one endpoint for its various cron related tasks. Send a GET to this endpoint to run the cron. The cron requires the CLI token to be present. The cron job will fire for all users.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**cli_token** | **String** | The CLI token of any user in Firefly III, required to run the cron job. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**date** | Option<**String**> | A date formatted YYYY-MM-DD. This can be used to make the cron job pretend it's running on another day. | |
**force** | Option<**bool**> | Forces the cron job to fire, regardless of whether it has fired before. This may result in double transactions or weird budgets, so be careful. | |
### Return type
[**models::CronResult**](CronResult.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_current_user
> models::UserSingle get_current_user(x_trace_id)
Currently authenticated user endpoint.
Returns the currently authenticated user.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**models::UserSingle**](UserSingle.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -1,41 +0,0 @@
# Account
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**created_at** | Option<**String**> | | [optional][readonly]
**updated_at** | Option<**String**> | | [optional][readonly]
**active** | Option<**bool**> | If omitted, defaults to true. | [optional][default to true]
**order** | Option<**i32**> | Order of the account. Is NULL if account is not asset or liability. | [optional]
**name** | **String** | |
**r#type** | [**models::ShortAccountTypeProperty**](ShortAccountTypeProperty.md) | |
**account_role** | Option<[**models::AccountRoleProperty**](AccountRoleProperty.md)> | | [optional]
**currency_id** | Option<**String**> | Use either currency_id or currency_code. Defaults to the user's default currency. | [optional]
**currency_code** | Option<**String**> | Use either currency_id or currency_code. Defaults to the user's default currency. | [optional]
**currency_symbol** | Option<**String**> | | [optional][readonly]
**currency_decimal_places** | Option<**i32**> | | [optional][readonly]
**current_balance** | Option<**String**> | | [optional][readonly]
**current_balance_date** | Option<**String**> | The timestamp for this date is always 23:59:59, to indicate it's the balance at the very END of that particular day. | [optional][readonly]
**iban** | Option<**String**> | | [optional]
**bic** | Option<**String**> | | [optional]
**account_number** | Option<**String**> | | [optional]
**opening_balance** | Option<**String**> | Represents the opening balance, the initial amount this account holds. | [optional]
**current_debt** | Option<**String**> | Represents the current debt for liabilities. | [optional]
**opening_balance_date** | Option<**String**> | Represents the date of the opening balance. | [optional]
**virtual_balance** | Option<**String**> | | [optional]
**include_net_worth** | Option<**bool**> | If omitted, defaults to true. | [optional][default to true]
**credit_card_type** | Option<[**models::CreditCardTypeProperty**](CreditCardTypeProperty.md)> | | [optional]
**monthly_payment_date** | Option<**String**> | Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank. | [optional]
**liability_type** | Option<[**models::LiabilityTypeProperty**](LiabilityTypeProperty.md)> | | [optional]
**liability_direction** | Option<[**models::LiabilityDirectionProperty**](LiabilityDirectionProperty.md)> | | [optional]
**interest** | Option<**String**> | Mandatory when type is liability. Interest percentage. | [optional]
**interest_period** | Option<[**models::InterestPeriodProperty**](InterestPeriodProperty.md)> | | [optional]
**notes** | Option<**String**> | | [optional]
**latitude** | Option<**f64**> | Latitude of the accounts's location, if applicable. Can be used to draw a map. | [optional]
**longitude** | Option<**f64**> | Latitude of the accounts's location, if applicable. Can be used to draw a map. | [optional]
**zoom_level** | Option<**i32**> | Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,12 +0,0 @@
# AccountArray
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**data** | [**Vec<models::AccountRead>**](AccountRead.md) | |
**meta** | [**models::Meta**](Meta.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,13 +0,0 @@
# AccountRead
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**r#type** | **String** | Immutable value |
**id** | **String** | |
**attributes** | [**models::Account**](Account.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,17 +0,0 @@
# AccountRoleProperty
## Enum Variants
| Name | Value |
|---- | -----|
| DefaultAsset | defaultAsset |
| SharedAsset | sharedAsset |
| SavingAsset | savingAsset |
| CcAsset | ccAsset |
| CashWalletAsset | cashWalletAsset |
| Null | null |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,16 +0,0 @@
# AccountSearchFieldFilter
## Enum Variants
| Name | Value |
|---- | -----|
| All | all |
| Iban | iban |
| Name | name |
| Number | number |
| Id | id |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,11 +0,0 @@
# AccountSingle
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**data** | [**models::AccountRead**](AccountRead.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,34 +0,0 @@
# AccountStore
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**name** | **String** | |
**r#type** | [**models::ShortAccountTypeProperty**](ShortAccountTypeProperty.md) | |
**iban** | Option<**String**> | | [optional]
**bic** | Option<**String**> | | [optional]
**account_number** | Option<**String**> | | [optional]
**opening_balance** | Option<**String**> | Represents the opening balance, the initial amount this account holds. | [optional]
**opening_balance_date** | Option<**String**> | Represents the date of the opening balance. | [optional]
**virtual_balance** | Option<**String**> | | [optional]
**currency_id** | Option<**String**> | Use either currency_id or currency_code. Defaults to the user's default currency. | [optional]
**currency_code** | Option<**String**> | Use either currency_id or currency_code. Defaults to the user's default currency. | [optional]
**active** | Option<**bool**> | If omitted, defaults to true. | [optional][default to true]
**order** | Option<**i32**> | Order of the account | [optional]
**include_net_worth** | Option<**bool**> | If omitted, defaults to true. | [optional][default to true]
**account_role** | Option<[**models::AccountRoleProperty**](AccountRoleProperty.md)> | | [optional]
**credit_card_type** | Option<[**models::CreditCardTypeProperty**](CreditCardTypeProperty.md)> | | [optional]
**monthly_payment_date** | Option<**String**> | Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank. | [optional]
**liability_type** | Option<[**models::LiabilityTypeProperty**](LiabilityTypeProperty.md)> | | [optional]
**liability_direction** | Option<[**models::LiabilityDirectionProperty**](LiabilityDirectionProperty.md)> | | [optional]
**interest** | Option<**String**> | Mandatory when type is liability. Interest percentage. | [optional][default to 0]
**interest_period** | Option<[**models::InterestPeriodProperty**](InterestPeriodProperty.md)> | | [optional]
**notes** | Option<**String**> | | [optional]
**latitude** | Option<**f64**> | Latitude of the accounts's location, if applicable. Can be used to draw a map. | [optional]
**longitude** | Option<**f64**> | Latitude of the accounts's location, if applicable. Can be used to draw a map. | [optional]
**zoom_level** | Option<**i32**> | Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,32 +0,0 @@
# AccountTypeFilter
## Enum Variants
| Name | Value |
|---- | -----|
| All | all |
| Asset | asset |
| Cash | cash |
| Expense | expense |
| Revenue | revenue |
| Special | special |
| Hidden | hidden |
| Liability | liability |
| Liabilities | liabilities |
| DefaultAccount | Default account |
| CashAccount | Cash account |
| AssetAccount | Asset account |
| ExpenseAccount | Expense account |
| RevenueAccount | Revenue account |
| InitialBalanceAccount | Initial balance account |
| BeneficiaryAccount | Beneficiary account |
| ImportAccount | Import account |
| ReconciliationAccount | Reconciliation account |
| Loan | Loan |
| Debt | Debt |
| Mortgage | Mortgage |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,23 +0,0 @@
# AccountTypeProperty
## Enum Variants
| Name | Value |
|---- | -----|
| DefaultAccount | Default account |
| CashAccount | Cash account |
| AssetAccount | Asset account |
| ExpenseAccount | Expense account |
| RevenueAccount | Revenue account |
| InitialBalanceAccount | Initial balance account |
| BeneficiaryAccount | Beneficiary account |
| ImportAccount | Import account |
| ReconciliationAccount | Reconciliation account |
| Loan | Loan |
| Debt | Debt |
| Mortgage | Mortgage |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,32 +0,0 @@
# AccountUpdate
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**name** | **String** | |
**iban** | Option<**String**> | | [optional]
**bic** | Option<**String**> | | [optional]
**account_number** | Option<**String**> | | [optional]
**opening_balance** | Option<**String**> | | [optional]
**opening_balance_date** | Option<**String**> | | [optional]
**virtual_balance** | Option<**String**> | | [optional]
**currency_id** | Option<**String**> | Use either currency_id or currency_code. Defaults to the user's default currency. | [optional]
**currency_code** | Option<**String**> | Use either currency_id or currency_code. Defaults to the user's default currency. | [optional]
**active** | Option<**bool**> | If omitted, defaults to true. | [optional][default to true]
**order** | Option<**i32**> | Order of the account | [optional]
**include_net_worth** | Option<**bool**> | If omitted, defaults to true. | [optional][default to true]
**account_role** | Option<[**models::AccountRoleProperty**](AccountRoleProperty.md)> | | [optional]
**credit_card_type** | Option<[**models::CreditCardTypeProperty**](CreditCardTypeProperty.md)> | | [optional]
**monthly_payment_date** | Option<**String**> | Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank. | [optional]
**liability_type** | Option<[**models::LiabilityTypeProperty**](LiabilityTypeProperty.md)> | | [optional]
**interest** | Option<**String**> | Mandatory when type is liability. Interest percentage. | [optional]
**interest_period** | Option<[**models::InterestPeriodProperty**](InterestPeriodProperty.md)> | | [optional]
**notes** | Option<**String**> | | [optional]
**latitude** | Option<**f64**> | Latitude of the account's location, if applicable. Can be used to draw a map. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed. | [optional]
**longitude** | Option<**f64**> | Latitude of the account's location, if applicable. Can be used to draw a map. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed. | [optional]
**zoom_level** | Option<**i32**> | Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,278 +0,0 @@
# \AccountsApi
All URIs are relative to *https://demo.firefly-iii.org/api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**delete_account**](AccountsApi.md#delete_account) | **DELETE** /v1/accounts/{id} | Permanently delete account.
[**get_account**](AccountsApi.md#get_account) | **GET** /v1/accounts/{id} | Get single account.
[**list_account**](AccountsApi.md#list_account) | **GET** /v1/accounts | List all accounts.
[**list_attachment_by_account**](AccountsApi.md#list_attachment_by_account) | **GET** /v1/accounts/{id}/attachments | Lists all attachments.
[**list_piggy_bank_by_account**](AccountsApi.md#list_piggy_bank_by_account) | **GET** /v1/accounts/{id}/piggy-banks | List all piggy banks related to the account.
[**list_transaction_by_account**](AccountsApi.md#list_transaction_by_account) | **GET** /v1/accounts/{id}/transactions | List all transactions related to the account.
[**store_account**](AccountsApi.md#store_account) | **POST** /v1/accounts | Create new account.
[**update_account**](AccountsApi.md#update_account) | **PUT** /v1/accounts/{id} | Update existing account.
## delete_account
> delete_account(id, x_trace_id)
Permanently delete account.
Will permanently delete an account. Any associated transactions and piggy banks are ALSO deleted. Cannot be recovered from.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the account. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
(empty response body)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_account
> models::AccountSingle get_account(id, x_trace_id, date)
Get single account.
Returns a single account by its ID.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the account. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**date** | Option<**String**> | A date formatted YYYY-MM-DD. When added to the request, Firefly III will show the account's balance on that day. | |
### Return type
[**models::AccountSingle**](AccountSingle.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## list_account
> models::AccountArray list_account(x_trace_id, limit, page, date, r#type)
List all accounts.
This endpoint returns a list of all the accounts owned by the authenticated user.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**limit** | Option<**i32**> | Number of items per page. The default pagination is per 50 items. | |
**page** | Option<**i32**> | Page number. The default pagination is per 50 items. | |
**date** | Option<**String**> | A date formatted YYYY-MM-DD. When added to the request, Firefly III will show the account's balance on that day. | |
**r#type** | Option<[**AccountTypeFilter**](.md)> | Optional filter on the account type(s) returned | |
### Return type
[**models::AccountArray**](AccountArray.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## list_attachment_by_account
> models::AttachmentArray list_attachment_by_account(id, x_trace_id, limit, page)
Lists all attachments.
Lists all attachments.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the account. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**limit** | Option<**i32**> | Number of items per page. The default pagination is per 50 items. | |
**page** | Option<**i32**> | Page number. The default pagination is per 50 items. | |
### Return type
[**models::AttachmentArray**](AttachmentArray.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## list_piggy_bank_by_account
> models::PiggyBankArray list_piggy_bank_by_account(id, x_trace_id, limit, page)
List all piggy banks related to the account.
This endpoint returns a list of all the piggy banks connected to the account.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the account. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**limit** | Option<**i32**> | Number of items per page. The default pagination is per 50 items. | |
**page** | Option<**i32**> | Page number. The default pagination is per 50 items. | |
### Return type
[**models::PiggyBankArray**](PiggyBankArray.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## list_transaction_by_account
> models::TransactionArray list_transaction_by_account(id, x_trace_id, limit, page, start, end, r#type)
List all transactions related to the account.
This endpoint returns a list of all the transactions connected to the account.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the account. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**limit** | Option<**i32**> | Number of items per page. The default pagination is per 50 items. | |
**page** | Option<**i32**> | Page number. The default pagination is per 50 items. | |
**start** | Option<**String**> | A date formatted YYYY-MM-DD. | |
**end** | Option<**String**> | A date formatted YYYY-MM-DD. | |
**r#type** | Option<[**TransactionTypeFilter**](.md)> | Optional filter on the transaction type(s) returned. | |
### Return type
[**models::TransactionArray**](TransactionArray.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## store_account
> models::AccountSingle store_account(account_store, x_trace_id)
Create new account.
Creates a new account. The data required can be submitted as a JSON body or as a list of parameters (in key=value pairs, like a webform).
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**account_store** | [**AccountStore**](AccountStore.md) | JSON array with the necessary account information or key=value pairs. See the model for the exact specifications. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**models::AccountSingle**](AccountSingle.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: application/json, application/x-www-form-urlencoded
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## update_account
> models::AccountSingle update_account(id, account_update, x_trace_id)
Update existing account.
Used to update a single account. All fields that are not submitted will be cleared (set to NULL). The model will tell you which fields are mandatory.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the account. | [required] |
**account_update** | [**AccountUpdate**](AccountUpdate.md) | JSON array or formdata with updated account information. See the model for the exact specifications. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**models::AccountSingle**](AccountSingle.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: application/json, application/x-www-form-urlencoded
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -1,17 +0,0 @@
# AttachableType
## Enum Variants
| Name | Value |
|---- | -----|
| Account | Account |
| Budget | Budget |
| Bill | Bill |
| TransactionJournal | TransactionJournal |
| PiggyBank | PiggyBank |
| Tag | Tag |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,22 +0,0 @@
# Attachment
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**created_at** | Option<**String**> | | [optional][readonly]
**updated_at** | Option<**String**> | | [optional][readonly]
**attachable_type** | [**models::AttachableType**](AttachableType.md) | |
**attachable_id** | **String** | ID of the model this attachment is linked to. |
**md5** | Option<**String**> | MD5 hash of the file for basic duplicate detection. | [optional]
**filename** | **String** | |
**download_url** | Option<**String**> | | [optional]
**upload_url** | Option<**String**> | | [optional]
**title** | Option<**String**> | | [optional]
**notes** | Option<**String**> | | [optional]
**mime** | Option<**String**> | | [optional][readonly]
**size** | Option<**i32**> | | [optional][readonly]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,12 +0,0 @@
# AttachmentArray
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**data** | [**Vec<models::AttachmentRead>**](AttachmentRead.md) | |
**meta** | [**models::Meta**](Meta.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,14 +0,0 @@
# AttachmentRead
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**r#type** | **String** | Immutable value |
**id** | **String** | |
**attributes** | [**models::Attachment**](Attachment.md) | |
**links** | [**models::ObjectLink**](ObjectLink.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,11 +0,0 @@
# AttachmentSingle
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**data** | [**models::AttachmentRead**](AttachmentRead.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,15 +0,0 @@
# AttachmentStore
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**filename** | **String** | |
**attachable_type** | [**models::AttachableType**](AttachableType.md) | |
**attachable_id** | **String** | ID of the model this attachment is linked to. |
**title** | Option<**String**> | | [optional]
**notes** | Option<**String**> | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,13 +0,0 @@
# AttachmentUpdate
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**filename** | Option<**String**> | | [optional]
**title** | Option<**String**> | | [optional]
**notes** | Option<**String**> | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,235 +0,0 @@
# \AttachmentsApi
All URIs are relative to *https://demo.firefly-iii.org/api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**delete_attachment**](AttachmentsApi.md#delete_attachment) | **DELETE** /v1/attachments/{id} | Delete an attachment.
[**download_attachment**](AttachmentsApi.md#download_attachment) | **GET** /v1/attachments/{id}/download | Download a single attachment.
[**get_attachment**](AttachmentsApi.md#get_attachment) | **GET** /v1/attachments/{id} | Get a single attachment.
[**list_attachment**](AttachmentsApi.md#list_attachment) | **GET** /v1/attachments | List all attachments.
[**store_attachment**](AttachmentsApi.md#store_attachment) | **POST** /v1/attachments | Store a new attachment.
[**update_attachment**](AttachmentsApi.md#update_attachment) | **PUT** /v1/attachments/{id} | Update existing attachment.
[**upload_attachment**](AttachmentsApi.md#upload_attachment) | **POST** /v1/attachments/{id}/upload | Upload an attachment.
## delete_attachment
> delete_attachment(id, x_trace_id)
Delete an attachment.
With this endpoint you delete an attachment, including any stored file data.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the single attachment. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
(empty response body)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## download_attachment
> std::path::PathBuf download_attachment(id, x_trace_id)
Download a single attachment.
This endpoint allows you to download the binary content of a transaction. It will be sent to you as a download, using the content type \"application/octet-stream\" and content disposition \"attachment; filename=example.pdf\".
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the attachment. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**std::path::PathBuf**](std::path::PathBuf.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/octet-stream, application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_attachment
> models::AttachmentSingle get_attachment(id, x_trace_id)
Get a single attachment.
Get a single attachment. This endpoint only returns the available metadata for the attachment. Actual file data is handled in two other endpoints (see below).
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the attachment. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**models::AttachmentSingle**](AttachmentSingle.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## list_attachment
> models::AttachmentArray list_attachment(x_trace_id, limit, page)
List all attachments.
This endpoint lists all attachments.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**limit** | Option<**i32**> | Number of items per page. The default pagination is per 50 items. | |
**page** | Option<**i32**> | Page number. The default pagination is per 50 items. | |
### Return type
[**models::AttachmentArray**](AttachmentArray.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## store_attachment
> models::AttachmentSingle store_attachment(attachment_store, x_trace_id)
Store a new attachment.
Creates a new attachment. The data required can be submitted as a JSON body or as a list of parameters. You cannot use this endpoint to upload the actual file data (see below). This endpoint only creates the attachment object.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**attachment_store** | [**AttachmentStore**](AttachmentStore.md) | JSON array or key=value pairs with the necessary attachment information. See the model for the exact specifications. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**models::AttachmentSingle**](AttachmentSingle.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: application/json, application/x-www-form-urlencoded
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## update_attachment
> models::AttachmentSingle update_attachment(id, attachment_update, x_trace_id)
Update existing attachment.
Update the meta data for an existing attachment. This endpoint does not allow you to upload or download data. For that, see below.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the attachment. | [required] |
**attachment_update** | [**AttachmentUpdate**](AttachmentUpdate.md) | JSON array with updated attachment information. See the model for the exact specifications. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
### Return type
[**models::AttachmentSingle**](AttachmentSingle.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: application/json, application/x-www-form-urlencoded
- **Accept**: application/json, application/vnd.api+json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## upload_attachment
> upload_attachment(id, x_trace_id, body)
Upload an attachment.
Use this endpoint to upload (and possible overwrite) the file contents of an attachment. Simply put the entire file in the body as binary data.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**id** | **String** | The ID of the attachment. | [required] |
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**body** | Option<**std::path::PathBuf**> | | |
### Return type
(empty response body)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: application/octet-stream
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -1,18 +0,0 @@
# AutoBudgetPeriod
## Enum Variants
| Name | Value |
|---- | -----|
| Daily | daily |
| Weekly | weekly |
| Monthly | monthly |
| Quarterly | quarterly |
| HalfYear | half-year |
| Yearly | yearly |
| Null | null |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,15 +0,0 @@
# AutoBudgetType
## Enum Variants
| Name | Value |
|---- | -----|
| Reset | reset |
| Rollover | rollover |
| None | none |
| Null | null |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,19 +0,0 @@
# AutocompleteAccount
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the account found by an auto-complete search. |
**name_with_balance** | **String** | Asset accounts and liabilities have a second field with the given date's account balance. |
**r#type** | **String** | Account type of the account found by the auto-complete search. |
**currency_id** | **String** | ID for the currency used by this account. |
**currency_name** | **String** | Currency name for the currency used by this account. |
**currency_code** | **String** | Currency code for the currency used by this account. |
**currency_symbol** | **String** | Currency symbol for the currency used by this account. |
**currency_decimal_places** | **i32** | Number of decimal places for the currency used by this account. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,506 +0,0 @@
# \AutocompleteApi
All URIs are relative to *https://demo.firefly-iii.org/api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**get_accounts_ac**](AutocompleteApi.md#get_accounts_ac) | **GET** /v1/autocomplete/accounts | Returns all accounts of the user returned in a basic auto-complete array.
[**get_bills_ac**](AutocompleteApi.md#get_bills_ac) | **GET** /v1/autocomplete/bills | Returns all bills of the user returned in a basic auto-complete array.
[**get_budgets_ac**](AutocompleteApi.md#get_budgets_ac) | **GET** /v1/autocomplete/budgets | Returns all budgets of the user returned in a basic auto-complete array.
[**get_categories_ac**](AutocompleteApi.md#get_categories_ac) | **GET** /v1/autocomplete/categories | Returns all categories of the user returned in a basic auto-complete array.
[**get_currencies_ac**](AutocompleteApi.md#get_currencies_ac) | **GET** /v1/autocomplete/currencies | Returns all currencies of the user returned in a basic auto-complete array.
[**get_currencies_code_ac**](AutocompleteApi.md#get_currencies_code_ac) | **GET** /v1/autocomplete/currencies-with-code | Returns all currencies of the user returned in a basic auto-complete array. This endpoint is DEPRECATED and I suggest you DO NOT use it.
[**get_object_groups_ac**](AutocompleteApi.md#get_object_groups_ac) | **GET** /v1/autocomplete/object-groups | Returns all object groups of the user returned in a basic auto-complete array.
[**get_piggies_ac**](AutocompleteApi.md#get_piggies_ac) | **GET** /v1/autocomplete/piggy-banks | Returns all piggy banks of the user returned in a basic auto-complete array.
[**get_piggies_balance_ac**](AutocompleteApi.md#get_piggies_balance_ac) | **GET** /v1/autocomplete/piggy-banks-with-balance | Returns all piggy banks of the user returned in a basic auto-complete array complemented with balance information.
[**get_recurring_ac**](AutocompleteApi.md#get_recurring_ac) | **GET** /v1/autocomplete/recurring | Returns all recurring transactions of the user returned in a basic auto-complete array.
[**get_rule_groups_ac**](AutocompleteApi.md#get_rule_groups_ac) | **GET** /v1/autocomplete/rule-groups | Returns all rule groups of the user returned in a basic auto-complete array.
[**get_rules_ac**](AutocompleteApi.md#get_rules_ac) | **GET** /v1/autocomplete/rules | Returns all rules of the user returned in a basic auto-complete array.
[**get_tag_ac**](AutocompleteApi.md#get_tag_ac) | **GET** /v1/autocomplete/tags | Returns all tags of the user returned in a basic auto-complete array.
[**get_transaction_types_ac**](AutocompleteApi.md#get_transaction_types_ac) | **GET** /v1/autocomplete/transaction-types | Returns all transaction types returned in a basic auto-complete array. English only.
[**get_transactions_ac**](AutocompleteApi.md#get_transactions_ac) | **GET** /v1/autocomplete/transactions | Returns all transaction descriptions of the user returned in a basic auto-complete array.
[**get_transactions_idac**](AutocompleteApi.md#get_transactions_idac) | **GET** /v1/autocomplete/transactions-with-id | Returns all transactions, complemented with their ID, of the user returned in a basic auto-complete array. This endpoint is DEPRECATED and I suggest you DO NOT use it.
## get_accounts_ac
> Vec<models::AutocompleteAccount> get_accounts_ac(x_trace_id, query, limit, date, types)
Returns all accounts of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
**date** | Option<**String**> | If the account is an asset account or a liability, the autocomplete will also return the balance of the account on this date. | |
**types** | Option<[**Vec<models::AccountTypeFilter>**](models::AccountTypeFilter.md)> | Optional filter on the account type(s) used in the autocomplete. | |
### Return type
[**Vec<models::AutocompleteAccount>**](AutocompleteAccount.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_bills_ac
> Vec<models::AutocompleteBill> get_bills_ac(x_trace_id, query, limit)
Returns all bills of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteBill>**](AutocompleteBill.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_budgets_ac
> Vec<models::AutocompleteBudget> get_budgets_ac(x_trace_id, query, limit)
Returns all budgets of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteBudget>**](AutocompleteBudget.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_categories_ac
> Vec<models::AutocompleteCategory> get_categories_ac(x_trace_id, query, limit)
Returns all categories of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteCategory>**](AutocompleteCategory.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_currencies_ac
> Vec<models::AutocompleteCurrency> get_currencies_ac(x_trace_id, query, limit)
Returns all currencies of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteCurrency>**](AutocompleteCurrency.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_currencies_code_ac
> Vec<models::AutocompleteCurrencyCode> get_currencies_code_ac(x_trace_id, query, limit)
Returns all currencies of the user returned in a basic auto-complete array. This endpoint is DEPRECATED and I suggest you DO NOT use it.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteCurrencyCode>**](AutocompleteCurrencyCode.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_object_groups_ac
> Vec<models::AutocompleteObjectGroup> get_object_groups_ac(x_trace_id, query, limit)
Returns all object groups of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteObjectGroup>**](AutocompleteObjectGroup.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_piggies_ac
> Vec<models::AutocompletePiggy> get_piggies_ac(x_trace_id, query, limit)
Returns all piggy banks of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompletePiggy>**](AutocompletePiggy.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_piggies_balance_ac
> Vec<models::AutocompletePiggyBalance> get_piggies_balance_ac(x_trace_id, query, limit)
Returns all piggy banks of the user returned in a basic auto-complete array complemented with balance information.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompletePiggyBalance>**](AutocompletePiggyBalance.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_recurring_ac
> Vec<models::AutocompleteRecurrence> get_recurring_ac(x_trace_id, query, limit)
Returns all recurring transactions of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteRecurrence>**](AutocompleteRecurrence.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_rule_groups_ac
> Vec<models::AutocompleteRuleGroup> get_rule_groups_ac(x_trace_id, query, limit)
Returns all rule groups of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteRuleGroup>**](AutocompleteRuleGroup.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_rules_ac
> Vec<models::AutocompleteRule> get_rules_ac(x_trace_id, query, limit)
Returns all rules of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteRule>**](AutocompleteRule.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_tag_ac
> Vec<models::AutocompleteTag> get_tag_ac(x_trace_id, query, limit)
Returns all tags of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteTag>**](AutocompleteTag.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_transaction_types_ac
> Vec<models::AutocompleteTransactionType> get_transaction_types_ac(x_trace_id, query, limit)
Returns all transaction types returned in a basic auto-complete array. English only.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteTransactionType>**](AutocompleteTransactionType.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_transactions_ac
> Vec<models::AutocompleteTransaction> get_transactions_ac(x_trace_id, query, limit)
Returns all transaction descriptions of the user returned in a basic auto-complete array.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteTransaction>**](AutocompleteTransaction.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## get_transactions_idac
> Vec<models::AutocompleteTransactionId> get_transactions_idac(x_trace_id, query, limit)
Returns all transactions, complemented with their ID, of the user returned in a basic auto-complete array. This endpoint is DEPRECATED and I suggest you DO NOT use it.
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**x_trace_id** | Option<**uuid::Uuid**> | Unique identifier associated with this request. | |
**query** | Option<**String**> | The autocomplete search query. | |
**limit** | Option<**i32**> | The number of items returned. | |
### Return type
[**Vec<models::AutocompleteTransactionId>**](AutocompleteTransactionID.md)
### Authorization
[firefly_iii_auth](../README.md#firefly_iii_auth), [local_bearer_auth](../README.md#local_bearer_auth)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -1,13 +0,0 @@
# AutocompleteBill
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the bill found by an auto-complete search. |
**active** | Option<**bool**> | Is the bill active or not? | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,12 +0,0 @@
# AutocompleteBudget
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the budget found by an auto-complete search. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,12 +0,0 @@
# AutocompleteCategory
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the category found by an auto-complete search. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,15 +0,0 @@
# AutocompleteCurrency
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Currency name. |
**code** | **String** | Currency code. |
**symbol** | **String** | |
**decimal_places** | **i32** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,15 +0,0 @@
# AutocompleteCurrencyCode
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Currency name with the code between brackets. |
**code** | **String** | Currency code. |
**symbol** | **String** | |
**decimal_places** | **i32** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,13 +0,0 @@
# AutocompleteObjectGroup
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**title** | **String** | Title of the object group found by an auto-complete search. |
**name** | **String** | Title of the object group found by an auto-complete search. |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,19 +0,0 @@
# AutocompletePiggy
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the piggy bank found by an auto-complete search. |
**currency_id** | Option<**String**> | Currency ID for this piggy bank. | [optional]
**currency_code** | Option<**String**> | Currency code for this piggy bank. | [optional]
**currency_symbol** | Option<**String**> | | [optional]
**currency_name** | Option<**String**> | Currency name for the currency used by this account. | [optional]
**currency_decimal_places** | Option<**i32**> | | [optional]
**object_group_id** | Option<**String**> | The group ID of the group this object is part of. NULL if no group. | [optional]
**object_group_title** | Option<**String**> | The name of the group. NULL if no group. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,19 +0,0 @@
# AutocompletePiggyBalance
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the piggy bank found by an auto-complete search. |
**name_with_balance** | Option<**String**> | Name of the piggy bank found by an auto-complete search with the current balance formatted nicely. | [optional]
**currency_id** | Option<**String**> | Currency ID for this piggy bank. | [optional]
**currency_code** | Option<**String**> | Currency code for this piggy bank. | [optional]
**currency_symbol** | Option<**String**> | | [optional]
**currency_decimal_places** | Option<**i32**> | | [optional]
**object_group_id** | Option<**String**> | The group ID of the group this object is part of. NULL if no group. | [optional]
**object_group_title** | Option<**String**> | The name of the group. NULL if no group. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,13 +0,0 @@
# AutocompleteRecurrence
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the recurrence found by an auto-complete search. |
**description** | Option<**String**> | Description of the recurrence found by auto-complete. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,13 +0,0 @@
# AutocompleteRule
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the rule found by an auto-complete search. |
**description** | Option<**String**> | Description of the rule found by auto-complete. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -1,13 +0,0 @@
# AutocompleteRuleGroup
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**name** | **String** | Name of the rule group found by an auto-complete search. |
**description** | Option<**String**> | Description of the rule group found by auto-complete. | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

Some files were not shown because too many files have changed in this diff Show More