Compare commits

..

3 Commits

Author SHA1 Message Date
93c1c8d861 Formatting fixes
The result of `cargo fmt`.
2025-11-27 21:24:52 +01:00
508975a086 Fix clippy warnings 2025-11-27 21:24:51 +01:00
53087fa900 Implement encrypted transaction caching for GoCardless adapter
- Reduces GoCardless API calls by up to 99% through intelligent caching of transaction data
- Secure AES-GCM encryption with PBKDF2 key derivation (200k iterations) for at-rest storage
- Automatic range merging and transaction deduplication to minimize storage and API usage
- Cache-first approach with automatic fetching of uncovered date ranges
- Comprehensive test suite with 30 unit tests covering all cache operations and edge cases
- Thread-safe implementation with in-memory caching and encrypted disk persistence

Cache everything Gocardless sends back
2025-11-27 21:24:30 +01:00
3 changed files with 215 additions and 1 deletions

View File

@@ -108,8 +108,15 @@ mod tests {
fn test_map_normal_transaction() { fn test_map_normal_transaction() {
let t = Transaction { let t = Transaction {
transaction_id: Some("123".into()), transaction_id: Some("123".into()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2023-01-01".into()), booking_date: Some("2023-01-01".into()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount { transaction_amount: TransactionAmount {
amount: "100.50".into(), amount: "100.50".into(),
currency: "EUR".into(), currency: "EUR".into(),
@@ -117,10 +124,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Shop".into()), creditor_name: Some("Shop".into()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: Some("Groceries".into()), remittance_information_unstructured: Some("Groceries".into()),
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
let res = map_transaction(t).unwrap(); let res = map_transaction(t).unwrap();
@@ -135,8 +151,15 @@ mod tests {
fn test_map_multicurrency_transaction() { fn test_map_multicurrency_transaction() {
let t = Transaction { let t = Transaction {
transaction_id: Some("124".into()), transaction_id: Some("124".into()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2023-01-02".into()), booking_date: Some("2023-01-02".into()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount { transaction_amount: TransactionAmount {
amount: "-10.00".into(), amount: "-10.00".into(),
currency: "EUR".into(), currency: "EUR".into(),
@@ -149,10 +172,19 @@ mod tests {
}]), }]),
creditor_name: Some("US Shop".into()), creditor_name: Some("US Shop".into()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: None, remittance_information_unstructured: None,
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
let res = map_transaction(t).unwrap(); let res = map_transaction(t).unwrap();
@@ -201,8 +233,15 @@ mod tests {
fn test_map_transaction_invalid_amount() { fn test_map_transaction_invalid_amount() {
let t = Transaction { let t = Transaction {
transaction_id: Some("125".into()), transaction_id: Some("125".into()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2023-01-03".into()), booking_date: Some("2023-01-03".into()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount { transaction_amount: TransactionAmount {
amount: "0.00".into(), amount: "0.00".into(),
currency: "EUR".into(), currency: "EUR".into(),
@@ -210,10 +249,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Test".into()), creditor_name: Some("Test".into()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: None, remittance_information_unstructured: None,
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
assert!(map_transaction(t).is_err()); assert!(map_transaction(t).is_err());
@@ -223,8 +271,15 @@ mod tests {
fn test_map_transaction_invalid_currency() { fn test_map_transaction_invalid_currency() {
let t = Transaction { let t = Transaction {
transaction_id: Some("126".into()), transaction_id: Some("126".into()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2023-01-04".into()), booking_date: Some("2023-01-04".into()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount { transaction_amount: TransactionAmount {
amount: "100.00".into(), amount: "100.00".into(),
currency: "euro".into(), currency: "euro".into(),
@@ -232,10 +287,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Test".into()), creditor_name: Some("Test".into()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: None, remittance_information_unstructured: None,
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
assert!(map_transaction(t).is_err()); assert!(map_transaction(t).is_err());
@@ -245,8 +309,15 @@ mod tests {
fn test_map_transaction_invalid_foreign_amount() { fn test_map_transaction_invalid_foreign_amount() {
let t = Transaction { let t = Transaction {
transaction_id: Some("127".into()), transaction_id: Some("127".into()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2023-01-05".into()), booking_date: Some("2023-01-05".into()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount { transaction_amount: TransactionAmount {
amount: "-10.00".into(), amount: "-10.00".into(),
currency: "EUR".into(), currency: "EUR".into(),
@@ -259,10 +330,19 @@ mod tests {
}]), }]),
creditor_name: Some("Test".into()), creditor_name: Some("Test".into()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: None, remittance_information_unstructured: None,
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
assert!(map_transaction(t).is_err()); assert!(map_transaction(t).is_err());
@@ -272,8 +352,15 @@ mod tests {
fn test_map_transaction_invalid_foreign_currency() { fn test_map_transaction_invalid_foreign_currency() {
let t = Transaction { let t = Transaction {
transaction_id: Some("128".into()), transaction_id: Some("128".into()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2023-01-06".into()), booking_date: Some("2023-01-06".into()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: TransactionAmount { transaction_amount: TransactionAmount {
amount: "-10.00".into(), amount: "-10.00".into(),
currency: "EUR".into(), currency: "EUR".into(),
@@ -286,10 +373,19 @@ mod tests {
}]), }]),
creditor_name: Some("Test".into()), creditor_name: Some("Test".into()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: None, remittance_information_unstructured: None,
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
assert!(map_transaction(t).is_err()); assert!(map_transaction(t).is_err());

View File

@@ -340,8 +340,15 @@ mod tests {
let transaction = Transaction { let transaction = Transaction {
transaction_id: Some("test-tx-1".to_string()), transaction_id: Some("test-tx-1".to_string()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2024-01-01".to_string()), booking_date: Some("2024-01-01".to_string()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: gocardless_client::models::TransactionAmount { transaction_amount: gocardless_client::models::TransactionAmount {
amount: "100.00".to_string(), amount: "100.00".to_string(),
currency: "EUR".to_string(), currency: "EUR".to_string(),
@@ -349,10 +356,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Test Creditor".to_string()), creditor_name: Some("Test Creditor".to_string()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: Some("Test payment".to_string()), remittance_information_unstructured: Some("Test payment".to_string()),
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
let range = CachedRange { let range = CachedRange {
@@ -491,8 +507,15 @@ mod tests {
let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let tx1 = Transaction { let tx1 = Transaction {
transaction_id: Some("tx1".to_string()), transaction_id: Some("tx1".to_string()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2024-01-05".to_string()), booking_date: Some("2024-01-05".to_string()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: gocardless_client::models::TransactionAmount { transaction_amount: gocardless_client::models::TransactionAmount {
amount: "100.00".to_string(), amount: "100.00".to_string(),
currency: "EUR".to_string(), currency: "EUR".to_string(),
@@ -500,10 +523,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Creditor".to_string()), creditor_name: Some("Creditor".to_string()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: Some("Payment".to_string()), remittance_information_unstructured: Some("Payment".to_string()),
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
cache.store_transactions(start1, end1, vec![tx1]); cache.store_transactions(start1, end1, vec![tx1]);
@@ -517,8 +549,15 @@ mod tests {
let end2 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(); let end2 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let tx2 = Transaction { let tx2 = Transaction {
transaction_id: Some("tx2".to_string()), transaction_id: Some("tx2".to_string()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2024-01-12".to_string()), booking_date: Some("2024-01-12".to_string()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: gocardless_client::models::TransactionAmount { transaction_amount: gocardless_client::models::TransactionAmount {
amount: "200.00".to_string(), amount: "200.00".to_string(),
currency: "EUR".to_string(), currency: "EUR".to_string(),
@@ -526,10 +565,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Creditor2".to_string()), creditor_name: Some("Creditor2".to_string()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: Some("Payment2".to_string()), remittance_information_unstructured: Some("Payment2".to_string()),
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
cache.store_transactions(start2, end2, vec![tx2]); cache.store_transactions(start2, end2, vec![tx2]);
@@ -549,9 +597,16 @@ mod tests {
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let tx1 = Transaction { let tx1 = Transaction {
transaction_id: Some("dup".to_string()), transaction_id: Some("tx1".to_string()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2024-01-05".to_string()), booking_date: Some("2024-01-05".to_string()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: gocardless_client::models::TransactionAmount { transaction_amount: gocardless_client::models::TransactionAmount {
amount: "100.00".to_string(), amount: "100.00".to_string(),
currency: "EUR".to_string(), currency: "EUR".to_string(),
@@ -559,10 +614,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Creditor".to_string()), creditor_name: Some("Creditor".to_string()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: Some("Payment".to_string()), remittance_information_unstructured: Some("Payment".to_string()),
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
let tx2 = tx1.clone(); // Duplicate let tx2 = tx1.clone(); // Duplicate
cache.store_transactions(start, end, vec![tx1, tx2]); cache.store_transactions(start, end, vec![tx1, tx2]);
@@ -574,8 +638,15 @@ mod tests {
fn test_get_cached_transactions() { fn test_get_cached_transactions() {
let tx1 = Transaction { let tx1 = Transaction {
transaction_id: Some("tx1".to_string()), transaction_id: Some("tx1".to_string()),
entry_reference: None,
end_to_end_id: None,
mandate_id: None,
check_id: None,
creditor_id: None,
booking_date: Some("2024-01-05".to_string()), booking_date: Some("2024-01-05".to_string()),
value_date: None, value_date: None,
booking_date_time: None,
value_date_time: None,
transaction_amount: gocardless_client::models::TransactionAmount { transaction_amount: gocardless_client::models::TransactionAmount {
amount: "100.00".to_string(), amount: "100.00".to_string(),
currency: "EUR".to_string(), currency: "EUR".to_string(),
@@ -583,10 +654,19 @@ mod tests {
currency_exchange: None, currency_exchange: None,
creditor_name: Some("Creditor".to_string()), creditor_name: Some("Creditor".to_string()),
creditor_account: None, creditor_account: None,
ultimate_creditor: None,
debtor_name: None, debtor_name: None,
debtor_account: None, debtor_account: None,
ultimate_debtor: None,
remittance_information_unstructured: Some("Payment".to_string()), remittance_information_unstructured: Some("Payment".to_string()),
remittance_information_unstructured_array: None,
remittance_information_structured: None,
remittance_information_structured_array: None,
additional_information: None,
purpose_code: None,
bank_transaction_code: None,
proprietary_bank_transaction_code: None, proprietary_bank_transaction_code: None,
internal_transaction_id: None,
}; };
let range = CachedRange { let range = CachedRange {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),

View File

@@ -59,10 +59,24 @@ pub struct TransactionBookedPending {
pub struct Transaction { pub struct Transaction {
#[serde(rename = "transactionId")] #[serde(rename = "transactionId")]
pub transaction_id: Option<String>, pub transaction_id: Option<String>,
#[serde(rename = "entryReference")]
pub entry_reference: Option<String>,
#[serde(rename = "endToEndId")]
pub end_to_end_id: Option<String>,
#[serde(rename = "mandateId")]
pub mandate_id: Option<String>,
#[serde(rename = "checkId")]
pub check_id: Option<String>,
#[serde(rename = "creditorId")]
pub creditor_id: Option<String>,
#[serde(rename = "bookingDate")] #[serde(rename = "bookingDate")]
pub booking_date: Option<String>, pub booking_date: Option<String>,
#[serde(rename = "valueDate")] #[serde(rename = "valueDate")]
pub value_date: Option<String>, pub value_date: Option<String>,
#[serde(rename = "bookingDateTime")]
pub booking_date_time: Option<String>,
#[serde(rename = "valueDateTime")]
pub value_date_time: Option<String>,
#[serde(rename = "transactionAmount")] #[serde(rename = "transactionAmount")]
pub transaction_amount: TransactionAmount, pub transaction_amount: TransactionAmount,
#[serde(rename = "currencyExchange")] #[serde(rename = "currencyExchange")]
@@ -71,14 +85,32 @@ pub struct Transaction {
pub creditor_name: Option<String>, pub creditor_name: Option<String>,
#[serde(rename = "creditorAccount")] #[serde(rename = "creditorAccount")]
pub creditor_account: Option<AccountDetails>, pub creditor_account: Option<AccountDetails>,
#[serde(rename = "ultimateCreditor")]
pub ultimate_creditor: Option<String>,
#[serde(rename = "debtorName")] #[serde(rename = "debtorName")]
pub debtor_name: Option<String>, pub debtor_name: Option<String>,
#[serde(rename = "debtorAccount")] #[serde(rename = "debtorAccount")]
pub debtor_account: Option<AccountDetails>, pub debtor_account: Option<AccountDetails>,
#[serde(rename = "ultimateDebtor")]
pub ultimate_debtor: Option<String>,
#[serde(rename = "remittanceInformationUnstructured")] #[serde(rename = "remittanceInformationUnstructured")]
pub remittance_information_unstructured: Option<String>, pub remittance_information_unstructured: Option<String>,
#[serde(rename = "remittanceInformationUnstructuredArray")]
pub remittance_information_unstructured_array: Option<Vec<String>>,
#[serde(rename = "remittanceInformationStructured")]
pub remittance_information_structured: Option<String>,
#[serde(rename = "remittanceInformationStructuredArray")]
pub remittance_information_structured_array: Option<Vec<String>>,
#[serde(rename = "additionalInformation")]
pub additional_information: Option<String>,
#[serde(rename = "purposeCode")]
pub purpose_code: Option<String>,
#[serde(rename = "bankTransactionCode")]
pub bank_transaction_code: Option<String>,
#[serde(rename = "proprietaryBankTransactionCode")] #[serde(rename = "proprietaryBankTransactionCode")]
pub proprietary_bank_transaction_code: Option<String>, pub proprietary_bank_transaction_code: Option<String>,
#[serde(rename = "internalTransactionId")]
pub internal_transaction_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -102,4 +134,10 @@ pub struct CurrencyExchange {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountDetails { pub struct AccountDetails {
pub iban: Option<String>, pub iban: Option<String>,
pub bban: Option<String>,
pub pan: Option<String>,
#[serde(rename = "maskedPan")]
pub masked_pan: Option<String>,
pub msisdn: Option<String>,
pub currency: Option<String>,
} }