(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

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
.gpskip .gpskip
ginpee.toml ginpee.toml
project.md project.md
temp.json

View 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"
}

View 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
View file

@ -2009,6 +2009,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
@ -2086,6 +2087,7 @@ dependencies = [
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"digest", "digest",
"dotenvy", "dotenvy",
@ -2127,6 +2129,7 @@ dependencies = [
"base64", "base64",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono",
"crc", "crc",
"dotenvy", "dotenvy",
"etcetera", "etcetera",
@ -2161,6 +2164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono",
"flume", "flume",
"futures-channel", "futures-channel",
"futures-core", "futures-core",

View file

@ -9,7 +9,7 @@ tokio = { version = "1.48", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
url = "2.5" 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" dotenvy = "0.15"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"

View file

@ -15,6 +15,22 @@ db-down:
db-logs: db-logs:
@docker compose logs -f postgres @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: db-reset:
@echo "🗑️ Resetting PostgreSQL (deleting all data)..." @echo "🗑️ Resetting PostgreSQL (deleting all data)..."
@docker compose down -v @docker compose down -v

View 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);

View file

@ -1,8 +1,10 @@
// src/api.rs // src/api.rs
// This file is the public-facing module for all external API interactions.
// Make the banking submodule public. pub mod client;
pub mod banking; 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 client::{Bank, BankClient, BankingClients, BankingError};
pub use banking::BankingClients; pub use models::{ApiResponse, AccountData, TransactionData, ConsentResponse};

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 reqwest::Client as HttpClient;
use serde::Deserialize; use std::{env, str::FromStr, sync::Arc};
use std::env;
use std::str::FromStr;
use std::sync::Arc; // Import Arc
use tokio::sync::RwLock; use tokio::sync::RwLock;
use url::Url; use url::Url;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
// --- Public Enums and Errors --- // --- Error Types ---
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum BankingError { pub enum BankingError {
@ -23,16 +21,40 @@ pub enum BankingError {
ApiError { status: u16, body: String }, 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 { pub enum Bank {
VBank, VBank,
ABank, ABank,
SBank, 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 struct BankTokenResponse {
pub access_token: String, pub access_token: String,
pub expires_in: i64, pub expires_in: i64,
@ -46,44 +68,21 @@ struct StoredToken {
impl StoredToken { impl StoredToken {
fn is_valid(&self) -> bool { 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) self.expires_at > Utc::now() + Duration::seconds(60)
} }
} }
// --- Client Implementation --- // --- BankClient ---
#[derive(Clone)] #[derive(Clone)]
pub struct BankClient { pub struct BankClient {
http_client: HttpClient, pub http_client: HttpClient,
base_url: Url, pub base_url: Url,
client_id: String, pub client_id: String,
client_secret: String, client_secret: String,
// FIX: Wrap the RwLock in an Arc to make it shareable and clonable.
token: Arc<RwLock<Option<StoredToken>>>, 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 { impl BankClient {
pub fn new( pub fn new(
http_client: HttpClient, http_client: HttpClient,
@ -96,19 +95,15 @@ impl BankClient {
base_url, base_url,
client_id, client_id,
client_secret, client_secret,
// FIX: Initialize the Arc<RwLock<...>>
token: Arc::new(RwLock::new(None)), token: Arc::new(RwLock::new(None)),
} }
} }
/// Fetches a new token from the bank's API.
async fn fetch_new_token(&self) -> Result<StoredToken, BankingError> { async fn fetch_new_token(&self) -> Result<StoredToken, BankingError> {
println!("🔄 Requesting new token for bank: {}", self.base_url.host_str().unwrap_or("")); 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 let response = self.http_client
.http_client .post(self.base_url.join("/auth/bank-token")?)
.post(token_url)
.query(&[ .query(&[
("client_id", self.client_id.as_str()), ("client_id", self.client_id.as_str()),
("client_secret", self.client_secret.as_str()), ("client_secret", self.client_secret.as_str()),
@ -118,80 +113,85 @@ impl BankClient {
match response.status().is_success() { match response.status().is_success() {
true => { true => {
let token_response: BankTokenResponse = response.json().await?; let token_response = response.json::<BankTokenResponse>().await?;
Ok(StoredToken { Ok(StoredToken {
access_token: token_response.access_token, access_token: token_response.access_token,
expires_at: Utc::now() + Duration::seconds(token_response.expires_in), expires_at: Utc::now() + Duration::seconds(token_response.expires_in),
}) })
}, },
false => { false => Err(BankingError::ApiError {
let status = response.status().as_u16(); status: response.status().as_u16(),
let body = response.text().await.unwrap_or_default(); body: response.text().await.unwrap_or_default(),
Err(BankingError::ApiError { status, body }) }),
}
} }
} }
/// Gets a valid token, refreshing if necessary. (Refactored)
pub async fn get_token(&self) -> Result<String, BankingError> { pub async fn get_token(&self) -> Result<String, BankingError> {
// First, perform a read-only check. // 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() { let read_guard = self.token.read().await;
if token.is_valid() { if let Some(token) = read_guard.as_ref().filter(|t| t.is_valid()) {
return Ok(token.access_token.clone()); 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; 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() { match write_guard.as_ref() {
Some(token) if token.is_valid() => { Some(token) if token.is_valid() => Ok(token.access_token.clone()),
return 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 new_token = self.fetch_new_token().await?;
let access_token = new_token.access_token.clone(); 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) Ok(access_token)
} }
} }
} }
}
// --- BankingClients ---
#[derive(Clone)]
pub struct BankingClients {
pub vbank: Arc<BankClient>,
pub abank: Arc<BankClient>,
pub sbank: Arc<BankClient>,
} }
impl BankingClients { impl BankingClients {
pub async fn new() -> Result<Self, BankingError> { pub async fn new() -> Result<Self, BankingError> {
let http_client = HttpClient::new(); 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 create_client = |bank_prefix: &str| -> Result<Arc<BankClient>, BankingError> {
let base_url = Url::parse(&get_env("VBANK_API_URL")?)?; let url_key = format!("{}_API_URL", bank_prefix);
let client_id = get_env("VBANK_CLIENT_ID")?; let id_key = format!("{}_CLIENT_ID", bank_prefix);
let client_secret = get_env("VBANK_CLIENT_SECRET")?; let secret_key = format!("{}_CLIENT_SECRET", bank_prefix);
Arc::new(BankClient::new(http_client.clone(), base_url, client_id, client_secret))
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 = { Ok(Self {
let base_url = Url::parse(&get_env("ABANK_API_URL")?)?; vbank: create_client("VBANK")?,
let client_id = get_env("ABANK_CLIENT_ID")?; abank: create_client("ABANK")?,
let client_secret = get_env("ABANK_CLIENT_SECRET")?; sbank: create_client("SBANK")?,
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 })
} }
pub fn get_client(&self, bank: Bank) -> &Arc<BankClient> { 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(),
}),
}
}
}

View file

@ -1,27 +1,19 @@
// src/db.rs // src/db.rs
// Почему это здесь?
// - Это всё, связанное с инициализацией и конфигурацией базы данных pub mod consents;
// - Здесь создаётся connection pool, который переиспользуется во всём приложении pub mod accounts;
pub mod transactions;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
use std::env; use std::env;
/// Инициализирует PgPool (connection pool для PostgreSQL)
///
/// Connection pool — это набор переиспользуемых соединений к БД.
/// Вместо того, чтобы открывать новое соединение для каждого запроса,
/// мы берём готовое соединение из пула.
///
/// Это **критически важно** для производительности:
/// - Открытие соединения — медленная операция
/// - Connection pool решает эту проблему
pub async fn init_pool() -> PgPool { pub async fn init_pool() -> PgPool {
let database_url = env::var("DATABASE_URL") let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set"); .expect("DATABASE_URL must be set");
PgPoolOptions::new() PgPoolOptions::new()
.max_connections(5) // Максимум 5 одновременных соединений .max_connections(5)
.connect(&database_url) .connect(&database_url)
.await .await
.expect("Failed to create Postgres connection pool") .expect("Failed to create Postgres connection pool")

0
src/db/accounts.rs Normal file
View file

57
src/db/consents.rs Normal file
View 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
View file