Skip to content
76 changes: 76 additions & 0 deletions app/controllers/api/v1/balances_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

class Api::V1::BalancesController < Api::V1::BaseController
include Pagy::Backend

before_action :ensure_read_scope
before_action :set_balance, only: :show
helper_method :format_money, :money_to_minor_units

def index
balances_query = apply_filters(balances_scope).order(date: :desc, created_at: :desc)
@per_page = safe_per_page_param

@pagy, @balances = pagy(
balances_query,
page: safe_page_param,
limit: @per_page
)

render :index
rescue InvalidFilterError => e
render json: {
error: "validation_failed",
message: e.message,
errors: [ e.message ]
}, status: :unprocessable_entity
end

def show
render :show
end

private

def set_balance
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])

@balance = balances_scope.find(params[:id])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AccountsController#show explicitly validates the UUID before calling find to avoid a Postgres ActiveRecord::StatementInvalid when the primary key column is UUID type and the input is not a valid UUID string. That exception is not covered by the rescue_from ActiveRecord::RecordNotFound in BaseController, so a request like GET /api/v1/balances/not-a-uuid would return a 500 instead of a 404.

The same guard used in AccountsController should be applied here:

def set_balance
  unless valid_uuid?(params[:id])
    render json: { error: "not_found", message: "Balance not found" }, status: :not_found
    return
  end
  @balance = balances_scope.find(params[:id])
end

Generated by Claude Code

end

def ensure_read_scope
authorize_scope!(:read)
end

def balances_scope
Balance
.joins(:account)
.where(accounts: { id: accessible_account_ids })
.includes(:account)
end

def accessible_account_ids
@accessible_account_ids ||= current_resource_owner.family.accounts.accessible_by(current_resource_owner).select(:id)
end

def apply_filters(query)
if params[:account_id].present?
raise InvalidFilterError, "account_id must be a valid UUID" unless valid_uuid?(params[:account_id])

query = query.where(account_id: params[:account_id])
end

query = query.where(currency: params[:currency].to_s.upcase) if params[:currency].present?
query = query.where("balances.date >= ?", parse_date_param(:start_date)) if params[:start_date].present?
query = query.where("balances.date <= ?", parse_date_param(:end_date)) if params[:end_date].present?
query
end

def format_money(money)
money&.format
end

def money_to_minor_units(money)
(money.amount * money.currency.minor_unit_conversion).round(0).to_i if money
end
end
8 changes: 8 additions & 0 deletions app/controllers/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class Api::V1::BaseController < ApplicationController
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
private_constant :UUID_PATTERN

InvalidFilterError = Class.new(StandardError)

# Skip regular session-based authentication for API
skip_authentication

Expand Down Expand Up @@ -254,6 +256,12 @@ def handle_bad_request(exception)
render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request)
end

def parse_date_param(key)
Date.iso8601(params[key].to_s)
rescue ArgumentError
raise InvalidFilterError, "#{key} must be an ISO 8601 date"
end

# Log API access for monitoring and debugging
def log_api_access
return unless current_resource_owner
Expand Down
33 changes: 33 additions & 0 deletions app/models/family/data_exporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,39 @@ def generate_ndjson
}.to_json
end

Balance.joins(:account)
.where(accounts: { family_id: @family.id })
.chronological
.each do |balance|
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

.chronological applies ORDER BY date ASC to the relation, but find_each ignores any scoped order and batches by primary key instead. In Rails 7, without error_on_ignored_order = true (not set in this project), the chronological order is silently dropped. The records will be emitted in UUID primary key order, not date order.

Since the export accumulates everything into the lines array in memory anyway (same as all other sections in this file), the simplest fix that actually gives chronological output is to replace find_each with each:

Balance.joins(:account)
  .where(accounts: { family_id: @family.id })
  .chronological
  .each do |balance|
  lines << { ... }.to_json
end

If batching is needed for very large datasets, a cursor-based approach using in_batches with explicit PK ordering, then resorting each batch by date, would be required — but that's additional complexity. For an export that already buffers everything in lines, each is the honest choice here.


Generated by Claude Code

lines << {
type: "Balance",
data: {
id: balance.id,
account_id: balance.account_id,
date: balance.date,
balance: balance.balance,
currency: balance.currency,
cash_balance: balance.cash_balance,
start_cash_balance: balance.start_cash_balance,
start_non_cash_balance: balance.start_non_cash_balance,
cash_inflows: balance.cash_inflows,
cash_outflows: balance.cash_outflows,
non_cash_inflows: balance.non_cash_inflows,
non_cash_outflows: balance.non_cash_outflows,
net_market_flows: balance.net_market_flows,
cash_adjustments: balance.cash_adjustments,
non_cash_adjustments: balance.non_cash_adjustments,
flows_factor: balance.flows_factor,
start_balance: balance.start_balance,
end_cash_balance: balance.end_cash_balance,
end_non_cash_balance: balance.end_non_cash_balance,
end_balance: balance.end_balance,
created_at: balance.created_at,
updated_at: balance.updated_at
}
}.to_json
end

# Export categories
@family.categories.find_each do |category|
lines << {
Expand Down
49 changes: 49 additions & 0 deletions app/views/api/v1/balances/_balance.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

json.id balance.id
json.date balance.date
json.currency balance.currency
json.flows_factor balance.flows_factor

json.balance format_money(balance.balance_money)
json.balance_cents money_to_minor_units(balance.balance_money)
json.cash_balance format_money(balance.cash_balance_money)
json.cash_balance_cents money_to_minor_units(balance.cash_balance_money)

json.start_cash_balance format_money(balance.start_cash_balance_money)
json.start_cash_balance_cents money_to_minor_units(balance.start_cash_balance_money)
json.start_non_cash_balance format_money(balance.start_non_cash_balance_money)
json.start_non_cash_balance_cents money_to_minor_units(balance.start_non_cash_balance_money)
json.start_balance format_money(balance.start_balance_money)
json.start_balance_cents money_to_minor_units(balance.start_balance_money)

json.cash_inflows format_money(balance.cash_inflows_money)
json.cash_inflows_cents money_to_minor_units(balance.cash_inflows_money)
json.cash_outflows format_money(balance.cash_outflows_money)
json.cash_outflows_cents money_to_minor_units(balance.cash_outflows_money)
json.non_cash_inflows format_money(balance.non_cash_inflows_money)
json.non_cash_inflows_cents money_to_minor_units(balance.non_cash_inflows_money)
json.non_cash_outflows format_money(balance.non_cash_outflows_money)
json.non_cash_outflows_cents money_to_minor_units(balance.non_cash_outflows_money)
json.net_market_flows format_money(balance.net_market_flows_money)
json.net_market_flows_cents money_to_minor_units(balance.net_market_flows_money)
json.cash_adjustments format_money(balance.cash_adjustments_money)
json.cash_adjustments_cents money_to_minor_units(balance.cash_adjustments_money)
json.non_cash_adjustments format_money(balance.non_cash_adjustments_money)
json.non_cash_adjustments_cents money_to_minor_units(balance.non_cash_adjustments_money)

json.end_cash_balance format_money(balance.end_cash_balance_money)
json.end_cash_balance_cents money_to_minor_units(balance.end_cash_balance_money)
json.end_non_cash_balance format_money(balance.end_non_cash_balance_money)
json.end_non_cash_balance_cents money_to_minor_units(balance.end_non_cash_balance_money)
json.end_balance format_money(balance.end_balance_money)
json.end_balance_cents money_to_minor_units(balance.end_balance_money)

json.account do
json.id balance.account.id
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

accountable_type can be nil (the BalanceAccount schema correctly marks account_type as nullable: true), but calling .underscore on nil will raise NoMethodError at runtime rather than rendering null. Needs a safe-navigation guard:

json.account_type balance.account.accountable_type&.underscore

Generated by Claude Code

json.name balance.account.name
json.account_type balance.account.accountable_type&.underscore
end

json.created_at balance.created_at.iso8601
json.updated_at balance.updated_at.iso8601
12 changes: 12 additions & 0 deletions app/views/api/v1/balances/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

json.balances @balances do |balance|
json.partial! "balance", balance: balance
end

json.pagination do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end
3 changes: 3 additions & 0 deletions app/views/api/v1/balances/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true

json.partial! "balance", balance: @balance
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@

# Production API endpoints
resources :accounts, only: [ :index, :show ]
resources :balances, only: [ :index, :show ]
resources :categories, only: [ :index, :show ]
resources :merchants, only: %i[index show]
resources :rules, only: [ :index, :show ]
Expand Down
Loading
Loading