(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
|
.gpskip
|
||||||
ginpee.toml
|
ginpee.toml
|
||||||
project.md
|
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 = [
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
16
justfile
16
justfile
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
// 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
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 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
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
|
// 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
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