(feat) refactoring, migration, sqlx prepare

This commit is contained in:
Rorik Star Platinum 2025-11-06 21:45:09 +03:00
parent da3dfe40b0
commit 15b92ba3a4
17 changed files with 528 additions and 107 deletions

31
src/api/accounts.rs Normal file
View file

@ -0,0 +1,31 @@
// src/api/accounts.rs
// Account data retrieval
use super::{client::{BankClient, BankingError}, models::{ApiResponse, AccountData}};
impl BankClient {
pub async fn get_accounts(
&self,
client_id: &str,
consent_id: &str,
) -> Result<ApiResponse<AccountData>, BankingError> {
let token = self.get_token().await?;
let response = self.http_client
.get(self.base_url.join("/accounts")?)
.bearer_auth(token)
.header("x-consent-id", consent_id)
.header("x-requesting-bank", self.client_id.as_str())
.query(&[("client_id", client_id)])
.send()
.await?;
match response.status().is_success() {
true => response.json().await.map_err(Into::into),
false => Err(BankingError::ApiError {
status: response.status().as_u16(),
body: response.text().await.unwrap_or_default(),
}),
}
}
}

View file

@ -1,15 +1,13 @@
// src/api/banking.rs
// src/api/client.rs
// Core banking API client implementation
use reqwest::Client as HttpClient;
use serde::Deserialize;
use std::env;
use std::str::FromStr;
use std::sync::Arc; // Import Arc
use std::{env, str::FromStr, sync::Arc};
use tokio::sync::RwLock;
use url::Url;
use chrono::{DateTime, Duration, Utc};
// --- Public Enums and Errors ---
// --- Error Types ---
#[derive(Debug, thiserror::Error)]
pub enum BankingError {
@ -23,16 +21,40 @@ pub enum BankingError {
ApiError { status: u16, body: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
// --- Bank Enum ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)]
pub enum Bank {
VBank,
ABank,
SBank,
}
// --- Data Models ---
impl FromStr for Bank {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"vbank" => Ok(Bank::VBank),
"abank" => Ok(Bank::ABank),
"sbank" => Ok(Bank::SBank),
_ => Err(anyhow::anyhow!("Unknown bank: {}", s)),
}
}
}
#[derive(Debug, Deserialize)]
impl Bank {
pub fn code(&self) -> &'static str {
match self {
Bank::VBank => "vbank",
Bank::ABank => "abank",
Bank::SBank => "sbank",
}
}
}
// --- Token Management ---
#[derive(Debug, serde::Deserialize)]
pub struct BankTokenResponse {
pub access_token: String,
pub expires_in: i64,
@ -46,44 +68,21 @@ struct StoredToken {
impl StoredToken {
fn is_valid(&self) -> bool {
// Check if the token is valid for at least the next 60 seconds.
self.expires_at > Utc::now() + Duration::seconds(60)
}
}
// --- Client Implementation ---
// --- BankClient ---
#[derive(Clone)]
pub struct BankClient {
http_client: HttpClient,
base_url: Url,
client_id: String,
pub http_client: HttpClient,
pub base_url: Url,
pub client_id: String,
client_secret: String,
// FIX: Wrap the RwLock in an Arc to make it shareable and clonable.
token: Arc<RwLock<Option<StoredToken>>>,
}
#[derive(Clone)]
pub struct BankingClients {
pub vbank: Arc<BankClient>,
pub abank: Arc<BankClient>,
pub sbank: Arc<BankClient>,
}
// --- impl Blocks ---
impl FromStr for Bank {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"vbank" => Ok(Bank::VBank),
"abank" => Ok(Bank::ABank),
"sbank" => Ok(Bank::SBank),
_ => Err(anyhow::anyhow!("Unknown bank: {}", s)),
}
}
}
impl BankClient {
pub fn new(
http_client: HttpClient,
@ -96,19 +95,15 @@ impl BankClient {
base_url,
client_id,
client_secret,
// FIX: Initialize the Arc<RwLock<...>>
token: Arc::new(RwLock::new(None)),
}
}
/// Fetches a new token from the bank's API.
async fn fetch_new_token(&self) -> Result<StoredToken, BankingError> {
println!("🔄 Requesting new token for bank: {}", self.base_url.host_str().unwrap_or(""));
let token_url = self.base_url.join("/auth/bank-token")?;
let response = self
.http_client
.post(token_url)
let response = self.http_client
.post(self.base_url.join("/auth/bank-token")?)
.query(&[
("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.as_str()),
@ -118,80 +113,85 @@ impl BankClient {
match response.status().is_success() {
true => {
let token_response: BankTokenResponse = response.json().await?;
let token_response = response.json::<BankTokenResponse>().await?;
Ok(StoredToken {
access_token: token_response.access_token,
expires_at: Utc::now() + Duration::seconds(token_response.expires_in),
})
},
false => {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
Err(BankingError::ApiError { status, body })
}
false => Err(BankingError::ApiError {
status: response.status().as_u16(),
body: response.text().await.unwrap_or_default(),
}),
}
}
/// Gets a valid token, refreshing if necessary. (Refactored)
pub async fn get_token(&self) -> Result<String, BankingError> {
// First, perform a read-only check.
let read_guard = self.token.read().await;
if let Some(token) = read_guard.as_ref() {
if token.is_valid() {
// Check if we have a valid token with a read lock
{
let read_guard = self.token.read().await;
if let Some(token) = read_guard.as_ref().filter(|t| t.is_valid()) {
return Ok(token.access_token.clone());
}
}
// Drop the read lock before acquiring a write lock.
drop(read_guard);
// If the token is invalid or missing, acquire a write lock.
// Acquire write lock and refresh if needed
let mut write_guard = self.token.write().await;
// Re-check in case another thread refreshed the token while we were waiting.
// Double-check in case another task refreshed while we waited
match write_guard.as_ref() {
Some(token) if token.is_valid() => {
return Ok(token.access_token.clone())
},
Some(token) if token.is_valid() => Ok(token.access_token.clone()),
_ => {
// The token is definitely invalid, so we fetch a new one.
let new_token = self.fetch_new_token().await?;
let access_token = new_token.access_token.clone();
*write_guard = Some(new_token); // Update the stored token
*write_guard = Some(new_token);
Ok(access_token)
}
}
}
}
// --- BankingClients ---
#[derive(Clone)]
pub struct BankingClients {
pub vbank: Arc<BankClient>,
pub abank: Arc<BankClient>,
pub sbank: Arc<BankClient>,
}
impl BankingClients {
pub async fn new() -> Result<Self, BankingError> {
let http_client = HttpClient::new();
let get_env = |key: &str| -> Result<String, BankingError> {
env::var(key).map_err(|_| BankingError::MissingEnvVar(key.to_string()))
let get_env_var = |key: String| -> Result<String, BankingError> {
env::var(&key).map_err(|_| BankingError::MissingEnvVar(key))
};
let create_client = |bank_prefix: &str| -> Result<Arc<BankClient>, BankingError> {
let url_key = format!("{}_API_URL", bank_prefix);
let id_key = format!("{}_CLIENT_ID", bank_prefix);
let secret_key = format!("{}_CLIENT_SECRET", bank_prefix);
let base_url = Url::parse(&get_env_var(url_key)?)?;
let client_id = get_env_var(id_key)?;
let client_secret = get_env_var(secret_key)?;
Ok(Arc::new(BankClient::new(
http_client.clone(),
base_url,
client_id,
client_secret,
)))
};
let vbank = {
let base_url = Url::parse(&get_env("VBANK_API_URL")?)?;
let client_id = get_env("VBANK_CLIENT_ID")?;
let client_secret = get_env("VBANK_CLIENT_SECRET")?;
Arc::new(BankClient::new(http_client.clone(), base_url, client_id, client_secret))
};
let abank = {
let base_url = Url::parse(&get_env("ABANK_API_URL")?)?;
let client_id = get_env("ABANK_CLIENT_ID")?;
let client_secret = get_env("ABANK_CLIENT_SECRET")?;
Arc::new(BankClient::new(http_client.clone(), base_url, client_id, client_secret))
};
let sbank = {
let base_url = Url::parse(&get_env("SBANK_API_URL")?)?;
let client_id = get_env("SBANK_CLIENT_ID")?;
let client_secret = get_env("SBANK_CLIENT_SECRET")?;
Arc::new(BankClient::new(http_client, base_url, client_id, client_secret))
};
Ok(Self { vbank, abank, sbank })
Ok(Self {
vbank: create_client("VBANK")?,
abank: create_client("ABANK")?,
sbank: create_client("SBANK")?,
})
}
pub fn get_client(&self, bank: Bank) -> &Arc<BankClient> {
@ -202,3 +202,4 @@ impl BankingClients {
}
}
}

38
src/api/consents.rs Normal file
View file

@ -0,0 +1,38 @@
// src/api/consents.rs
// Consent request and retrieval logic
use super::{client::{BankClient, BankingError}, models::{ConsentRequestBody, ConsentResponse}};
impl BankClient {
pub async fn request_consent(&self, client_id: &str) -> Result<ConsentResponse, BankingError> {
let token = self.get_token().await?;
let body = ConsentRequestBody {
client_id: client_id.to_string(),
permissions: vec![
"ReadAccountsDetail".to_string(),
"ReadBalances".to_string(),
"ReadTransactionsDetail".to_string(),
],
reason: "Account aggregation for Multiberry app".to_string(),
requesting_bank: self.client_id.clone(),
requesting_bank_name: "Multiberry Backend".to_string(),
};
let response = self.http_client
.post(self.base_url.join("/account-consents/request")?)
.bearer_auth(token)
.header("x-requesting-bank", self.client_id.as_str())
.json(&body)
.send()
.await?;
match response.status().is_success() {
true => response.json().await.map_err(Into::into),
false => Err(BankingError::ApiError {
status: response.status().as_u16(),
body: response.text().await.unwrap_or_default(),
}),
}
}
}

130
src/api/models.rs Normal file
View file

@ -0,0 +1,130 @@
// src/api/models.rs
// This file contains all the data structures for the banking API.
// These are the "nouns" of our API interaction.
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc, NaiveDate};
// --- Generic API Wrappers ---
#[derive(Debug, Deserialize, Clone)]
pub struct ApiResponse<T> {
pub data: T,
pub links: Links,
pub meta: Meta,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Links {
#[serde(rename = "self")]
pub self_link: String,
pub next: Option<String>,
pub prev: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Meta {
pub total_pages: Option<i32>,
pub total_records: Option<i32>,
pub current_page: Option<i32>,
pub page_size: Option<i32>,
}
// --- Consent Models ---
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ConsentRequestBody {
pub client_id: String,
pub permissions: Vec<String>,
pub reason: String,
pub requesting_bank: String,
pub requesting_bank_name: String,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "snake_case")]
pub struct ConsentResponse {
pub request_id: String,
pub consent_id: String,
pub status: String,
pub message: String,
pub created_at: DateTime<Utc>,
pub auto_approved: bool,
}
// --- Account & Transaction Models ---
#[derive(Debug, Deserialize, Clone)]
pub struct AccountData {
pub account: Vec<Account>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub account_id: String,
pub status: Option<String>,
pub currency: String,
pub account_type: String,
pub account_sub_type: Option<String>,
pub description: Option<String>,
pub nickname: String,
pub opening_date: Option<NaiveDate>,
pub account: Option<Vec<AccountIdentification>>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AccountIdentification {
pub scheme_name: String,
pub identification: String,
pub name: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct BalanceData {
pub balance: Vec<Balance>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Balance {
pub account_id: String,
#[serde(rename = "type")]
pub balance_type: String,
pub date_time: DateTime<Utc>,
pub amount: Amount,
pub credit_debit_indicator: String,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TransactionData {
pub transaction: Vec<Transaction>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Transaction {
pub account_id: String,
pub transaction_id: String,
pub amount: Amount,
pub credit_debit_indicator: String,
pub status: String,
pub booking_date_time: DateTime<Utc>,
pub value_date_time: Option<DateTime<Utc>>,
pub transaction_information: String,
pub bank_transaction_code: Option<BankTransactionCode>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Amount {
pub amount: String,
pub currency: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct BankTransactionCode {
pub code: String,
}

39
src/api/transactions.rs Normal file
View file

@ -0,0 +1,39 @@
// src/api/transactions.rs
// Transaction data retrieval with pagination support
use super::{client::{BankClient, BankingError}, models::{ApiResponse, TransactionData}};
impl BankClient {
pub async fn get_transactions(
&self,
account_id: &str,
consent_id: &str,
page: Option<u32>,
limit: Option<u32>,
) -> Result<ApiResponse<TransactionData>, BankingError> {
let token = self.get_token().await?;
let mut req = self.http_client
.get(self.base_url.join(&format!("/accounts/{}/transactions", account_id))?)
.bearer_auth(token)
.header("x-consent-id", consent_id)
.header("x-requesting-bank", self.client_id.as_str());
if let Some(p) = page {
req = req.query(&[("page", p.to_string())]);
}
if let Some(l) = limit {
req = req.query(&[("limit", l.to_string())]);
}
let response = req.send().await?;
match response.status().is_success() {
true => response.json().await.map_err(Into::into),
false => Err(BankingError::ApiError {
status: response.status().as_u16(),
body: response.text().await.unwrap_or_default(),
}),
}
}
}