From aeb9514aa32818f32ff35f7ff595e84670fcab38 Mon Sep 17 00:00:00 2001 From: Rorik Star Platinum Date: Fri, 7 Nov 2025 21:22:52 +0300 Subject: [PATCH] (feat) balance --- ...7a81c218f77f35fd20e28bf178bc2d8c92d49.json | 22 +++++ ...6cbbe03f9a54e22f02837ccd6332ebe66d4cc.json | 34 ++++++++ ...496f4ad93973efd79c8fd0ddd9861b26a6357.json | 24 +++++ ...fb66876a3190ab7c8e382fb453a8fbf4db77e.json | 18 ++++ .../20251106181838_init_banking_schema.sql | 2 +- .../20251107163729_add_balances_table.sql.sql | 15 ++++ src/api/accounts.rs | 87 +++++++++++++++++-- src/api/models.rs | 5 ++ src/db.rs | 1 + src/db/balances.rs | 52 +++++++++++ src/db/consents.rs | 19 ++++ src/db/transactions.rs | 70 +++++++++++++++ src/route.rs | 3 + src/route/handlers.rs | 65 +++++++++++++- test_multiberry.sh | 70 +++++++++++---- 15 files changed, 461 insertions(+), 26 deletions(-) create mode 100644 .sqlx/query-c76f312865fd8d9dd42b09448be7a81c218f77f35fd20e28bf178bc2d8c92d49.json create mode 100644 .sqlx/query-ec6e46778012d093e99364fc0e46cbbe03f9a54e22f02837ccd6332ebe66d4cc.json create mode 100644 .sqlx/query-ef51346809b942caa815860988e496f4ad93973efd79c8fd0ddd9861b26a6357.json create mode 100644 .sqlx/query-f486b903acc5ff7d9adbb0be711fb66876a3190ab7c8e382fb453a8fbf4db77e.json create mode 100644 migrations/20251107163729_add_balances_table.sql.sql create mode 100644 src/db/balances.rs diff --git a/.sqlx/query-c76f312865fd8d9dd42b09448be7a81c218f77f35fd20e28bf178bc2d8c92d49.json b/.sqlx/query-c76f312865fd8d9dd42b09448be7a81c218f77f35fd20e28bf178bc2d8c92d49.json new file mode 100644 index 0000000..a24a12c --- /dev/null +++ b/.sqlx/query-c76f312865fd8d9dd42b09448be7a81c218f77f35fd20e28bf178bc2d8c92d49.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT consent_id\n FROM user_consents\n WHERE bank_code = $1 AND expires_at > NOW() AND status = 'active'\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "consent_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c76f312865fd8d9dd42b09448be7a81c218f77f35fd20e28bf178bc2d8c92d49" +} diff --git a/.sqlx/query-ec6e46778012d093e99364fc0e46cbbe03f9a54e22f02837ccd6332ebe66d4cc.json b/.sqlx/query-ec6e46778012d093e99364fc0e46cbbe03f9a54e22f02837ccd6332ebe66d4cc.json new file mode 100644 index 0000000..6f96a1b --- /dev/null +++ b/.sqlx/query-ec6e46778012d093e99364fc0e46cbbe03f9a54e22f02837ccd6332ebe66d4cc.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT amount, currency, balance_type\n FROM balances\n WHERE account_id = $1\n ORDER BY date_time DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "amount", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "currency", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "balance_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "ec6e46778012d093e99364fc0e46cbbe03f9a54e22f02837ccd6332ebe66d4cc" +} diff --git a/.sqlx/query-ef51346809b942caa815860988e496f4ad93973efd79c8fd0ddd9861b26a6357.json b/.sqlx/query-ef51346809b942caa815860988e496f4ad93973efd79c8fd0ddd9861b26a6357.json new file mode 100644 index 0000000..8d0283d --- /dev/null +++ b/.sqlx/query-ef51346809b942caa815860988e496f4ad93973efd79c8fd0ddd9861b26a6357.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO transactions \n (transaction_id, account_id, bank_code, amount, currency, credit_debit_indicator, \n status, booking_date_time, value_date_time, transaction_information, bank_transaction_code)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\n ON CONFLICT (transaction_id, bank_code)\n DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Text", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Timestamptz", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ef51346809b942caa815860988e496f4ad93973efd79c8fd0ddd9861b26a6357" +} diff --git a/.sqlx/query-f486b903acc5ff7d9adbb0be711fb66876a3190ab7c8e382fb453a8fbf4db77e.json b/.sqlx/query-f486b903acc5ff7d9adbb0be711fb66876a3190ab7c8e382fb453a8fbf4db77e.json new file mode 100644 index 0000000..bc18e41 --- /dev/null +++ b/.sqlx/query-f486b903acc5ff7d9adbb0be711fb66876a3190ab7c8e382fb453a8fbf4db77e.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO balances \n (account_id, balance_type, amount, currency, date_time)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (account_id, balance_type)\n DO UPDATE SET amount = $3, date_time = $5\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "f486b903acc5ff7d9adbb0be711fb66876a3190ab7c8e382fb453a8fbf4db77e" +} diff --git a/migrations/20251106181838_init_banking_schema.sql b/migrations/20251106181838_init_banking_schema.sql index d5ecf21..8646029 100644 --- a/migrations/20251106181838_init_banking_schema.sql +++ b/migrations/20251106181838_init_banking_schema.sql @@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS transactions ( account_id VARCHAR(50) NOT NULL, bank_code VARCHAR(20) NOT NULL, - amount NUMERIC(15, 2) NOT NULL, + amount TEXT NOT NULL, currency VARCHAR(3) NOT NULL, credit_debit_indicator VARCHAR(10) NOT NULL, status VARCHAR(20) NOT NULL, diff --git a/migrations/20251107163729_add_balances_table.sql.sql b/migrations/20251107163729_add_balances_table.sql.sql new file mode 100644 index 0000000..d8666b2 --- /dev/null +++ b/migrations/20251107163729_add_balances_table.sql.sql @@ -0,0 +1,15 @@ +-- migrations/XXXXXX_add_balances_table.sql + +CREATE TABLE IF NOT EXISTS balances ( + id SERIAL PRIMARY KEY, + account_id VARCHAR(50) NOT NULL, + balance_type VARCHAR(50) NOT NULL, + amount TEXT NOT NULL, + currency VARCHAR(3) NOT NULL, + date_time TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(account_id, balance_type) +); + +CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id); diff --git a/src/api/accounts.rs b/src/api/accounts.rs index 2d7bdbd..553a738 100644 --- a/src/api/accounts.rs +++ b/src/api/accounts.rs @@ -1,7 +1,9 @@ // src/api/accounts.rs // Account data retrieval -use super::{client::{BankClient, BankingError}, models::{ApiResponse, AccountData, TransactionData}}; +use super::{client::{BankClient, BankingError}, models::{ApiResponse, AccountData, BalanceData, BalanceResponse}}; +use tracing::{info, error, debug}; + impl BankClient { pub async fn get_accounts( @@ -9,7 +11,10 @@ impl BankClient { client_id: &str, consent_id: &str, ) -> Result, BankingError> { + info!("📋 Fetching accounts for client_id: {}", client_id); + let token = self.get_token().await?; + debug!("✅ Got bank token"); let response = self.http_client .get(self.base_url.join("/accounts")?) @@ -20,13 +25,83 @@ impl BankClient { .send() .await?; + let status = response.status(); + debug!("📥 Response status: {}", status); + 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(), - }), + true => { + let text = response.text().await?; + info!("✅ Accounts response received"); + + serde_json::from_str::>(&text) + .map_err(|e| { + error!("❌ Failed to deserialize AccountData: {}", e); + BankingError::ApiError { + status: 500, + body: format!("Deserialization error: {}", e), + } + }) + }, + false => { + let error_body = response.text().await.unwrap_or_default(); + error!("❌ Bank API error status {}: {}", status, error_body); + Err(BankingError::ApiError { + status: status.as_u16(), + body: error_body, + }) + } } } + + pub async fn get_balances( + &self, + account_id: &str, + consent_id: &str, + ) -> Result { // ← Changed return type! + info!("📊 Fetching balances for account: {}", account_id); + + let token = self.get_token().await?; + debug!("✅ Got bank token"); + + let url = self.base_url.join(&format!("/accounts/{}/balances", account_id))?; + debug!("📍 Calling: {}", url); + + let response = self.http_client + .get(url) + .bearer_auth(token) + .header("x-consent-id", consent_id) + .header("x-requesting-bank", self.client_id.as_str()) + .send() + .await?; + + let status = response.status(); + debug!("📥 Response status: {}", status); + + match response.status().is_success() { + true => { + let text = response.text().await?; + info!("✅ Balance response received"); + + serde_json::from_str::(&text) // ← Parse as BalanceResponse + .map_err(|e| { + error!("❌ Failed to deserialize BalanceResponse: {}", e); + error!("Raw: {}", text); + BankingError::ApiError { + status: 500, + body: format!("Deserialization error: {}", e), + } + }) + }, + false => { + let error_body = response.text().await.unwrap_or_default(); + error!("❌ Bank API error status {}: {}", status, error_body); + Err(BankingError::ApiError { + status: status.as_u16(), + body: error_body, + }) + } + } + } } + diff --git a/src/api/models.rs b/src/api/models.rs index 76b4f2c..e928374 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -81,6 +81,11 @@ pub struct AccountIdentification { pub name: String, } +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct BalanceResponse { + pub data: BalanceData, +} + #[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here pub struct BalanceData { pub balance: Vec, diff --git a/src/db.rs b/src/db.rs index 928c758..40fd36b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,6 +4,7 @@ pub mod consents; pub mod accounts; pub mod transactions; pub mod users; +pub mod balances; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; diff --git a/src/db/balances.rs b/src/db/balances.rs new file mode 100644 index 0000000..accc612 --- /dev/null +++ b/src/db/balances.rs @@ -0,0 +1,52 @@ +// src/db/balances.rs + +use sqlx::PgPool; +use chrono::{DateTime, Utc}; + +pub async fn store_balance( + pool: &PgPool, + account_id: &str, + balance_type: &str, + amount: &str, + currency: &str, + date_time: DateTime, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO balances + (account_id, balance_type, amount, currency, date_time) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (account_id, balance_type) + DO UPDATE SET amount = $3, date_time = $5 + "#, + account_id, + balance_type, + amount, + currency, + date_time, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_balance( + pool: &PgPool, + account_id: &str, +) -> Result, sqlx::Error> { + // Returns (amount, currency, balance_type) + sqlx::query!( + r#" + SELECT amount, currency, balance_type + FROM balances + WHERE account_id = $1 + ORDER BY date_time DESC + LIMIT 1 + "#, + account_id + ) + .fetch_optional(pool) + .await + .map(|row| row.map(|r| (r.amount, r.currency, r.balance_type))) +} diff --git a/src/db/consents.rs b/src/db/consents.rs index 7a8e2cd..eb32c0e 100644 --- a/src/db/consents.rs +++ b/src/db/consents.rs @@ -57,6 +57,25 @@ pub async fn get_valid_consent( Ok(result.map(|r| r.consent_id)) } +pub async fn get_first_valid_consent( + pool: &PgPool, + bank_code: &str, +) -> Result, sqlx::Error> { + let result = sqlx::query!( + r#" + SELECT consent_id + FROM user_consents + WHERE bank_code = $1 AND expires_at > NOW() AND status = 'active' + LIMIT 1 + "#, + bank_code + ) + .fetch_optional(pool) + .await?; + + Ok(result.map(|r| r.consent_id)) +} + pub async fn delete_consent( pool: &PgPool, consent_id: &str, diff --git a/src/db/transactions.rs b/src/db/transactions.rs index e69de29..1ca661e 100644 --- a/src/db/transactions.rs +++ b/src/db/transactions.rs @@ -0,0 +1,70 @@ +// src/db/transactions.rs + +use sqlx::PgPool; +use chrono::{DateTime, Utc}; +use tracing::info; + +pub async fn store_transaction( + pool: &PgPool, + transaction_id: &str, + account_id: &str, + bank_code: &str, + amount: &str, + currency: &str, + credit_debit_indicator: &str, + status: &str, + booking_date_time: DateTime, + value_date_time: Option>, + transaction_information: &str, + bank_transaction_code: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO transactions + (transaction_id, account_id, bank_code, amount, currency, credit_debit_indicator, + status, booking_date_time, value_date_time, transaction_information, bank_transaction_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (transaction_id, bank_code) + DO NOTHING + "#, + transaction_id, + account_id, + bank_code, + amount, + currency, + credit_debit_indicator, + status, + booking_date_time, + value_date_time, + transaction_information, + bank_transaction_code, + ) + .execute(pool) + .await?; + + info!("✅ Stored transaction: {}", transaction_id); + Ok(()) +} + +pub async fn get_cached_transactions( + pool: &PgPool, + account_id: &str, + page: u32, + limit: u32, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, String>( + r#" + SELECT transaction_id + FROM transactions + WHERE account_id = $1 + ORDER BY booking_date_time DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(account_id) + .bind(limit as i64) + .bind(((page - 1) * limit) as i64) + .fetch_all(pool) + .await +} + diff --git a/src/route.rs b/src/route.rs index d1d2f88..9ac1379 100644 --- a/src/route.rs +++ b/src/route.rs @@ -26,6 +26,9 @@ pub fn router(app_state: AppState) -> Router { .route("/api/transactions/{bank}/{user_id}/{account_id}", get(handlers::get_transactions_handler) ) + .route("/api/balances/{bank}/{account_id}", + get(handlers::get_balances_handler) + ) .layer(middleware::from_fn(auth::auth_middleware)); // Merge both diff --git a/src/route/handlers.rs b/src/route/handlers.rs index bef9db3..27957e3 100644 --- a/src/route/handlers.rs +++ b/src/route/handlers.rs @@ -15,6 +15,8 @@ use crate::{ db, }; +use tracing::info; + // --- Health Check --- pub async fn health_handler( @@ -185,12 +187,33 @@ pub async fn get_transactions_handler( let client = state.banking_clients.get_client(bank); - client.get_transactions(&account_id, &consent_id, params.page, params.limit) + let transactions_response = client.get_transactions(&account_id, &consent_id, params.page, params.limit) .await - .map(|transactions| Json(serde_json::to_value(transactions).unwrap())) - .map_err(map_banking_error) + .map_err(map_banking_error)?; + + // ✨ NEW: Save all transactions to cache + for tx in &transactions_response.data.transaction { + let _ = db::transactions::store_transaction( + &state.db_pool, + &tx.transaction_id, + &tx.account_id, + bank.code(), + &tx.amount.amount, + &tx.amount.currency, + &tx.credit_debit_indicator, + &tx.status, + tx.booking_date_time, // Already DateTime + tx.value_date_time, // Already Option> + &tx.transaction_information, + tx.bank_transaction_code.as_ref().map(|b| b.code.as_str()), + ).await; + } + + Ok(Json(serde_json::to_value(transactions_response).unwrap())) } + + pub async fn delete_consent_handler( State(state): State, Path((bank_code, user_id)): Path<(String, String)>, @@ -218,6 +241,42 @@ pub async fn delete_consent_handler( Ok::<_, (StatusCode, Json)>((StatusCode::OK, Json(json!({ "status": "deleted" })))) } +pub async fn get_balances_handler( + State(state): State, + Path((bank_code, account_id)): Path<(String, String)>, // ← account_id, not user_id! +) -> Result, (StatusCode, Json)> { + let bank = bank_code.parse::() + .map_err(|_| (StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid bank code" }))))?; + + // We need to get consent from the account's user + // For now, just get the first valid consent for this bank + let consent_id = db::consents::get_first_valid_consent(&state.db_pool, bank.code()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))))? + .ok_or_else(|| (StatusCode::FORBIDDEN, Json(json!({ "error": "No valid consent" }))))?; + + let client = state.banking_clients.get_client(bank); + + info!("📊 Fetching balances for account: {} from bank: {}", account_id, bank.code()); + + let response = client.get_balances(&account_id, &consent_id).await.map_err(map_banking_error)?; + + // ✨ Cache balances + for balance in &response.data.balance { + let _ = db::balances::store_balance( + &state.db_pool, + &balance.account_id, + &balance.balance_type, + &balance.amount.amount, + &balance.amount.currency, + balance.date_time, + ).await; + } + + Ok(Json(serde_json::to_value(response).unwrap())) +} + + // --- Error Mapping --- diff --git a/test_multiberry.sh b/test_multiberry.sh index b675f9c..cec5468 100644 --- a/test_multiberry.sh +++ b/test_multiberry.sh @@ -1,5 +1,6 @@ #!/bin/bash # Save as test_multiberry.sh +# Extended comprehensive test with DB verification BASE_URL="http://localhost:3000/api" @@ -8,7 +9,7 @@ echo "1️⃣ REGISTER USER" echo "==========================================" REGISTER=$(curl -s -X POST $BASE_URL/auth/register \ -H 'Content-Type: application/json' \ - -d '{"bank_user_number": 8, "password": "testpass123"}') + -d '{"bank_user_number": 1, "password": "testpass123"}') echo "$REGISTER" | jq . TOKEN=$(echo "$REGISTER" | jq -r '.token') @@ -21,11 +22,9 @@ echo "" echo "==========================================" echo "2️⃣ LOGIN (verify token works)" echo "==========================================" -LOGIN=$(curl -s -X POST $BASE_URL/auth/login \ +curl -s -X POST $BASE_URL/auth/login \ -H 'Content-Type: application/json' \ - -d "{\"bank_user_id\": \"$BANK_USER_ID\", \"password\": \"testpass123\"}") - -echo "$LOGIN" | jq . + -d "{\"bank_user_id\": \"$BANK_USER_ID\", \"password\": \"testpass123\"}" | jq . echo "" echo "==========================================" @@ -58,17 +57,25 @@ echo "✅ Account ID: $ACCOUNT_ID" echo "" echo "==========================================" -echo "6️⃣ GET BALANCES" +echo "6️⃣ GET BALANCES (auto-saved to DB)" echo "==========================================" -curl -s $BASE_URL/balances/vbank/$BANK_USER_ID \ - -H "Authorization: Bearer $TOKEN" | jq . +BALANCES=$(curl -s $BASE_URL/balances/vbank/$ACCOUNT_ID \ + -H "Authorization: Bearer $TOKEN") + +echo "$BALANCES" | jq . +BALANCE_AMOUNT=$(echo "$BALANCES" | jq -r '.data.balance[0].amount.amount') +echo "✅ Current Balance: $BALANCE_AMOUNT RUB" echo "" echo "==========================================" -echo "7️⃣ GET TRANSACTIONS (page 1, limit 6)" +echo "7️⃣ GET TRANSACTIONS (page 1, limit 6 - auto-saved)" echo "==========================================" -curl -s "$BASE_URL/transactions/vbank/$BANK_USER_ID/$ACCOUNT_ID?page=1&limit=6" \ - -H "Authorization: Bearer $TOKEN" | jq . +TRANS_PAGE1=$(curl -s "$BASE_URL/transactions/vbank/$BANK_USER_ID/$ACCOUNT_ID?page=1&limit=6" \ + -H "Authorization: Bearer $TOKEN") + +echo "$TRANS_PAGE1" | jq . +TOTAL_RECORDS=$(echo "$TRANS_PAGE1" | jq -r '.meta.totalRecords') +echo "✅ Total Records: $TOTAL_RECORDS" echo "" echo "==========================================" @@ -86,11 +93,42 @@ curl -s -X DELETE $BASE_URL/consent/vbank/$BANK_USER_ID \ echo "" echo "==========================================" -echo "🔟 VERIFY DB (from another terminal)" +echo "🔟 VERIFY DATABASE CACHE" echo "==========================================" +echo "" +echo "Run in another terminal:" echo "just psql-exec" -echo "SELECT * FROM users;" -echo "SELECT * FROM user_consents;" -echo "SELECT * FROM accounts;" -echo "SELECT * FROM transactions;" +echo "" +echo "Then execute these queries:" +echo "" +echo "-- Check users registered:" +echo "SELECT bank_user_id, created_at FROM users ORDER BY created_at DESC LIMIT 5;" +echo "" +echo "-- Check consents granted:" +echo "SELECT user_id, bank_code, consent_id, status, expires_at FROM user_consents;" +echo "" +echo "-- Check accounts cached:" +echo "SELECT account_id, user_id, bank_code, nickname, currency FROM accounts;" +echo "" +echo "-- Check balances cached:" +echo "SELECT account_id, balance_type, amount, currency, date_time FROM balances;" +echo "" +echo "-- Check transactions cached (show count by account):" +echo "SELECT account_id, COUNT(*) as tx_count, MIN(booking_date_time) as oldest, MAX(booking_date_time) as newest FROM transactions GROUP BY account_id;" +echo "" +echo "-- Show recent transactions:" +echo "SELECT transaction_id, account_id, amount, currency, credit_debit_indicator, transaction_information, booking_date_time FROM transactions ORDER BY booking_date_time DESC LIMIT 10;" +echo "" +echo "==========================================" +echo "✅ FULL TEST COMPLETE!" +echo "==========================================" +echo "" +echo "Summary:" +echo "✅ Authentication (register/login/auth middleware)" +echo "✅ Consent Management (request consent)" +echo "✅ Account Aggregation (fetch & cache accounts)" +echo "✅ Balance Retrieval (fetch & cache balances)" +echo "✅ Transaction History (fetch & cache transactions)" +echo "✅ Data Persistence (all cached in PostgreSQL)" +echo ""