From 03c2cdb8a5b69465fd704b4e8cd1485753a7bf80 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Tue, 14 Oct 2025 23:00:21 +0800 Subject: [PATCH 1/5] api: unify Decimal mapping in accounts handler; fix clippy in metrics and currency_service --- jive-api/src/handlers/accounts.rs | 165 +++++++++++++++------- jive-api/src/metrics.rs | 2 +- jive-api/src/services/currency_service.rs | 2 +- 3 files changed, 113 insertions(+), 56 deletions(-) diff --git a/jive-api/src/handlers/accounts.rs b/jive-api/src/handlers/accounts.rs index 0d656fe3..6b95fe3b 100644 --- a/jive-api/src/handlers/accounts.rs +++ b/jive-api/src/handlers/accounts.rs @@ -179,40 +179,66 @@ pub async fn get_account( Path(id): Path, State(pool): State, ) -> ApiResult> { - let account = sqlx::query!( + let row = sqlx::query( r#" SELECT id, ledger_id, bank_id, name, account_type, account_number, institution_name, - currency, current_balance, available_balance, credit_limit, status, + currency, + current_balance, + available_balance, + credit_limit, + status, is_manual, color, notes, created_at, updated_at FROM accounts WHERE id = $1 AND deleted_at IS NULL "#, - id ) + .bind(id) .fetch_optional(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))? .ok_or(ApiError::NotFound("Account not found".to_string()))?; let response = AccountResponse { - id: account.id, - ledger_id: account.ledger_id, - bank_id: account.bank_id, - name: account.name, - account_type: account.account_type, - account_number: account.account_number, - institution_name: account.institution_name, - currency: account.currency.unwrap_or_else(|| "CNY".to_string()), - current_balance: account.current_balance.unwrap_or(Decimal::ZERO), - available_balance: account.available_balance, - credit_limit: account.credit_limit, - status: account.status.unwrap_or_else(|| "active".to_string()), - is_manual: account.is_manual.unwrap_or(true), - color: account.color, - icon: None, - notes: account.notes, - created_at: account.created_at.unwrap_or_else(chrono::Utc::now), - updated_at: account.updated_at.unwrap_or_else(chrono::Utc::now), + id: row.get("id"), + ledger_id: row.get("ledger_id"), + bank_id: row.get("bank_id"), + name: row.get("name"), + account_type: row.get("account_type"), + account_number: row.get("account_number"), + institution_name: row.get("institution_name"), + currency: row + .try_get::, _>("currency") + .unwrap_or(None) + .unwrap_or_else(|| "CNY".to_string()), + current_balance: row + .try_get::, _>("current_balance") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO), + available_balance: row + .try_get::, _>("available_balance") + .unwrap_or(None), + credit_limit: row + .try_get::, _>("credit_limit") + .unwrap_or(None), + status: row + .try_get::, _>("status") + .unwrap_or(None) + .unwrap_or_else(|| "active".to_string()), + is_manual: row + .try_get::, _>("is_manual") + .unwrap_or(None) + .unwrap_or(true), + color: row.get("color"), + icon: row.get("icon"), + notes: row.get("notes"), + created_at: row + .try_get::>, _>("created_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), + updated_at: row + .try_get::>, _>("updated_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), }; Ok(Json(response)) @@ -238,7 +264,7 @@ pub async fn create_account( .account_type .unwrap_or_else(|| req.account_sub_type.clone()); - let account = sqlx::query!( + let row = sqlx::query( r#" INSERT INTO accounts ( id, ledger_id, bank_id, name, account_type, account_main_type, account_sub_type, @@ -248,23 +274,28 @@ pub async fn create_account( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', true, $12, $13, NOW(), NOW() ) RETURNING id, ledger_id, bank_id, name, account_type, account_number, institution_name, +<<<<<<< HEAD currency, current_balance, available_balance, credit_limit, status, is_manual, color, notes, created_at, updated_at +======= + currency, current_balance, available_balance, credit_limit, + status, is_manual, color, notes, created_at, updated_at +>>>>>>> 46ef8086 (api: unify Decimal mapping in accounts handler; fix clippy in metrics and currency_service) "#, - id, - req.ledger_id, - req.bank_id, - req.name, - legacy_type, - main_type.to_string(), - sub_type.to_string(), - req.account_number, - req.institution_name, - currency, - initial_balance, - req.color, - req.notes ) + .bind(id) + .bind(req.ledger_id) + .bind(req.bank_id) + .bind(&req.name) + .bind(&legacy_type) + .bind(main_type.to_string()) + .bind(sub_type.to_string()) + .bind(&req.account_number) + .bind(&req.institution_name) + .bind(¤cy) + .bind(initial_balance) + .bind(&req.color) + .bind(&req.notes) .fetch_one(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))?; @@ -278,6 +309,7 @@ pub async fn create_account( "#, Uuid::new_v4(), id, + // 存入余额历史表使用 DECIMAL/numeric 字段,保持高精度 initial_balance ) .execute(&pool) @@ -285,25 +317,48 @@ pub async fn create_account( .map_err(|e| ApiError::DatabaseError(e.to_string()))?; } + // 响应里保持 Decimal,一致向前端输出 let response = AccountResponse { - id: account.id, - ledger_id: account.ledger_id, - bank_id: account.bank_id, - name: account.name, - account_type: account.account_type, - account_number: account.account_number, - institution_name: account.institution_name, - currency: account.currency.unwrap_or_else(|| "CNY".to_string()), - current_balance: account.current_balance.unwrap_or(Decimal::ZERO), - available_balance: account.available_balance, - credit_limit: account.credit_limit, - status: account.status.unwrap_or_else(|| "active".to_string()), - is_manual: account.is_manual.unwrap_or(true), - color: account.color, - icon: None, - notes: account.notes, - created_at: account.created_at.unwrap_or_else(chrono::Utc::now), - updated_at: account.updated_at.unwrap_or_else(chrono::Utc::now), + id: row.get("id"), + ledger_id: row.get("ledger_id"), + bank_id: row.get("bank_id"), + name: row.get("name"), + account_type: row.get("account_type"), + account_number: row.get("account_number"), + institution_name: row.get("institution_name"), + currency: row + .try_get::, _>("currency") + .unwrap_or(None) + .unwrap_or_else(|| "CNY".to_string()), + current_balance: row + .try_get::, _>("current_balance") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO), + available_balance: row + .try_get::, _>("available_balance") + .unwrap_or(None), + credit_limit: row + .try_get::, _>("credit_limit") + .unwrap_or(None), + status: row + .try_get::, _>("status") + .unwrap_or(None) + .unwrap_or_else(|| "active".to_string()), + is_manual: row + .try_get::, _>("is_manual") + .unwrap_or(None) + .unwrap_or(true), + color: row.get("color"), + icon: row.get("icon"), + notes: row.get("notes"), + created_at: row + .try_get::>, _>("created_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), + updated_at: row + .try_get::>, _>("updated_at") + .unwrap_or(None) + .unwrap_or_else(chrono::Utc::now), }; Ok(Json(response)) @@ -363,7 +418,9 @@ pub async fn update_account( query.push(" WHERE id = "); query.push_bind(id); - query.push(" RETURNING id, ledger_id, bank_id, name, account_type, account_number, institution_name, currency, current_balance, available_balance, credit_limit, status, is_manual, color, icon, notes, created_at, updated_at"); + query.push(" RETURNING id, ledger_id, bank_id, name, account_type, account_number, institution_name, currency, "); + query.push(" current_balance::numeric as current_balance, available_balance::numeric as available_balance, credit_limit::numeric as credit_limit, "); + query.push(" status, is_manual, color, icon, notes, created_at, updated_at"); let account = query .build() diff --git a/jive-api/src/metrics.rs b/jive-api/src/metrics.rs index 35f9a43e..b607fed1 100644 --- a/jive-api/src/metrics.rs +++ b/jive-api/src/metrics.rs @@ -48,7 +48,7 @@ pub async fn metrics_handler( ) -> impl IntoResponse { // Optional access control if std::env::var("ALLOW_PUBLIC_METRICS").map(|v| v == "0").unwrap_or(false) { - if let Some(addr) = std::env::var("METRICS_ALLOW_LOCALONLY").ok() { + if let Ok(addr) = std::env::var("METRICS_ALLOW_LOCALONLY") { if addr == "1" { // Only allow loopback; we rely on X-Forwarded-For not being spoofed internally (basic safeguard) // In Axum we don't have the request here directly (simplified), extension to pass remote addr could be added. diff --git a/jive-api/src/services/currency_service.rs b/jive-api/src/services/currency_service.rs index 78bd1abb..f3a8899d 100644 --- a/jive-api/src/services/currency_service.rs +++ b/jive-api/src/services/currency_service.rs @@ -467,7 +467,7 @@ impl CurrencyService { // effective_date 为非空(schema 约束);直接使用 effective_date: row.effective_date, // created_at 可能为 NULL;使用当前时间回填 - created_at: row.created_at.unwrap_or_else(|| Utc::now()), + created_at: row.created_at.unwrap_or_else(Utc::now), }) .collect()) } From dbba450dc61852778b3323f17874afc0c5a410b4 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Tue, 14 Oct 2025 23:15:51 +0800 Subject: [PATCH 2/5] api: fix Decimal/f64 mismatches and Option handling; refactor dynamic SQLx reads for accounts + currency/exchange --- jive-api/src/handlers/accounts.rs | 39 +++++++++++++------ .../src/handlers/currency_handler_enhanced.rs | 26 +++++++------ jive-api/src/services/currency_service.rs | 30 ++++++++++---- jive-api/src/services/exchange_rate_api.rs | 5 +-- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/jive-api/src/handlers/accounts.rs b/jive-api/src/handlers/accounts.rs index 6b95fe3b..f418099e 100644 --- a/jive-api/src/handlers/accounts.rs +++ b/jive-api/src/handlers/accounts.rs @@ -485,8 +485,8 @@ pub async fn get_account_statistics( .ledger_id .ok_or(ApiError::BadRequest("ledger_id is required".to_string()))?; - // 获取总体统计 - let stats = sqlx::query!( + // 获取总体统计(使用动态查询以避免 SQLx 离线缓存耦合) + let stats_row = sqlx::query( r#" SELECT COUNT(*) as total_accounts, @@ -495,14 +495,14 @@ pub async fn get_account_statistics( FROM accounts WHERE ledger_id = $1 AND deleted_at IS NULL "#, - ledger_id ) + .bind(ledger_id) .fetch_one(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))?; // 按类型统计 - let type_stats = sqlx::query!( + let type_rows = sqlx::query( r#" SELECT account_type, @@ -513,26 +513,41 @@ pub async fn get_account_statistics( GROUP BY account_type ORDER BY account_type "#, - ledger_id ) + .bind(ledger_id) .fetch_all(&pool) .await .map_err(|e| ApiError::DatabaseError(e.to_string()))?; - let by_type: Vec = type_stats + let by_type: Vec = type_rows .into_iter() .map(|row| TypeStatistics { - account_type: row.account_type, - count: row.count.unwrap_or(0), - total_balance: row.total_balance.unwrap_or(Decimal::ZERO), + account_type: row.get::("account_type"), + count: row + .try_get::, _>("count") + .unwrap_or(None) + .unwrap_or(0), + total_balance: row + .try_get::, _>("total_balance") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO), }) .collect(); - let total_assets = stats.total_assets.unwrap_or(Decimal::ZERO); - let total_liabilities = stats.total_liabilities.unwrap_or(Decimal::ZERO); + let total_assets = stats_row + .try_get::, _>("total_assets") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO); + let total_liabilities = stats_row + .try_get::, _>("total_liabilities") + .unwrap_or(None) + .unwrap_or(Decimal::ZERO); let response = AccountStatistics { - total_accounts: stats.total_accounts.unwrap_or(0), + total_accounts: stats_row + .try_get::, _>("total_accounts") + .unwrap_or(None) + .unwrap_or(0), total_assets, total_liabilities, net_worth: total_assets - total_liabilities, diff --git a/jive-api/src/handlers/currency_handler_enhanced.rs b/jive-api/src/handlers/currency_handler_enhanced.rs index f89e1d21..a8462265 100644 --- a/jive-api/src/handlers/currency_handler_enhanced.rs +++ b/jive-api/src/handlers/currency_handler_enhanced.rs @@ -628,7 +628,7 @@ pub async fn get_crypto_prices( .unwrap_or_else(|| vec!["BTC".to_string(), "ETH".to_string(), "USDT".to_string()]); // Get crypto prices from exchange_rates table - let prices = sqlx::query!( + let prices = sqlx::query( r#" SELECT from_currency as crypto_code, @@ -640,9 +640,9 @@ pub async fn get_crypto_prices( AND created_at > NOW() - INTERVAL '5 minutes' ORDER BY created_at DESC "#, - fiat_currency, - &crypto_codes ) + .bind(&fiat_currency) + .bind(&crypto_codes) .fetch_all(&pool) .await .map_err(|_| ApiError::InternalServerError)?; @@ -651,14 +651,18 @@ pub async fn get_crypto_prices( let mut last_updated: Option = None; for row in prices { - let price = Decimal::ONE / row.price; - crypto_prices.insert(row.crypto_code, price); - // created_at 可能为 NULL,防御性处理 - if let Some(created_at) = row.created_at { - let created_naive = created_at.naive_utc(); - if last_updated.map(|lu| created_naive > lu).unwrap_or(true) { - last_updated = Some(created_naive); - } + let code: String = row.get("crypto_code"); + let rate: Decimal = row.get("price"); + let price = Decimal::ONE / rate; + crypto_prices.insert(code, price); + // created_at 可能为空;回退为当前时间 + let created_naive = row + .try_get::>, _>("created_at") + .unwrap_or(None) + .map(|dt| dt.naive_utc()) + .unwrap_or_else(|| Utc::now().naive_utc()); + if last_updated.map(|lu| created_naive > lu).unwrap_or(true) { + last_updated = Some(created_naive); } } diff --git a/jive-api/src/services/currency_service.rs b/jive-api/src/services/currency_service.rs index f3a8899d..8ea97ecd 100644 --- a/jive-api/src/services/currency_service.rs +++ b/jive-api/src/services/currency_service.rs @@ -187,27 +187,41 @@ impl CurrencyService { &self, family_id: Uuid, ) -> Result { - // 获取基本设置 - let settings = sqlx::query!( + // 获取基本设置(动态行,避免 SQLx 宏类型差异) + let settings_row = sqlx::query( r#" SELECT base_currency, allow_multi_currency, auto_convert FROM family_currency_settings WHERE family_id = $1 "#, - family_id ) + .bind(family_id) .fetch_optional(&self.pool) .await?; - if let Some(settings) = settings { + if let Some(row) = settings_row { // 获取支持的货币列表 let supported = self.get_family_supported_currencies(family_id).await?; + use sqlx::Row; + let base_currency = row + .try_get::, _>("base_currency") + .unwrap_or(None) + .unwrap_or_else(|| "CNY".to_string()); + let allow_multi_currency = row + .try_get::, _>("allow_multi_currency") + .unwrap_or(None) + .unwrap_or(false); + let auto_convert = row + .try_get::, _>("auto_convert") + .unwrap_or(None) + .unwrap_or(false); + Ok(FamilyCurrencySettings { family_id, - base_currency: settings.base_currency.unwrap_or_else(|| "CNY".to_string()), - allow_multi_currency: settings.allow_multi_currency.unwrap_or(false), - auto_convert: settings.auto_convert.unwrap_or(false), + base_currency, + allow_multi_currency, + auto_convert, supported_currencies: supported, }) } else { @@ -467,7 +481,7 @@ impl CurrencyService { // effective_date 为非空(schema 约束);直接使用 effective_date: row.effective_date, // created_at 可能为 NULL;使用当前时间回填 - created_at: row.created_at.unwrap_or_else(Utc::now), + created_at: row.created_at.unwrap_or_else(chrono::Utc::now), }) .collect()) } diff --git a/jive-api/src/services/exchange_rate_api.rs b/jive-api/src/services/exchange_rate_api.rs index 5c48cbc5..61be1c27 100644 --- a/jive-api/src/services/exchange_rate_api.rs +++ b/jive-api/src/services/exchange_rate_api.rs @@ -1125,10 +1125,7 @@ impl ExchangeRateApiService { match db_result { Ok(Some(record)) => { // updated_at may be NULL in some rows; guard it - let age_hours = record - .updated_at - .map(|dt| (Utc::now().signed_duration_since(dt)).num_hours()) - .unwrap_or(0); + let age_hours = (Utc::now().signed_duration_since(record.updated_at.unwrap_or_else(chrono::Utc::now))).num_hours(); info!("✅ Step 1 SUCCESS: Found historical rate in database for {}->{}: rate={}, age={} hours ago", crypto_code, fiat_currency, record.rate, age_hours); return Ok(Some(record.rate)); From a5a63bab7264bbe31ef10b5ed11d18c6668e305f Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Wed, 15 Oct 2025 20:10:54 +0800 Subject: [PATCH 3/5] api/migrations: make net worth tables idempotent; ensure pgcrypto; fix IF NOT EXISTS for indexes/triggers --- .../migrations/037_add_net_worth_tracking.sql | 58 +++++++++++++------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/jive-api/migrations/037_add_net_worth_tracking.sql b/jive-api/migrations/037_add_net_worth_tracking.sql index 726c7a86..4dd25958 100644 --- a/jive-api/migrations/037_add_net_worth_tracking.sql +++ b/jive-api/migrations/037_add_net_worth_tracking.sql @@ -3,6 +3,9 @@ -- Author: Claude (inspired by Maybe Finance) -- Date: 2025-09-29 +-- Ensure required extensions are available for gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS pgcrypto; + -- Account valuations table: Track account values over time CREATE TABLE IF NOT EXISTS valuations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -118,24 +121,45 @@ CREATE TABLE IF NOT EXISTS net_worth_goals ( ); -- Create indexes for better performance -CREATE INDEX idx_valuations_account_id ON valuations(account_id); -CREATE INDEX idx_valuations_date ON valuations(valuation_date); -CREATE INDEX idx_balance_snapshots_family_id ON balance_snapshots(family_id); -CREATE INDEX idx_balance_snapshots_date ON balance_snapshots(snapshot_date); -CREATE INDEX idx_account_snapshots_balance_snapshot_id ON account_snapshots(balance_snapshot_id); -CREATE INDEX idx_account_snapshots_account_id ON account_snapshots(account_id); -CREATE INDEX idx_net_worth_goals_family_id ON net_worth_goals(family_id); -CREATE INDEX idx_net_worth_goals_status ON net_worth_goals(status); +CREATE INDEX IF NOT EXISTS idx_valuations_account_id ON valuations(account_id); +CREATE INDEX IF NOT EXISTS idx_valuations_date ON valuations(valuation_date); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_family_id ON balance_snapshots(family_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); +CREATE INDEX IF NOT EXISTS idx_account_snapshots_balance_snapshot_id ON account_snapshots(balance_snapshot_id); +CREATE INDEX IF NOT EXISTS idx_account_snapshots_account_id ON account_snapshots(account_id); +CREATE INDEX IF NOT EXISTS idx_net_worth_goals_family_id ON net_worth_goals(family_id); +CREATE INDEX IF NOT EXISTS idx_net_worth_goals_status ON net_worth_goals(status); -- Apply updated_at triggers -CREATE TRIGGER update_valuations_updated_at BEFORE UPDATE ON valuations - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_balance_snapshots_updated_at BEFORE UPDATE ON balance_snapshots - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_net_worth_goals_updated_at BEFORE UPDATE ON net_worth_goals - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_valuations_updated_at' + ) THEN + CREATE TRIGGER update_valuations_updated_at BEFORE UPDATE ON valuations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_balance_snapshots_updated_at' + ) THEN + CREATE TRIGGER update_balance_snapshots_updated_at BEFORE UPDATE ON balance_snapshots + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_net_worth_goals_updated_at' + ) THEN + CREATE TRIGGER update_net_worth_goals_updated_at BEFORE UPDATE ON net_worth_goals + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END$$; -- Function to calculate and store daily balance snapshot CREATE OR REPLACE FUNCTION calculate_daily_balance_snapshot(p_family_id UUID, p_date DATE DEFAULT CURRENT_DATE) @@ -244,4 +268,4 @@ COMMENT ON FUNCTION calculate_daily_balance_snapshot IS 'Calculate and store dai GRANT ALL ON valuations TO jive_user; GRANT ALL ON balance_snapshots TO jive_user; GRANT ALL ON account_snapshots TO jive_user; -GRANT ALL ON net_worth_goals TO jive_user; \ No newline at end of file +GRANT ALL ON net_worth_goals TO jive_user; From 7a8991b07e53c3414a6e817c258b88d20ae0fceb Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Wed, 15 Oct 2025 22:36:21 +0800 Subject: [PATCH 4/5] chore: workspace sync before merging base into PR branch --- jive-api/src/services/currency_service.rs | 4 +- jive-api/src/services/exchange_rate_api.rs | 5 +- jive-api/target/.rustc_info.json | 2 +- .../tests/contract_decimal_serialization.rs | 68 +++++++ .../repositories/balance_repository.rs | 150 +++++++++++++++ .../repositories/user_repository.rs | 176 ++++++++++++++++++ 6 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 jive-api/tests/contract_decimal_serialization.rs create mode 100644 jive-core/src/infrastructure/repositories/balance_repository.rs create mode 100644 jive-core/src/infrastructure/repositories/user_repository.rs diff --git a/jive-api/src/services/currency_service.rs b/jive-api/src/services/currency_service.rs index 8ea97ecd..1e9f8f42 100644 --- a/jive-api/src/services/currency_service.rs +++ b/jive-api/src/services/currency_service.rs @@ -480,8 +480,8 @@ impl CurrencyService { source: row.source.unwrap_or_else(|| "manual".to_string()), // effective_date 为非空(schema 约束);直接使用 effective_date: row.effective_date, - // created_at 可能为 NULL;使用当前时间回填 - created_at: row.created_at.unwrap_or_else(chrono::Utc::now), + // created_at 非空(schema 约束) + created_at: row.created_at, }) .collect()) } diff --git a/jive-api/src/services/exchange_rate_api.rs b/jive-api/src/services/exchange_rate_api.rs index 61be1c27..4d4d31d4 100644 --- a/jive-api/src/services/exchange_rate_api.rs +++ b/jive-api/src/services/exchange_rate_api.rs @@ -1124,8 +1124,9 @@ impl ExchangeRateApiService { match db_result { Ok(Some(record)) => { - // updated_at may be NULL in some rows; guard it - let age_hours = (Utc::now().signed_duration_since(record.updated_at.unwrap_or_else(chrono::Utc::now))).num_hours(); + // updated_at 非空(schema 约束) + let updated_at = record.updated_at; + let age_hours = (Utc::now().signed_duration_since(updated_at)).num_hours(); info!("✅ Step 1 SUCCESS: Found historical rate in database for {}->{}: rate={}, age={} hours ago", crypto_code, fiat_currency, record.rate, age_hours); return Ok(Some(record.rate)); diff --git a/jive-api/target/.rustc_info.json b/jive-api/target/.rustc_info.json index 660295c8..1dd549d0 100644 --- a/jive-api/target/.rustc_info.json +++ b/jive-api/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":1863893085117187729,"outputs":{"18122065246313386177":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: aarch64-apple-darwin\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"13007759520587589747":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/huazhou/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":8876508001675379479,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/huazhou/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: aarch64-apple-darwin\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/jive-api/tests/contract_decimal_serialization.rs b/jive-api/tests/contract_decimal_serialization.rs new file mode 100644 index 00000000..6b34bad6 --- /dev/null +++ b/jive-api/tests/contract_decimal_serialization.rs @@ -0,0 +1,68 @@ +use chrono::{TimeZone, Utc}; +use rust_decimal::Decimal; +use serde_json::{json, Value}; + +#[test] +fn transaction_amount_serializes_as_string() { + use jive_money_api::models::transaction::{Transaction, TransactionStatus, TransactionType}; + use uuid::Uuid; + + let tx = Transaction { + id: Uuid::nil(), + ledger_id: Uuid::nil(), + account_id: Uuid::nil(), + transaction_date: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), + amount: Decimal::new(12345, 2), // 123.45 + transaction_type: TransactionType::Income, + category_id: None, + category_name: Some("Salary".to_string()), + payee: Some("Company".to_string()), + notes: None, + status: TransactionStatus::Cleared, + related_transaction_id: None, + created_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), + updated_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), + }; + + let val: Value = serde_json::to_value(&tx).expect("serialize transaction"); + // amount should be a JSON string to avoid JS float issues + assert!(val.get("amount").and_then(|v| v.as_str()).is_some()); + assert_eq!(val["amount"].as_str().unwrap(), "123.45"); +} + +#[test] +fn budget_report_amounts_serialize_as_string() { + use jive_money_api::services::budget_service::{BudgetReport, BudgetSummary}; + + let report = BudgetReport { + period: "2025-10-01 - 2025-10-31".to_string(), + total_budgeted: Decimal::new(100000, 2), // 1000.00 + total_spent: Decimal::new(12345, 2), // 123.45 + total_remaining: Decimal::new(87655, 2), // 876.55 + overall_percentage: 12.345, + budget_summaries: vec![BudgetSummary { + budget_name: "Food".to_string(), + budgeted: Decimal::new(50000, 2), + spent: Decimal::new(1200, 2), + remaining: Decimal::new(48800, 2), + percentage: 2.4, + }], + unbudgeted_spending: Decimal::new(0, 0), + generated_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), + }; + + let val: Value = serde_json::to_value(&report).expect("serialize budget report"); + for key in [ + "total_budgeted", + "total_spent", + "total_remaining", + "unbudgeted_spending", + ] { + assert!(val.get(key).and_then(|v| v.as_str()).is_some(), "{} should be string", key); + } + + let first = &val["budget_summaries"][0]; + for key in ["budgeted", "spent", "remaining"] { + assert!(first.get(key).and_then(|v| v.as_str()).is_some(), "summary {} string", key); + } +} diff --git a/jive-core/src/infrastructure/repositories/balance_repository.rs b/jive-core/src/infrastructure/repositories/balance_repository.rs new file mode 100644 index 00000000..904ac77f --- /dev/null +++ b/jive-core/src/infrastructure/repositories/balance_repository.rs @@ -0,0 +1,150 @@ +use super::*; +use crate::infrastructure::entities::balance::Balance; +use async_trait::async_trait; +use sqlx::{postgres::PgRow, PgPool, Row}; +use std::sync::Arc; +use uuid::Uuid; + +pub struct BalanceRepository { + pool: Arc, +} + +impl BalanceRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + pub async fn find_by_account(&self, account_id: Uuid) -> Result, RepositoryError> { + let rows = sqlx::query( + r#" + SELECT id, account_id, date, balance, currency, + cash_balance, holdings_value, is_materialized, is_synced, + created_at, updated_at + FROM balances WHERE account_id = $1 ORDER BY date DESC + "#, + ) + .bind(account_id) + .fetch_all(&*self.pool) + .await?; + + Ok(rows.into_iter().map(map_balance).collect()) + } +} + +fn map_balance(row: PgRow) -> Balance { + Balance { + id: row.get("id"), + account_id: row.get("account_id"), + date: row.get("date"), + balance: row.get("balance"), + currency: row.get("currency"), + cash_balance: row.get("cash_balance"), + holdings_value: row.get("holdings_value"), + is_materialized: row.get("is_materialized"), + is_synced: row.get("is_synced"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } +} + +#[async_trait] +impl Repository for BalanceRepository { + type Error = RepositoryError; + + async fn find_by_id(&self, id: Uuid) -> Result, Self::Error> { + let row = sqlx::query( + r#" + SELECT id, account_id, date, balance, currency, + cash_balance, holdings_value, is_materialized, is_synced, + created_at, updated_at + FROM balances WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&*self.pool) + .await?; + + Ok(row.map(map_balance)) + } + + async fn find_all(&self) -> Result, Self::Error> { + let rows = sqlx::query( + r#" + SELECT id, account_id, date, balance, currency, + cash_balance, holdings_value, is_materialized, is_synced, + created_at, updated_at + FROM balances ORDER BY date DESC + "#, + ) + .fetch_all(&*self.pool) + .await?; + Ok(rows.into_iter().map(map_balance).collect()) + } + + async fn create(&self, entity: Balance) -> Result { + let row = sqlx::query( + r#" + INSERT INTO balances ( + id, account_id, date, balance, currency, + cash_balance, holdings_value, is_materialized, is_synced, + created_at, updated_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11 + ) RETURNING id, account_id, date, balance, currency, + cash_balance, holdings_value, is_materialized, is_synced, + created_at, updated_at + "#, + ) + .bind(entity.id) + .bind(entity.account_id) + .bind(entity.date) + .bind(entity.balance) + .bind(&entity.currency) + .bind(&entity.cash_balance) + .bind(&entity.holdings_value) + .bind(entity.is_materialized) + .bind(entity.is_synced) + .bind(entity.created_at) + .bind(entity.updated_at) + .fetch_one(&*self.pool) + .await?; + Ok(map_balance(row)) + } + + async fn update(&self, entity: Balance) -> Result { + let row = sqlx::query( + r#" + UPDATE balances SET + account_id=$2, date=$3, balance=$4, currency=$5, + cash_balance=$6, holdings_value=$7, is_materialized=$8, is_synced=$9, + updated_at=$10 + WHERE id=$1 + RETURNING id, account_id, date, balance, currency, + cash_balance, holdings_value, is_materialized, is_synced, + created_at, updated_at + "#, + ) + .bind(entity.id) + .bind(entity.account_id) + .bind(entity.date) + .bind(entity.balance) + .bind(&entity.currency) + .bind(&entity.cash_balance) + .bind(&entity.holdings_value) + .bind(entity.is_materialized) + .bind(entity.is_synced) + .bind(entity.updated_at) + .fetch_one(&*self.pool) + .await?; + Ok(map_balance(row)) + } + + async fn delete(&self, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM balances WHERE id = $1") + .bind(id) + .execute(&*self.pool) + .await?; + Ok(result.rows_affected() > 0) + } +} + diff --git a/jive-core/src/infrastructure/repositories/user_repository.rs b/jive-core/src/infrastructure/repositories/user_repository.rs new file mode 100644 index 00000000..ee61cbaa --- /dev/null +++ b/jive-core/src/infrastructure/repositories/user_repository.rs @@ -0,0 +1,176 @@ +use super::*; +use crate::infrastructure::entities::user::User; +use async_trait::async_trait; +use sqlx::{postgres::PgRow, PgPool, Row}; +use std::sync::Arc; +use uuid::Uuid; + +pub struct UserRepository { + pool: Arc, +} + +impl UserRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + // Example method: find by email (runtime query to avoid .sqlx) + pub async fn find_by_email(&self, email: &str) -> Result, RepositoryError> { + let row = sqlx::query( + r#" + SELECT id, family_id, email, first_name, last_name, role, + preferences, last_seen_at, last_seen_version, + remember_created_at, confirmed_at, confirmation_sent_at, + confirmation_token, unconfirmed_email, created_at, updated_at + FROM users WHERE email = $1 + "#, + ) + .bind(email) + .fetch_optional(&*self.pool) + .await?; + + Ok(row.map(|r| map_user(r))) + } +} + +fn map_user(row: PgRow) -> User { + User { + id: row.get("id"), + family_id: row.get("family_id"), + email: row.get("email"), + first_name: row.get("first_name"), + last_name: row.get("last_name"), + role: row.get("role"), + preferences: row.get("preferences"), + last_seen_at: row.get("last_seen_at"), + last_seen_version: row.get("last_seen_version"), + remember_created_at: row.get("remember_created_at"), + confirmed_at: row.get("confirmed_at"), + confirmation_sent_at: row.get("confirmation_sent_at"), + confirmation_token: row.get("confirmation_token"), + unconfirmed_email: row.get("unconfirmed_email"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } +} + +#[async_trait] +impl Repository for UserRepository { + type Error = RepositoryError; + + async fn find_by_id(&self, id: Uuid) -> Result, Self::Error> { + let row = sqlx::query( + r#" + SELECT id, family_id, email, first_name, last_name, role, + preferences, last_seen_at, last_seen_version, + remember_created_at, confirmed_at, confirmation_sent_at, + confirmation_token, unconfirmed_email, created_at, updated_at + FROM users WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&*self.pool) + .await?; + + Ok(row.map(|r| map_user(r))) + } + + async fn find_all(&self) -> Result, Self::Error> { + let rows = sqlx::query( + r#" + SELECT id, family_id, email, first_name, last_name, role, + preferences, last_seen_at, last_seen_version, + remember_created_at, confirmed_at, confirmation_sent_at, + confirmation_token, unconfirmed_email, created_at, updated_at + FROM users ORDER BY created_at DESC + "#, + ) + .fetch_all(&*self.pool) + .await?; + + Ok(rows.into_iter().map(map_user).collect()) + } + + async fn create(&self, entity: User) -> Result { + let row = sqlx::query( + r#" + INSERT INTO users ( + id, family_id, email, first_name, last_name, role, + preferences, last_seen_at, last_seen_version, + remember_created_at, confirmed_at, confirmation_sent_at, + confirmation_token, unconfirmed_email, created_at, updated_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16 + ) RETURNING id, family_id, email, first_name, last_name, role, + preferences, last_seen_at, last_seen_version, + remember_created_at, confirmed_at, confirmation_sent_at, + confirmation_token, unconfirmed_email, created_at, updated_at + "#, + ) + .bind(entity.id) + .bind(entity.family_id) + .bind(&entity.email) + .bind(&entity.first_name) + .bind(&entity.last_name) + .bind(&entity.role) + .bind(&entity.preferences) + .bind(&entity.last_seen_at) + .bind(&entity.last_seen_version) + .bind(&entity.remember_created_at) + .bind(&entity.confirmed_at) + .bind(&entity.confirmation_sent_at) + .bind(&entity.confirmation_token) + .bind(&entity.unconfirmed_email) + .bind(entity.created_at) + .bind(entity.updated_at) + .fetch_one(&*self.pool) + .await?; + + Ok(map_user(row)) + } + + async fn update(&self, entity: User) -> Result { + let row = sqlx::query( + r#" + UPDATE users SET + family_id=$2, email=$3, first_name=$4, last_name=$5, role=$6, + preferences=$7, last_seen_at=$8, last_seen_version=$9, + remember_created_at=$10, confirmed_at=$11, confirmation_sent_at=$12, + confirmation_token=$13, unconfirmed_email=$14, updated_at=$15 + WHERE id=$1 + RETURNING id, family_id, email, first_name, last_name, role, + preferences, last_seen_at, last_seen_version, + remember_created_at, confirmed_at, confirmation_sent_at, + confirmation_token, unconfirmed_email, created_at, updated_at + "#, + ) + .bind(entity.id) + .bind(entity.family_id) + .bind(&entity.email) + .bind(&entity.first_name) + .bind(&entity.last_name) + .bind(&entity.role) + .bind(&entity.preferences) + .bind(&entity.last_seen_at) + .bind(&entity.last_seen_version) + .bind(&entity.remember_created_at) + .bind(&entity.confirmed_at) + .bind(&entity.confirmation_sent_at) + .bind(&entity.confirmation_token) + .bind(&entity.unconfirmed_email) + .bind(entity.updated_at) + .fetch_one(&*self.pool) + .await?; + + Ok(map_user(row)) + } + + async fn delete(&self, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(&*self.pool) + .await?; + Ok(result.rows_affected() > 0) + } +} + From db62daa4674874e5a5e2889ef24a1e8b3ef58c11 Mon Sep 17 00:00:00 2001 From: zensgit <77236085+zensgit@users.noreply.github.com> Date: Wed, 15 Oct 2025 22:48:18 +0800 Subject: [PATCH 5/5] tests: remove WIP contract serialization test from this PR --- ...1e3ab1ef0ae87c6632d0f6bc1ee31b679cdf4.json | 34 -- ...6f57632846cbc7ab93c8a6c58cf276c745e69.json | 130 ------- ...17e364f68bee20f1b8949af098d2c15d254a4.json | 118 ------ ...c33fcdd4b9dfe43055726985841977b8723e5.json | 35 -- ...0f3b90ec7a106b9277dcb40fe7bcef98e7bf7.json | 34 -- ...90fda34420de3a7c5e024a356e68b0dd64081.json | 34 -- jive-api/src/services/currency_service.rs | 4 +- jive-api/src/services/exchange_rate_api.rs | 4 +- jive-api/target/.rustc_info.json | 2 +- .../tests/contract_decimal_serialization.rs | 68 ---- .../src/infrastructure/repositories/mod.rs | 4 +- .../repositories/transaction_repository.rs | 367 +++++++++++------- 12 files changed, 234 insertions(+), 600 deletions(-) delete mode 100644 jive-api/.sqlx/query-1a60d1207fa4af06e02f770592f1e3ab1ef0ae87c6632d0f6bc1ee31b679cdf4.json delete mode 100644 jive-api/.sqlx/query-1dade7571ba6291d0ff148280c26f57632846cbc7ab93c8a6c58cf276c745e69.json delete mode 100644 jive-api/.sqlx/query-55d06f356978b20c3de14d2cdd717e364f68bee20f1b8949af098d2c15d254a4.json delete mode 100644 jive-api/.sqlx/query-a0d2dfbf3b31cbde7611cc07eb8c33fcdd4b9dfe43055726985841977b8723e5.json delete mode 100644 jive-api/.sqlx/query-d9740c18a47d026853f7b8542fe0f3b90ec7a106b9277dcb40fe7bcef98e7bf7.json delete mode 100644 jive-api/.sqlx/query-d9c2adc5f3a0d08582f6de1e1cf90fda34420de3a7c5e024a356e68b0dd64081.json delete mode 100644 jive-api/tests/contract_decimal_serialization.rs diff --git a/jive-api/.sqlx/query-1a60d1207fa4af06e02f770592f1e3ab1ef0ae87c6632d0f6bc1ee31b679cdf4.json b/jive-api/.sqlx/query-1a60d1207fa4af06e02f770592f1e3ab1ef0ae87c6632d0f6bc1ee31b679cdf4.json deleted file mode 100644 index 90324c92..00000000 --- a/jive-api/.sqlx/query-1a60d1207fa4af06e02f770592f1e3ab1ef0ae87c6632d0f6bc1ee31b679cdf4.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT \n COUNT(*) as total_accounts,\n SUM(CASE WHEN current_balance > 0 THEN current_balance ELSE 0 END)::numeric as total_assets,\n SUM(CASE WHEN current_balance < 0 THEN ABS(current_balance) ELSE 0 END)::numeric as total_liabilities\n FROM accounts\n WHERE ledger_id = $1 AND deleted_at IS NULL\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "total_accounts", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "total_assets", - "type_info": "Numeric" - }, - { - "ordinal": 2, - "name": "total_liabilities", - "type_info": "Numeric" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null, - null, - null - ] - }, - "hash": "1a60d1207fa4af06e02f770592f1e3ab1ef0ae87c6632d0f6bc1ee31b679cdf4" -} diff --git a/jive-api/.sqlx/query-1dade7571ba6291d0ff148280c26f57632846cbc7ab93c8a6c58cf276c745e69.json b/jive-api/.sqlx/query-1dade7571ba6291d0ff148280c26f57632846cbc7ab93c8a6c58cf276c745e69.json deleted file mode 100644 index 3a516930..00000000 --- a/jive-api/.sqlx/query-1dade7571ba6291d0ff148280c26f57632846cbc7ab93c8a6c58cf276c745e69.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO accounts (\n id, ledger_id, bank_id, name, account_type, account_main_type, account_sub_type,\n account_number, institution_name, currency, current_balance, status,\n is_manual, color, notes, created_at, updated_at\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::numeric, 'active', true, $12, $13, NOW(), NOW()\n )\n RETURNING id, ledger_id, bank_id, name, account_type, account_number, institution_name,\n currency,\n current_balance::numeric as current_balance,\n available_balance::numeric as available_balance,\n credit_limit::numeric as credit_limit,\n status,\n is_manual, color, notes, created_at, updated_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "ledger_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "bank_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "account_type", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "account_number", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "institution_name", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "currency", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "current_balance", - "type_info": "Numeric" - }, - { - "ordinal": 9, - "name": "available_balance", - "type_info": "Numeric" - }, - { - "ordinal": 10, - "name": "credit_limit", - "type_info": "Numeric" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "is_manual", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "color", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "notes", - "type_info": "Text" - }, - { - "ordinal": 15, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 16, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Uuid", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Numeric", - "Varchar", - "Text" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - true, - null, - null, - null, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "1dade7571ba6291d0ff148280c26f57632846cbc7ab93c8a6c58cf276c745e69" -} diff --git a/jive-api/.sqlx/query-55d06f356978b20c3de14d2cdd717e364f68bee20f1b8949af098d2c15d254a4.json b/jive-api/.sqlx/query-55d06f356978b20c3de14d2cdd717e364f68bee20f1b8949af098d2c15d254a4.json deleted file mode 100644 index b4af0c5f..00000000 --- a/jive-api/.sqlx/query-55d06f356978b20c3de14d2cdd717e364f68bee20f1b8949af098d2c15d254a4.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, ledger_id, bank_id, name, account_type, account_number, institution_name,\n currency,\n current_balance::numeric as current_balance,\n available_balance::numeric as available_balance,\n credit_limit::numeric as credit_limit,\n status,\n is_manual, color, notes, created_at, updated_at\n FROM accounts\n WHERE id = $1 AND deleted_at IS NULL\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "ledger_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "bank_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "account_type", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "account_number", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "institution_name", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "currency", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "current_balance", - "type_info": "Numeric" - }, - { - "ordinal": 9, - "name": "available_balance", - "type_info": "Numeric" - }, - { - "ordinal": 10, - "name": "credit_limit", - "type_info": "Numeric" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "is_manual", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "color", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "notes", - "type_info": "Text" - }, - { - "ordinal": 15, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 16, - "name": "updated_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - true, - null, - null, - null, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "55d06f356978b20c3de14d2cdd717e364f68bee20f1b8949af098d2c15d254a4" -} diff --git a/jive-api/.sqlx/query-a0d2dfbf3b31cbde7611cc07eb8c33fcdd4b9dfe43055726985841977b8723e5.json b/jive-api/.sqlx/query-a0d2dfbf3b31cbde7611cc07eb8c33fcdd4b9dfe43055726985841977b8723e5.json deleted file mode 100644 index 26314d0d..00000000 --- a/jive-api/.sqlx/query-a0d2dfbf3b31cbde7611cc07eb8c33fcdd4b9dfe43055726985841977b8723e5.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT \n from_currency as crypto_code,\n rate as price,\n created_at\n FROM exchange_rates\n WHERE to_currency = $1\n AND from_currency = ANY($2)\n AND created_at > NOW() - INTERVAL '5 minutes'\n ORDER BY created_at DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "crypto_code", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "price", - "type_info": "Numeric" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "TextArray" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "a0d2dfbf3b31cbde7611cc07eb8c33fcdd4b9dfe43055726985841977b8723e5" -} diff --git a/jive-api/.sqlx/query-d9740c18a47d026853f7b8542fe0f3b90ec7a106b9277dcb40fe7bcef98e7bf7.json b/jive-api/.sqlx/query-d9740c18a47d026853f7b8542fe0f3b90ec7a106b9277dcb40fe7bcef98e7bf7.json deleted file mode 100644 index 5f17d107..00000000 --- a/jive-api/.sqlx/query-d9740c18a47d026853f7b8542fe0f3b90ec7a106b9277dcb40fe7bcef98e7bf7.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT base_currency, allow_multi_currency, auto_convert\n FROM family_currency_settings\n WHERE family_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "base_currency", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "allow_multi_currency", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "auto_convert", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true, - true, - true - ] - }, - "hash": "d9740c18a47d026853f7b8542fe0f3b90ec7a106b9277dcb40fe7bcef98e7bf7" -} diff --git a/jive-api/.sqlx/query-d9c2adc5f3a0d08582f6de1e1cf90fda34420de3a7c5e024a356e68b0dd64081.json b/jive-api/.sqlx/query-d9c2adc5f3a0d08582f6de1e1cf90fda34420de3a7c5e024a356e68b0dd64081.json deleted file mode 100644 index 30a645d2..00000000 --- a/jive-api/.sqlx/query-d9c2adc5f3a0d08582f6de1e1cf90fda34420de3a7c5e024a356e68b0dd64081.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT \n account_type,\n COUNT(*) as count,\n SUM(current_balance)::numeric as total_balance\n FROM accounts\n WHERE ledger_id = $1 AND deleted_at IS NULL\n GROUP BY account_type\n ORDER BY account_type\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "account_type", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "count", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "total_balance", - "type_info": "Numeric" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - null, - null - ] - }, - "hash": "d9c2adc5f3a0d08582f6de1e1cf90fda34420de3a7c5e024a356e68b0dd64081" -} diff --git a/jive-api/src/services/currency_service.rs b/jive-api/src/services/currency_service.rs index 1e9f8f42..275b9816 100644 --- a/jive-api/src/services/currency_service.rs +++ b/jive-api/src/services/currency_service.rs @@ -480,8 +480,8 @@ impl CurrencyService { source: row.source.unwrap_or_else(|| "manual".to_string()), // effective_date 为非空(schema 约束);直接使用 effective_date: row.effective_date, - // created_at 非空(schema 约束) - created_at: row.created_at, + // created_at 可能为 NULL;使用当前时间回填 + created_at: row.created_at.unwrap_or(chrono::Utc::now()), }) .collect()) } diff --git a/jive-api/src/services/exchange_rate_api.rs b/jive-api/src/services/exchange_rate_api.rs index 4d4d31d4..91a5881a 100644 --- a/jive-api/src/services/exchange_rate_api.rs +++ b/jive-api/src/services/exchange_rate_api.rs @@ -1124,8 +1124,8 @@ impl ExchangeRateApiService { match db_result { Ok(Some(record)) => { - // updated_at 非空(schema 约束) - let updated_at = record.updated_at; + // updated_at 可能为 NULL(历史数据),使用当前时间回填 + let updated_at = record.updated_at.unwrap_or(Utc::now()); let age_hours = (Utc::now().signed_duration_since(updated_at)).num_hours(); info!("✅ Step 1 SUCCESS: Found historical rate in database for {}->{}: rate={}, age={} hours ago", crypto_code, fiat_currency, record.rate, age_hours); diff --git a/jive-api/target/.rustc_info.json b/jive-api/target/.rustc_info.json index 1dd549d0..2e254c01 100644 --- a/jive-api/target/.rustc_info.json +++ b/jive-api/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":8876508001675379479,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/huazhou/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: aarch64-apple-darwin\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":8876508001675379479,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: aarch64-apple-darwin\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/huazhou/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/jive-api/tests/contract_decimal_serialization.rs b/jive-api/tests/contract_decimal_serialization.rs deleted file mode 100644 index 6b34bad6..00000000 --- a/jive-api/tests/contract_decimal_serialization.rs +++ /dev/null @@ -1,68 +0,0 @@ -use chrono::{TimeZone, Utc}; -use rust_decimal::Decimal; -use serde_json::{json, Value}; - -#[test] -fn transaction_amount_serializes_as_string() { - use jive_money_api::models::transaction::{Transaction, TransactionStatus, TransactionType}; - use uuid::Uuid; - - let tx = Transaction { - id: Uuid::nil(), - ledger_id: Uuid::nil(), - account_id: Uuid::nil(), - transaction_date: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - amount: Decimal::new(12345, 2), // 123.45 - transaction_type: TransactionType::Income, - category_id: None, - category_name: Some("Salary".to_string()), - payee: Some("Company".to_string()), - notes: None, - status: TransactionStatus::Cleared, - related_transaction_id: None, - created_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - updated_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - }; - - let val: Value = serde_json::to_value(&tx).expect("serialize transaction"); - // amount should be a JSON string to avoid JS float issues - assert!(val.get("amount").and_then(|v| v.as_str()).is_some()); - assert_eq!(val["amount"].as_str().unwrap(), "123.45"); -} - -#[test] -fn budget_report_amounts_serialize_as_string() { - use jive_money_api::services::budget_service::{BudgetReport, BudgetSummary}; - - let report = BudgetReport { - period: "2025-10-01 - 2025-10-31".to_string(), - total_budgeted: Decimal::new(100000, 2), // 1000.00 - total_spent: Decimal::new(12345, 2), // 123.45 - total_remaining: Decimal::new(87655, 2), // 876.55 - overall_percentage: 12.345, - budget_summaries: vec![BudgetSummary { - budget_name: "Food".to_string(), - budgeted: Decimal::new(50000, 2), - spent: Decimal::new(1200, 2), - remaining: Decimal::new(48800, 2), - percentage: 2.4, - }], - unbudgeted_spending: Decimal::new(0, 0), - generated_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(), - }; - - let val: Value = serde_json::to_value(&report).expect("serialize budget report"); - for key in [ - "total_budgeted", - "total_spent", - "total_remaining", - "unbudgeted_spending", - ] { - assert!(val.get(key).and_then(|v| v.as_str()).is_some(), "{} should be string", key); - } - - let first = &val["budget_summaries"][0]; - for key in ["budgeted", "spent", "remaining"] { - assert!(first.get(key).and_then(|v| v.as_str()).is_some(), "summary {} string", key); - } -} diff --git a/jive-core/src/infrastructure/repositories/mod.rs b/jive-core/src/infrastructure/repositories/mod.rs index f4b42768..d0bbddd3 100644 --- a/jive-core/src/infrastructure/repositories/mod.rs +++ b/jive-core/src/infrastructure/repositories/mod.rs @@ -46,7 +46,7 @@ impl BaseRepository { #[derive(Debug, thiserror::Error)] pub enum RepositoryError { #[error("Database error: {0}")] - Database(#[from] sqlx::Error), + Database(sqlx::Error), #[error("Entity not found")] NotFound, @@ -78,4 +78,4 @@ impl From for RepositoryError { _ => RepositoryError::Database(err), } } -} \ No newline at end of file +} diff --git a/jive-core/src/infrastructure/repositories/transaction_repository.rs b/jive-core/src/infrastructure/repositories/transaction_repository.rs index 3eb5f902..3ddc7534 100644 --- a/jive-core/src/infrastructure/repositories/transaction_repository.rs +++ b/jive-core/src/infrastructure/repositories/transaction_repository.rs @@ -1,6 +1,7 @@ use super::*; use crate::error::TransactionSplitError; use crate::infrastructure::entities::transaction::*; +use crate::infrastructure::entities::transaction::TransactionKind; use crate::infrastructure::entities::{Entry, DateRange}; use async_trait::async_trait; use chrono::{DateTime, NaiveDate, Utc}; @@ -27,10 +28,9 @@ impl TransactionRepository { transaction: Transaction, ) -> Result { let mut tx = self.pool.begin().await?; - - // First create the entry - let created_entry = sqlx::query_as!( - Entry, + + // First create the entry (runtime query) + let created_entry = sqlx::query( r#" INSERT INTO entries ( id, account_id, entryable_type, entryable_id, @@ -39,29 +39,31 @@ impl TransactionRepository { created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) - RETURNING * + RETURNING id, account_id, entryable_type, entryable_id, + amount, currency, date, name, notes, + excluded, pending, nature, created_at, updated_at "#, - entry.id, - entry.account_id, - "Transaction", - transaction.id, - entry.amount, - entry.currency, - entry.date, - entry.name, - entry.notes, - entry.excluded, - entry.pending, - entry.nature, - entry.created_at, - entry.updated_at ) + .bind(entry.id) + .bind(entry.account_id) + .bind("Transaction") + .bind(transaction.id) + .bind(entry.amount) + .bind(&entry.currency) + .bind(entry.date) + .bind(&entry.name) + .bind(&entry.notes) + .bind(entry.excluded) + .bind(entry.pending) + .bind(&entry.nature) + .bind(entry.created_at) + .bind(entry.updated_at) .fetch_one(&mut *tx) .await?; - - // Then create the transaction - let created_transaction = sqlx::query_as!( - Transaction, + let created_entry: Entry = sqlx::FromRow::from_row(&created_entry)?; + + // Then create the transaction (runtime query) + let created_transaction_row = sqlx::query( r#" INSERT INTO transactions ( id, entry_id, category_id, payee_id, @@ -77,33 +79,41 @@ impl TransactionRepository { $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 ) - RETURNING * + RETURNING id, entry_id, category_id, payee_id, + ledger_id, ledger_account_id, + scheduled_transaction_id, original_transaction_id, + reimbursement_batch_id, notes, kind, tags, + reimbursable, reimbursed, reimbursed_at, + is_refund, refund_amount, + exclude_from_reports, exclude_from_budget, + discount, created_at, updated_at "#, - transaction.id, - created_entry.id, - transaction.category_id, - transaction.payee_id, - transaction.ledger_id, - transaction.ledger_account_id, - transaction.scheduled_transaction_id, - transaction.original_transaction_id, - transaction.reimbursement_batch_id, - transaction.notes, - transaction.kind as TransactionKind, - serde_json::to_value(&transaction.tags).unwrap(), - transaction.reimbursable, - transaction.reimbursed, - transaction.reimbursed_at, - transaction.is_refund, - transaction.refund_amount, - transaction.exclude_from_reports, - transaction.exclude_from_budget, - transaction.discount, - transaction.created_at, - transaction.updated_at ) + .bind(transaction.id) + .bind(created_entry.id) + .bind(&transaction.category_id) + .bind(&transaction.payee_id) + .bind(&transaction.ledger_id) + .bind(&transaction.ledger_account_id) + .bind(&transaction.scheduled_transaction_id) + .bind(&transaction.original_transaction_id) + .bind(&transaction.reimbursement_batch_id) + .bind(&transaction.notes) + .bind(transaction.kind as TransactionKind) + .bind(&transaction.tags) + .bind(transaction.reimbursable) + .bind(transaction.reimbursed) + .bind(&transaction.reimbursed_at) + .bind(transaction.is_refund) + .bind(&transaction.refund_amount) + .bind(transaction.exclude_from_reports) + .bind(transaction.exclude_from_budget) + .bind(&transaction.discount) + .bind(transaction.created_at) + .bind(transaction.updated_at) .fetch_one(&mut *tx) .await?; + let created_transaction: Transaction = sqlx::FromRow::from_row(&created_transaction_row)?; tx.commit().await?; @@ -120,7 +130,7 @@ impl TransactionRepository { date_range: Option, ) -> Result, RepositoryError> { let query = if let Some(range) = date_range { - sqlx::query!( + sqlx::query( r#" SELECT t.*, @@ -134,12 +144,12 @@ impl TransactionRepository { AND e.date <= $3 ORDER BY e.date DESC, t.created_at DESC "#, - account_id, - range.start, - range.end ) + .bind(account_id) + .bind(range.start) + .bind(range.end) } else { - sqlx::query!( + sqlx::query( r#" SELECT t.*, @@ -151,15 +161,35 @@ impl TransactionRepository { WHERE e.account_id = $1 ORDER BY e.date DESC, t.created_at DESC "#, - account_id ) + .bind(account_id) }; let rows = query.fetch_all(&*self.pool).await?; - - // Map rows to TransactionWithEntry - // Note: This is simplified - actual implementation would properly map all fields - Ok(vec![]) + // TODO: Properly map to TransactionWithEntry using manual row mapping + let mut results = Vec::new(); + for row in rows { + // Split into two structs by selecting columns explicitly above. + let transaction: Transaction = sqlx::FromRow::from_row(&row)?; + let entry = Entry { + id: row.get("entry_id"), + account_id: row.get("account_id"), + entryable_type: "Transaction".to_string(), + entryable_id: transaction.id, + amount: row.get("amount"), + currency: row.get("currency"), + date: row.get("date"), + name: row.get("entry_name"), + notes: row.get("entry_notes"), + excluded: row.get("excluded"), + pending: row.get("pending"), + nature: row.get("nature"), + created_at: transaction.created_at, + updated_at: transaction.updated_at, + }; + results.push(TransactionWithEntry { transaction, entry }); + } + Ok(results) } // Find transactions by category @@ -168,7 +198,7 @@ impl TransactionRepository { category_id: Uuid, family_id: Uuid, ) -> Result, RepositoryError> { - let rows = sqlx::query!( + let rows = sqlx::query( r#" SELECT t.*, @@ -181,14 +211,34 @@ impl TransactionRepository { WHERE t.category_id = $1 AND a.family_id = $2 ORDER BY e.date DESC "#, - category_id, - family_id ) + .bind(category_id) + .bind(family_id) .fetch_all(&*self.pool) .await?; - - // Map rows to TransactionWithEntry - Ok(vec![]) + + let mut results = Vec::new(); + for row in rows { + let transaction: Transaction = sqlx::FromRow::from_row(&row)?; + let entry = Entry { + id: row.get("entry_id"), + account_id: row.get("account_id"), + entryable_type: "Transaction".to_string(), + entryable_id: transaction.id, + amount: row.get("amount"), + currency: row.get("currency"), + date: row.get("date"), + name: row.get("entry_name"), + notes: row.get("entry_notes"), + excluded: row.get("excluded"), + pending: row.get("pending"), + nature: row.get("nature"), + created_at: transaction.created_at, + updated_at: transaction.updated_at, + }; + results.push(TransactionWithEntry { transaction, entry }); + } + Ok(results) } // Find transactions by payee @@ -196,7 +246,7 @@ impl TransactionRepository { &self, payee_id: Uuid, ) -> Result, RepositoryError> { - let rows = sqlx::query!( + let rows = sqlx::query( r#" SELECT t.*, @@ -208,12 +258,32 @@ impl TransactionRepository { WHERE t.payee_id = $1 ORDER BY e.date DESC "#, - payee_id ) + .bind(payee_id) .fetch_all(&*self.pool) .await?; - - Ok(vec![]) + let mut results = Vec::new(); + for row in rows { + let transaction: Transaction = sqlx::FromRow::from_row(&row)?; + let entry = Entry { + id: row.get("entry_id"), + account_id: row.get("account_id"), + entryable_type: "Transaction".to_string(), + entryable_id: transaction.id, + amount: row.get("amount"), + currency: row.get("currency"), + date: row.get("date"), + name: row.get("entry_name"), + notes: row.get("entry_notes"), + excluded: row.get("excluded"), + pending: row.get("pending"), + nature: row.get("nature"), + created_at: transaction.created_at, + updated_at: transaction.updated_at, + }; + results.push(TransactionWithEntry { transaction, entry }); + } + Ok(results) } // Find reimbursable transactions @@ -223,7 +293,7 @@ impl TransactionRepository { pending_only: bool, ) -> Result, RepositoryError> { let query = if pending_only { - sqlx::query!( + sqlx::query( r#" SELECT t.*, @@ -238,10 +308,10 @@ impl TransactionRepository { AND t.reimbursed = false ORDER BY e.date DESC "#, - family_id ) + .bind(family_id) } else { - sqlx::query!( + sqlx::query( r#" SELECT t.*, @@ -254,12 +324,33 @@ impl TransactionRepository { WHERE a.family_id = $1 AND t.reimbursable = true ORDER BY e.date DESC "#, - family_id ) + .bind(family_id) }; let rows = query.fetch_all(&*self.pool).await?; - Ok(vec![]) + let mut results = Vec::new(); + for row in rows { + let transaction: Transaction = sqlx::FromRow::from_row(&row)?; + let entry = Entry { + id: row.get("entry_id"), + account_id: row.get("account_id"), + entryable_type: "Transaction".to_string(), + entryable_id: transaction.id, + amount: row.get("amount"), + currency: row.get("currency"), + date: row.get("date"), + name: row.get("entry_name"), + notes: row.get("entry_notes"), + excluded: row.get("excluded"), + pending: row.get("pending"), + nature: row.get("nature"), + created_at: transaction.created_at, + updated_at: transaction.updated_at, + }; + results.push(TransactionWithEntry { transaction, entry }); + } + Ok(results) } /// Split a transaction into multiple parts with full validation and concurrency control @@ -340,7 +431,7 @@ impl TransactionRepository { .await?; // 3. Get and lock original transaction (Entry-Transaction model) - let original = match sqlx::query!( + let original = match sqlx::query( r#" SELECT e.id as entry_id, @@ -362,9 +453,9 @@ impl TransactionRepository { WHERE e.entryable_id = $1 AND e.entryable_type = 'Transaction' FOR UPDATE NOWAIT - "#, - original_id + "# ) + .bind(original_id) .fetch_optional(&mut *tx) .await { Ok(Some(row)) => row, @@ -390,15 +481,15 @@ impl TransactionRepository { } // 4. Check for existing splits (with lock) - let existing_splits = sqlx::query!( + let existing_splits = sqlx::query( r#" SELECT split_transaction_id FROM transaction_splits WHERE original_transaction_id = $1 FOR UPDATE - "#, - original_id + "# ) + .bind(original_id) .fetch_all(&mut *tx) .await?; @@ -443,7 +534,7 @@ impl TransactionRepository { .clone() .unwrap_or_else(|| format!("Split from: {}", original.name)); - sqlx::query!( + sqlx::query( r#" INSERT INTO entries ( id, account_id, entryable_type, entryable_id, @@ -458,18 +549,18 @@ impl TransactionRepository { $5, $5 FROM entries WHERE id = $6 "#, - split_entry_id, - split_transaction_id, - split.amount.to_string(), - split_name, - Utc::now(), - original.entry_id ) + .bind(split_entry_id) + .bind(split_transaction_id) + .bind(split.amount) + .bind(&split_name) + .bind(Utc::now()) + .bind(original.entry_id) .execute(&mut *tx) .await?; // Create transaction for split - sqlx::query!( + sqlx::query( r#" INSERT INTO transactions ( id, entry_id, category_id, payee_id, @@ -479,23 +570,22 @@ impl TransactionRepository { created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'standard', $9, $9) - "#, - split_transaction_id, - split_entry_id, - split.category_id.or(original.category_id), - original.payee_id, - original.ledger_id, - original.ledger_account_id, - original_id, - split.description.clone(), - Utc::now() + "# ) + .bind(split_transaction_id) + .bind(split_entry_id) + .bind(&split.category_id.or(original.category_id)) + .bind(&original.payee_id) + .bind(&original.ledger_id) + .bind(&original.ledger_account_id) + .bind(original_id) + .bind(&split.description) + .bind(Utc::now()) .execute(&mut *tx) .await?; // Create split record - let split_record = sqlx::query_as!( - TransactionSplit, + let split_record_row = sqlx::query( r#" INSERT INTO transaction_splits ( id, original_transaction_id, split_transaction_id, @@ -503,27 +593,19 @@ impl TransactionRepository { created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $6) - RETURNING - id, - original_transaction_id, - split_transaction_id, - description, - amount, - percentage, - created_at, - updated_at, - deleted_at - "#, - Uuid::new_v4(), - original_id, - split_transaction_id, - split.description, - split.amount.to_string(), - Utc::now() + RETURNING id, original_transaction_id, split_transaction_id, + description, amount, percentage, created_at, updated_at + "# ) + .bind(Uuid::new_v4()) + .bind(original_id) + .bind(split_transaction_id) + .bind(&split.description) + .bind(split.amount) + .bind(Utc::now()) .fetch_one(&mut *tx) .await?; - + let split_record: TransactionSplit = sqlx::FromRow::from_row(&split_record_row)?; created_splits.push(split_record); } @@ -532,29 +614,29 @@ impl TransactionRepository { if remaining_amount == Decimal::ZERO { // Complete split - soft delete original - sqlx::query!( + sqlx::query( r#" UPDATE entries SET deleted_at = $1, updated_at = $1 WHERE id = $2 - "#, - Some(Utc::now()), - original.entry_id + "# ) + .bind(Some(Utc::now())) + .bind(original.entry_id) .execute(&mut *tx) .await?; } else { // Partial split - update amount - sqlx::query!( + sqlx::query( r#" UPDATE entries SET amount = $1, updated_at = $2 WHERE id = $3 - "#, - remaining_amount.to_string(), - Utc::now(), - original.entry_id + "# ) + .bind(remaining_amount) + .bind(Utc::now()) + .bind(original.entry_id) .execute(&mut *tx) .await?; } @@ -573,29 +655,34 @@ impl TransactionRepository { refund_date: NaiveDate, ) -> Result { // Get original transaction details - let original = sqlx::query!( + let original = sqlx::query( r#" - SELECT e.*, t.category_id, t.payee_id + SELECT + e.account_id, e.currency, e.date, e.name, + t.category_id, t.payee_id FROM entries e JOIN transactions t ON t.entry_id = e.id WHERE t.id = $1 - "#, - original_id + "# ) + .bind(original_id) .fetch_one(&*self.pool) .await?; // Create refund entry (with opposite sign) let refund_entry = Entry { id: Uuid::new_v4(), - account_id: original.account_id, + account_id: original.get::("account_id"), entryable_type: "Transaction".to_string(), entryable_id: Uuid::new_v4(), amount: -refund_amount, - currency: original.currency, + currency: original.get::("currency"), date: refund_date, - name: format!("Refund: {}", original.name), - notes: Some(format!("Refund for transaction on {}", original.date)), + name: format!("Refund: {}", original.get::("name")), + notes: Some(format!( + "Refund for transaction on {}", + original.get::("date") + )), excluded: false, pending: false, nature: if refund_amount > Decimal::ZERO { "inflow".to_string() } else { "outflow".to_string() }, @@ -607,8 +694,8 @@ impl TransactionRepository { let refund_transaction = Transaction { id: refund_entry.entryable_id, entry_id: refund_entry.id, - category_id: original.category_id, - payee_id: original.payee_id, + category_id: original.get::, _>("category_id"), + payee_id: original.get::, _>("payee_id"), original_transaction_id: Some(original_id), is_refund: true, refund_amount: Some(refund_amount), @@ -625,7 +712,7 @@ impl TransactionRepository { transaction_ids: Vec, batch_id: Option, ) -> Result { - let result = sqlx::query!( + let result = sqlx::query( r#" UPDATE transactions SET @@ -634,11 +721,11 @@ impl TransactionRepository { reimbursement_batch_id = $2, updated_at = $1 WHERE id = ANY($3) AND reimbursable = true - "#, - Utc::now(), - batch_id, - &transaction_ids + "# ) + .bind(Utc::now()) + .bind(batch_id) + .bind(&transaction_ids) .execute(&*self.pool) .await?; @@ -767,4 +854,4 @@ impl Repository for TransactionRepository { Ok(result.rows_affected() > 0) } -} \ No newline at end of file +}