Skip to content

Conversation

@zensgit
Copy link
Owner

@zensgit zensgit commented Oct 15, 2025

Purpose

  • Migrate transaction and budget money types from f64 to Decimal to eliminate floating‑point precision errors and align with jive‑core Money/Decimal design.

Scope

  • Models
    • jive-api/src/models/transaction.rs: Transaction.amount → Decimal; TransactionCreate.amount → Decimal; TransactionUpdate.amount → Option
  • Services
    • jive-api/src/services/transaction_service.rs: account balances, aggregates and in‑memory maps use Decimal; SQL bindings updated
    • jive-api/src/services/budget_service.rs: Budget.amount → Decimal; BudgetProgress.{budgeted_amount, spent_amount, remaining_amount, average_daily_spend} → Decimal; projected_overspend → Option; CategorySpending.amount_spent → Decimal; BudgetReport.{total_budgeted, total_spent, total_remaining, unbudgeted_spending} → Decimal; BudgetSummary.{budgeted, spent, remaining} → Decimal; CreateBudgetRequest.amount → Decimal; percentage fields remain f64 for presentation

DB Compatibility

  • transactions.amount is DECIMAL(19,4) already (no new migration needed).

Validation

  • SQLx offline: prepared cache via make api-sqlx-prepare-local; check passes
  • Clippy strict: SQLX_OFFLINE=true cargo clippy -- -D warnings passes
  • Tests: SQLX_OFFLINE=true cargo test --tests passes (34/34)

Out of scope (future PRs)

  • Exchange rate / market modules still use f64 (non-monetary presentation or external data); to be migrated separately if needed:
    • jive-api/src/services/exchange_rate_service.rs, jive-api/src/services/exchange_rate_api.rs, jive-api/src/models/global_market.rs

Rollback plan

  • Revert the three touched areas to f64 if necessary (not recommended).

@Copilot Copilot AI review requested due to automatic review settings October 15, 2025 14:13
@gemini-code-assist
Copy link

Summary of Changes

Hello @zensgit, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request primarily focuses on enhancing the precision of financial calculations within the API by migrating monetary data types from f64 (floating-point numbers) to Decimal. This change addresses potential floating-point inaccuracies in transaction and budget-related operations. The migration involved updating data models, service logic, and SQL query patterns across several files to correctly handle the Decimal type, along with improvements to database migration scripts for robustness and explicit handling of optional database fields.

Highlights

  • Decimal Type Migration: Migrated various money-related fields in transaction and budget models and services from f64 to Decimal to ensure financial precision and align with jive-core design principles.
  • SQLx Query Refactoring: Updated SQL queries from using the sqlx::query! macro to the sqlx::query function with explicit .bind() calls and Row::get/try_get for result parsing. This improves type safety and avoids issues with SQLx offline cache coupling.
  • Database Migration Idempotency: Enhanced the 037_add_net_worth_tracking.sql migration script to ensure idempotency for index and trigger creation, preventing errors on repeated execution. Also added pgcrypto extension creation for UUID generation.
  • Improved Null Handling: Implemented more robust handling for potentially NULL database fields (like created_at, updated_at, and various Option<Decimal> fields) by using unwrap_or_else with appropriate default values or chrono::Utc::now().
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR migrates transaction and budget money types from f64 to Decimal to eliminate floating-point precision errors. While the core scope focuses on transaction/budget models and services, several auxiliary files also received updates to resolve SQLx offline cache inconsistencies, improve error handling patterns, and fix merge conflicts. The PR includes fixes for SQL query migrations from macro-based to dynamic queries, corrections to closure syntax for unwrap_or_else, and improvements to database migration idempotency.

Key changes:

  • Transaction and budget models now use Decimal for all monetary amounts
  • SQL query patterns migrated from sqlx::query! macro to dynamic sqlx::query() with manual binding to resolve offline cache issues
  • Error handling improved with more idiomatic Rust patterns

Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
jive-api/src/services/exchange_rate_api.rs Simplified closure syntax for unwrap_or_else with chrono::Utc::now
jive-api/src/services/currency_service.rs Migrated from SQLx macro to dynamic query with manual type extraction; fixed closure syntax
jive-api/src/metrics.rs Fixed redundant ok() call in if let pattern
jive-api/src/handlers/currency_handler_enhanced.rs Migrated SQLx macro query to dynamic query with manual row extraction; fixed deleted closing brace
jive-api/src/handlers/accounts.rs Migrated multiple SQLx macro queries to dynamic queries with manual Decimal type extraction; contains unresolved merge conflict markers
jive-api/migrations/037_add_net_worth_tracking.sql Added idempotent index and trigger creation with IF NOT EXISTS checks

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines 277 to 283
<<<<<<< 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)
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unresolved merge conflict markers present in the RETURNING clause. These conflict markers must be removed and the correct column list retained before merging.

Suggested change
<<<<<<< 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)
currency, current_balance, available_balance, credit_limit, status,
is_manual, color, notes, created_at, updated_at

Copilot uses AI. Check for mistakes.
.unwrap_or(None)
.unwrap_or(true),
color: row.get("color"),
icon: row.get("icon"),
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field 'icon' is not included in the SELECT clause (line 190) but is being extracted from the row. This will cause a runtime error when the query executes.

Copilot uses AI. Check for mistakes.
.unwrap_or(None)
.unwrap_or(true),
color: row.get("color"),
icon: row.get("icon"),
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field 'icon' is not included in the SELECT clause of the INSERT...RETURNING statement (lines 276-283) but is being extracted from the row. This will cause a runtime error.

Copilot uses AI. Check for mistakes.
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request aims to migrate monetary types from f64 to Decimal to improve precision, which is a solid improvement. The changes primarily involve switching from sqlx::query! to sqlx::query() to better handle Decimal types. The updates to the SQL migration script to ensure idempotency are also a great addition. However, there are a couple of significant issues that need to be addressed. First, there are unresolved merge conflict markers in jive-api/src/handlers/accounts.rs which will prevent the code from compiling. Second, a recurring compilation error exists across multiple files where .unwrap_or(None) is incorrectly used on a Result type. This pattern needs to be fixed throughout the PR.

Comment on lines 277 to 283
<<<<<<< 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This file contains unresolved merge conflict markers. These need to be resolved before the pull request can be merged.

Comment on lines 201 to 242
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::<Option<String>, _>("currency")
.unwrap_or(None)
.unwrap_or_else(|| "CNY".to_string()),
current_balance: row
.try_get::<Option<Decimal>, _>("current_balance")
.unwrap_or(None)
.unwrap_or(Decimal::ZERO),
available_balance: row
.try_get::<Option<Decimal>, _>("available_balance")
.unwrap_or(None),
credit_limit: row
.try_get::<Option<Decimal>, _>("credit_limit")
.unwrap_or(None),
status: row
.try_get::<Option<String>, _>("status")
.unwrap_or(None)
.unwrap_or_else(|| "active".to_string()),
is_manual: row
.try_get::<Option<bool>, _>("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::<Option<DateTime<Utc>>, _>("created_at")
.unwrap_or(None)
.unwrap_or_else(chrono::Utc::now),
updated_at: row
.try_get::<Option<DateTime<Utc>>, _>("updated_at")
.unwrap_or(None)
.unwrap_or_else(chrono::Utc::now),
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There's a recurring pattern of using .unwrap_or(None) on the Result returned by try_get. The Result type doesn't have an unwrap_or method; this will cause a compilation error. You might be intending to convert the Result to an Option first. A more idiomatic way to handle this is to use .ok().flatten() to convert Result<Option<T>, E> to Option<T>. This pattern appears multiple times in this function. I've provided a suggestion to fix this for the entire AccountResponse struct initialization.

    let response = AccountResponse {
        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::<Option<String>, _>("currency")
            .ok()
            .flatten()
            .unwrap_or_else(|| "CNY".to_string()),
        current_balance: row
            .try_get::<Option<Decimal>, _>("current_balance")
            .ok()
            .flatten()
            .unwrap_or(Decimal::ZERO),
        available_balance: row
            .try_get::<Option<Decimal>, _>("available_balance")
            .ok()
            .flatten(),
        credit_limit: row
            .try_get::<Option<Decimal>, _>("credit_limit")
            .ok()
            .flatten(),
        status: row
            .try_get::<Option<String>, _>("status")
            .ok()
            .flatten()
            .unwrap_or_else(|| "active".to_string()),
        is_manual: row
            .try_get::<Option<bool>, _>("is_manual")
            .ok()
            .flatten()
            .unwrap_or(true),
        color: row.get("color"),
        icon: row.get("icon"),
        notes: row.get("notes"),
        created_at: row
            .try_get::<Option<DateTime<Utc>>, _>("created_at")
            .ok()
            .flatten()
            .unwrap_or_else(chrono::Utc::now),
        updated_at: row
            .try_get::<Option<DateTime<Utc>>, _>("updated_at")
            .ok()
            .flatten()
            .unwrap_or_else(chrono::Utc::now),
    };

Comment on lines 321 to 362
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::<Option<String>, _>("currency")
.unwrap_or(None)
.unwrap_or_else(|| "CNY".to_string()),
current_balance: row
.try_get::<Option<Decimal>, _>("current_balance")
.unwrap_or(None)
.unwrap_or(Decimal::ZERO),
available_balance: row
.try_get::<Option<Decimal>, _>("available_balance")
.unwrap_or(None),
credit_limit: row
.try_get::<Option<Decimal>, _>("credit_limit")
.unwrap_or(None),
status: row
.try_get::<Option<String>, _>("status")
.unwrap_or(None)
.unwrap_or_else(|| "active".to_string()),
is_manual: row
.try_get::<Option<bool>, _>("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::<Option<DateTime<Utc>>, _>("created_at")
.unwrap_or(None)
.unwrap_or_else(chrono::Utc::now),
updated_at: row
.try_get::<Option<DateTime<Utc>>, _>("updated_at")
.unwrap_or(None)
.unwrap_or_else(chrono::Utc::now),
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the get_account function, there's a recurring compilation error here due to using .unwrap_or(None) on a Result. This should be corrected by using .ok().flatten() to correctly handle the Result<Option<T>, E> type. This applies to all try_get calls in this block.

    let response = AccountResponse {
        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::<Option<String>, _>("currency")
            .ok()
            .flatten()
            .unwrap_or_else(|| "CNY".to_string()),
        current_balance: row
            .try_get::<Option<Decimal>, _>("current_balance")
            .ok()
            .flatten()
            .unwrap_or(Decimal::ZERO),
        available_balance: row
            .try_get::<Option<Decimal>, _>("available_balance")
            .ok()
            .flatten(),
        credit_limit: row
            .try_get::<Option<Decimal>, _>("credit_limit")
            .ok()
            .flatten(),
        status: row
            .try_get::<Option<String>, _>("status")
            .ok()
            .flatten()
            .unwrap_or_else(|| "active".to_string()),
        is_manual: row
            .try_get::<Option<bool>, _>("is_manual")
            .ok()
            .flatten()
            .unwrap_or(true),
        color: row.get("color"),
        icon: row.get("icon"),
        notes: row.get("notes"),
        created_at: row
            .try_get::<Option<DateTime<Utc>>, _>("created_at")
            .ok()
            .flatten()
            .unwrap_or_else(chrono::Utc::now),
        updated_at: row
            .try_get::<Option<DateTime<Utc>>, _>("updated_at")
            .ok()
            .flatten()
            .unwrap_or_else(chrono::Utc::now),
    };

Comment on lines +659 to +663
let created_naive = row
.try_get::<Option<chrono::DateTime<Utc>>, _>("created_at")
.unwrap_or(None)
.map(|dt| dt.naive_utc())
.unwrap_or_else(|| Utc::now().naive_utc());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The use of .unwrap_or(None) on the Result from try_get will cause a compilation error. You should use .ok().flatten() to convert the Result<Option<T>> to an Option<T> before calling .map().

        let created_naive = row
            .try_get::<Option<chrono::DateTime<Utc>>, _>("created_at")
            .ok()
            .flatten()
            .map(|dt| dt.naive_utc())
            .unwrap_or_else(|| Utc::now().naive_utc());

Comment on lines +207 to +218
let base_currency = row
.try_get::<Option<String>, _>("base_currency")
.unwrap_or(None)
.unwrap_or_else(|| "CNY".to_string());
let allow_multi_currency = row
.try_get::<Option<bool>, _>("allow_multi_currency")
.unwrap_or(None)
.unwrap_or(false);
let auto_convert = row
.try_get::<Option<bool>, _>("auto_convert")
.unwrap_or(None)
.unwrap_or(false);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This block contains the same compilation error as seen in other files, using .unwrap_or(None) on a Result. Please use .ok().flatten() to correctly chain Option methods.

            let base_currency = row
                .try_get::<Option<String>, _>("base_currency")
                .ok()
                .flatten()
                .unwrap_or_else(|| "CNY".to_string());
            let allow_multi_currency = row
                .try_get::<Option<bool>, _>("allow_multi_currency")
                .ok()
                .flatten()
                .unwrap_or(false);
            let auto_convert = row
                .try_get::<Option<bool>, _>("auto_convert")
                .ok()
                .flatten()
                .unwrap_or(false);

@zensgit zensgit merged commit 62055c0 into chore/invitations-audit-align-dev-mock Oct 15, 2025
@zensgit zensgit deleted the chore/core-gating-sqlx-prep branch October 15, 2025 15:00
zensgit added a commit that referenced this pull request Oct 16, 2025
* api: unify Decimal mapping in accounts handler; fix clippy in metrics and currency_service

* api: fix Decimal/f64 mismatches and Option<DateTime> handling; refactor dynamic SQLx reads for accounts + currency/exchange

* api/migrations: make net worth tables idempotent; ensure pgcrypto; fix IF NOT EXISTS for indexes/triggers

* chore: workspace sync before merging base into PR branch

* tests: remove WIP contract serialization test from this PR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant