Implement logic

This commit is contained in:
2025-11-19 21:18:37 +00:00
parent ab81c729c7
commit 9a5c6d0f68
31 changed files with 4802 additions and 139 deletions

View File

@@ -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 }

View File

@@ -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<String>,
access_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[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<Self, GoCardlessError> {
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<PaginatedResponse<Requisition>, 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<Account, GoCardlessError> {
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<TransactionsResponse, GoCardlessError> {
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<T: for<'de> Deserialize<'de>>(&self, url: Url) -> Result<T, GoCardlessError> {
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)
}
}

View File

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

View File

@@ -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<String>,
pub refresh_expires: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Requisition {
pub id: String,
pub status: String,
pub accounts: Option<Vec<String>>,
pub reference: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginatedResponse<T> {
pub count: Option<i32>,
pub next: Option<String>,
pub previous: Option<String>,
pub results: Vec<T>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: String,
pub created: Option<String>,
pub last_accessed: Option<String>,
pub iban: Option<String>,
pub institution_id: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionsResponse {
pub transactions: TransactionBookedPending,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionBookedPending {
pub booked: Vec<Transaction>,
pub pending: Option<Vec<Transaction>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
#[serde(rename = "transactionId")]
pub transaction_id: Option<String>,
#[serde(rename = "bookingDate")]
pub booking_date: Option<String>,
#[serde(rename = "valueDate")]
pub value_date: Option<String>,
#[serde(rename = "transactionAmount")]
pub transaction_amount: TransactionAmount,
#[serde(rename = "currencyExchange")]
pub currency_exchange: Option<Vec<CurrencyExchange>>,
#[serde(rename = "creditorName")]
pub creditor_name: Option<String>,
#[serde(rename = "creditorAccount")]
pub creditor_account: Option<AccountDetails>,
#[serde(rename = "debtorName")]
pub debtor_name: Option<String>,
#[serde(rename = "debtorAccount")]
pub debtor_account: Option<AccountDetails>,
#[serde(rename = "remittanceInformationUnstructured")]
pub remittance_information_unstructured: Option<String>,
#[serde(rename = "proprietaryBankTransactionCode")]
pub proprietary_bank_transaction_code: Option<String>,
}
#[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<String>,
#[serde(rename = "exchangeRate")]
pub exchange_rate: Option<String>,
#[serde(rename = "unitCurrency")]
pub unit_currency: Option<String>,
#[serde(rename = "targetCurrency")]
pub target_currency: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountDetails {
pub iban: Option<String>,
}

View File

@@ -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"));
}

View File

@@ -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": []
}
}