mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
* Highlight current month in trends insights table Refactored the logic to apply special styling and label to the row representing the current month, using a date comparison instead of relying on the last index. This ensures the current month is always highlighted, regardless of its position in the data. * Highlight current month in trends insights Added an is_current_month flag to trends data in the controller and updated the view to use this flag for highlighting the current month. This improves clarity and avoids redundant date comparisons in the view.
872 lines
30 KiB
Ruby
872 lines
30 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
|
|
@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: true)
|
|
|
|
# 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
|
|
|
|
# Spending patterns (weekday vs weekend)
|
|
@spending_patterns = build_spending_patterns
|
|
|
|
# Transactions breakdown
|
|
@transactions = build_transactions_breakdown
|
|
|
|
# Build reports sections for collapsible/reorderable UI
|
|
@reports_sections = build_reports_sections
|
|
|
|
@breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ]
|
|
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
|
|
|
|
private
|
|
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: "trends_insights",
|
|
title: "reports.trends.title",
|
|
partial: "reports/trends_insights",
|
|
locals: { trends_data: @trends_data, spending_patterns: @spending_patterns },
|
|
visible: Current.family.transactions.any?,
|
|
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: Current.family.transactions.any?,
|
|
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)
|
|
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_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
|
|
# 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: [ "funds_movement", "one_time", "cc_payment" ])
|
|
.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 (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
|
|
transactions = transactions.where(category_id: all_category_ids)
|
|
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
|
|
# 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: [ "funds_movement", "one_time", "cc_payment" ])
|
|
.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: [ "funds_movement", "one_time", "cc_payment" ])
|
|
.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
|