-
Notifications
You must be signed in to change notification settings - Fork 7
feat(api): expose balance history #1641
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b992cb0
c8bf32d
bbf5f58
22d3c18
9076134
ae8e971
d732bf5
688d64e
9f8270c
97febd2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]) | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -154,6 +154,39 @@ def generate_ndjson | |
| }.to_json | ||
| end | ||
|
|
||
| Balance.joins(:account) | ||
| .where(accounts: { family_id: @family.id }) | ||
| .chronological | ||
| .each do |balance| | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Since the export accumulates everything into the Balance.joins(:account)
.where(accounts: { family_id: @family.id })
.chronological
.each do |balance|
lines << { ... }.to_json
endIf batching is needed for very large datasets, a cursor-based approach using 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 << { | ||
|
|
||
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
json.account_type balance.account.accountable_type&.underscoreGenerated 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 | ||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| json.partial! "balance", balance: @balance |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AccountsController#showexplicitly validates the UUID before callingfindto avoid a PostgresActiveRecord::StatementInvalidwhen the primary key column is UUID type and the input is not a valid UUID string. That exception is not covered by therescue_from ActiveRecord::RecordNotFoundinBaseController, so a request likeGET /api/v1/balances/not-a-uuidwould return a 500 instead of a 404.The same guard used in
AccountsControllershould be applied here:Generated by Claude Code