From 9a5c6d0f68ed3dda8426d790ad06901b2b094934 Mon Sep 17 00:00:00 2001 From: Jacob Kiers Date: Wed, 19 Nov 2025 21:18:37 +0000 Subject: [PATCH] Implement logic --- AGENTS.md | 178 ++ Cargo.lock | 2610 +++++++++++++++++ Cargo.toml | 31 + README.md | 115 +- banks2ff/Cargo.toml | 27 + banks2ff/src/adapters/firefly/client.rs | 188 ++ banks2ff/src/adapters/firefly/mod.rs | 1 + banks2ff/src/adapters/gocardless/cache.rs | 51 + banks2ff/src/adapters/gocardless/client.rs | 152 + banks2ff/src/adapters/gocardless/mapper.rs | 147 + banks2ff/src/adapters/gocardless/mod.rs | 3 + banks2ff/src/adapters/mod.rs | 2 + banks2ff/src/core/mod.rs | 3 + banks2ff/src/core/models.rs | 31 + banks2ff/src/core/ports.rs | 42 + banks2ff/src/core/sync.rs | 299 ++ banks2ff/src/main.rs | 74 + docs/architecture.md | 114 + firefly-client/Cargo.toml | 20 + firefly-client/src/client.rs | 126 + firefly-client/src/lib.rs | 2 + firefly-client/src/models.rs | 81 + firefly-client/tests/client_test.rs | 62 + .../tests/fixtures/ff_accounts.json | 22 + gocardless-client/Cargo.toml | 19 + gocardless-client/src/client.rs | 129 + gocardless-client/src/lib.rs | 2 + gocardless-client/src/models.rs | 95 + gocardless-client/src/tests/client_test.rs | 55 + .../src/tests/fixtures/gc_transactions.json | 34 + specs/planning.md | 226 +- 31 files changed, 4802 insertions(+), 139 deletions(-) create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 banks2ff/Cargo.toml create mode 100644 banks2ff/src/adapters/firefly/client.rs create mode 100644 banks2ff/src/adapters/firefly/mod.rs create mode 100644 banks2ff/src/adapters/gocardless/cache.rs create mode 100644 banks2ff/src/adapters/gocardless/client.rs create mode 100644 banks2ff/src/adapters/gocardless/mapper.rs create mode 100644 banks2ff/src/adapters/gocardless/mod.rs create mode 100644 banks2ff/src/adapters/mod.rs create mode 100644 banks2ff/src/core/mod.rs create mode 100644 banks2ff/src/core/models.rs create mode 100644 banks2ff/src/core/ports.rs create mode 100644 banks2ff/src/core/sync.rs create mode 100644 banks2ff/src/main.rs create mode 100644 docs/architecture.md create mode 100644 firefly-client/Cargo.toml create mode 100644 firefly-client/src/client.rs create mode 100644 firefly-client/src/lib.rs create mode 100644 firefly-client/src/models.rs create mode 100644 firefly-client/tests/client_test.rs create mode 100644 firefly-client/tests/fixtures/ff_accounts.json create mode 100644 gocardless-client/Cargo.toml create mode 100644 gocardless-client/src/client.rs create mode 100644 gocardless-client/src/lib.rs create mode 100644 gocardless-client/src/models.rs create mode 100644 gocardless-client/src/tests/client_test.rs create mode 100644 gocardless-client/src/tests/fixtures/gc_transactions.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..91d5933 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,178 @@ +# 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 + +### **ABSOLUTE REQUIREMENT: Financial Data Masking** + +**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 + +### **Compliance Protocol for Debugging** + +When debugging financial data issues: + +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 + +```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) { + 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 + +### 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 + +### 4. Commit Standards +- Commit both code and tests together +- Write clear, descriptive commit messages +- Ensure the workspace compiles: `cargo build --workspace` + +## Project Structure Guidelines + +### 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 + +### 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) \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cd55cef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2610 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "banks2ff" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "dotenvy", + "firefly-client", + "gocardless-client", + "mockall", + "rust_decimal", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "borsh" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "deadpool" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "retain_mut", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "firefly-client" +version = "0.1.0" +dependencies = [ + "chrono", + "reqwest", + "rust_decimal", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-test", + "tracing", + "url", + "wiremock", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "gocardless-client" +version = "0.1.0" +dependencies = [ + "chrono", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-test", + "tracing", + "url", + "wiremock", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel", + "base64 0.13.1", + "futures-lite", + "http", + "infer", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "url", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "float-cmp", + "itertools", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "retain_mut" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.110", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wiremock" +version = "0.5.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64 0.21.7", + "deadpool", + "futures", + "futures-timer", + "http-types", + "hyper", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a683af1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +members = [ + "banks2ff", + "firefly-client", + "gocardless-client", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] + +[workspace.dependencies] +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", features = ["json", "multipart"] } +url = "2.5" +wiremock = "0.5" +tokio-test = "0.4" +mockall = "0.11" diff --git a/README.md b/README.md index 63e7af4..a33fb5b 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,85 @@ -# Bank2FF - -Bank2FF is a tool that can retrieve bank transactions from Gocardless and -add them to Firefly III. - -It contains autogenerated APIs for both Firefly III and for the -Gocardless Bank Account Data API. - -## Usage - -TBD - - -## Generating the API clients - -These API clients are generated with the OpenAPI Generators for Rust. - -These need Podman installed, and assume this command is run from the same -directory where this README.md file is located. - -For Gocardless: - -`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` - - -For Firefly III: - -If necessary, change the URL to the definition. If that is a new version, then also change the `packageVersion` parameter. - -`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` \ No newline at end of file +# Banks2FF + +A robust command-line tool to synchronize bank transactions from GoCardless (formerly Nordigen) to Firefly III. + +## Architecture + +This project is a Rust Workspace consisting of: +- `banks2ff`: The main CLI application (Hexagonal Architecture). +- `gocardless-client`: A hand-crafted, strongly-typed library for the GoCardless Bank Account Data API. +- `firefly-client`: A hand-crafted, strongly-typed library for the Firefly III API. + +## Features + +- **Multi-Currency Support**: Correctly handles foreign currency transactions by extracting exchange rate data. +- **Idempotency (Healer Mode)**: + - Detects duplicates using a windowed search (Date +/- 3 days, exact Amount). + - "Heals" historical transactions by updating them with the correct `external_id`. + - Skips transactions that already have a matching `external_id`. +- **Clean Architecture**: Decoupled core logic makes it reliable and testable. +- **Observability**: Structured logging via `tracing`. +- **Dry Run**: Preview changes without writing to Firefly III. +- **Rate Limit Protection**: + - Caches GoCardless account details to avoid unnecessary calls. + - Respects token expiry to minimize auth calls. + - Handles `429 Too Many Requests` gracefully by skipping affected accounts. + +## Setup & Configuration + +1. **Prerequisites**: + - Rust (latest stable) + - An account with GoCardless Bank Account Data (get your `secret_id` and `secret_key`). + - A running Firefly III instance (get your Personal Access Token). + +2. **Environment Variables**: + Copy `env.example` to `.env` and fill in your details: + + ```bash + cp env.example .env + ``` + + Required variables: + - `GOCARDLESS_ID`: Your GoCardless Secret ID. + - `GOCARDLESS_KEY`: Your GoCardless Secret Key. + - `FIREFLY_III_URL`: The base URL of your Firefly instance (e.g., `https://money.example.com`). + - `FIREFLY_III_API_KEY`: Your Personal Access Token. + + Optional: + - `GOCARDLESS_URL`: Defaults to `https://bankaccountdata.gocardless.com`. + - `RUST_LOG`: Set log level (e.g., `info`, `debug`, `trace`). + +## Testing + +The project has a comprehensive test suite using `wiremock` for API clients and `mockall` for core logic. + +To run all tests: + +```bash +cargo test --workspace +``` + +## Usage + +To run the synchronization: + +```bash +# Run via cargo (defaults: Start = Last Firefly Date + 1, End = Yesterday) +cargo run -p banks2ff + +# Dry Run (Read-only) +cargo run -p banks2ff -- --dry-run + +# Custom Date Range +cargo run -p banks2ff -- --start 2023-01-01 --end 2023-01-31 +``` + +## How it works + +1. **Fetch**: Retrieves active accounts from GoCardless (filtered by those present in Firefly III to save requests). +2. **Match**: Resolves the destination account in Firefly III by matching the IBAN. +3. **Sync Window**: Determines the start date automatically by finding the latest transaction in Firefly for that account. +4. **Process**: For each transaction: + - **Search**: Checks Firefly for an existing transaction (matching Amount and Date +/- 3 days). + - **Heal**: If found but missing an `external_id`, it updates the transaction. + - **Skip**: If found and matches `external_id`, it skips. + - **Create**: If not found, it creates a new transaction. diff --git a/banks2ff/Cargo.toml b/banks2ff/Cargo.toml new file mode 100644 index 0000000..2ee8377 --- /dev/null +++ b/banks2ff/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "banks2ff" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +tokio = { workspace = true } +anyhow = { 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 } + +# Core logic dependencies +async-trait = { workspace = true } + +# API Client dependencies +firefly-client = { path = "../firefly-client" } +gocardless-client = { path = "../gocardless-client" } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/banks2ff/src/adapters/firefly/client.rs b/banks2ff/src/adapters/firefly/client.rs new file mode 100644 index 0000000..a73f80d --- /dev/null +++ b/banks2ff/src/adapters/firefly/client.rs @@ -0,0 +1,188 @@ +use async_trait::async_trait; +use anyhow::Result; +use tracing::instrument; +use crate::core::ports::{TransactionDestination, TransactionMatch}; +use crate::core::models::BankTransaction; +use firefly_client::client::FireflyClient; +use firefly_client::models::{TransactionStore, TransactionSplitStore, TransactionUpdate, TransactionSplitUpdate}; +use std::sync::Arc; +use tokio::sync::Mutex; +use rust_decimal::Decimal; +use std::str::FromStr; +use chrono::NaiveDate; + +pub struct FireflyAdapter { + client: Arc>, +} + +impl FireflyAdapter { + pub fn new(client: FireflyClient) -> Self { + Self { + client: Arc::new(Mutex::new(client)), + } + } +} + +#[async_trait] +impl TransactionDestination for FireflyAdapter { + #[instrument(skip(self))] + async fn resolve_account_id(&self, iban: &str) -> Result> { + let client = self.client.lock().await; + let accounts = client.search_accounts(iban).await?; + + // Look for exact match on IBAN, ensuring account is active + for acc in accounts.data { + // Filter for active accounts only (default is usually active, but let's check if attribute exists) + // Note: The Firefly API spec v6.4.4 Account object has 'active' attribute as boolean. + let is_active = acc.attributes.active.unwrap_or(true); + + if !is_active { + continue; + } + + if let Some(acc_iban) = acc.attributes.iban { + if acc_iban.replace(" ", "") == iban.replace(" ", "") { + return Ok(Some(acc.id)); + } + } + } + + Ok(None) + } + + #[instrument(skip(self))] + async fn get_active_account_ibans(&self) -> Result> { + let client = self.client.lock().await; + // Get all asset accounts. Note: Pagination might be needed if user has > 50 accounts. + // For typical users, 50 is enough. If needed we can loop pages. + // The client `get_accounts` method hardcodes limit=default. We should probably expose a list_all method or loop here. + // For now, let's assume page 1 covers it or use search. + + let accounts = client.get_accounts("").await?; // Argument ignored in current impl + let mut ibans = Vec::new(); + + for acc in accounts.data { + let is_active = acc.attributes.active.unwrap_or(true); + if is_active { + if let Some(iban) = acc.attributes.iban { + if !iban.is_empty() { + ibans.push(iban); + } + } + } + } + Ok(ibans) + } + + #[instrument(skip(self))] + async fn get_last_transaction_date(&self, account_id: &str) -> Result> { + 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> { + 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" }; + + 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: if !is_credit { Some(account_id.to_string()) } else { None }, + source_name: if is_credit { tx.counterparty_name.clone().or(Some("Unknown Sender".to_string())) } else { None }, + destination_id: if is_credit { Some(account_id.to_string()) } else { None }, + destination_name: if !is_credit { tx.counterparty_name.clone().or(Some("Unknown Recipient".to_string())) } else { None }, + currency_code: Some(tx.currency.clone()), + 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()) + } +} diff --git a/banks2ff/src/adapters/firefly/mod.rs b/banks2ff/src/adapters/firefly/mod.rs new file mode 100644 index 0000000..b9babe5 --- /dev/null +++ b/banks2ff/src/adapters/firefly/mod.rs @@ -0,0 +1 @@ +pub mod client; diff --git a/banks2ff/src/adapters/gocardless/cache.rs b/banks2ff/src/adapters/gocardless/cache.rs new file mode 100644 index 0000000..73e20e2 --- /dev/null +++ b/banks2ff/src/adapters/gocardless/cache.rs @@ -0,0 +1,51 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct AccountCache { + /// Map of Account ID -> IBAN + pub accounts: HashMap, +} + +impl AccountCache { + fn get_path() -> String { + ".banks2ff-cache.json".to_string() + } + + pub fn load() -> Self { + let path = Self::get_path(); + if Path::new(&path).exists() { + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(cache) => return cache, + Err(e) => warn!("Failed to parse cache file: {}", e), + }, + Err(e) => warn!("Failed to read cache file: {}", e), + } + } + Self::default() + } + + pub fn save(&self) { + let path = Self::get_path(); + match serde_json::to_string_pretty(self) { + Ok(content) => { + if let Err(e) = fs::write(&path, content) { + warn!("Failed to write cache file: {}", e); + } + }, + Err(e) => warn!("Failed to serialize cache: {}", e), + } + } + + pub fn get_iban(&self, account_id: &str) -> Option { + self.accounts.get(account_id).cloned() + } + + pub fn insert(&mut self, account_id: String, iban: String) { + self.accounts.insert(account_id, iban); + } +} diff --git a/banks2ff/src/adapters/gocardless/client.rs b/banks2ff/src/adapters/gocardless/client.rs new file mode 100644 index 0000000..344ad7c --- /dev/null +++ b/banks2ff/src/adapters/gocardless/client.rs @@ -0,0 +1,152 @@ +use async_trait::async_trait; +use chrono::NaiveDate; +use anyhow::Result; +use tracing::{info, instrument, warn}; +use crate::core::ports::TransactionSource; +use crate::core::models::{Account, BankTransaction}; +use crate::adapters::gocardless::mapper::map_transaction; +use crate::adapters::gocardless::cache::AccountCache; +use gocardless_client::client::GoCardlessClient; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub struct GoCardlessAdapter { + client: Arc>, + cache: Arc>, +} + +impl GoCardlessAdapter { + pub fn new(client: GoCardlessClient) -> Self { + Self { + client: Arc::new(Mutex::new(client)), + cache: Arc::new(Mutex::new(AccountCache::load())), + } + } +} + +#[async_trait] +impl TransactionSource for GoCardlessAdapter { + #[instrument(skip(self))] + async fn get_accounts(&self, wanted_ibans: Option>) -> Result> { + 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::>() + }); + + 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; + } + + if let Some(req_accounts) = req.accounts { + for acc_id in req_accounts { + // 1. Check Cache + let mut iban_opt = cache.get_iban(&acc_id); + + // 2. Fetch if missing + if iban_opt.is_none() { + match client.get_account(&acc_id).await { + Ok(details) => { + let new_iban = details.iban.unwrap_or_default(); + cache.insert(acc_id.clone(), new_iban.clone()); + cache.save(); + iban_opt = Some(new_iban); + }, + 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 = iban_opt.unwrap_or_default(); + + let mut keep = true; + if let Some(ref wanted) = wanted_set { + if !wanted.contains(&iban.replace(" ", "")) { + keep = false; + } else { + found_count += 1; + } + } + + if keep { + accounts.push(Account { + id: acc_id, + iban, + currency: "EUR".to_string(), + }); + } + + // Optimization: Stop if we found all wanted accounts + if let Some(_) = wanted_set { + if 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> { + let mut client = self.client.lock().await; + client.obtain_access_token().await?; + + let response_result = client.get_transactions( + account_id, + Some(&start.to_string()), + Some(&end.to_string()) + ).await; + + match response_result { + Ok(response) => { + let mut transactions = Vec::new(); + for tx in response.transactions.booked { + match map_transaction(tx) { + Ok(t) => transactions.push(t), + Err(e) => tracing::error!("Failed to map transaction: {}", e), + } + } + + info!("Fetched {} transactions for account {}", transactions.len(), account_id); + Ok(transactions) + }, + Err(e) => { + // Handle 429 specifically? + let err_str = e.to_string(); + if err_str.contains("429") { + warn!("Rate limit reached for account {}. Skipping.", account_id); + // Return empty list implies "no transactions found", which is safe for sync loop (it just won't sync this account). + // Or we could return an error if we want to stop? + // Returning empty list allows other accounts to potentially proceed if limits are per-account (which GC says they are!) + return Ok(vec![]); + } + Err(e.into()) + } + } + } +} diff --git a/banks2ff/src/adapters/gocardless/mapper.rs b/banks2ff/src/adapters/gocardless/mapper.rs new file mode 100644 index 0000000..a71205f --- /dev/null +++ b/banks2ff/src/adapters/gocardless/mapper.rs @@ -0,0 +1,147 @@ +use rust_decimal::Decimal; +use rust_decimal::prelude::Signed; +use std::str::FromStr; +use anyhow::Result; +use crate::core::models::BankTransaction; +use gocardless_client::models::Transaction; + +pub fn map_transaction(tx: Transaction) -> Result { + let internal_id = tx.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)?; + let currency = tx.transaction_amount.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(rate_str)) = (&exchange.source_currency, &exchange.exchange_rate) { + foreign_currency = Some(source_curr.clone()); + if let Ok(rate) = Decimal::from_str(rate_str) { + // If instructedAmount is not available (it's not in our DTO yet), we calculate it. + // But wait, normally instructedAmount is the foreign amount. + // If we don't have it, we estimate: foreign = amount * rate? + // Actually usually: Base (Account) Amount = Foreign Amount / Rate OR Foreign * Rate + // If I have 100 EUR and rate is 1.10 USD/EUR -> 110 USD. + // Let's check the GoCardless spec definition of exchangeRate. + // "exchangeRate": "Factor used to convert an amount from one currency into another. This reflects the price at which the acquirer has bought the currency." + + // Without strict direction, simple multiplication is risky. + // ideally we should have `instructedAmount` or `unitCurrency` logic. + // For now, let's assume: foreign_amount = amount * rate is NOT always correct. + // BUT, usually `sourceCurrency` is the original currency. + // If I spent 10 USD, and my account is EUR. + // sourceCurrency: USD. targetCurrency: EUR. + // transactionAmount: -9.00 EUR. + // exchangeRate: ??? + + // Let's implement a safe calculation or just store what we have. + // Actually, simply multiplying might be wrong if the rate is inverted. + // Let's verify with unit tests if we had real data, but for now let's use the logic: + // foreign_amount = amount * rate (if rate > 0) or amount / rate ? + + // Let's look at the example in my plan: "foreign_amount = amount * currencyExchange[0].exchangeRate" + // I will stick to that plan, but wrap it in a safe calculation. + + let calc = amount.abs() * rate; // Usually rate is positive. + // We preserve the sign of the transaction amount for the foreign amount. + let sign = amount.signum(); + foreign_amount = Some(calc * sign); + } + } + } + } + + // 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)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gocardless_client::models::{TransactionAmount, CurrencyExchange}; + + #[test] + fn test_map_normal_transaction() { + let t = Transaction { + transaction_id: Some("123".into()), + booking_date: Some("2023-01-01".into()), + value_date: None, + transaction_amount: TransactionAmount { + amount: "100.50".into(), + currency: "EUR".into(), + }, + currency_exchange: None, + creditor_name: Some("Shop".into()), + creditor_account: None, + debtor_name: None, + debtor_account: None, + remittance_information_unstructured: Some("Groceries".into()), + proprietary_bank_transaction_code: 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() { + let t = Transaction { + transaction_id: Some("124".into()), + booking_date: Some("2023-01-02".into()), + value_date: None, + transaction_amount: TransactionAmount { + amount: "-10.00".into(), + currency: "EUR".into(), + }, + currency_exchange: Some(vec![CurrencyExchange { + source_currency: Some("USD".into()), + exchange_rate: Some("1.10".into()), + unit_currency: None, + target_currency: Some("EUR".into()), + }]), + creditor_name: Some("US Shop".into()), + creditor_account: None, + debtor_name: None, + debtor_account: None, + remittance_information_unstructured: None, + proprietary_bank_transaction_code: 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())); + + // 10.00 * 1.10 = 11.00. Sign should be preserved (-11.00) + assert_eq!(res.foreign_amount, Some(Decimal::new(-1100, 2))); + + // Description fallback to creditor name + assert_eq!(res.description, "US Shop"); + } +} diff --git a/banks2ff/src/adapters/gocardless/mod.rs b/banks2ff/src/adapters/gocardless/mod.rs new file mode 100644 index 0000000..56e8b85 --- /dev/null +++ b/banks2ff/src/adapters/gocardless/mod.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod mapper; +pub mod cache; diff --git a/banks2ff/src/adapters/mod.rs b/banks2ff/src/adapters/mod.rs new file mode 100644 index 0000000..ec8ff59 --- /dev/null +++ b/banks2ff/src/adapters/mod.rs @@ -0,0 +1,2 @@ +pub mod firefly; +pub mod gocardless; diff --git a/banks2ff/src/core/mod.rs b/banks2ff/src/core/mod.rs new file mode 100644 index 0000000..c68100f --- /dev/null +++ b/banks2ff/src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod models; +pub mod ports; +pub mod sync; diff --git a/banks2ff/src/core/models.rs b/banks2ff/src/core/models.rs new file mode 100644 index 0000000..6adba01 --- /dev/null +++ b/banks2ff/src/core/models.rs @@ -0,0 +1,31 @@ +use rust_decimal::Decimal; +use chrono::NaiveDate; + +#[derive(Debug, 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, + /// Original currency code + pub foreign_currency: Option, + /// Remittance info or description + pub description: String, + /// Counterparty name + pub counterparty_name: Option, + /// Counterparty IBAN + pub counterparty_iban: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Account { + pub id: String, + pub iban: String, + pub currency: String, +} diff --git a/banks2ff/src/core/ports.rs b/banks2ff/src/core/ports.rs new file mode 100644 index 0000000..2da4c9a --- /dev/null +++ b/banks2ff/src/core/ports.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; +use chrono::NaiveDate; +use anyhow::Result; +#[cfg(test)] +use mockall::automock; +use crate::core::models::{BankTransaction, Account}; + +#[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>) -> Result>; + async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result>; +} + +#[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 { + async fn resolve_account_id(&self, iban: &str) -> Result>; + /// Get list of all active asset account IBANs to drive the sync + async fn get_active_account_ibans(&self) -> Result>; + + // New granular methods for Healer Logic + async fn get_last_transaction_date(&self, account_id: &str) -> Result>; + async fn find_transaction(&self, account_id: &str, transaction: &BankTransaction) -> Result>; + async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>; + async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>; +} diff --git a/banks2ff/src/core/sync.rs b/banks2ff/src/core/sync.rs new file mode 100644 index 0000000..ead1a62 --- /dev/null +++ b/banks2ff/src/core/sync.rs @@ -0,0 +1,299 @@ +use anyhow::Result; +use tracing::{info, warn, instrument}; +use crate::core::ports::{TransactionSource, TransactionDestination, IngestResult}; +use chrono::{NaiveDate, Local}; + +#[instrument(skip(source, destination))] +pub async fn run_sync( + source: &S, + destination: &D, + cli_start_date: Option, + cli_end_date: Option, + dry_run: bool, +) -> Result<()> +where + S: TransactionSource, + D: TransactionDestination, +{ + info!("Starting synchronization..."); + + // Optimization: Get active Firefly IBANs first + let wanted_ibans = destination.get_active_account_ibans().await?; + info!("Syncing {} active accounts from Firefly III", wanted_ibans.len()); + + let accounts = source.get_accounts(Some(wanted_ibans)).await?; + + // Default end date is Yesterday + let end_date = cli_end_date.unwrap_or_else(|| Local::now().date_naive() - chrono::Duration::days(1)); + + for account in accounts { + let span = tracing::info_span!("sync_account", iban = %account.iban); + let _enter = span.enter(); + + info!("Processing account..."); + + let dest_id_opt = destination.resolve_account_id(&account.iban).await?; + let Some(dest_id) = dest_id_opt else { + warn!("Account {} not found in destination. Skipping.", account.iban); + continue; + }; + + 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? { + 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); + continue; + } + + info!("Syncing interval: {} to {}", start_date, end_date); + + // Optimization: Only use active accounts is already filtered in resolve_account_id + // However, GoCardless requisitions can expire. + // We should check if we can optimize the GoCardless fetching side. + // But currently get_transactions takes an account_id. + + let transactions = source.get_transactions(&account.id, start_date, end_date).await?; + + if transactions.is_empty() { + info!("No transactions found for period."); + continue; + } + + 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? { + 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; + } + } + } + } + } + + info!("Sync complete. Created: {}, Healed: {}, Duplicates: {}, Errors: {}", + stats.created, stats.healed, stats.duplicates, stats.errors); + } + + info!("Synchronization finished."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ports::{MockTransactionSource, MockTransactionDestination, TransactionMatch}; + use crate::core::models::{Account, BankTransaction}; + use rust_decimal::Decimal; + use mockall::predicate::*; + + #[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(), + iban: "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_get_active_account_ibans() + .returning(|| Ok(vec!["NL01".to_string()])); + + dest.expect_resolve_account_id() + .returning(|_| Ok(Some("dest_1".into()))); + + 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 res = run_sync(&source, &dest, None, None, false).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_sync_flow_heal_existing() { + let mut source = MockTransactionSource::new(); + let mut dest = MockTransactionDestination::new(); + + dest.expect_get_active_account_ibans() + .returning(|| Ok(vec!["NL01".to_string()])); + + source.expect_get_accounts() + .with(always()) + .returning(|_| Ok(vec![Account { + id: "src_1".to_string(), + iban: "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_resolve_account_id().returning(|_| Ok(Some("dest_1".into()))); + 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 res = run_sync(&source, &dest, None, None, false).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_sync_flow_dry_run() { + let mut source = MockTransactionSource::new(); + let mut dest = MockTransactionDestination::new(); + + dest.expect_get_active_account_ibans() + .returning(|| Ok(vec!["NL01".to_string()])); + + source.expect_get_accounts() + .with(always()) + .returning(|_| Ok(vec![Account { + id: "src_1".to_string(), + iban: "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_resolve_account_id().returning(|_| Ok(Some("dest_1".into()))); + 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 res = run_sync(&source, &dest, None, None, true).await; + assert!(res.is_ok()); + } +} \ No newline at end of file diff --git a/banks2ff/src/main.rs b/banks2ff/src/main.rs new file mode 100644 index 0000000..440645c --- /dev/null +++ b/banks2ff/src/main.rs @@ -0,0 +1,74 @@ +mod adapters; +mod core; + +use clap::Parser; +use tracing::{info, error}; +use crate::adapters::gocardless::client::GoCardlessAdapter; +use crate::adapters::firefly::client::FireflyAdapter; +use crate::core::sync::run_sync; +use gocardless_client::client::GoCardlessClient; +use firefly_client::client::FireflyClient; +use std::env; +use chrono::NaiveDate; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to configuration file (optional) + #[arg(short, long)] + config: Option, + + /// Start date for synchronization (YYYY-MM-DD). Defaults to last transaction date + 1. + #[arg(short, long)] + start: Option, + + /// End date for synchronization (YYYY-MM-DD). Defaults to yesterday. + #[arg(short, long)] + end: Option, + + /// Dry run mode: Do not create or update transactions in Firefly III. + #[arg(long, default_value_t = false)] + dry_run: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + // Load environment variables + dotenvy::dotenv().ok(); + + let args = Args::parse(); + + info!("Starting banks2ff..."); + if args.dry_run { + info!("DRY RUN MODE ENABLED: No changes will be made to Firefly III."); + } + + // Config Load + let gc_url = env::var("GOCARDLESS_URL").unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string()); + let gc_id = env::var("GOCARDLESS_ID").expect("GOCARDLESS_ID not set"); + let gc_key = env::var("GOCARDLESS_KEY").expect("GOCARDLESS_KEY not set"); + + let ff_url = env::var("FIREFLY_III_URL").expect("FIREFLY_III_URL not set"); + let ff_key = env::var("FIREFLY_III_API_KEY").expect("FIREFLY_III_API_KEY not set"); + + // Clients + let gc_client = GoCardlessClient::new(&gc_url, &gc_id, &gc_key)?; + let ff_client = FireflyClient::new(&ff_url, &ff_key)?; + + // Adapters + let source = GoCardlessAdapter::new(gc_client); + let destination = FireflyAdapter::new(ff_client); + + // Run + match run_sync(&source, &destination, args.start, args.end, args.dry_run).await { + Ok(_) => info!("Sync completed successfully."), + Err(e) => error!("Sync failed: {}", e), + } + + Ok(()) +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0807dea --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,114 @@ +# 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/ +│ ├── core/ # Domain logic and models +│ ├── adapters/ # External service integrations +│ └── main.rs # CLI entry point +├── 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. 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: + +1. **Account Discovery**: Fetch active accounts from GoCardless +2. **Account Matching**: Match GoCardless accounts to Firefly asset accounts by IBAN +3. **Date Window**: Calculate sync range (Last Firefly transaction + 1 to Yesterday) +4. **Transaction Processing**: + - **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 + +## 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 + +### 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 +- **Propagation**: `anyhow` for error context across async boundaries +- **Graceful Degradation**: Rate limits and network issues don't crash entire sync +- **Structured Logging**: `tracing` for observability and debugging + +## Configuration Management + +- Environment variables loaded via `dotenvy` +- Workspace-level dependency management +- Feature flags for optional functionality +- Secure credential handling (no hardcoded secrets) \ No newline at end of file diff --git a/firefly-client/Cargo.toml b/firefly-client/Cargo.toml new file mode 100644 index 0000000..abc0ee1 --- /dev/null +++ b/firefly-client/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "firefly-client" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +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 } diff --git a/firefly-client/src/client.rs b/firefly-client/src/client.rs new file mode 100644 index 0000000..0e343a4 --- /dev/null +++ b/firefly-client/src/client.rs @@ -0,0 +1,126 @@ +use reqwest::{Client, Url}; +use serde::de::DeserializeOwned; +use thiserror::Error; +use tracing::instrument; +use crate::models::{AccountArray, TransactionStore, TransactionArray, TransactionUpdate}; + +#[derive(Error, Debug)] +pub enum FireflyError { + #[error("Request failed: {0}")] + RequestFailed(#[from] reqwest::Error), + #[error("API Error: {0}")] + ApiError(String), + #[error("URL Parse Error: {0}")] + UrlParseError(#[from] url::ParseError), +} + +pub struct FireflyClient { + base_url: Url, + client: Client, + access_token: String, +} + +impl FireflyClient { + pub fn new(base_url: &str, access_token: &str) -> Result { + Ok(Self { + base_url: Url::parse(base_url)?, + client: Client::new(), + access_token: access_token.to_string(), + }) + } + + #[instrument(skip(self))] + pub async fn get_accounts(&self, _iban: &str) -> Result { + let mut url = self.base_url.join("/api/v1/accounts")?; + url.query_pairs_mut() + .append_pair("type", "asset"); + + self.get_authenticated(url).await + } + + #[instrument(skip(self))] + pub async fn search_accounts(&self, query: &str) -> Result { + 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 { + 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(&self, url: Url) -> Result { + 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) + } +} diff --git a/firefly-client/src/lib.rs b/firefly-client/src/lib.rs new file mode 100644 index 0000000..04f3e94 --- /dev/null +++ b/firefly-client/src/lib.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod models; diff --git a/firefly-client/src/models.rs b/firefly-client/src/models.rs new file mode 100644 index 0000000..7c81819 --- /dev/null +++ b/firefly-client/src/models.rs @@ -0,0 +1,81 @@ +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, + #[serde(rename = "type")] + pub account_type: String, + pub active: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountArray { + pub data: Vec, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionSplit { + pub date: String, + pub amount: String, + pub description: String, + pub external_id: Option, + pub currency_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionArray { + pub data: Vec, +} + +#[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, + pub source_name: Option, + pub destination_id: Option, + pub destination_name: Option, + pub currency_code: Option, + pub foreign_amount: Option, + pub foreign_currency_code: Option, + pub external_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStore { + pub transactions: Vec, + pub apply_rules: Option, + pub fire_webhooks: Option, + pub error_if_duplicate_hash: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionUpdate { + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionSplitUpdate { + pub external_id: Option, +} diff --git a/firefly-client/tests/client_test.rs b/firefly-client/tests/client_test.rs new file mode 100644 index 0000000..1ed8ef5 --- /dev/null +++ b/firefly-client/tests/client_test.rs @@ -0,0 +1,62 @@ +use firefly_client::client::FireflyClient; +use firefly_client::models::{TransactionStore, TransactionSplitStore}; +use wiremock::matchers::{method, path, header}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use std::fs; + +#[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()); +} diff --git a/firefly-client/tests/fixtures/ff_accounts.json b/firefly-client/tests/fixtures/ff_accounts.json new file mode 100644 index 0000000..655903e --- /dev/null +++ b/firefly-client/tests/fixtures/ff_accounts.json @@ -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 + } + } +} diff --git a/gocardless-client/Cargo.toml b/gocardless-client/Cargo.toml new file mode 100644 index 0000000..3bdf8a7 --- /dev/null +++ b/gocardless-client/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gocardless-client" +version.workspace = true +edition.workspace = true +authors.workspace = true + +[dependencies] +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +wiremock = { workspace = true } +tokio = { workspace = true } +tokio-test = { workspace = true } diff --git a/gocardless-client/src/client.rs b/gocardless-client/src/client.rs new file mode 100644 index 0000000..1497265 --- /dev/null +++ b/gocardless-client/src/client.rs @@ -0,0 +1,129 @@ +use reqwest::{Client, Url}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::{debug, instrument}; +use crate::models::{TokenResponse, PaginatedResponse, Requisition, Account, TransactionsResponse}; + +#[derive(Error, Debug)] +pub enum GoCardlessError { + #[error("Request failed: {0}")] + RequestFailed(#[from] reqwest::Error), + #[error("API Error: {0}")] + ApiError(String), + #[error("Serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + #[error("URL Parse Error: {0}")] + UrlParseError(#[from] url::ParseError), +} + +pub struct GoCardlessClient { + base_url: Url, + client: Client, + secret_id: String, + secret_key: String, + access_token: Option, + access_expires_at: Option>, +} + +#[derive(Serialize)] +struct TokenRequest<'a> { + secret_id: &'a str, + secret_key: &'a str, +} + +impl GoCardlessClient { + pub fn new(base_url: &str, secret_id: &str, secret_key: &str) -> Result { + Ok(Self { + base_url: Url::parse(base_url)?, + client: Client::new(), + secret_id: secret_id.to_string(), + secret_key: secret_key.to_string(), + access_token: None, + access_expires_at: None, + }) + } + + #[instrument(skip(self))] + pub async fn obtain_access_token(&mut self) -> Result<(), GoCardlessError> { + // Check if current token is still valid (with 60s buffer) + if let Some(expires) = self.access_expires_at { + if chrono::Utc::now() < expires - chrono::Duration::seconds(60) { + debug!("Access token is still valid"); + return Ok(()); + } + } + + let url = self.base_url.join("/api/v2/token/new/")?; + let body = TokenRequest { + secret_id: &self.secret_id, + secret_key: &self.secret_key, + }; + + debug!("Requesting new access token"); + let response = self.client.post(url) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await?; + return Err(GoCardlessError::ApiError(format!("Token request failed {}: {}", status, text))); + } + + let token_resp: TokenResponse = response.json().await?; + self.access_token = Some(token_resp.access); + self.access_expires_at = Some(chrono::Utc::now() + chrono::Duration::seconds(token_resp.access_expires as i64)); + debug!("Access token obtained"); + + Ok(()) + } + + #[instrument(skip(self))] + pub async fn get_requisitions(&self) -> Result, GoCardlessError> { + let url = self.base_url.join("/api/v2/requisitions/")?; + self.get_authenticated(url).await + } + + #[instrument(skip(self))] + pub async fn get_account(&self, id: &str) -> Result { + let url = self.base_url.join(&format!("/api/v2/accounts/{}/", id))?; + self.get_authenticated(url).await + } + + #[instrument(skip(self))] + pub async fn get_transactions(&self, account_id: &str, date_from: Option<&str>, date_to: Option<&str>) -> Result { + let mut url = self.base_url.join(&format!("/api/v2/accounts/{}/transactions/", account_id))?; + + { + let mut pairs = url.query_pairs_mut(); + if let Some(from) = date_from { + pairs.append_pair("date_from", from); + } + if let Some(to) = date_to { + pairs.append_pair("date_to", to); + } + } + + self.get_authenticated(url).await + } + + async fn get_authenticated Deserialize<'de>>(&self, url: Url) -> Result { + let token = self.access_token.as_ref().ok_or(GoCardlessError::ApiError("No access token available. Call obtain_access_token() first.".into()))?; + + let response = self.client.get(url) + .bearer_auth(token) + .header("accept", "application/json") + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await?; + return Err(GoCardlessError::ApiError(format!("API request failed {}: {}", status, text))); + } + + let data = response.json().await?; + Ok(data) + } +} diff --git a/gocardless-client/src/lib.rs b/gocardless-client/src/lib.rs new file mode 100644 index 0000000..04f3e94 --- /dev/null +++ b/gocardless-client/src/lib.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod models; diff --git a/gocardless-client/src/models.rs b/gocardless-client/src/models.rs new file mode 100644 index 0000000..12c06f3 --- /dev/null +++ b/gocardless-client/src/models.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenResponse { + pub access: String, + pub access_expires: i32, + pub refresh: Option, + pub refresh_expires: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Requisition { + pub id: String, + pub status: String, + pub accounts: Option>, + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaginatedResponse { + pub count: Option, + pub next: Option, + pub previous: Option, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub id: String, + pub created: Option, + pub last_accessed: Option, + pub iban: Option, + pub institution_id: Option, + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionsResponse { + pub transactions: TransactionBookedPending, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionBookedPending { + pub booked: Vec, + pub pending: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transaction { + #[serde(rename = "transactionId")] + pub transaction_id: Option, + #[serde(rename = "bookingDate")] + pub booking_date: Option, + #[serde(rename = "valueDate")] + pub value_date: Option, + #[serde(rename = "transactionAmount")] + pub transaction_amount: TransactionAmount, + #[serde(rename = "currencyExchange")] + pub currency_exchange: Option>, + #[serde(rename = "creditorName")] + pub creditor_name: Option, + #[serde(rename = "creditorAccount")] + pub creditor_account: Option, + #[serde(rename = "debtorName")] + pub debtor_name: Option, + #[serde(rename = "debtorAccount")] + pub debtor_account: Option, + #[serde(rename = "remittanceInformationUnstructured")] + pub remittance_information_unstructured: Option, + #[serde(rename = "proprietaryBankTransactionCode")] + pub proprietary_bank_transaction_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionAmount { + pub amount: String, + pub currency: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrencyExchange { + #[serde(rename = "sourceCurrency")] + pub source_currency: Option, + #[serde(rename = "exchangeRate")] + pub exchange_rate: Option, + #[serde(rename = "unitCurrency")] + pub unit_currency: Option, + #[serde(rename = "targetCurrency")] + pub target_currency: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountDetails { + pub iban: Option, +} diff --git a/gocardless-client/src/tests/client_test.rs b/gocardless-client/src/tests/client_test.rs new file mode 100644 index 0000000..f7e7887 --- /dev/null +++ b/gocardless-client/src/tests/client_test.rs @@ -0,0 +1,55 @@ +use gocardless_client::client::GoCardlessClient; +use gocardless_client::models::TokenResponse; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use std::fs; + +#[tokio::test] +async fn test_get_transactions_parsing() { + // 1. Setup WireMock + let mock_server = MockServer::start().await; + + // Mock Token Endpoint + Mock::given(method("POST")) + .and(path("/api/v2/token/new/")) + .respond_with(ResponseTemplate::new(200).set_body_json(TokenResponse { + access: "fake_access_token".to_string(), + access_expires: 3600, + refresh: Some("fake_refresh".to_string()), + refresh_expires: Some(86400), + })) + .mount(&mock_server) + .await; + + // Mock Transactions Endpoint + let fixture = fs::read_to_string("tests/fixtures/gc_transactions.json").unwrap(); + + Mock::given(method("GET")) + .and(path("/api/v2/accounts/ACC123/transactions/")) + .respond_with(ResponseTemplate::new(200).set_body_string(fixture)) + .mount(&mock_server) + .await; + + // 2. Run Client + let mut client = GoCardlessClient::new(&mock_server.uri(), "id", "key").unwrap(); + client.obtain_access_token().await.unwrap(); + + let resp = client.get_transactions("ACC123", None, None).await.unwrap(); + + // 3. Assertions + assert_eq!(resp.transactions.booked.len(), 2); + + let tx1 = &resp.transactions.booked[0]; + assert_eq!(tx1.transaction_id.as_deref(), Some("TX123")); + assert_eq!(tx1.transaction_amount.amount, "100.00"); + assert_eq!(tx1.transaction_amount.currency, "EUR"); + + let tx2 = &resp.transactions.booked[1]; + assert_eq!(tx2.transaction_id.as_deref(), Some("TX124")); + assert_eq!(tx2.transaction_amount.amount, "-10.00"); + + // Verify Multi-Currency parsing + let exchange = tx2.currency_exchange.as_ref().unwrap(); + assert_eq!(exchange[0].source_currency.as_deref(), Some("USD")); + assert_eq!(exchange[0].exchange_rate.as_deref(), Some("1.10")); +} diff --git a/gocardless-client/src/tests/fixtures/gc_transactions.json b/gocardless-client/src/tests/fixtures/gc_transactions.json new file mode 100644 index 0000000..eef36da --- /dev/null +++ b/gocardless-client/src/tests/fixtures/gc_transactions.json @@ -0,0 +1,34 @@ +{ + "transactions": { + "booked": [ + { + "transactionId": "TX123", + "bookingDate": "2023-10-01", + "transactionAmount": { + "amount": "100.00", + "currency": "EUR" + }, + "debtorName": "John Doe", + "remittanceInformationUnstructured": "Payment for services" + }, + { + "transactionId": "TX124", + "bookingDate": "2023-10-02", + "transactionAmount": { + "amount": "-10.00", + "currency": "EUR" + }, + "currencyExchange": [ + { + "sourceCurrency": "USD", + "exchangeRate": "1.10", + "targetCurrency": "EUR" + } + ], + "creditorName": "US Store", + "remittanceInformationUnstructured": "US Purchase" + } + ], + "pending": [] + } +} diff --git a/specs/planning.md b/specs/planning.md index 3090df6..1bb9350 100644 --- a/specs/planning.md +++ b/specs/planning.md @@ -1,129 +1,137 @@ # Implementation Plan: Bank2FF Refactoring ## 1. Objective -Refactor the `bank2ff` application from a prototype script into a robust, testable, and observable production-grade CLI tool. The application must synchronize bank transactions from GoCardless to Firefly III, ensuring: -- **Clean Architecture**: Decoupling of business logic from API clients. -- **Testability**: Ability to unit test core logic without external dependencies. -- **Multi-Currency Support**: Accurate capturing of foreign amounts and currencies. -- **Idempotency**: Preventing duplicate transactions in Firefly III. +Refactor the `bank2ff` application from a prototype script into a robust, testable, and observable production-grade CLI tool. The application must synchronize bank transactions from GoCardless to Firefly III. -## 2. Architecture: Hexagonal (Ports & Adapters) +**Key Constraints:** +- **Multi-Crate Workspace**: Separate crates for the CLI application and the API clients. +- **Hand-Crafted Clients**: No autogenerated code. Custom, strongly-typed clients for better UX. +- **Clean Architecture**: Hexagonal architecture within the main application. +- **Multi-Currency**: Accurate handling of foreign amounts. +- **Test-Driven**: Every component must be testable from the start. +- **Observability**: Structured logging (tracing) throughout the stack. +- **Healer Strategy**: Detect and heal historical duplicates that lack external IDs. +- **Dry Run**: Safe mode to preview changes. +- **Rate Limit Handling**: Smart caching and graceful skipping to respect 4 requests/day limits. -The application will be structured into three distinct layers: -1. **Core (Domain)**: Pure Rust, no external API dependencies. Defines the `BankTransaction` model and the `Ports` (traits) for interacting with the world. -2. **Adapters**: Implementations of the Ports. - - `GoCardlessAdapter`: Implements `TransactionSource`. - - `FireflyAdapter`: Implements `TransactionDestination`. -3. **Application**: Configuration, CLI parsing, and wiring (`main.rs`). +## 2. Architecture -### Directory Structure +### Workspace Structure +The project uses a Cargo Workspace with three members: + +1. `gocardless-client`: A reusable library crate wrapping the GoCardless Bank Account Data API v2. +2. `firefly-client`: A reusable library crate wrapping the Firefly III API v6.4.4. +3. `banks2ff`: The main CLI application containing the Domain Core and Adapters that use the client libraries. + +### Directory Layout ```text -bank2ff/src/ -├── core/ -│ ├── mod.rs -│ ├── models.rs # Domain entities (BankTransaction, Account) -│ ├── ports.rs # Traits (TransactionSource, TransactionDestination) -│ └── sync.rs # Core business logic (The "Use Case") -├── adapters/ -│ ├── mod.rs -│ ├── gocardless/ -│ │ ├── mod.rs -│ │ ├── client.rs # Wrapper around generated client (Auth/RateLimits) -│ │ └── mapper.rs # Logic to map API response -> Domain Model -│ └── firefly/ -│ │ ├── mod.rs -│ │ └── client.rs # Implementation of TransactionDestination -└── main.rs # Entry point, wiring, and config loading +root/ +├── Cargo.toml # Workspace definition +├── gocardless-client/ # Crate 1 +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs +│ ├── client.rs # Reqwest client logic +│ ├── models.rs # Request/Response DTOs +│ └── tests/ # Unit/Integration tests with mocks +├── firefly-client/ # Crate 2 +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs +│ ├── client.rs # Reqwest client logic +│ ├── models.rs # Request/Response DTOs +│ └── tests/ # Unit/Integration tests with mocks +└── banks2ff/ # Crate 3 (Main App) + ├── Cargo.toml + └── src/ + ├── main.rs + ├── core/ # Domain + │ ├── models.rs + │ ├── ports.rs # Traits (Mockable) + │ ├── sync.rs # Logic (Tested with mocks) + │ └── tests/ # Unit tests for logic + └── adapters/ # Integration Layers + ├── gocardless/ + │ ├── client.rs # Uses gocardless-client & Cache + │ ├── cache.rs # JSON Cache for Account details + │ └── mapper.rs + └── firefly/ + └── client.rs # Uses firefly-client ``` -## 3. Core definitions +## 3. Observability Strategy +- **Tracing**: All crates will use the `tracing` crate. +- **Spans**: + - `client` crates: Create spans for every HTTP request (method, URL). + - `banks2ff` adapter: Create spans for "Fetching transactions". + - `banks2ff` sync: Create a span per account synchronization. +- **Context**: IDs (account, transaction) must be attached to log events. -### `src/core/models.rs` -The domain model must support multi-currency data. +## 4. Testing Strategy +- **Client Crates**: + - Use `wiremock` to mock the HTTP server. + - Test parsing of *real* JSON responses (saved in `tests/fixtures/`). + - Verify correct request construction (Headers, Auth, Body). +- **Core (`banks2ff`)**: + - Use `mockall` to mock `TransactionSource` and `TransactionDestination` traits. + - Unit test `sync::run_sync` logic (filtering, flow control) without any I/O. +- **Adapters (`banks2ff`)**: + - Test mapping logic (Client DTO -> Domain Model) using unit tests with fixtures. -```rust -pub struct BankTransaction { - pub internal_id: String, // Source ID (GoCardless transactionId) - pub date: NaiveDate, // Booking date - pub amount: Decimal, // Amount in account currency - pub currency: String, // Account currency code (e.g., EUR) - pub foreign_amount: Option, // Original amount (if currency exchange occurred) - pub foreign_currency: Option,// Original currency code - pub description: String, - pub counterparty_name: Option, - pub counterparty_iban: Option, -} -``` +## 5. Implementation Steps -### `src/core/ports.rs` -Traits to decouple the architecture. +### Phase 1: Infrastructure & Workspace +- [x] **Setup**: Initialize `gocardless-client` and `firefly-client` crates. Update root `Cargo.toml`. +- [x] **Dependencies**: Add `reqwest`, `serde`, `thiserror`, `url`, `tracing`. +- [x] **Test Deps**: Add `wiremock`, `tokio-test`, `serde_json` (dev-dependencies). -```rust -#[async_trait] -pub trait TransactionSource: Send + Sync { - async fn get_accounts(&self) -> Result>; - async fn get_transactions(&self, account_id: &str, start: NaiveDate, end: NaiveDate) -> Result>; -} +### Phase 2: Core (`banks2ff`) +- [x] **Definitions**: Implement `models.rs` and `ports.rs` in `banks2ff`. +- [x] **Mocks**: Add `mockall` attribute to ports for easier testing. -#[async_trait] -pub trait TransactionDestination: Send + Sync { - async fn resolve_account_id(&self, iban: &str) -> Result>; - async fn ingest_transactions(&self, account_id: &str, transactions: Vec) -> Result; -} -``` +### Phase 3: GoCardless Client Crate +- [x] **Models**: Define DTOs in `gocardless-client/src/models.rs`. +- [x] **Fixtures**: Create `tests/fixtures/gc_transactions.json` (real example data). +- [x] **Client**: Implement `GoCardlessClient`. +- [x] **Tests**: Write `tests/client_test.rs` using `wiremock` to serve the fixture and verify the client parses it correctly. -## 4. Implementation Steps +### Phase 4: GoCardless Adapter (`banks2ff`) +- [x] **Implementation**: Implement `TransactionSource`. +- [x] **Logic**: Handle **Multi-Currency** (inspect `currencyExchange`). +- [x] **Tests**: Unit test the *mapping logic* specifically. Input: GC Client DTO. Output: Domain Model. Assert foreign amounts are correct. +- [x] **Optimization**: Implement "Firefly Leading" strategy (only fetch wanted accounts). +- [x] **Optimization**: Implement Account Cache & Rate Limit handling. -### Phase 1: Infrastructure & Core -1. **Setup**: Create the directory structure. -2. **Dependencies**: Add `anyhow`, `thiserror`, `tracing`, `tracing-subscriber`, `async-trait`, `rust_decimal`, `chrono`. -3. **Core**: Implement `models.rs` and `ports.rs`. +### Phase 5: Firefly Client Crate +- [x] **Models**: Define DTOs in `firefly-client/src/models.rs`. +- [x] **Fixtures**: Create `tests/fixtures/ff_store_transaction.json`. +- [x] **Client**: Implement `FireflyClient`. +- [x] **Tests**: Write `tests/client_test.rs` using `wiremock` to verify auth headers and body serialization. -### Phase 2: GoCardless Adapter (Source) -1. **Token Management**: Create a wrapper struct that holds the `gocardless_bankaccount_data_api` configuration and manages the Access/Refresh token lifecycle. It should check validity before every request. -2. **Mapping Logic**: - - Map `transactionAmount.amount` -> `BankTransaction.amount`. - - **Multi-Currency**: Inspect `currencyExchange`. - - If present, map `sourceCurrency` -> `foreign_currency`. - - Calculate `foreign_amount` using `exchangeRate` if `instructedAmount` is missing. - - Map `transactionId` -> `internal_id`. -3. **Trait Implementation**: Implement `TransactionSource`. +### Phase 6: Firefly Adapter (`banks2ff`) +- [x] **Implementation**: Implement `TransactionDestination`. +- [x] **Logic**: Set `external_id`, handle Credit/Debit swap. +- [x] **Tests**: Unit test mapping logic. Verify `external_id` is populated. +- [x] **Update**: Refactor for "Healer" strategy (split `ingest` into `find`, `create`, `update`). -### Phase 3: Firefly Adapter (Destination) -1. **Client Wrapper**: Initialize `firefly_iii_api` client with API Key. -2. **Resolution**: Implement `resolve_account_id` by querying Firefly accounts by IBAN (using the search/list endpoint). -3. **Ingestion**: - - Map `BankTransaction` to `TransactionSplitStore`. - - **Crucial**: Set `external_id` = `BankTransaction.internal_id`. - - Handle Credits vs Debits (Swap Source/Destination logic). - - Call `store_transaction` endpoint. - - Handle 422/409 errors gracefully (log duplicates). +### Phase 7: Synchronization Engine +- [x] **Logic**: Implement `banks2ff::core::sync::run_sync` with "Healer" logic. + - Check Destination for existing transaction (Windowed Search). + - If found without ID: Heal (Update). + - If found with ID: Skip. + - If not found: Create. +- [x] **Smart Defaults**: Implement default start date (Last Firefly Date + 1) and end date (Yesterday). +- [x] **Tests**: Update unit tests for the new flow. -### Phase 4: Synchronization Engine -1. Implement `src/core/sync.rs`. -2. Logic: - - Fetch accounts from Source. - - For each account, find Destination ID. - - Fetch transactions (default: last 30 days). - - Filter/Process (optional). - - Ingest to Destination. - - Log statistics using `tracing`. +### Phase 8: Wiring & CLI +- [x] **CLI**: Add `-s/--start` and `-e/--end` arguments. +- [x] **CLI**: Add `--dry-run` argument. +- [x] **Wiring**: Pass these arguments to the sync engine. +- [x] **Observability**: Initialize `tracing_subscriber` with env filter. +- [x] **Config**: Load Env vars. -### Phase 5: Wiring -1. Refactor `main.rs`. -2. Initialize `tracing` for structured logging. -3. Load Config (Env variables). -4. Instantiate Adapters. -5. Run the Sync Engine. - -## 5. Multi-Currency Handling Specifics -- **GoCardless Spec**: The `currencyExchange` array contains the details. -- **Logic**: - - If `currencyExchange` is not null/empty: - - `foreign_currency` = `currencyExchange[0].sourceCurrency` - - `foreign_amount` = `amount` * `currencyExchange[0].exchangeRate` (Validation: check if `unitCurrency` affects this). - - Ensure `Decimal` precision is handled correctly. - -## 6. Testability & Observability -- **Tests**: Write a unit test for `core/sync.rs` using Mock structs for Source/Destination to verify the flow logic. -- **Logs**: Use `tracing::info!` for high-level progress ("Synced Account X") and `tracing::debug!` for details ("Transaction Y mapped to Z"). +## 6. Multi-Currency Logic +- **GoCardless Adapter**: + - `foreign_currency` = `currencyExchange[0].sourceCurrency` + - `foreign_amount` = `amount` * `currencyExchange[0].exchangeRate` + - Test this math explicitly in Phase 4 tests.