Implement logic
This commit is contained in:
19
gocardless-client/Cargo.toml
Normal file
19
gocardless-client/Cargo.toml
Normal 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 }
|
||||
129
gocardless-client/src/client.rs
Normal file
129
gocardless-client/src/client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
2
gocardless-client/src/lib.rs
Normal file
2
gocardless-client/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
95
gocardless-client/src/models.rs
Normal file
95
gocardless-client/src/models.rs
Normal 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>,
|
||||
}
|
||||
55
gocardless-client/src/tests/client_test.rs
Normal file
55
gocardless-client/src/tests/client_test.rs
Normal 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"));
|
||||
}
|
||||
34
gocardless-client/src/tests/fixtures/gc_transactions.json
vendored
Normal file
34
gocardless-client/src/tests/fixtures/gc_transactions.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user