diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..495b13dfd --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -0,0 +1,809 @@ +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 + @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 + + # 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 comparison data + @comparison_data = build_comparison_data + + # Build trend data (last 6 months) + @trends_data = build_trends_data + + # Spending patterns (weekday vs weekend) + @spending_patterns = build_spending_patterns + + # Transactions breakdown + @transactions = build_transactions_breakdown + + @breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ] + 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 + @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 + + private + + 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) + return 0 if budget.nil? || budget.allocated_spending.zero? + + (budget.actual_spending / budget.allocated_spending * 100).round(1) + rescue StandardError + nil + end + + def build_comparison_data + currency_symbol = Money::Currency.new(Current.family.currency).symbol + + # Totals are BigDecimal amounts in dollars - pass directly to Money.new() + { + current: { + income: @current_income_totals.total, + expenses: @current_expense_totals.total, + net: @current_income_totals.total - @current_expense_totals.total + }, + previous: { + income: @previous_income_totals.total, + expenses: @previous_expense_totals.total, + net: @previous_income_totals.total - @previous_expense_totals.total + }, + currency_symbol: currency_symbol + } + 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"), + income: income, + expenses: expenses, + net: income - expenses + } + + current_month = current_month.next_month + end + + trends + end + + def build_spending_patterns + # Analyze weekday vs weekend spending + weekday_total = 0 + weekend_total = 0 + weekday_count = 0 + weekend_count = 0 + + # Build query matching income_statement logic: + # Expenses are transactions with positive amounts, regardless of category + expense_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(kind: [ "standard", "loan_payment" ]) + .where("entries.amount > 0") # Positive amount = expense (matching income_statement logic) + + # Sum up amounts by weekday vs weekend + expense_transactions.each do |transaction| + entry = transaction.entry + amount = entry.amount.abs + + if entry.date.wday.in?([ 0, 6 ]) # Sunday or Saturday + weekend_total += amount + weekend_count += 1 + else + weekday_total += amount + weekday_count += 1 + end + end + + weekday_avg = weekday_count.positive? ? (weekday_total / weekday_count) : 0 + weekend_avg = weekend_count.positive? ? (weekend_total / weekend_count) : 0 + + { + weekday_total: weekday_total, + weekend_total: weekend_total, + weekday_avg: weekday_avg, + weekend_avg: weekend_avg, + weekday_count: weekday_count, + weekend_count: weekend_count + } + end + + def default_spending_patterns + { + weekday_total: 0, + weekend_total: 0, + weekday_avg: 0, + weekend_avg: 0, + weekday_count: 0, + weekend_count: 0 + } + end + + def build_transactions_breakdown + # Base query: all transactions in the period + 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 }) + .includes(entry: :account, category: []) + + # Apply filters + transactions = apply_transaction_filters(transactions) + + # Get sort parameters + sort_by = params[:sort_by] || "amount" + sort_direction = params[:sort_direction] || "desc" + + # Group by category and type + all_transactions = transactions.to_a + grouped_data = {} + + all_transactions.each do |transaction| + entry = transaction.entry + is_expense = entry.amount > 0 + type = is_expense ? "expense" : "income" + category_name = transaction.category&.name || "Uncategorized" + category_color = transaction.category&.color || "#9CA3AF" + + key = [ category_name, type, category_color ] + grouped_data[key] ||= { total: 0, count: 0 } + grouped_data[key][:count] += 1 + grouped_data[key][:total] += entry.amount.abs + end + + # Convert to array + result = grouped_data.map do |key, data| + { + category_name: key[0], + type: key[1], + category_color: key[2], + total: data[:total], + count: data[:count] + } + 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 apply_transaction_filters(transactions) + # Filter by category + if params[:filter_category_id].present? + transactions = transactions.where(category_id: params[:filter_category_id]) + end + + # Filter by account + if params[:filter_account_id].present? + transactions = transactions.where(entries: { account_id: params[:filter_account_id] }) + end + + # Filter by tag + if params[:filter_tag_id].present? + transactions = transactions.joins(:taggings).where(taggings: { tag_id: params[:filter_tag_id] }) + end + + # Filter by amount range + if params[:filter_amount_min].present? + transactions = transactions.where("ABS(entries.amount) >= ?", params[:filter_amount_min].to_f) + end + + if params[:filter_amount_max].present? + transactions = transactions.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]) + transactions = transactions.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]) + transactions = transactions.where("entries.date <= ?", filter_end) if filter_end <= @end_date + end + + transactions + rescue Date::Error + transactions + end + + def build_transactions_breakdown_for_export + # Get flat transactions list (not grouped) for export + 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 }) + .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 + 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 }) + .includes(entry: :account, category: []) + + transactions = apply_transaction_filters(transactions) + + # Group transactions by category, type, and month + breakdown = {} + + 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 + + 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] += entry.amount.abs + breakdown[key][:total] += entry.amount.abs + 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 +end diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index a2a848503..7ef6467db 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -75,6 +75,34 @@ class BudgetCategory < ApplicationRecord (actual_spending / budgeted_spending) * 100 end + def bar_width_percent + [ percent_of_budget_spent, 100 ].min + end + + def over_budget? + available_to_spend.negative? + end + + def near_limit? + !over_budget? && percent_of_budget_spent >= 90 + end + + # Returns hash with suggested daily spending info or nil if not applicable + def suggested_daily_spending + return nil unless available_to_spend > 0 + + budget_date = budget.start_date + return nil unless budget_date.month == Date.current.month && budget_date.year == Date.current.year + + days_remaining = (budget_date.end_of_month - Date.current).to_i + 1 + return nil unless days_remaining > 0 + + { + amount: Money.new((available_to_spend / days_remaining), budget.family.currency), + days_remaining: days_remaining + } + end + def to_donut_segments_json unused_segment_id = "unused" overage_segment_id = "overage" diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 81e857a2f..b867783a7 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -1,54 +1,118 @@ <%# locals: (budget_category:) %> -<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %> - <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-container", data: { turbo_frame: "drawer" } do %> +<%= turbo_frame_tag dom_id(budget_category), class: "w-full block" do %> + <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full p-4 bg-container hover:bg-surface-inset transition-colors", data: { turbo_frame: "drawer" } do %> <% if budget_category.initialized? %> -
- <%= render "budget_categories/budget_category_donut", budget_category: budget_category %> + <%# Category Header with Status Badge %> +
+
+
+

<%= budget_category.category.name %>

+
+ +
+ <% if budget_category.over_budget? %> + + <%= icon("alert-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.over") %> + + <% elsif budget_category.near_limit? %> + + <%= icon("alert-triangle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.warning") %> + + <% else %> + + <%= icon("check-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.good") %> + + <% end %> + + + <%= budget_category.percent_of_budget_spent.round(0) %>% + +
+ + <%# Progress Bar %> +
+
+ <% bar_color = budget_category.over_budget? ? "bg-danger" : (budget_category.near_limit? ? "bg-warning" : "bg-success") %> +
+
+
+ + <%# Budget Details %> +
+
+ <%= t("reports.budget_performance.spent") %>: + + <%= format_money(budget_category.actual_spending_money) %> + +
+
+ <%= t("reports.budget_performance.budgeted") %>: + + <%= format_money(budget_category.budgeted_spending_money) %> + +
+
+ <% if budget_category.available_to_spend >= 0 %> + <%= t("reports.budget_performance.remaining") %>: + + <%= format_money(budget_category.available_to_spend_money) %> + + <% else %> + <%= t("reports.budget_performance.over_by") %>: + + <%= format_money(budget_category.available_to_spend_money.abs) %> + + <% end %> +
+
+ + <%# Suggested Daily Limit (if remaining days in month) %> + <% if budget_category.suggested_daily_spending.present? %> + <% daily_info = budget_category.suggested_daily_spending %> +
+

+ <%= t("reports.budget_performance.suggested_daily", + amount: daily_info[:amount].format, + days: daily_info[:days_remaining]) %> +

+
+ <% end %> + <% else %> -
- <% if budget_category.category.lucide_icon %> - <%= icon(budget_category.category.lucide_icon, color: "current") %> - <% else %> - <%= render DS::FilledIcon.new( - variant: :text, - hex_color: budget_category.category.color, - text: budget_category.category.name, - size: "sm", - rounded: true - ) %> - <% end %> + <%# Uninitialized budget - show simple view %> +
+
+ <% if budget_category.category.lucide_icon %> + <%= icon(budget_category.category.lucide_icon, color: "current") %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + hex_color: budget_category.category.color, + text: budget_category.category.name, + size: "sm", + rounded: true + ) %> + <% end %> +
+ +
+

<%= budget_category.category.name %>

+

+ <%= budget_category.median_monthly_expense_money.format %> avg +

+
+ +
+

<%= format_money(budget_category.actual_spending_money) %>

+
<% end %> - -
-

<%= budget_category.category.name %>

- - <% if budget_category.initialized? %> - <% if budget_category.available_to_spend.negative? %> -

<%= format_money(budget_category.available_to_spend_money.abs) %> over

- <% elsif budget_category.available_to_spend.zero? %> -

"> - <%= format_money(budget_category.available_to_spend_money) %> left -

- <% else %> -

<%= format_money(budget_category.available_to_spend_money) %> left

- <% end %> - <% else %> -

- <%= budget_category.median_monthly_expense_money.format %> avg -

- <% end %> -
- -
-

<%= format_money(budget_category.actual_spending_money) %>

- - <% if budget_category.initialized? %> -

from <%= format_money(budget_category.budgeted_spending_money) %>

- <% end %> -
<% end %> <% end %> diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index de47b27c2..57eee66ad 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -5,8 +5,10 @@ next_budget: @next_budget, latest_budget: @latest_budget %> -
-
+
+ <%# Top Section: Donut and Summary side by side %> +
+ <%# Budget Donut %>
<% if @budget.available_to_allocate.negative? %> <%= render "budgets/over_allocation_warning", budget: @budget %> @@ -15,8 +17,8 @@ <% end %>
-
- + <%# Actuals Summary %> +
<% if @budget.initialized? && @budget.available_to_allocate.positive? %> <%= render DS::Tabs.new(active_tab: params[:tab].presence || "budgeted") do |tabs| %> <% tabs.with_nav do |nav| %> @@ -37,14 +39,13 @@ <% end %> <% end %> <% else %> -
- <%= render "budgets/actuals_summary", budget: @budget %> -
+ <%= render "budgets/actuals_summary", budget: @budget %> <% end %>
-
+ <%# Bottom Section: Categories full width %> +

Categories

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 02854276b..796c55aeb 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,6 +1,7 @@ <% mobile_nav_items = [ { name: "Home", path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) }, { name: "Transactions", path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, + { name: "Reports", path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) }, { name: "Budgets", path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) }, { name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } ] %> diff --git a/app/views/reports/_budget_performance.html.erb b/app/views/reports/_budget_performance.html.erb new file mode 100644 index 000000000..9e1030a30 --- /dev/null +++ b/app/views/reports/_budget_performance.html.erb @@ -0,0 +1,117 @@ +
+
+

+ <%= t("reports.budget_performance.title") %> +

+

+ <%= start_date.strftime("%B %Y") %> +

+
+ + <% if budget_data.any? %> +
+ <% budget_data.each do |budget_item| %> +
+ <%# Category Header %> +
+
+
+

<%= budget_item[:category_name] %>

+
+ +
+ <% case budget_item[:status] %> + <% when :over %> + + <%= icon("alert-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.over") %> + + <% when :warning %> + + <%= icon("alert-triangle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.warning") %> + + <% when :good %> + + <%= icon("check-circle", class: "w-3 h-3") %> + <%= t("reports.budget_performance.status.good") %> + + <% end %> + + + <%= budget_item[:percent_used].round(0) %>% + +
+
+ + <%# Progress Bar %> +
+
+ <% bar_width = [budget_item[:percent_used], 100].min %> + <% bar_color = case budget_item[:status] + when :over then "bg-danger" + when :warning then "bg-warning" + else "bg-success" + end %> +
+
+
+ + <%# Budget Details %> +
+
+
+ <%= t("reports.budget_performance.spent") %>: + + <%= Money.new(budget_item[:actual], Current.family.currency).format %> + +
+
+ <%= t("reports.budget_performance.budgeted") %>: + + <%= Money.new(budget_item[:budgeted], Current.family.currency).format %> + +
+
+ +
+ <% if budget_item[:remaining] >= 0 %> + <%= t("reports.budget_performance.remaining") %>: + + <%= Money.new(budget_item[:remaining], Current.family.currency).format %> + + <% else %> + <%= t("reports.budget_performance.over_by") %>: + + <%= Money.new(budget_item[:remaining].abs, Current.family.currency).format %> + + <% end %> +
+
+ + <%# Suggested Daily Limit (if remaining days in month) %> + <% if budget_item[:remaining] > 0 && start_date.month == Date.current.month && start_date.year == Date.current.year %> + <% days_remaining = (start_date.end_of_month - Date.current).to_i + 1 %> + <% if days_remaining > 0 %> +
+

+ <%= t("reports.budget_performance.suggested_daily", + amount: Money.new((budget_item[:remaining] / days_remaining), Current.family.currency).format, + days: days_remaining) %> +

+
+ <% end %> + <% end %> +
+ <% end %> +
+ <% else %> +
+ <%= icon("gauge", class: "w-12 h-12 text-tertiary mx-auto mb-4") %> +

+ <%= t("reports.budget_performance.no_budgets") %> +

+
+ <% end %> +
diff --git a/app/views/reports/_comparison_chart.html.erb b/app/views/reports/_comparison_chart.html.erb new file mode 100644 index 000000000..c48531eb9 --- /dev/null +++ b/app/views/reports/_comparison_chart.html.erb @@ -0,0 +1,250 @@ +<% + currency = Current.family.currency + + # Helper to calculate percentage change and determine if it's good or bad + def comparison_class(current, previous, inverse: false) + return "text-primary" if previous.zero? + + change = current - previous + is_positive_change = change > 0 + + # For expenses, lower is better (inverse logic) + is_good = inverse ? !is_positive_change : is_positive_change + + is_good ? "text-green-600" : "text-gray-600" + end + + def percentage_change(current, previous) + return 0 if previous.zero? + ((current - previous) / previous.abs * 100).round(1) + end +%> + +
+
+

+ <%= t("reports.comparison.title") %> +

+

+ <%= t("reports.comparison.currency", symbol: comparison_data[:currency_symbol]) %> +

+
+ +
+ <%# Income Comparison %> +
+
+

+ <%= icon("trending-up", class: "w-4 h-4 text-success") %> + <%= t("reports.comparison.income") %> +

+
+ +
+
+ + <%= Money.new(comparison_data[:current][:income], currency).format %> + + <% change = percentage_change(comparison_data[:current][:income], comparison_data[:previous][:income]) %> + <% if change != 0 %> + <% income_improved = comparison_data[:current][:income] > comparison_data[:previous][:income] %> +
+ + <%= change >= 0 ? "+" : "" %><%= change %>% + + + <%= icon(income_improved ? "trending-up" : "trending-down", class: "w-3 h-3") %> + <%= t(income_improved ? "reports.comparison.status.improved" : "reports.comparison.status.decreased") %> + +
+ <% end %> +
+ + <%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:income], currency).format %> + +
+ + <%# Overlapping bars %> +
+ <% + current_income_abs = comparison_data[:current][:income].to_f.abs + previous_income_abs = comparison_data[:previous][:income].to_f.abs + max_income = [current_income_abs, previous_income_abs].max + + if max_income > 0 + current_width = [3, (current_income_abs / max_income * 100)].max + previous_width = [3, (previous_income_abs / max_income * 100)].max + else + current_width = 0 + previous_width = 0 + end + + # Income: green if increased, gray/primary if decreased + income_increased = comparison_data[:current][:income] >= comparison_data[:previous][:income] + income_bar_color = income_increased ? "bg-green-500" : "bg-gray-600" + income_bg_color = income_increased ? "bg-green-200" : "bg-gray-300" + %> + <% if previous_width > 0 || current_width > 0 %> + <%# Previous period bar (background) %> + <% if previous_width > 0 %> +
+ <% end %> + <%# Current period bar (foreground) %> + <% if current_width > 0 %> +
+ <% end %> + <% else %> +
+ <%= t("reports.comparison.no_data") %> +
+ <% end %> +
+
+ + <%# Expenses Comparison %> +
+
+

+ <%= icon("trending-down", class: "w-4 h-4 text-danger") %> + <%= t("reports.comparison.expenses") %> +

+
+ +
+
+ + <%= Money.new(comparison_data[:current][:expenses], currency).format %> + + <% change = percentage_change(comparison_data[:current][:expenses], comparison_data[:previous][:expenses]) %> + <% if change != 0 %> + <% expenses_improved = comparison_data[:current][:expenses] < comparison_data[:previous][:expenses] %> +
+ + <%= change >= 0 ? "+" : "" %><%= change %>% + + + <%= icon(expenses_improved ? "trending-down" : "trending-up", class: "w-3 h-3") %> + <%= t(expenses_improved ? "reports.comparison.status.reduced" : "reports.comparison.status.increased") %> + +
+ <% end %> +
+ + <%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:expenses], currency).format %> + +
+ + <%# Overlapping bars %> +
+ <% + current_expenses_abs = comparison_data[:current][:expenses].to_f.abs + previous_expenses_abs = comparison_data[:previous][:expenses].to_f.abs + max_expenses = [current_expenses_abs, previous_expenses_abs].max + + if max_expenses > 0 + current_width = [3, (current_expenses_abs / max_expenses * 100)].max + previous_width = [3, (previous_expenses_abs / max_expenses * 100)].max + else + current_width = 0 + previous_width = 0 + end + + # Expenses: green if decreased (inverse logic), gray/primary if increased + expenses_decreased = comparison_data[:current][:expenses] <= comparison_data[:previous][:expenses] + expenses_bar_color = expenses_decreased ? "bg-green-500" : "bg-gray-600" + expenses_bg_color = expenses_decreased ? "bg-green-200" : "bg-gray-300" + %> + <% if previous_width > 0 || current_width > 0 %> + <%# Previous period bar (background) %> + <% if previous_width > 0 %> +
+ <% end %> + <%# Current period bar (foreground) %> + <% if current_width > 0 %> +
+ <% end %> + <% else %> +
+ <%= t("reports.comparison.no_data") %> +
+ <% end %> +
+
+ + <%# Net Savings Comparison %> +
+
+

+ <%= icon("piggy-bank", class: "w-4 h-4 text-primary") %> + <%= t("reports.comparison.net_savings") %> +

+
+ +
+
+ + <%= Money.new(comparison_data[:current][:net], currency).format %> + + <% change = percentage_change(comparison_data[:current][:net], comparison_data[:previous][:net]) %> + <% if change != 0 %> + <% net_improved = comparison_data[:current][:net] > comparison_data[:previous][:net] %> +
+ + <%= change >= 0 ? "+" : "" %><%= change %>% + + + <%= icon(net_improved ? "trending-up" : "trending-down", class: "w-3 h-3") %> + <%= t(net_improved ? "reports.comparison.status.improved" : "reports.comparison.status.decreased") %> + +
+ <% end %> +
+ + <%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:net], currency).format %> + +
+ + <%# Overlapping bars %> +
+ <% + current_net_abs = comparison_data[:current][:net].to_f.abs + previous_net_abs = comparison_data[:previous][:net].to_f.abs + max_net = [current_net_abs, previous_net_abs].max + + if max_net > 0 + current_width = [3, (current_net_abs / max_net * 100)].max + previous_width = [3, (previous_net_abs / max_net * 100)].max + else + current_width = 0 + previous_width = 0 + end + + # Net Savings: green if improved (increased), gray/primary if got worse + net_improved = comparison_data[:current][:net] >= comparison_data[:previous][:net] + net_bar_color = net_improved ? "bg-green-500" : "bg-gray-600" + net_bg_color = net_improved ? "bg-green-200" : "bg-gray-300" + %> + <% if previous_width > 0 || current_width > 0 %> + <%# Previous period bar (background) %> + <% if previous_width > 0 %> +
+ <% end %> + <%# Current period bar (foreground) %> + <% if current_width > 0 %> +
+ <% end %> + <% else %> +
+ <%= t("reports.comparison.no_data") %> +
+ <% end %> +
+
+
+
diff --git a/app/views/reports/_empty_state.html.erb b/app/views/reports/_empty_state.html.erb new file mode 100644 index 000000000..774bc56eb --- /dev/null +++ b/app/views/reports/_empty_state.html.erb @@ -0,0 +1,27 @@ +
+ <%= icon("chart-bar", class: "w-16 h-16 text-tertiary mx-auto mb-6") %> + +

+ <%= t("reports.empty_state.title") %> +

+ +

+ <%= t("reports.empty_state.description") %> +

+ +
+ <%= render DS::Link.new( + text: t("reports.empty_state.add_transaction"), + href: new_transaction_path, + variant: "primary", + frame: :modal + ) %> + + <%= render DS::Link.new( + text: t("reports.empty_state.add_account"), + href: new_account_path, + variant: "secondary", + frame: :modal + ) %> +
+
diff --git a/app/views/reports/_summary_dashboard.html.erb b/app/views/reports/_summary_dashboard.html.erb new file mode 100644 index 000000000..d83041911 --- /dev/null +++ b/app/views/reports/_summary_dashboard.html.erb @@ -0,0 +1,132 @@ +
+ <%# Total Income Card %> +
+
+
+ <%= icon("trending-up", class: "w-5 h-5 text-success") %> +

+ <%= t("reports.summary.total_income") %> +

+
+
+ +
+

+ <%= metrics[:current_income].format %> +

+ + <% if metrics[:income_change] %> +
+ <% if metrics[:income_change] >= 0 %> + <%= icon("arrow-up", class: "w-4 h-4 text-success") %> + + +<%= metrics[:income_change] %>% + + <% else %> + <%= icon("arrow-down", class: "w-4 h-4 text-danger") %> + + <%= metrics[:income_change] %>% + + <% end %> + + <%= t("reports.summary.vs_previous") %> + +
+ <% end %> +
+
+ + <%# Total Expenses Card %> +
+
+
+ <%= icon("trending-down", class: "w-5 h-5 text-danger") %> +

+ <%= t("reports.summary.total_expenses") %> +

+
+
+ +
+

+ <%= metrics[:current_expenses].format %> +

+ + <% if metrics[:expense_change] %> +
+ <% if metrics[:expense_change] >= 0 %> + <%= icon("arrow-up", class: "w-4 h-4 text-danger") %> + + +<%= metrics[:expense_change] %>% + + <% else %> + <%= icon("arrow-down", class: "w-4 h-4 text-success") %> + + <%= metrics[:expense_change] %>% + + <% end %> + + <%= t("reports.summary.vs_previous") %> + +
+ <% end %> +
+
+ + <%# Net Savings Card %> +
+
+
+ <%= icon("piggy-bank", class: "w-5 h-5 text-primary") %> +

+ <%= t("reports.summary.net_savings") %> +

+
+
+ +
+

"> + <%= metrics[:net_savings].format %> +

+ +

+ <%= t("reports.summary.income_minus_expenses") %> +

+
+
+ + <%# Budget Performance Card %> +
+
+
+ <%= icon("gauge", class: "w-5 h-5 text-primary") %> +

+ <%= t("reports.summary.budget_performance") %> +

+
+
+ +
+ <% if metrics[:budget_percent] %> +

+ <%= metrics[:budget_percent] %>% +

+ +
+
+
= 80 ? "bg-warning" : "bg-success" %> rounded-full transition-all" + style="width: <%= [metrics[:budget_percent], 100].min %>%">
+
+ +

+ <%= t("reports.summary.of_budget_used") %> +

+
+ <% else %> +

+ <%= t("reports.summary.no_budget_data") %> +

+ <% end %> +
+
+
diff --git a/app/views/reports/_transactions_breakdown.html.erb b/app/views/reports/_transactions_breakdown.html.erb new file mode 100644 index 000000000..9cb4d26d8 --- /dev/null +++ b/app/views/reports/_transactions_breakdown.html.erb @@ -0,0 +1,180 @@ +
+ <%# Header %> +
+

+ <%= t("reports.transactions_breakdown.title") %> +

+
+ + <%# Export Controls %> +
+ <% + # Build params hash for links + base_params = { + period_type: period_type, + start_date: start_date, + end_date: end_date, + sort_by: params[:sort_by], + sort_direction: params[:sort_direction] + }.compact + %> + + <%# Export Options %> +
+ <%= t("reports.transactions_breakdown.export.label") %>: + <%= link_to export_transactions_reports_path(base_params.merge(format: :csv)), + class: "inline-flex items-center gap-1 text-sm px-3 py-1 bg-surface-inset text-secondary hover:bg-surface-hover rounded-lg" do %> + <%= icon("download", class: "w-3 h-3") %> + <%= t("reports.transactions_breakdown.export.csv") %> + <% end %> + <%= link_to google_sheets_instructions_reports_path(base_params), + class: "inline-flex items-center gap-1 text-sm px-3 py-1 bg-surface-inset text-secondary hover:bg-surface-hover rounded-lg", + data: { turbo_frame: "modal" } do %> + <%= icon("external-link", class: "w-3 h-3") %> + <%= t("reports.transactions_breakdown.export.google_sheets") %> + <% end %> +
+
+ + <%# Transactions Tables - Split by Income and Expenses %> + <% if transactions.any? %> + <% + # Separate income and expenses + income_groups = transactions.select { |g| g[:type] == "income" } + expense_groups = transactions.select { |g| g[:type] == "expense" } + + # Calculate totals + income_total = income_groups.sum { |g| g[:total] } + expense_total = expense_groups.sum { |g| g[:total] } + + # Determine sort direction for Amount column + current_sort_by = params[:sort_by] + current_sort_direction = params[:sort_direction] + + # Toggle sort direction: if currently sorting by amount desc, switch to asc; otherwise default to desc + next_sort_direction = (current_sort_by == "amount" && current_sort_direction == "desc") ? "asc" : "desc" + + # Build params for amount sort link + amount_sort_params = base_params.merge(sort_by: "amount", sort_direction: next_sort_direction) + %> + +
+ <%# Income Section %> + <% if income_groups.any? %> +
+

+ <%= icon("trending-up", class: "w-5 h-5") %> + <%= t("reports.transactions_breakdown.table.income") %> + (<%= Money.new(income_total, Current.family.currency).format %>) +

+ +
+ + + + + + + + + + <% income_groups.each do |group| %> + <% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(1) %> + + + + + + <% end %> + +
<%= t("reports.transactions_breakdown.table.category") %> + <%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %> + <%= t("reports.transactions_breakdown.table.amount") %> + <% if current_sort_by == "amount" %> + <%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %> + <% end %> + <% end %> + <%= t("reports.transactions_breakdown.table.percentage") %>
+
+ + <%= group[:category_name] %> + (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>) +
+
+ + <%= Money.new(group[:total], Current.family.currency).format %> + + + + <%= percentage %>% + +
+
+
+ <% end %> + + <%# Expenses Section %> + <% if expense_groups.any? %> +
+

+ <%= icon("trending-down", class: "w-5 h-5") %> + <%= t("reports.transactions_breakdown.table.expense") %> + (<%= Money.new(expense_total, Current.family.currency).format %>) +

+ +
+ + + + + + + + + + <% expense_groups.each do |group| %> + <% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(1) %> + + + + + + <% end %> + +
<%= t("reports.transactions_breakdown.table.category") %> + <%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %> + <%= t("reports.transactions_breakdown.table.amount") %> + <% if current_sort_by == "amount" %> + <%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %> + <% end %> + <% end %> + <%= t("reports.transactions_breakdown.table.percentage") %>
+
+ + <%= group[:category_name] %> + (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>) +
+
+ + <%= Money.new(group[:total], Current.family.currency).format %> + + + + <%= percentage %>% + +
+
+
+ <% end %> +
+ + <%# Summary Stats %> +
+ <%= t("reports.transactions_breakdown.pagination.showing", count: transactions.sum { |g| g[:count] }) %> +
+ <% else %> +
+ <%= t("reports.transactions_breakdown.no_transactions") %> +
+ <% end %> +
diff --git a/app/views/reports/_trends_insights.html.erb b/app/views/reports/_trends_insights.html.erb new file mode 100644 index 000000000..84e05657d --- /dev/null +++ b/app/views/reports/_trends_insights.html.erb @@ -0,0 +1,203 @@ +
+

+ <%= t("reports.trends.title") %> +

+ +
+ <%# Month-over-Month Trends %> +
+

+ <%= t("reports.trends.monthly_breakdown") %> +

+ + <% if trends_data.any? %> +
+ + + + + + + + + + + + <% trends_data.each_with_index do |trend, index| %> + "> + + + + + + + <% end %> + +
<%= t("reports.trends.month") %><%= t("reports.trends.income") %><%= t("reports.trends.expenses") %><%= t("reports.trends.net") %><%= t("reports.trends.savings_rate") %>
+
+ + <%# Trend Insights %> +
+ <% avg_income = trends_data.sum { |t| t[:income] } / trends_data.length %> + <% avg_expenses = trends_data.sum { |t| t[:expenses] } / trends_data.length %> + <% avg_net = trends_data.sum { |t| t[:net] } / trends_data.length %> + +
+

<%= t("reports.trends.avg_monthly_income") %>

+

+ <%= Money.new(avg_income, Current.family.currency).format %> +

+
+ +
+

<%= t("reports.trends.avg_monthly_expenses") %>

+

+ <%= Money.new(avg_expenses, Current.family.currency).format %> +

+
+ +
+

<%= t("reports.trends.avg_monthly_savings") %>

+

"> + <%= Money.new(avg_net, Current.family.currency).format %> +

+
+
+ <% else %> +
+ <%= t("reports.trends.no_data") %> +
+ <% end %> +
+ + <%# Spending Patterns %> +
+

+ <%= t("reports.trends.spending_patterns") %> +

+ + <% if spending_patterns[:weekday_count] + spending_patterns[:weekend_count] > 0 %> +
+ <%# Weekday Spending %> +
+
+ <%= icon("calendar", class: "w-5 h-5 text-primary") %> +

<%= t("reports.trends.weekday_spending") %>

+
+ +
+
+

<%= t("reports.trends.total") %>

+

+ <%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %> +

+
+ +
+

<%= t("reports.trends.avg_per_transaction") %>

+

+ <%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %> +

+
+ +
+

<%= t("reports.trends.transactions") %>

+

+ <%= spending_patterns[:weekday_count] %> +

+
+
+
+ + <%# Weekend Spending %> +
+
+ <%= icon("calendar-check", class: "w-5 h-5 text-primary") %> +

<%= t("reports.trends.weekend_spending") %>

+
+ +
+
+

<%= t("reports.trends.total") %>

+

+ <%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %> +

+
+ +
+

<%= t("reports.trends.avg_per_transaction") %>

+

+ <%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %> +

+
+ +
+

<%= t("reports.trends.transactions") %>

+

+ <%= spending_patterns[:weekend_count] %> +

+
+
+
+
+ + <%# Comparison Insight %> + <% if spending_patterns[:weekday_avg] > 0 && spending_patterns[:weekend_avg] > 0 %> +
+
+ <%= icon("lightbulb", class: "w-5 h-5 text-warning mt-0.5") %> +
+

+ <%= t("reports.trends.insight_title") %> +

+

+ <% + weekday = spending_patterns[:weekday_avg].to_f + weekend = spending_patterns[:weekend_avg].to_f + + if weekend > weekday + percent_diff = ((weekend - weekday) / weekday * 100).round(0) + if percent_diff > 20 + message = t("reports.trends.insight_higher_weekend", percent: percent_diff) + else + message = t("reports.trends.insight_similar") + end + elsif weekday > weekend + percent_diff = ((weekday - weekend) / weekend * 100).round(0) + if percent_diff > 20 + message = t("reports.trends.insight_higher_weekday", percent: percent_diff) + else + message = t("reports.trends.insight_similar") + end + else + message = t("reports.trends.insight_similar") + end + %> + <%= message %> +

+
+
+
+ <% end %> + <% else %> +
+ <%= t("reports.trends.no_spending_data") %> +
+ <% end %> +
+
+
diff --git a/app/views/reports/google_sheets_instructions.html.erb b/app/views/reports/google_sheets_instructions.html.erb new file mode 100644 index 000000000..ba8e79a9f --- /dev/null +++ b/app/views/reports/google_sheets_instructions.html.erb @@ -0,0 +1,67 @@ +<%= render DS::Dialog.new(variant: "modal", width: "md") do |dialog| %> + <% dialog.with_body do %> +
+
+

+ <% if @api_key_present %> + <%= t("reports.google_sheets_instructions.title_with_key") %> + <% else %> + <%= t("reports.google_sheets_instructions.title_no_key") %> + <% end %> +

+ <%= icon("x", as_button: true, data: { action: "DS--dialog#close" }, class: "text-subdued hover:text-primary") %> +
+ +
+ <% if @api_key_present %> +

<%= t("reports.google_sheets_instructions.ready") %>

+

<%= t("reports.google_sheets_instructions.steps") %>

+
+ =IMPORTDATA("<%= @csv_url %>") +
+

<%= icon("alert-triangle", class: "w-4 h-4 inline") %> <%= t("reports.google_sheets_instructions.security_warning") %>

+ <% else %> +

<%= t("reports.google_sheets_instructions.need_key") %>

+
    +
  1. <%= t("reports.google_sheets_instructions.step1") %>
  2. +
  3. <%= t("reports.google_sheets_instructions.step2") %>
  4. +
  5. <%= t("reports.google_sheets_instructions.step3") %>
  6. +
  7. <%= t("reports.google_sheets_instructions.step4") %>
  8. +
+

<%= t("reports.google_sheets_instructions.example") %>:

+
+ <%= @csv_url %>&api_key=YOUR_API_KEY_HERE +
+

<%= t("reports.google_sheets_instructions.then_use") %>

+ <% end %> +
+ +
+ <% if @api_key_present %> + <%= render DS::Button.new( + text: t("reports.google_sheets_instructions.open_sheets"), + variant: "primary", + full_width: true, + href: "https://sheets.google.com/create", + target: "_blank", + data: { action: "click->DS--dialog#close" } + ) %> + <% else %> + <%= render DS::Button.new( + text: t("reports.google_sheets_instructions.go_to_api_keys"), + variant: "primary", + full_width: true, + href: settings_api_key_path, + frame: "_top" + ) %> + <% end %> + <%= render DS::Button.new( + text: t("reports.google_sheets_instructions.close"), + variant: "outline", + full_width: true, + data: { action: "DS--dialog#close" } + ) %> +
+
+ <% end %> +<% end %> diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb new file mode 100644 index 000000000..dd659bf87 --- /dev/null +++ b/app/views/reports/index.html.erb @@ -0,0 +1,122 @@ +<% content_for :page_header do %> +
+
+

+ <%= t("reports.index.title") %> +

+

+ <%= t("reports.index.subtitle") %> +

+
+ + <%# Period Navigation Tabs %> +
+ <%= render DS::Link.new( + text: t("reports.index.periods.monthly"), + variant: @period_type == :monthly ? "secondary" : "ghost", + href: reports_path(period_type: :monthly), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.quarterly"), + variant: @period_type == :quarterly ? "secondary" : "ghost", + href: reports_path(period_type: :quarterly), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.ytd"), + variant: @period_type == :ytd ? "secondary" : "ghost", + href: reports_path(period_type: :ytd), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.last_6_months"), + variant: @period_type == :last_6_months ? "secondary" : "ghost", + href: reports_path(period_type: :last_6_months), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.custom"), + variant: @period_type == :custom ? "secondary" : "ghost", + href: reports_path(period_type: :custom), + size: :sm + ) %> +
+ + <%# Custom Date Range Picker (only shown when custom is selected) %> + <% if @period_type == :custom %> + <%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %> + <%= f.hidden_field :period_type, value: :custom %> + +
+ + <%= f.date_field :start_date, + value: @start_date.strftime("%Y-%m-%d"), + data: { auto_submit_form_target: "auto" }, + autocomplete: "off", + class: "px-3 py-1.5 border border-primary rounded-lg text-sm" %> +
+ +
+ + <%= f.date_field :end_date, + value: @end_date.strftime("%Y-%m-%d"), + data: { auto_submit_form_target: "auto" }, + autocomplete: "off", + class: "px-3 py-1.5 border border-primary rounded-lg text-sm" %> +
+ <% end %> + <% end %> + + <%# Period Display %> +
+ <%= t("reports.index.showing_period", + start: @start_date.strftime("%b %-d, %Y"), + end: @end_date.strftime("%b %-d, %Y")) %> +
+
+<% end %> + +
+ <% if Current.family.transactions.any? %> + <%# Summary Dashboard %> +
+ <%= render partial: "reports/summary_dashboard", locals: { + metrics: @summary_metrics, + period_type: @period_type + } %> +
+ + <%# Comparison Chart %> +
+ <%= render partial: "reports/comparison_chart", locals: { + comparison_data: @comparison_data, + period_type: @period_type, + start_date: @start_date + } %> +
+ + <%# Trends & Insights %> +
+ <%= render partial: "reports/trends_insights", locals: { + trends_data: @trends_data, + spending_patterns: @spending_patterns + } %> +
+ + <%# Transactions Breakdown %> +
+ <%= render partial: "reports/transactions_breakdown", locals: { + transactions: @transactions, + period_type: @period_type, + start_date: @start_date, + end_date: @end_date + } %> +
+ <% else %> + <%# Empty State %> +
+ <%= render partial: "reports/empty_state" %> +
+ <% end %> +
diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 000000000..549789cbf --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Add new mime types for use in respond_to blocks: +# Mime::Type.register "text/richtext", :rtf +Mime::Type.register "text/csv", :csv +Mime::Type.register "application/pdf", :pdf +Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :xlsx diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml new file mode 100644 index 000000000..3b4e952e2 --- /dev/null +++ b/config/locales/views/reports/en.yml @@ -0,0 +1,137 @@ +--- +en: + reports: + index: + title: Reports + subtitle: Comprehensive insights into your financial health + export: Export CSV + periods: + monthly: Monthly + quarterly: Quarterly + ytd: Year to Date + last_6_months: Last 6 Months + custom: Custom Range + date_range: + from: From + to: To + showing_period: "Showing data from %{start} to %{end}" + summary: + total_income: Total Income + total_expenses: Total Expenses + net_savings: Net Savings + budget_performance: Budget Performance + vs_previous: vs previous period + income_minus_expenses: Income minus expenses + of_budget_used: of budget used + no_budget_data: No budget data for this period + comparison: + title: Period Comparison + currency: "Currency: %{symbol}" + income: Income + expenses: Expenses + net_savings: Net Savings + current: Current Period + previous: Previous Period + no_data: No data available + status: + improved: Improved + decreased: Decreased + reduced: Reduced + increased: Increased + budget_performance: + title: Budget Performance + spent: Spent + budgeted: Budgeted + remaining: Remaining + over_by: Over by + suggested_daily: "%{amount} suggested per day for %{days} remaining days" + no_budgets: No budget categories set up for this month + status: + good: On Track + warning: Near Limit + over: Over Budget + trends: + title: Trends & Insights + monthly_breakdown: Monthly Breakdown + month: Month + income: Income + expenses: Expenses + net: Net + savings_rate: Savings Rate + current: current + avg_monthly_income: Avg. Monthly Income + avg_monthly_expenses: Avg. Monthly Expenses + avg_monthly_savings: Avg. Monthly Savings + no_data: No trend data available + spending_patterns: Spending Patterns + weekday_spending: Weekday Spending + weekend_spending: Weekend Spending + total: Total + avg_per_transaction: Avg. per transaction + transactions: Transactions + insight_title: Insight + insight_higher_weekend: "You spend %{percent}% more per transaction on weekends than weekdays" + insight_higher_weekday: "You spend %{percent}% more per transaction on weekdays than weekends" + insight_similar: "Your spending per transaction is similar on weekdays and weekends" + no_spending_data: No spending data available for this period + empty_state: + title: No Data Available + description: Start tracking your finances by adding transactions or connecting your accounts to see comprehensive reports + add_transaction: Add Transaction + add_account: Add Account + transactions_breakdown: + title: Transactions Breakdown + no_transactions: No transactions found for the selected period and filters + filters: + title: Filters + category: Category + account: Account + tag: Tag + amount_min: Min Amount + amount_max: Max Amount + date_range: Date Range + all_categories: All Categories + all_accounts: All Accounts + all_tags: All Tags + apply: Apply Filters + clear: Clear Filters + sort: + label: Sort by + date_desc: Date (Newest) + amount_desc: Amount (High to Low) + amount_asc: Amount (Low to High) + export: + label: Export + csv: CSV + excel: Excel + pdf: PDF + google_sheets: Open in Google Sheets + table: + category: Category + amount: Amount + type: Type + expense: Expenses + income: Income + uncategorized: Uncategorized + transactions: transactions + percentage: "% of Total" + pagination: + showing: Showing %{count} transactions + previous: Previous + next: Next + google_sheets_instructions: + title_with_key: "✅ Copy URL for Google Sheets" + title_no_key: "⚠️ API Key Required" + ready: Your CSV URL (with API key) is ready. + steps: "To import into Google Sheets:\n1. Create a new Google Sheet\n2. In cell A1, enter the formula shown below\n3. Press Enter" + security_warning: "This URL includes your API key. Keep it secure!" + need_key: To import data into Google Sheets, you need an API key. + step1: "Go to Settings → API Keys" + step2: "Create a new API key with \"read\" permission" + step3: Copy the API key + step4: "Add it to this URL as: ?api_key=YOUR_KEY" + example: Example + then_use: Then use the full URL with =IMPORTDATA() in Google Sheets. + open_sheets: Open Google Sheets + go_to_api_keys: Go to API Keys + close: Got it diff --git a/config/routes.rb b/config/routes.rb index 1f8f2d0da..3cb7771de 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,6 +103,11 @@ Rails.application.routes.draw do delete :destroy_all, on: :collection end + resources :reports, only: %i[index] do + get :export_transactions, on: :collection + get :google_sheets_instructions, on: :collection + end + resources :budgets, only: %i[index show edit update], param: :month_year do get :picker, on: :collection diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb new file mode 100644 index 000000000..c2e7a8cb2 --- /dev/null +++ b/test/controllers/reports_controller_test.rb @@ -0,0 +1,196 @@ +require "test_helper" + +class ReportsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @family = @user.family + end + + test "index renders successfully" do + get reports_path + assert_response :ok + end + + test "index with monthly period" do + get reports_path(period_type: :monthly) + assert_response :ok + assert_select "h1", text: I18n.t("reports.index.title") + end + + test "index with quarterly period" do + get reports_path(period_type: :quarterly) + assert_response :ok + end + + test "index with ytd period" do + get reports_path(period_type: :ytd) + assert_response :ok + end + + test "index with custom period and date range" do + get reports_path( + period_type: :custom, + start_date: 1.month.ago.to_date.to_s, + end_date: Date.current.to_s + ) + assert_response :ok + end + + test "index with last 6 months period" do + get reports_path(period_type: :last_6_months) + assert_response :ok + end + + test "index shows empty state when no transactions" do + # Delete all transactions for the family by deleting from accounts + @family.accounts.each { |account| account.entries.destroy_all } + + get reports_path + assert_response :ok + assert_select "h3", text: I18n.t("reports.empty_state.title") + end + + test "index with budget performance for current month" do + # Create a budget for current month + budget = Budget.find_or_bootstrap(@family, start_date: Date.current.beginning_of_month) + category = @family.categories.expenses.first + + # Fail fast if test setup is incomplete + assert_not_nil category, "Test setup failed: no expense category found for family" + assert_not_nil budget, "Test setup failed: budget could not be created or found" + + # Find or create budget category to avoid duplicate errors + budget_category = budget.budget_categories.find_or_initialize_by(category: category) + budget_category.budgeted_spending = Money.new(50000, @family.currency) + budget_category.save! + + get reports_path(period_type: :monthly) + assert_response :ok + end + + test "index calculates summary metrics correctly" do + get reports_path(period_type: :monthly) + assert_response :ok + assert_select "h3", text: I18n.t("reports.summary.total_income") + assert_select "h3", text: I18n.t("reports.summary.total_expenses") + assert_select "h3", text: I18n.t("reports.summary.net_savings") + end + + test "index builds comparison data" do + get reports_path(period_type: :monthly) + assert_response :ok + assert_select "h2", text: I18n.t("reports.comparison.title") + assert_select "h3", text: I18n.t("reports.comparison.income") + assert_select "h3", text: I18n.t("reports.comparison.expenses") + end + + test "index builds trends data" do + get reports_path(period_type: :monthly) + assert_response :ok + assert_select "h2", text: I18n.t("reports.trends.title") + assert_select "th", text: I18n.t("reports.trends.month") + end + + test "index handles invalid date parameters gracefully" do + get reports_path( + period_type: :custom, + start_date: "invalid-date", + end_date: "also-invalid" + ) + assert_response :ok # Should not crash, uses defaults + end + + test "spending patterns returns data when expense transactions exist" do + # Create expense category + expense_category = @family.categories.create!( + name: "Test Groceries", + classification: "expense" + ) + + # Create account + account = @family.accounts.first + + # Create expense transaction on a weekday (Monday) + weekday_date = Date.current.beginning_of_month + 2.days + weekday_date = weekday_date.next_occurring(:monday) + + entry = account.entries.create!( + name: "Grocery shopping", + date: weekday_date, + amount: -50.00, + currency: "USD", + entryable: Transaction.new( + category: expense_category, + kind: "standard" + ) + ) + + # Create expense transaction on a weekend (Saturday) + weekend_date = weekday_date.next_occurring(:saturday) + + weekend_entry = account.entries.create!( + name: "Weekend shopping", + date: weekend_date, + amount: -75.00, + currency: "USD", + entryable: Transaction.new( + category: expense_category, + kind: "standard" + ) + ) + + get reports_path(period_type: :monthly) + assert_response :ok + + # Verify spending patterns shows data (not the "no data" message) + assert_select ".text-center.py-8.text-tertiary", { text: /No spending data/, count: 0 }, "Should not show 'No spending data' message when transactions exist" + end + + test "export transactions with API key authentication" do + # Use an active API key with read permissions + api_key = api_keys(:active_key) + + # Make sure the API key has the correct source + api_key.update!(source: "web") unless api_key.source == "web" + + get export_transactions_reports_path( + format: :csv, + period_type: :ytd, + start_date: Date.current.beginning_of_year, + end_date: Date.current, + api_key: api_key.plain_key + ) + + assert_response :ok + assert_equal "text/csv", @response.media_type + assert_match /Category/, @response.body + end + + test "export transactions with invalid API key" do + get export_transactions_reports_path( + format: :csv, + period_type: :ytd, + api_key: "invalid_key" + ) + + assert_response :unauthorized + assert_match /Invalid or expired API key/, @response.body + end + + test "export transactions without API key uses session auth" do + # Should use normal session-based authentication + # The setup already signs in @user = users(:family_admin) + assert_not_nil @user, "User should be set in test setup" + assert_not_nil @family, "Family should be set in test setup" + + get export_transactions_reports_path( + format: :csv, + period_type: :ytd, + start_date: Date.current.beginning_of_year, + end_date: Date.current + ) + + assert_response :ok, "Export should work with session auth. Response: #{@response.body}" + assert_equal "text/csv", @response.media_type + end +end