(feat) refactoring, migration, sqlx prepare
This commit is contained in:
parent
da3dfe40b0
commit
15b92ba3a4
17 changed files with 528 additions and 107 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@
|
|||
.gpskip
|
||||
ginpee.toml
|
||||
project.md
|
||||
temp.json
|
||||
|
|
|
|||
23
.sqlx/query-51af07266e370fc9282455cbf872b1558a4b7653972a8e8166b29856d63c37d8.json
generated
Normal file
23
.sqlx/query-51af07266e370fc9282455cbf872b1558a4b7653972a8e8166b29856d63c37d8.json
generated
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT consent_id\n FROM user_consents\n WHERE user_id = $1 AND bank_code = $2 AND expires_at > NOW() AND status = 'active'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "consent_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "51af07266e370fc9282455cbf872b1558a4b7653972a8e8166b29856d63c37d8"
|
||||
}
|
||||
17
.sqlx/query-7c81c4347f1e0d7a8109bfea0ad6692d7babfabd77abaedf17bccb414843c21b.json
generated
Normal file
17
.sqlx/query-7c81c4347f1e0d7a8109bfea0ad6692d7babfabd77abaedf17bccb414843c21b.json
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO user_consents (user_id, bank_code, consent_id, expires_at)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, bank_code)\n DO UPDATE SET consent_id = $3, expires_at = $4, created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7c81c4347f1e0d7a8109bfea0ad6692d7babfabd77abaedf17bccb414843c21b"
|
||||
}
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -2009,6 +2009,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
|||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
|
|
@ -2086,6 +2087,7 @@ dependencies = [
|
|||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"dotenvy",
|
||||
|
|
@ -2127,6 +2129,7 @@ dependencies = [
|
|||
"base64",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
|
|
@ -2161,6 +2164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ tokio = { version = "1.48", features = ["full"] }
|
|||
reqwest = { version = "0.12", features = ["json"] }
|
||||
url = "2.5"
|
||||
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] }
|
||||
dotenvy = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
|
|
|||
16
justfile
16
justfile
|
|
@ -15,6 +15,22 @@ db-down:
|
|||
db-logs:
|
||||
@docker compose logs -f postgres
|
||||
|
||||
db-migrate-create NAME:
|
||||
@echo "📝 Creating new migration: {{NAME}}"
|
||||
@{{sops_run}} 'sqlx migrate add {{NAME}}'
|
||||
|
||||
db-migrate:
|
||||
@echo "🚀 Running database migrations..."
|
||||
@{{sops_run}} 'sqlx migrate run'
|
||||
|
||||
db-migrate-revert:
|
||||
@echo "⏪ Reverting last migration..."
|
||||
@{{sops_run}} 'sqlx migrate revert'
|
||||
|
||||
db-prepare:
|
||||
@echo "📦 Preparing sqlx query metadata..."
|
||||
@{{sops_run}} 'cargo sqlx prepare'
|
||||
|
||||
db-reset:
|
||||
@echo "🗑️ Resetting PostgreSQL (deleting all data)..."
|
||||
@docker compose down -v
|
||||
|
|
|
|||
70
migrations/20251106181838_init_banking_schema.sql
Normal file
70
migrations/20251106181838_init_banking_schema.sql
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
-- migrations/XXXXXX_init_banking_schema.sql
|
||||
-- Complete schema for multi-bank transaction aggregation
|
||||
|
||||
-- Table: user_consents
|
||||
-- Stores consent IDs granted by users for each bank
|
||||
CREATE TABLE IF NOT EXISTS user_consents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
bank_code VARCHAR(20) NOT NULL,
|
||||
consent_id VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
|
||||
UNIQUE(user_id, bank_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_consents_user_bank ON user_consents(user_id, bank_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_consents_consent_id ON user_consents(consent_id);
|
||||
|
||||
-- Table: accounts
|
||||
-- Stores all bank accounts across all banks for all users
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
account_id VARCHAR(50) NOT NULL,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
bank_code VARCHAR(20) NOT NULL,
|
||||
|
||||
status VARCHAR(20),
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
account_type VARCHAR(50) NOT NULL,
|
||||
account_sub_type VARCHAR(50),
|
||||
nickname VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
opening_date DATE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE(account_id, bank_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_bank_code ON accounts(bank_code);
|
||||
|
||||
-- Table: transactions
|
||||
-- Stores all transactions from all accounts
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transaction_id VARCHAR(100) NOT NULL,
|
||||
account_id VARCHAR(50) NOT NULL,
|
||||
bank_code VARCHAR(20) NOT NULL,
|
||||
|
||||
amount NUMERIC(15, 2) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
credit_debit_indicator VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
booking_date_time TIMESTAMPTZ NOT NULL,
|
||||
value_date_time TIMESTAMPTZ,
|
||||
transaction_information TEXT,
|
||||
bank_transaction_code VARCHAR(50),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE(transaction_id, bank_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_account_id ON transactions(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_booking_date ON transactions(booking_date_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_bank_code ON transactions(bank_code);
|
||||
12
src/api.rs
12
src/api.rs
|
|
@ -1,8 +1,10 @@
|
|||
// src/api.rs
|
||||
// This file is the public-facing module for all external API interactions.
|
||||
|
||||
// Make the banking submodule public.
|
||||
pub mod banking;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
pub mod consents;
|
||||
pub mod accounts;
|
||||
pub mod transactions;
|
||||
|
||||
// Re-export the primary client struct so other modules can use it via `crate::api::*`.
|
||||
pub use banking::BankingClients;
|
||||
pub use client::{Bank, BankClient, BankingClients, BankingError};
|
||||
pub use models::{ApiResponse, AccountData, TransactionData, ConsentResponse};
|
||||
|
|
|
|||
31
src/api/accounts.rs
Normal file
31
src/api/accounts.rs
Normal 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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 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 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
38
src/api/consents.rs
Normal 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
130
src/api/models.rs
Normal 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
39
src/api/transactions.rs
Normal 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/db.rs
20
src/db.rs
|
|
@ -1,27 +1,19 @@
|
|||
// src/db.rs
|
||||
// Почему это здесь?
|
||||
// - Это всё, связанное с инициализацией и конфигурацией базы данных
|
||||
// - Здесь создаётся connection pool, который переиспользуется во всём приложении
|
||||
// src/db.rs
|
||||
|
||||
pub mod consents;
|
||||
pub mod accounts;
|
||||
pub mod transactions;
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use std::env;
|
||||
|
||||
/// Инициализирует PgPool (connection pool для PostgreSQL)
|
||||
///
|
||||
/// Connection pool — это набор переиспользуемых соединений к БД.
|
||||
/// Вместо того, чтобы открывать новое соединение для каждого запроса,
|
||||
/// мы берём готовое соединение из пула.
|
||||
///
|
||||
/// Это **критически важно** для производительности:
|
||||
/// - Открытие соединения — медленная операция
|
||||
/// - Connection pool решает эту проблему
|
||||
pub async fn init_pool() -> PgPool {
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL must be set");
|
||||
|
||||
PgPoolOptions::new()
|
||||
.max_connections(5) // Максимум 5 одновременных соединений
|
||||
.max_connections(5)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.expect("Failed to create Postgres connection pool")
|
||||
|
|
|
|||
0
src/db/accounts.rs
Normal file
0
src/db/accounts.rs
Normal file
57
src/db/consents.rs
Normal file
57
src/db/consents.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// src/db/consents.rs
|
||||
// Database operations for consent management
|
||||
|
||||
use sqlx::PgPool;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
pub struct StoredConsent {
|
||||
pub user_id: String,
|
||||
pub bank_code: String,
|
||||
pub consent_id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn store_consent(
|
||||
pool: &PgPool,
|
||||
user_id: &str,
|
||||
bank_code: &str,
|
||||
consent_id: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_consents (user_id, bank_code, consent_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, bank_code)
|
||||
DO UPDATE SET consent_id = $3, expires_at = $4, created_at = NOW()
|
||||
"#,
|
||||
user_id,
|
||||
bank_code,
|
||||
consent_id,
|
||||
expires_at
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_valid_consent(
|
||||
pool: &PgPool,
|
||||
user_id: &str,
|
||||
bank_code: &str,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT consent_id
|
||||
FROM user_consents
|
||||
WHERE user_id = $1 AND bank_code = $2 AND expires_at > NOW() AND status = 'active'
|
||||
"#,
|
||||
user_id,
|
||||
bank_code
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| r.consent_id))
|
||||
}
|
||||
0
src/db/transactions.rs
Normal file
0
src/db/transactions.rs
Normal file
Loading…
Add table
Add a link
Reference in a new issue