diff --git a/.gitignore b/.gitignore index 2c004ad..80a3ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .gpskip ginpee.toml project.md +temp.json diff --git a/.sqlx/query-51af07266e370fc9282455cbf872b1558a4b7653972a8e8166b29856d63c37d8.json b/.sqlx/query-51af07266e370fc9282455cbf872b1558a4b7653972a8e8166b29856d63c37d8.json new file mode 100644 index 0000000..95a82c6 --- /dev/null +++ b/.sqlx/query-51af07266e370fc9282455cbf872b1558a4b7653972a8e8166b29856d63c37d8.json @@ -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" +} diff --git a/.sqlx/query-7c81c4347f1e0d7a8109bfea0ad6692d7babfabd77abaedf17bccb414843c21b.json b/.sqlx/query-7c81c4347f1e0d7a8109bfea0ad6692d7babfabd77abaedf17bccb414843c21b.json new file mode 100644 index 0000000..b66b36e --- /dev/null +++ b/.sqlx/query-7c81c4347f1e0d7a8109bfea0ad6692d7babfabd77abaedf17bccb414843c21b.json @@ -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" +} diff --git a/Cargo.lock b/Cargo.lock index d8dd2eb..292a754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 26c8b1a..f140dae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/justfile b/justfile index f9f338a..288ee25 100644 --- a/justfile +++ b/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 diff --git a/migrations/20251106181838_init_banking_schema.sql b/migrations/20251106181838_init_banking_schema.sql new file mode 100644 index 0000000..d5ecf21 --- /dev/null +++ b/migrations/20251106181838_init_banking_schema.sql @@ -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); diff --git a/src/api.rs b/src/api.rs index 937e7f0..93f0903 100644 --- a/src/api.rs +++ b/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}; diff --git a/src/api/accounts.rs b/src/api/accounts.rs new file mode 100644 index 0000000..259c5c7 --- /dev/null +++ b/src/api/accounts.rs @@ -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, 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(), + }), + } + } +} diff --git a/src/api/banking.rs b/src/api/client.rs similarity index 53% rename from src/api/banking.rs rename to src/api/client.rs index 4eae367..9c1f62a 100644 --- a/src/api/banking.rs +++ b/src/api/client.rs @@ -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 { + 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>>, } -#[derive(Clone)] -pub struct BankingClients { - pub vbank: Arc, - pub abank: Arc, - pub sbank: Arc, -} - -// --- impl Blocks --- - -impl FromStr for Bank { - type Err = anyhow::Error; - fn from_str(s: &str) -> Result { - 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> token: Arc::new(RwLock::new(None)), } } - /// Fetches a new token from the bank's API. async fn fetch_new_token(&self) -> Result { 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::().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 { - // 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, + pub abank: Arc, + pub sbank: Arc, } impl BankingClients { pub async fn new() -> Result { let http_client = HttpClient::new(); - let get_env = |key: &str| -> Result { - env::var(key).map_err(|_| BankingError::MissingEnvVar(key.to_string())) + + let get_env_var = |key: String| -> Result { + env::var(&key).map_err(|_| BankingError::MissingEnvVar(key)) + }; + + let create_client = |bank_prefix: &str| -> Result, 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 { @@ -202,3 +202,4 @@ impl BankingClients { } } } + diff --git a/src/api/consents.rs b/src/api/consents.rs new file mode 100644 index 0000000..d9539f3 --- /dev/null +++ b/src/api/consents.rs @@ -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 { + 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(), + }), + } + } +} diff --git a/src/api/models.rs b/src/api/models.rs new file mode 100644 index 0000000..7524558 --- /dev/null +++ b/src/api/models.rs @@ -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 { + 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, + pub prev: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Meta { + pub total_pages: Option, + pub total_records: Option, + pub current_page: Option, + pub page_size: Option, +} + +// --- Consent Models --- +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConsentRequestBody { + pub client_id: String, + pub permissions: Vec, + 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, + pub auto_approved: bool, +} + +// --- Account & Transaction Models --- +#[derive(Debug, Deserialize, Clone)] +pub struct AccountData { + pub account: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub account_id: String, + pub status: Option, + pub currency: String, + pub account_type: String, + pub account_sub_type: Option, + pub description: Option, + pub nickname: String, + pub opening_date: Option, + pub account: Option>, +} + +#[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, +} + +#[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, + pub amount: Amount, + pub credit_debit_indicator: String, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TransactionData { + pub transaction: Vec, +} + +#[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, + pub value_date_time: Option>, + pub transaction_information: String, + pub bank_transaction_code: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Amount { + pub amount: String, + pub currency: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct BankTransactionCode { + pub code: String, +} + diff --git a/src/api/transactions.rs b/src/api/transactions.rs new file mode 100644 index 0000000..ebc5e12 --- /dev/null +++ b/src/api/transactions.rs @@ -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, + limit: Option, + ) -> Result, 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(), + }), + } + } +} diff --git a/src/db.rs b/src/db.rs index 7e83020..ea712b8 100644 --- a/src/db.rs +++ b/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") diff --git a/src/db/accounts.rs b/src/db/accounts.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/db/consents.rs b/src/db/consents.rs new file mode 100644 index 0000000..459ec65 --- /dev/null +++ b/src/db/consents.rs @@ -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, +} + +pub async fn store_consent( + pool: &PgPool, + user_id: &str, + bank_code: &str, + consent_id: &str, + expires_at: DateTime, +) -> 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, 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)) +} diff --git a/src/db/transactions.rs b/src/db/transactions.rs new file mode 100644 index 0000000..e69de29