- Implement robust End User Agreement expiry detection and handling - Add graceful error recovery for failed accounts - Rewrite README.md to focus on user benefits - Add documentation guidelines to AGENTS.md
170 lines
6.2 KiB
Rust
170 lines
6.2 KiB
Rust
use reqwest::Url;
|
|
use reqwest_middleware::ClientWithMiddleware;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
use tracing::{debug, instrument};
|
|
use crate::models::{TokenResponse, PaginatedResponse, Requisition, Account, TransactionsResponse, EndUserAgreement};
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum GoCardlessError {
|
|
#[error("Request failed: {0}")]
|
|
RequestFailed(#[from] reqwest::Error),
|
|
#[error("Middleware error: {0}")]
|
|
MiddlewareError(#[from] reqwest_middleware::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: ClientWithMiddleware,
|
|
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> {
|
|
Self::with_client(base_url, secret_id, secret_key, None)
|
|
}
|
|
|
|
pub fn with_client(base_url: &str, secret_id: &str, secret_key: &str, client: Option<ClientWithMiddleware>) -> Result<Self, GoCardlessError> {
|
|
Ok(Self {
|
|
base_url: Url::parse(base_url)?,
|
|
client: client.unwrap_or_else(|| reqwest_middleware::ClientBuilder::new(reqwest::Client::new()).build()),
|
|
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_agreements(&self) -> Result<PaginatedResponse<EndUserAgreement>, GoCardlessError> {
|
|
let url = self.base_url.join("/api/v2/agreements/enduser/")?;
|
|
self.get_authenticated(url).await
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn get_agreement(&self, id: &str) -> Result<EndUserAgreement, GoCardlessError> {
|
|
let url = self.base_url.join(&format!("/api/v2/agreements/enduser/{}/", id))?;
|
|
self.get_authenticated(url).await
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn is_agreement_expired(&self, agreement_id: &str) -> Result<bool, GoCardlessError> {
|
|
let agreement = self.get_agreement(agreement_id).await?;
|
|
|
|
// If not accepted, it's not valid
|
|
let Some(accepted_str) = agreement.accepted else {
|
|
return Ok(true);
|
|
};
|
|
|
|
// Parse acceptance date
|
|
let accepted = chrono::DateTime::parse_from_rfc3339(&accepted_str)
|
|
.map_err(|e| GoCardlessError::ApiError(format!("Invalid date format: {}", e)))?
|
|
.with_timezone(&chrono::Utc);
|
|
|
|
// Get validity period (default 90 days)
|
|
let valid_days = agreement.access_valid_for_days.unwrap_or(90) as i64;
|
|
let expiry = accepted + chrono::Duration::days(valid_days);
|
|
|
|
Ok(chrono::Utc::now() > expiry)
|
|
}
|
|
|
|
#[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)
|
|
}
|
|
}
|