mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
* add missing Hungarian translations for newly extracted strings Replace hard-coded UI strings with I18n lookups across controllers, models and views (breadcrumbs, dashboard, reports, settings, transactions, balance sheet, MFA status). Update models to use translations for category defaults, account/display names, classification group and period labels; remove a few hardcoded display_name methods. Add and update numerous locale files (English and extensive Hungarian translations, plus model/view/doorkeeper entries) to provide the required keys. These changes centralize copy for localization and prepare the app for Hungarian/English UI text. * Pluralize account type labels; tidy Crypto model Update English locale account type labels to use plural forms for consistency (Investment(s), Properties, Vehicles, Other Assets, Credit Cards, Loans, Other Liabilities). Also remove an extra blank line in app/models/crypto.rb to tidy up formatting. * Back to singular * fix(i18n): separate singular and group account labels * Update _accountable_group.html.erb * Use I18n plural names for account types Change Accountable#display_name to look up pluralized account type names via I18n (accounts.types_plural.<underscored_class>) with a fallback to the legacy display logic. Add legacy_display_name helper to preserve previous behavior (singular for Depository and Crypto, pluralized otherwise). Add corresponding types_plural entries in English and Hungarian locale files for various account types. --------- Co-authored-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: sure-admin <sure-admin@splashblot.com>
1147 lines
43 KiB
Ruby
1147 lines
43 KiB
Ruby
class ReportsController < ApplicationController
|
|
include Periodable
|
|
|
|
# Allow API key authentication for exports (for Google Sheets integration)
|
|
# Note: We run authentication_for_export which handles both session and API key auth
|
|
skip_authentication only: :export_transactions
|
|
before_action :authenticate_for_export, only: :export_transactions
|
|
|
|
def index
|
|
setup_report_data(show_flash: true)
|
|
|
|
# Build reports sections for collapsible/reorderable UI
|
|
@reports_sections = build_reports_sections
|
|
|
|
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.reports"), nil ] ]
|
|
end
|
|
|
|
def print
|
|
setup_report_data(show_flash: false)
|
|
|
|
render layout: "print"
|
|
end
|
|
|
|
def update_preferences
|
|
if Current.user.update_reports_preferences(preferences_params)
|
|
head :ok
|
|
else
|
|
head :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def export_transactions
|
|
@period_type = params[:period_type]&.to_sym || :monthly
|
|
@start_date = parse_date_param(:start_date) || default_start_date
|
|
@end_date = parse_date_param(:end_date) || default_end_date
|
|
|
|
# Validate and fix date range if end_date is before start_date
|
|
# Don't show flash message since we're returning CSV data
|
|
validate_and_fix_date_range(show_flash: false)
|
|
|
|
@period = Period.custom(start_date: @start_date, end_date: @end_date)
|
|
|
|
# Build monthly breakdown data for export
|
|
@export_data = build_monthly_breakdown_for_export
|
|
|
|
respond_to do |format|
|
|
format.csv do
|
|
csv_data = generate_transactions_csv
|
|
send_data csv_data,
|
|
filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.csv",
|
|
type: "text/csv"
|
|
end
|
|
|
|
# Excel and PDF exports require additional gems (caxlsx and prawn)
|
|
# Uncomment and install gems if needed:
|
|
#
|
|
# format.xlsx do
|
|
# xlsx_data = generate_transactions_xlsx
|
|
# send_data xlsx_data,
|
|
# filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.xlsx",
|
|
# type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
# end
|
|
#
|
|
# format.pdf do
|
|
# pdf_data = generate_transactions_pdf
|
|
# send_data pdf_data,
|
|
# filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.pdf",
|
|
# type: "application/pdf"
|
|
# end
|
|
end
|
|
end
|
|
|
|
def google_sheets_instructions
|
|
# Re-build the params needed for the export URL
|
|
base_params = {
|
|
period_type: params[:period_type],
|
|
start_date: params[:start_date],
|
|
end_date: params[:end_date],
|
|
sort_by: params[:sort_by],
|
|
sort_direction: params[:sort_direction]
|
|
}.compact
|
|
|
|
# Build the full URL with the API key, if present
|
|
@csv_url = export_transactions_reports_url(base_params.merge(format: :csv))
|
|
@api_key_present = @csv_url.include?("api_key=")
|
|
|
|
# This action will render `app/views/reports/google_sheets_instructions.html.erb`
|
|
# It will render *inside* the modal frame.
|
|
end
|
|
|
|
def picker
|
|
@period_type = params[:period_type]&.to_sym || :monthly
|
|
@start_date = parse_date_param(:start_date) || Date.current.beginning_of_month
|
|
render partial: "reports/period_picker", locals: {
|
|
period_type: @period_type,
|
|
start_date: @start_date
|
|
}
|
|
end
|
|
|
|
private
|
|
def setup_report_data(show_flash: false)
|
|
@period_type = params[:period_type]&.to_sym || :monthly
|
|
@start_date = parse_date_param(:start_date) || default_start_date
|
|
@end_date = parse_date_param(:end_date) || default_end_date
|
|
|
|
# Validate and fix date range if end_date is before start_date
|
|
validate_and_fix_date_range(show_flash: show_flash)
|
|
|
|
# Build the period
|
|
@period = Period.custom(start_date: @start_date, end_date: @end_date)
|
|
@previous_period = build_previous_period
|
|
|
|
# Get aggregated data
|
|
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
|
|
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
|
|
|
|
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
|
|
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
|
|
|
|
# Calculate summary metrics
|
|
@summary_metrics = build_summary_metrics
|
|
|
|
# Build trend data (last 6 months)
|
|
@trends_data = build_trends_data
|
|
|
|
# Net worth metrics
|
|
@net_worth_metrics = build_net_worth_metrics
|
|
|
|
# Transactions breakdown
|
|
@transactions = build_transactions_breakdown
|
|
|
|
# Investment metrics
|
|
@investment_metrics = build_investment_metrics
|
|
|
|
# Investment flows (contributions/withdrawals)
|
|
@investment_flows = InvestmentFlowStatement.new(Current.family, user: Current.user).period_totals(period: @period)
|
|
|
|
# Flags for view rendering
|
|
@has_accounts = accessible_accounts.any?
|
|
|
|
# Build navigation links for period switching
|
|
@nav = build_period_navigation
|
|
end
|
|
|
|
def preferences_params
|
|
prefs = params.require(:preferences)
|
|
{}.tap do |permitted|
|
|
permitted["reports_collapsed_sections"] = prefs[:reports_collapsed_sections].to_unsafe_h if prefs[:reports_collapsed_sections]
|
|
permitted["reports_section_order"] = prefs[:reports_section_order] if prefs[:reports_section_order]
|
|
end
|
|
end
|
|
|
|
def build_reports_sections
|
|
all_sections = [
|
|
{
|
|
key: "net_worth",
|
|
title: "reports.net_worth.title",
|
|
partial: "reports/net_worth",
|
|
locals: { net_worth_metrics: @net_worth_metrics },
|
|
visible: accessible_accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "trends_insights",
|
|
title: "reports.trends.title",
|
|
partial: "reports/trends_insights",
|
|
locals: { trends_data: @trends_data },
|
|
visible: @has_accounts,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "investment_performance",
|
|
title: "reports.investment_performance.title",
|
|
partial: "reports/investment_performance",
|
|
locals: { investment_metrics: @investment_metrics },
|
|
visible: @investment_metrics[:has_investments],
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "investment_flows",
|
|
title: "reports.investment_flows.title",
|
|
partial: "reports/investment_flows",
|
|
locals: { investment_flows: @investment_flows },
|
|
visible: @investment_metrics[:has_investments] && (@investment_flows.contributions.amount > 0 || @investment_flows.withdrawals.amount > 0),
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "transactions_breakdown",
|
|
title: "reports.transactions_breakdown.title",
|
|
partial: "reports/transactions_breakdown",
|
|
locals: {
|
|
transactions: @transactions,
|
|
period_type: @period_type,
|
|
start_date: @start_date,
|
|
end_date: @end_date
|
|
},
|
|
visible: @has_accounts,
|
|
collapsible: true
|
|
}
|
|
]
|
|
|
|
# Order sections according to user preference
|
|
section_order = Current.user.reports_section_order
|
|
ordered_sections = section_order.map do |key|
|
|
all_sections.find { |s| s[:key] == key }
|
|
end.compact
|
|
|
|
# Add any new sections that aren't in the saved order (future-proofing)
|
|
all_sections.each do |section|
|
|
ordered_sections << section unless ordered_sections.include?(section)
|
|
end
|
|
|
|
ordered_sections
|
|
end
|
|
|
|
def validate_and_fix_date_range(show_flash: false)
|
|
return unless @start_date > @end_date
|
|
|
|
# Swap the dates to maintain user's intended date range
|
|
@start_date, @end_date = @end_date, @start_date
|
|
flash.now[:alert] = t("reports.invalid_date_range") if show_flash
|
|
end
|
|
|
|
def ensure_money(value)
|
|
return value if value.is_a?(Money)
|
|
# Value is numeric (BigDecimal or Integer) in dollars - pass directly to Money.new
|
|
Money.new(value, Current.family.currency)
|
|
end
|
|
|
|
def parse_date_param(param_name)
|
|
date_string = params[param_name]
|
|
return nil if date_string.blank?
|
|
|
|
Date.parse(date_string)
|
|
rescue Date::Error
|
|
nil
|
|
end
|
|
|
|
def default_start_date
|
|
case @period_type
|
|
when :monthly
|
|
Date.current.beginning_of_month.to_date
|
|
when :quarterly
|
|
Date.current.beginning_of_quarter.to_date
|
|
when :ytd
|
|
Date.current.beginning_of_year.to_date
|
|
when :last_6_months
|
|
6.months.ago.beginning_of_month.to_date
|
|
when :custom
|
|
1.month.ago.to_date
|
|
else
|
|
Date.current.beginning_of_month.to_date
|
|
end
|
|
end
|
|
|
|
def default_end_date
|
|
case @period_type
|
|
when :monthly, :last_6_months
|
|
Date.current.end_of_month.to_date
|
|
when :quarterly
|
|
Date.current.end_of_quarter.to_date
|
|
when :ytd
|
|
Date.current
|
|
when :custom
|
|
Date.current
|
|
else
|
|
Date.current.end_of_month.to_date
|
|
end
|
|
end
|
|
|
|
def build_previous_period
|
|
duration = (@end_date - @start_date).to_i
|
|
previous_end = @start_date - 1.day
|
|
previous_start = previous_end - duration.days
|
|
|
|
Period.custom(start_date: previous_start, end_date: previous_end)
|
|
end
|
|
|
|
def build_summary_metrics
|
|
# Ensure we always have Money objects
|
|
current_income = ensure_money(@current_income_totals.total)
|
|
current_expenses = ensure_money(@current_expense_totals.total)
|
|
net_savings = current_income - current_expenses
|
|
|
|
previous_income = ensure_money(@previous_income_totals.total)
|
|
previous_expenses = ensure_money(@previous_expense_totals.total)
|
|
|
|
# Calculate percentage changes
|
|
income_change = calculate_percentage_change(previous_income, current_income)
|
|
expense_change = calculate_percentage_change(previous_expenses, current_expenses)
|
|
|
|
# Get budget performance for current period
|
|
budget_percent = calculate_budget_performance
|
|
|
|
{
|
|
current_income: current_income,
|
|
income_change: income_change,
|
|
current_expenses: current_expenses,
|
|
expense_change: expense_change,
|
|
net_savings: net_savings,
|
|
budget_percent: budget_percent
|
|
}
|
|
end
|
|
|
|
def calculate_percentage_change(previous_value, current_value)
|
|
return 0 if previous_value.zero?
|
|
|
|
((current_value - previous_value) / previous_value * 100).round(1)
|
|
end
|
|
|
|
def calculate_budget_performance
|
|
# Only calculate if we're looking at current month
|
|
return nil unless @period_type == :monthly && @start_date.beginning_of_month.to_date == Date.current.beginning_of_month.to_date
|
|
|
|
budget = Budget.find_or_bootstrap(Current.family, start_date: @start_date.beginning_of_month.to_date, user: Current.user)
|
|
return 0 if budget.nil? || budget.allocated_spending.zero?
|
|
|
|
(budget.actual_spending / budget.allocated_spending * 100).round(1)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
|
|
def build_trends_data
|
|
# Generate month-by-month data based on the current period filter
|
|
trends = []
|
|
|
|
# Generate list of months within the period
|
|
current_month = @start_date.beginning_of_month
|
|
end_of_period = @end_date.end_of_month
|
|
|
|
while current_month <= end_of_period
|
|
month_start = current_month
|
|
month_end = current_month.end_of_month
|
|
|
|
# Ensure we don't go beyond the end date
|
|
month_end = @end_date if month_end > @end_date
|
|
|
|
period = Period.custom(start_date: month_start, end_date: month_end)
|
|
|
|
income = Current.family.income_statement.income_totals(period: period).total
|
|
expenses = Current.family.income_statement.expense_totals(period: period).total
|
|
|
|
trends << {
|
|
month: month_start.strftime("%b %Y"),
|
|
is_current_month: (month_start.month == Date.current.month && month_start.year == Date.current.year),
|
|
income: income,
|
|
expenses: expenses,
|
|
net: income - expenses
|
|
}
|
|
|
|
current_month = current_month.next_month
|
|
end
|
|
|
|
trends
|
|
end
|
|
|
|
def build_transactions_breakdown
|
|
# Base query: all transactions in the period
|
|
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
|
|
transactions = Transaction
|
|
.joins(:entry)
|
|
.joins(entry: :account)
|
|
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
|
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
|
.where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS)
|
|
.includes(entry: :account, category: :parent)
|
|
|
|
# Apply filters (includes finance account scoping)
|
|
transactions = apply_transaction_filters(transactions)
|
|
|
|
# Get trades in the period (matching income_statement logic)
|
|
trades = Trade
|
|
.joins(:entry)
|
|
.joins(entry: :account)
|
|
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
|
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
|
|
.includes(entry: :account, category: :parent)
|
|
|
|
trades = apply_entry_filters(trades)
|
|
|
|
# Get sort parameters
|
|
sort_by = params[:sort_by] || "amount"
|
|
sort_direction = params[:sort_direction] || "desc"
|
|
|
|
# Group by category (tracking parent relationship) and type
|
|
# Structure: { [parent_category_id, type] => { parent_data, subcategories: { subcategory_id => data } } }
|
|
grouped_data = {}
|
|
family_currency = Current.family.currency
|
|
|
|
# Helper to initialize a category group hash
|
|
init_category_group = ->(id, name, color, icon, type) do
|
|
{ category_id: id, category_name: name, category_color: color, category_icon: icon, type: type, total: 0, count: 0, subcategories: {} }
|
|
end
|
|
|
|
# Helper to initialize a subcategory hash
|
|
init_subcategory = ->(category) do
|
|
{ category_id: category.id, category_name: category.name, category_color: category.color, category_icon: category.lucide_icon, total: 0, count: 0 }
|
|
end
|
|
|
|
# Helper to process an entry (transaction or trade)
|
|
process_entry = ->(category, entry, is_trade) do
|
|
type = entry.amount > 0 ? "expense" : "income"
|
|
begin
|
|
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency).amount
|
|
rescue Money::ConversionError
|
|
converted_amount = entry.amount.abs
|
|
end
|
|
|
|
if category.nil?
|
|
# Uncategorized or Other Investments (for trades)
|
|
if is_trade
|
|
parent_key = [ :other_investments, type ]
|
|
grouped_data[parent_key] ||= init_category_group.call(:other_investments, Category.other_investments.name, Category.other_investments.color, Category.other_investments.lucide_icon, type)
|
|
else
|
|
parent_key = [ :uncategorized, type ]
|
|
grouped_data[parent_key] ||= init_category_group.call(:uncategorized, Category.uncategorized.name, Category.uncategorized.color, Category.uncategorized.lucide_icon, type)
|
|
end
|
|
elsif category.parent_id.present?
|
|
# This is a subcategory - group under parent
|
|
parent = category.parent
|
|
parent_key = [ parent.id, type ]
|
|
grouped_data[parent_key] ||= init_category_group.call(parent.id, parent.name, parent.color || Category::UNCATEGORIZED_COLOR, parent.lucide_icon, type)
|
|
|
|
# Add to subcategory
|
|
grouped_data[parent_key][:subcategories][category.id] ||= init_subcategory.call(category)
|
|
grouped_data[parent_key][:subcategories][category.id][:count] += 1
|
|
grouped_data[parent_key][:subcategories][category.id][:total] += converted_amount
|
|
else
|
|
# This is a root category (no parent)
|
|
parent_key = [ category.id, type ]
|
|
grouped_data[parent_key] ||= init_category_group.call(category.id, category.name, category.color || Category::UNCATEGORIZED_COLOR, category.lucide_icon, type)
|
|
end
|
|
|
|
grouped_data[parent_key][:count] += 1
|
|
grouped_data[parent_key][:total] += converted_amount
|
|
end
|
|
|
|
# Process transactions
|
|
transactions.each do |transaction|
|
|
process_entry.call(transaction.category, transaction.entry, false)
|
|
end
|
|
|
|
# Process trades
|
|
trades.each do |trade|
|
|
process_entry.call(trade.category, trade.entry, true)
|
|
end
|
|
|
|
# Convert to array and sort subcategories
|
|
result = grouped_data.values.map do |parent_data|
|
|
subcategories = parent_data[:subcategories].values.sort_by { |s| sort_direction == "asc" ? s[:total] : -s[:total] }
|
|
parent_data.merge(subcategories: subcategories)
|
|
end
|
|
|
|
# Sort by amount (total) with the specified direction
|
|
if sort_direction == "asc"
|
|
result.sort_by { |g| g[:total] }
|
|
else
|
|
result.sort_by { |g| -g[:total] }
|
|
end
|
|
end
|
|
|
|
def build_investment_metrics
|
|
investment_statement = Current.family.investment_statement
|
|
investment_accounts = investment_statement.investment_accounts
|
|
|
|
return { has_investments: false } unless investment_accounts.any?
|
|
|
|
period_totals = investment_statement.totals(period: @period)
|
|
|
|
{
|
|
has_investments: true,
|
|
portfolio_value: investment_statement.portfolio_value_money,
|
|
unrealized_trend: investment_statement.unrealized_gains_trend,
|
|
period_contributions: period_totals.contributions,
|
|
period_withdrawals: period_totals.withdrawals,
|
|
top_holdings: investment_statement.top_holdings(limit: 5),
|
|
accounts: investment_accounts.to_a,
|
|
gains_by_tax_treatment: build_gains_by_tax_treatment(investment_statement)
|
|
}
|
|
end
|
|
|
|
def build_gains_by_tax_treatment(investment_statement)
|
|
currency = Current.family.currency
|
|
# Eager-load account and accountable to avoid N+1 when accessing tax_treatment
|
|
current_holdings = investment_statement.current_holdings
|
|
.includes(account: :accountable)
|
|
.to_a
|
|
|
|
# Group holdings by tax treatment (from account)
|
|
holdings_by_treatment = current_holdings.group_by { |h| h.account.tax_treatment || :taxable }
|
|
|
|
# Get sell trades in period with realized gains
|
|
# Eager-load security, account, and accountable to avoid N+1
|
|
sell_trades = Current.family.trades
|
|
.joins(:entry)
|
|
.where(entries: { date: @period.date_range })
|
|
.where("trades.qty < 0")
|
|
.includes(:security, entry: { account: :accountable })
|
|
.to_a
|
|
|
|
# Preload holdings for all accounts that have sell trades to avoid N+1 in realized_gain_loss
|
|
account_ids = sell_trades.map { |t| t.entry.account_id }.uniq
|
|
holdings_by_account = Holding
|
|
.where(account_id: account_ids)
|
|
.where("date <= ?", @period.date_range.end)
|
|
.order(date: :desc)
|
|
.group_by(&:account_id)
|
|
|
|
# Inject preloaded holdings into trades for realized_gain_loss calculation
|
|
sell_trades.each do |trade|
|
|
trade.instance_variable_set(:@preloaded_holdings, holdings_by_account[trade.entry.account_id] || [])
|
|
end
|
|
|
|
trades_by_treatment = sell_trades.group_by { |t| t.entry.account.tax_treatment || :taxable }
|
|
|
|
# Unwrap helper: Trend#value / realized_gain_loss#value are Money objects,
|
|
# and this codebase's Money keeps the source currency through `*` and
|
|
# through `Money.new(money, _)`. Unwrapping to BigDecimal first keeps sums
|
|
# and the final Money.new(..., currency) correctly labeled in family currency.
|
|
to_numeric = ->(value) { value.is_a?(Money) ? value.amount : value }
|
|
|
|
# Unrealized gains mark holdings to market, so convert at today's FX.
|
|
foreign_holding_currencies = current_holdings.map(&:currency).compact.uniq.reject { |c| c == currency }
|
|
holding_rates = ExchangeRate.rates_for(foreign_holding_currencies, to: currency, date: Date.current)
|
|
convert_current = ->(amount, from) {
|
|
numeric = to_numeric.call(amount)
|
|
from == currency ? numeric : numeric * (holding_rates[from] || 1)
|
|
}
|
|
|
|
# Realized gains are locked at trade time, so convert each at its own
|
|
# entry-date FX. Mirrors InvestmentStatement::Totals, which also uses
|
|
# entry-date rates for contributions/withdrawals on this same card.
|
|
foreign_trade_currencies = sell_trades.map(&:currency).compact.uniq.reject { |c| c == currency }
|
|
rates_by_trade_date = sell_trades.map { |t| t.entry.date }.uniq.each_with_object({}) do |date, memo|
|
|
memo[date] = ExchangeRate.rates_for(foreign_trade_currencies, to: currency, date: date)
|
|
end
|
|
convert_trade = ->(amount, from, date) {
|
|
numeric = to_numeric.call(amount)
|
|
from == currency ? numeric : numeric * (rates_by_trade_date.dig(date, from) || 1)
|
|
}
|
|
|
|
# Build metrics per treatment
|
|
%i[taxable tax_deferred tax_exempt tax_advantaged].each_with_object({}) do |treatment, hash|
|
|
holdings = holdings_by_treatment[treatment] || []
|
|
trades = trades_by_treatment[treatment] || []
|
|
|
|
# Sum unrealized gains from holdings (only those with known cost basis)
|
|
unrealized = holdings.sum do |h|
|
|
trend = h.trend
|
|
trend ? convert_current.call(trend.value, h.currency) : 0
|
|
end
|
|
|
|
# Sum realized gains from sell trades
|
|
realized = trades.sum do |t|
|
|
gain = t.realized_gain_loss
|
|
gain ? convert_trade.call(gain.value, t.currency, t.entry.date) : 0
|
|
end
|
|
|
|
# Only include treatment groups that have some activity
|
|
next if holdings.empty? && trades.empty?
|
|
|
|
hash[treatment] = {
|
|
holdings: holdings,
|
|
sell_trades: trades,
|
|
unrealized_gain: Money.new(unrealized, currency),
|
|
realized_gain: Money.new(realized, currency),
|
|
total_gain: Money.new(unrealized + realized, currency)
|
|
}
|
|
end
|
|
end
|
|
|
|
def build_net_worth_metrics
|
|
balance_sheet = Current.family.balance_sheet
|
|
currency = Current.family.currency
|
|
|
|
# Current net worth
|
|
current_net_worth = balance_sheet.net_worth
|
|
total_assets = balance_sheet.assets.total
|
|
total_liabilities = balance_sheet.liabilities.total
|
|
|
|
# Get net worth series for the period to calculate change
|
|
# The series.trend gives us the change from first to last value in the period
|
|
net_worth_series = balance_sheet.net_worth_series(period: @period)
|
|
trend = net_worth_series&.trend
|
|
|
|
# Get asset and liability groups for breakdown
|
|
asset_groups = balance_sheet.assets.account_groups.map do |group|
|
|
{ name: group.name, total: Money.new(group.total, currency) }
|
|
end.reject { |g| g[:total].zero? }
|
|
|
|
liability_groups = balance_sheet.liabilities.account_groups.map do |group|
|
|
{ name: group.name, total: Money.new(group.total, currency) }
|
|
end.reject { |g| g[:total].zero? }
|
|
|
|
{
|
|
current_net_worth: Money.new(current_net_worth, currency),
|
|
total_assets: Money.new(total_assets, currency),
|
|
total_liabilities: Money.new(total_liabilities, currency),
|
|
trend: trend,
|
|
asset_groups: asset_groups,
|
|
liability_groups: liability_groups
|
|
}
|
|
end
|
|
|
|
def apply_transaction_filters(scope)
|
|
scope = apply_entry_filters(scope)
|
|
|
|
# Filter by tag (Transaction-specific — trades don't have taggings)
|
|
if params[:filter_tag_id].present?
|
|
scope = scope.joins(:taggings).where(taggings: { tag_id: params[:filter_tag_id] })
|
|
end
|
|
|
|
scope
|
|
end
|
|
|
|
# Filters applicable to both transactions and trades (entry-level + category)
|
|
def apply_entry_filters(scope)
|
|
# Scope to user's finance accounts
|
|
finance_account_ids = Current.user&.finance_accounts&.pluck(:id) || []
|
|
scope = scope.where(entries: { account_id: finance_account_ids })
|
|
|
|
# Filter by category (including subcategories)
|
|
if params[:filter_category_id].present?
|
|
category_id = params[:filter_category_id]
|
|
# Scope to family's categories to prevent cross-family data access
|
|
subcategory_ids = Current.family.categories.where(parent_id: category_id).pluck(:id)
|
|
all_category_ids = [ category_id ] + subcategory_ids
|
|
scope = scope.where(category_id: all_category_ids)
|
|
end
|
|
|
|
# Filter by account
|
|
if params[:filter_account_id].present?
|
|
scope = scope.where(entries: { account_id: params[:filter_account_id] })
|
|
end
|
|
|
|
# Filter by amount range
|
|
if params[:filter_amount_min].present?
|
|
scope = scope.where("ABS(entries.amount) >= ?", params[:filter_amount_min].to_f)
|
|
end
|
|
|
|
if params[:filter_amount_max].present?
|
|
scope = scope.where("ABS(entries.amount) <= ?", params[:filter_amount_max].to_f)
|
|
end
|
|
|
|
# Filter by date range (within the period)
|
|
if params[:filter_date_start].present?
|
|
filter_start = Date.parse(params[:filter_date_start])
|
|
scope = scope.where("entries.date >= ?", filter_start) if filter_start >= @start_date
|
|
end
|
|
|
|
if params[:filter_date_end].present?
|
|
filter_end = Date.parse(params[:filter_date_end])
|
|
scope = scope.where("entries.date <= ?", filter_end) if filter_end <= @end_date
|
|
end
|
|
|
|
scope
|
|
rescue Date::Error
|
|
scope
|
|
end
|
|
|
|
def build_transactions_breakdown_for_export
|
|
# Get flat transactions list (not grouped) for export
|
|
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
|
|
transactions = Transaction
|
|
.joins(:entry)
|
|
.joins(entry: :account)
|
|
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
|
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
|
.where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS)
|
|
.includes(entry: :account, category: [])
|
|
|
|
transactions = apply_transaction_filters(transactions)
|
|
|
|
sort_by = params[:sort_by] || "date"
|
|
# Whitelist sort_direction to prevent SQL injection
|
|
sort_direction = %w[asc desc].include?(params[:sort_direction]&.downcase) ? params[:sort_direction].upcase : "DESC"
|
|
|
|
case sort_by
|
|
when "date"
|
|
transactions.order("entries.date #{sort_direction}")
|
|
when "amount"
|
|
transactions.order("entries.amount #{sort_direction}")
|
|
else
|
|
transactions.order("entries.date DESC")
|
|
end
|
|
end
|
|
|
|
def build_monthly_breakdown_for_export
|
|
# Generate list of months in the period
|
|
months = []
|
|
current_month = @start_date.beginning_of_month
|
|
end_of_period = @end_date.end_of_month
|
|
|
|
while current_month <= end_of_period
|
|
months << current_month
|
|
current_month = current_month.next_month
|
|
end
|
|
|
|
# Get all transactions in the period
|
|
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
|
|
transactions = Transaction
|
|
.joins(:entry)
|
|
.joins(entry: :account)
|
|
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
|
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
|
.where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS)
|
|
.includes(entry: :account, category: [])
|
|
|
|
transactions = apply_transaction_filters(transactions)
|
|
|
|
# Group by category, type, and month
|
|
breakdown = {}
|
|
family_currency = Current.family.currency
|
|
|
|
# Process transactions
|
|
transactions.each do |transaction|
|
|
entry = transaction.entry
|
|
is_expense = entry.amount > 0
|
|
type = is_expense ? "expense" : "income"
|
|
category_name = transaction.category&.name || "Uncategorized"
|
|
month_key = entry.date.beginning_of_month
|
|
|
|
# Convert to family currency
|
|
begin
|
|
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency).amount
|
|
rescue Money::ConversionError
|
|
converted_amount = entry.amount.abs
|
|
end
|
|
|
|
key = [ category_name, type ]
|
|
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
|
|
breakdown[key][:months][month_key] ||= 0
|
|
breakdown[key][:months][month_key] += converted_amount
|
|
breakdown[key][:total] += converted_amount
|
|
end
|
|
|
|
# Convert to array and sort by type and total (descending)
|
|
result = breakdown.map do |key, data|
|
|
{
|
|
category: data[:category],
|
|
type: data[:type],
|
|
months: data[:months],
|
|
total: data[:total]
|
|
}
|
|
end
|
|
|
|
# Separate and sort income and expenses
|
|
income_data = result.select { |r| r[:type] == "income" }.sort_by { |r| -r[:total] }
|
|
expense_data = result.select { |r| r[:type] == "expense" }.sort_by { |r| -r[:total] }
|
|
|
|
{
|
|
months: months,
|
|
income: income_data,
|
|
expenses: expense_data
|
|
}
|
|
end
|
|
|
|
def generate_transactions_csv
|
|
require "csv"
|
|
|
|
CSV.generate do |csv|
|
|
# Build header row: Category + Month columns + Total
|
|
month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") }
|
|
header_row = [ "Category" ] + month_headers + [ "Total" ]
|
|
csv << header_row
|
|
|
|
# Income section
|
|
if @export_data[:income].any?
|
|
csv << [ "INCOME" ] + Array.new(month_headers.length + 1, "")
|
|
|
|
@export_data[:income].each do |category_data|
|
|
row = [ category_data[:category] ]
|
|
|
|
# Add amounts for each month
|
|
@export_data[:months].each do |month|
|
|
amount = category_data[:months][month] || 0
|
|
row << Money.new(amount, Current.family.currency).format
|
|
end
|
|
|
|
# Add row total
|
|
row << Money.new(category_data[:total], Current.family.currency).format
|
|
csv << row
|
|
end
|
|
|
|
# Income totals row
|
|
totals_row = [ "TOTAL INCOME" ]
|
|
@export_data[:months].each do |month|
|
|
month_total = @export_data[:income].sum { |c| c[:months][month] || 0 }
|
|
totals_row << Money.new(month_total, Current.family.currency).format
|
|
end
|
|
grand_income_total = @export_data[:income].sum { |c| c[:total] }
|
|
totals_row << Money.new(grand_income_total, Current.family.currency).format
|
|
csv << totals_row
|
|
|
|
# Blank row
|
|
csv << []
|
|
end
|
|
|
|
# Expenses section
|
|
if @export_data[:expenses].any?
|
|
csv << [ "EXPENSES" ] + Array.new(month_headers.length + 1, "")
|
|
|
|
@export_data[:expenses].each do |category_data|
|
|
row = [ category_data[:category] ]
|
|
|
|
# Add amounts for each month
|
|
@export_data[:months].each do |month|
|
|
amount = category_data[:months][month] || 0
|
|
row << Money.new(amount, Current.family.currency).format
|
|
end
|
|
|
|
# Add row total
|
|
row << Money.new(category_data[:total], Current.family.currency).format
|
|
csv << row
|
|
end
|
|
|
|
# Expenses totals row
|
|
totals_row = [ "TOTAL EXPENSES" ]
|
|
@export_data[:months].each do |month|
|
|
month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 }
|
|
totals_row << Money.new(month_total, Current.family.currency).format
|
|
end
|
|
grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] }
|
|
totals_row << Money.new(grand_expenses_total, Current.family.currency).format
|
|
csv << totals_row
|
|
end
|
|
end
|
|
end
|
|
|
|
def generate_transactions_xlsx
|
|
require "caxlsx"
|
|
|
|
package = Axlsx::Package.new
|
|
workbook = package.workbook
|
|
bold_style = workbook.styles.add_style(b: true)
|
|
|
|
workbook.add_worksheet(name: "Breakdown") do |sheet|
|
|
# Build header row: Category + Month columns + Total
|
|
month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") }
|
|
header_row = [ "Category" ] + month_headers + [ "Total" ]
|
|
sheet.add_row header_row, style: bold_style
|
|
|
|
# Income section
|
|
if @export_data[:income].any?
|
|
sheet.add_row [ "INCOME" ] + Array.new(month_headers.length + 1, ""), style: bold_style
|
|
|
|
@export_data[:income].each do |category_data|
|
|
row = [ category_data[:category] ]
|
|
|
|
# Add amounts for each month
|
|
@export_data[:months].each do |month|
|
|
amount = category_data[:months][month] || 0
|
|
row << Money.new(amount, Current.family.currency).format
|
|
end
|
|
|
|
# Add row total
|
|
row << Money.new(category_data[:total], Current.family.currency).format
|
|
sheet.add_row row
|
|
end
|
|
|
|
# Income totals row
|
|
totals_row = [ "TOTAL INCOME" ]
|
|
@export_data[:months].each do |month|
|
|
month_total = @export_data[:income].sum { |c| c[:months][month] || 0 }
|
|
totals_row << Money.new(month_total, Current.family.currency).format
|
|
end
|
|
grand_income_total = @export_data[:income].sum { |c| c[:total] }
|
|
totals_row << Money.new(grand_income_total, Current.family.currency).format
|
|
sheet.add_row totals_row, style: bold_style
|
|
|
|
# Blank row
|
|
sheet.add_row []
|
|
end
|
|
|
|
# Expenses section
|
|
if @export_data[:expenses].any?
|
|
sheet.add_row [ "EXPENSES" ] + Array.new(month_headers.length + 1, ""), style: bold_style
|
|
|
|
@export_data[:expenses].each do |category_data|
|
|
row = [ category_data[:category] ]
|
|
|
|
# Add amounts for each month
|
|
@export_data[:months].each do |month|
|
|
amount = category_data[:months][month] || 0
|
|
row << Money.new(amount, Current.family.currency).format
|
|
end
|
|
|
|
# Add row total
|
|
row << Money.new(category_data[:total], Current.family.currency).format
|
|
sheet.add_row row
|
|
end
|
|
|
|
# Expenses totals row
|
|
totals_row = [ "TOTAL EXPENSES" ]
|
|
@export_data[:months].each do |month|
|
|
month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 }
|
|
totals_row << Money.new(month_total, Current.family.currency).format
|
|
end
|
|
grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] }
|
|
totals_row << Money.new(grand_expenses_total, Current.family.currency).format
|
|
sheet.add_row totals_row, style: bold_style
|
|
end
|
|
end
|
|
|
|
package.to_stream.read
|
|
end
|
|
|
|
def generate_transactions_pdf
|
|
require "prawn"
|
|
|
|
Prawn::Document.new(page_layout: :landscape) do |pdf|
|
|
pdf.text "Transaction Breakdown Report", size: 20, style: :bold
|
|
pdf.text "Period: #{@start_date.strftime('%b %-d, %Y')} to #{@end_date.strftime('%b %-d, %Y')}", size: 12
|
|
pdf.move_down 20
|
|
|
|
if @export_data[:income].any? || @export_data[:expenses].any?
|
|
# Build header row
|
|
month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") }
|
|
header_row = [ "Category" ] + month_headers + [ "Total" ]
|
|
|
|
# Income section
|
|
if @export_data[:income].any?
|
|
pdf.text "INCOME", size: 14, style: :bold
|
|
pdf.move_down 10
|
|
|
|
income_table_data = [ header_row ]
|
|
|
|
@export_data[:income].each do |category_data|
|
|
row = [ category_data[:category] ]
|
|
|
|
@export_data[:months].each do |month|
|
|
amount = category_data[:months][month] || 0
|
|
row << Money.new(amount, Current.family.currency).format
|
|
end
|
|
|
|
row << Money.new(category_data[:total], Current.family.currency).format
|
|
income_table_data << row
|
|
end
|
|
|
|
# Income totals row
|
|
totals_row = [ "TOTAL INCOME" ]
|
|
@export_data[:months].each do |month|
|
|
month_total = @export_data[:income].sum { |c| c[:months][month] || 0 }
|
|
totals_row << Money.new(month_total, Current.family.currency).format
|
|
end
|
|
grand_income_total = @export_data[:income].sum { |c| c[:total] }
|
|
totals_row << Money.new(grand_income_total, Current.family.currency).format
|
|
income_table_data << totals_row
|
|
|
|
pdf.table(income_table_data, header: true, width: pdf.bounds.width, cell_style: { size: 8 }) do
|
|
row(0).font_style = :bold
|
|
row(0).background_color = "CCFFCC"
|
|
row(-1).font_style = :bold
|
|
row(-1).background_color = "99FF99"
|
|
columns(0).align = :left
|
|
columns(1..-1).align = :right
|
|
self.row_colors = [ "FFFFFF", "F9F9F9" ]
|
|
end
|
|
|
|
pdf.move_down 20
|
|
end
|
|
|
|
# Expenses section
|
|
if @export_data[:expenses].any?
|
|
pdf.text "EXPENSES", size: 14, style: :bold
|
|
pdf.move_down 10
|
|
|
|
expenses_table_data = [ header_row ]
|
|
|
|
@export_data[:expenses].each do |category_data|
|
|
row = [ category_data[:category] ]
|
|
|
|
@export_data[:months].each do |month|
|
|
amount = category_data[:months][month] || 0
|
|
row << Money.new(amount, Current.family.currency).format
|
|
end
|
|
|
|
row << Money.new(category_data[:total], Current.family.currency).format
|
|
expenses_table_data << row
|
|
end
|
|
|
|
# Expenses totals row
|
|
totals_row = [ "TOTAL EXPENSES" ]
|
|
@export_data[:months].each do |month|
|
|
month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 }
|
|
totals_row << Money.new(month_total, Current.family.currency).format
|
|
end
|
|
grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] }
|
|
totals_row << Money.new(grand_expenses_total, Current.family.currency).format
|
|
expenses_table_data << totals_row
|
|
|
|
pdf.table(expenses_table_data, header: true, width: pdf.bounds.width, cell_style: { size: 8 }) do
|
|
row(0).font_style = :bold
|
|
row(0).background_color = "FFCCCC"
|
|
row(-1).font_style = :bold
|
|
row(-1).background_color = "FF9999"
|
|
columns(0).align = :left
|
|
columns(1..-1).align = :right
|
|
self.row_colors = [ "FFFFFF", "F9F9F9" ]
|
|
end
|
|
end
|
|
else
|
|
pdf.text "No transactions found for this period.", size: 12
|
|
end
|
|
end.render
|
|
end
|
|
|
|
# Export Authentication - handles both session and API key auth
|
|
def authenticate_for_export
|
|
if api_key_present?
|
|
# Use API key authentication
|
|
authenticate_with_api_key
|
|
else
|
|
# Use normal session authentication
|
|
authenticate_user!
|
|
end
|
|
end
|
|
|
|
# API Key Authentication Methods
|
|
def api_key_present?
|
|
params[:api_key].present? || request.headers["X-Api-Key"].present?
|
|
end
|
|
|
|
def authenticate_with_api_key
|
|
api_key_value = params[:api_key] || request.headers["X-Api-Key"]
|
|
|
|
unless api_key_value
|
|
render plain: "API key is required", status: :unauthorized
|
|
return false
|
|
end
|
|
|
|
@api_key = ApiKey.find_by_value(api_key_value)
|
|
|
|
unless @api_key && @api_key.active?
|
|
render plain: "Invalid or expired API key", status: :unauthorized
|
|
return false
|
|
end
|
|
|
|
# Check if API key has read permissions
|
|
unless @api_key.scopes&.include?("read") || @api_key.scopes&.include?("read_write")
|
|
render plain: "API key does not have read permission", status: :forbidden
|
|
return false
|
|
end
|
|
|
|
# Set up the current user and session context
|
|
@current_user = @api_key.user
|
|
@api_key.update_last_used!
|
|
|
|
# Set up Current context for API requests (similar to Api::V1::BaseController)
|
|
# Return false if setup fails to halt the filter chain
|
|
return false unless setup_current_context_for_api_key
|
|
|
|
true
|
|
end
|
|
|
|
def setup_current_context_for_api_key
|
|
unless @current_user
|
|
render plain: "User not found for API key", status: :internal_server_error
|
|
return false
|
|
end
|
|
|
|
# Find or create a session for this API request
|
|
# We need to find or create a persisted session so that Current.user delegation works properly
|
|
session = @current_user.sessions.first_or_create!(
|
|
user_agent: request.user_agent,
|
|
ip_address: request.ip
|
|
)
|
|
|
|
Current.session = session
|
|
|
|
# Verify the delegation chain works
|
|
unless Current.user
|
|
render plain: "Failed to establish user context", status: :internal_server_error
|
|
return false
|
|
end
|
|
|
|
# Ensure we have a valid family context
|
|
unless Current.family
|
|
render plain: "User does not have an associated family", status: :internal_server_error
|
|
return false
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def build_period_navigation
|
|
# Called at the end of setup_report_data, so @start_date and @end_date are guaranteed to be set.
|
|
case @period_type
|
|
when :monthly
|
|
prev_start = @start_date.beginning_of_month - 1.month
|
|
prev_end = prev_start.end_of_month
|
|
next_start = @start_date.beginning_of_month + 1.month
|
|
next_end = next_start.end_of_month
|
|
at_latest = @start_date.beginning_of_month >= Date.current.beginning_of_month
|
|
when :quarterly
|
|
prev_start = (@start_date.beginning_of_quarter - 1.day).beginning_of_quarter
|
|
prev_end = prev_start.end_of_quarter
|
|
next_start = @end_date.end_of_quarter + 1.day
|
|
next_end = next_start.end_of_quarter
|
|
at_latest = @start_date.beginning_of_quarter >= Date.current.beginning_of_quarter
|
|
when :ytd
|
|
prev_year = @start_date.year - 1
|
|
prev_start = Date.new(prev_year, 1, 1)
|
|
prev_end = Date.new(prev_year, 12, 31)
|
|
next_year = @start_date.year + 1
|
|
next_start = Date.new(next_year, 1, 1)
|
|
next_end = next_year == Date.current.year ? Date.current : Date.new(next_year, 12, 31)
|
|
at_latest = @start_date.year >= Date.current.year
|
|
when :last_6_months
|
|
prev_start = @start_date.beginning_of_month - 6.months
|
|
prev_end = prev_start + 6.months - 1.day
|
|
candidate_start = @start_date.beginning_of_month + 6.months
|
|
if candidate_start + 6.months >= Date.current.beginning_of_month
|
|
next_end = Date.current.end_of_month
|
|
next_start = (next_end + 1.day - 6.months).beginning_of_month
|
|
else
|
|
next_start = candidate_start
|
|
next_end = next_start + 6.months - 1.day
|
|
end
|
|
at_latest = @end_date >= Date.current.end_of_month
|
|
else
|
|
return nil
|
|
end
|
|
|
|
{ prev_start: prev_start, prev_end: prev_end, next_start: next_start, next_end: next_end, at_latest: at_latest, label: period_label }
|
|
end
|
|
|
|
def period_label
|
|
case @period_type
|
|
when :monthly
|
|
I18n.l(@start_date, format: :month_year)
|
|
when :quarterly
|
|
t("reports.index.period_label.quarterly", quarter: @start_date.quarter, year: @start_date.year)
|
|
when :ytd
|
|
if @start_date.year == Date.current.year
|
|
t("reports.index.period_label.ytd", year: @start_date.year)
|
|
else
|
|
t("reports.index.period_label.past_year", year: @start_date.year)
|
|
end
|
|
when :last_6_months
|
|
t("reports.index.period_label.last_6_months",
|
|
start: I18n.l(@start_date, format: :short_month_year),
|
|
end: I18n.l(@end_date, format: :short_month_year))
|
|
end
|
|
end
|
|
end
|