Skip to content
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def rule_prompt_settings_params
end

def user_params
family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ]
family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :fiscal_year_start_month, :fiscal_year_start_day, :id ]
if Current.user.admin?
family_attrs.push(:moniker, :default_account_sharing)
family_attrs << { enabled_currencies: [] }
Expand Down
12 changes: 12 additions & 0 deletions app/models/family.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class Family < ApplicationRecord
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
validates :month_start_day, inclusion: { in: 1..28 }
validates :moniker, inclusion: { in: MONIKERS }
validates :fiscal_year_start_month, inclusion: { in: 1..12 }
validates :fiscal_year_start_day, inclusion: { in: 1..28 }
validates :assistant_type, inclusion: { in: ASSISTANT_TYPES }
validates :default_account_sharing, inclusion: { in: SHARING_DEFAULTS }

Expand Down Expand Up @@ -118,6 +120,16 @@ def current_custom_month_period
Period.custom(start_date: start_date, end_date: end_date)
end

def uses_fiscal_year?
fiscal_year_start_month != 1 || fiscal_year_start_day != 1
end

def current_fiscal_year_start
today = Date.current
fy_start_this_year = Date.new(today.year, fiscal_year_start_month, fiscal_year_start_day)
fy_start_this_year <= today ? fy_start_this_year : fy_start_this_year - 1.year
end

def assigned_merchants
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
Merchant.where(id: merchant_ids)
Expand Down
18 changes: 17 additions & 1 deletion app/models/period.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ class InvalidKeyError < StandardError; end
label: "Current Year",
comparison_label: "vs. start of year"
},
"fiscal_year_to_date" => {
date_range: -> {
family = Current.family
# When family is nil the fallback silently produces a YTD-equivalent range
# (beginning_of_year..today) identical to current_year; callers such as
# Periodable should guard against this with uses_fiscal_year?.
start_date = family ? family.current_fiscal_year_start : Date.current.beginning_of_year
[ start_date, Date.current ]
},
label_short: "FYTD",
label: "Financial Year",
comparison_label: "vs. start of financial year"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"last_365_days" => {
date_range: -> { [ 365.days.ago.to_date, Date.current ] },
label_short: "365D",
Expand Down Expand Up @@ -114,7 +127,10 @@ def custom(start_date:, end_date:)
end

def all
PERIODS.map { |key, period| from_key(key) }
PERIODS.filter_map do |key, _period|
next if key == "fiscal_year_to_date" && !Current.family&.uses_fiscal_year?
from_key(key)
end
end

def as_options
Expand Down
11 changes: 11 additions & 0 deletions app/views/settings/preferences/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@
<%= t(".month_start_day_warning") %>
</div>
<% end %>
<% if @user.family.uses_fiscal_year? %>
<%= family_form.select :fiscal_year_start_month,
Date::MONTHNAMES.compact.each_with_index.map { |name, i| [name, i + 1] },
{ label: t(".fiscal_year_start_month") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :fiscal_year_start_day,
(1..28).map { |day| [day.ordinalize, day] },
{ label: t(".fiscal_year_start_day") },
{ data: { auto_submit_form_target: "auto" } } %>
<p class="text-xs text-secondary pl-2"><%= t(".fiscal_year_start_hint") %></p>
<% end %>
<p class="text-xs italic pl-2 text-secondary">Please note, we are still working on translations for various languages.</p>
<% end %>
<% end %>
Expand Down
3 changes: 3 additions & 0 deletions config/locales/views/settings/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ en:
month_start_day: Budget month starts on
month_start_day_hint: Set when your budget month starts (e.g., payday)
month_start_day_warning: Your budgets and MTD calculations will use this custom start day instead of the 1st of each month.
fiscal_year_start_month: Financial year starts in
fiscal_year_start_day: Financial year starts on
fiscal_year_start_hint: Set the start of your financial year for FYTD calculations (e.g., March 1 for a March - February fiscal year)
currencies_title: "%{moniker} Currencies"
currencies_subtitle: Choose which currencies appear in money fields for your %{moniker}
base_currency_label: Base currency
Expand Down
14 changes: 14 additions & 0 deletions db/migrate/20260421000000_add_fiscal_year_start_to_families.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class AddFiscalYearStartToFamilies < ActiveRecord::Migration[7.2]
def change
add_column :families, :fiscal_year_start_month, :integer, default: 1, null: false
add_column :families, :fiscal_year_start_day, :integer, default: 1, null: false

add_check_constraint :families,
"fiscal_year_start_month >= 1 AND fiscal_year_start_month <= 12",
name: "fiscal_year_start_month_range"

add_check_constraint :families,
"fiscal_year_start_day >= 1 AND fiscal_year_start_day <= 28",
name: "fiscal_year_start_day_range"
end
end
6 changes: 5 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions test/models/family/fiscal_year_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
require "test_helper"

class Family::FiscalYearTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
end

test "fiscal_year_start_month and fiscal_year_start_day default to 1" do
assert_equal 1, @family.fiscal_year_start_month
assert_equal 1, @family.fiscal_year_start_day
end

test "validates fiscal_year_start_month is between 1 and 12" do
@family.fiscal_year_start_month = 0
assert_not @family.valid?

@family.fiscal_year_start_month = 13
assert_not @family.valid?

@family.fiscal_year_start_month = 3
assert @family.valid?
end

test "validates fiscal_year_start_day is between 1 and 28" do
@family.fiscal_year_start_day = 0
assert_not @family.valid?

@family.fiscal_year_start_day = 29
assert_not @family.valid?

@family.fiscal_year_start_day = 1
assert @family.valid?
end

test "uses_fiscal_year? returns false when start is January 1" do
@family.fiscal_year_start_month = 1
@family.fiscal_year_start_day = 1
assert_not @family.uses_fiscal_year?
end

test "uses_fiscal_year? returns true when start month is not January" do
@family.fiscal_year_start_month = 3
assert @family.uses_fiscal_year?
end

test "uses_fiscal_year? returns true when start day is not 1" do
@family.fiscal_year_start_day = 15
assert @family.uses_fiscal_year?
end

test "current_fiscal_year_start returns this year's start when today is on or after it" do
@family.fiscal_year_start_month = 3
@family.fiscal_year_start_day = 1

travel_to Date.new(2026, 4, 21) do
assert_equal Date.new(2026, 3, 1), @family.current_fiscal_year_start
end
end

test "current_fiscal_year_start returns this year's start on the exact boundary date" do
@family.fiscal_year_start_month = 3
@family.fiscal_year_start_day = 1

travel_to Date.new(2026, 3, 1) do
assert_equal Date.new(2026, 3, 1), @family.current_fiscal_year_start
end
end

test "current_fiscal_year_start rolls back one year when today is before the start" do
@family.fiscal_year_start_month = 7
@family.fiscal_year_start_day = 1

travel_to Date.new(2026, 4, 21) do
assert_equal Date.new(2025, 7, 1), @family.current_fiscal_year_start
end
end

test "current_fiscal_year_start returns Jan 1 of current year for default settings" do
travel_to Date.new(2026, 4, 21) do
assert_equal Date.new(2026, 1, 1), @family.current_fiscal_year_start
end
end
end
32 changes: 32 additions & 0 deletions test/models/period_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,36 @@ class PeriodTest < ActiveSupport::TestCase
assert_equal 5.years.ago.to_date, period.start_date
assert_equal Date.current, period.end_date
end

test "fiscal_year_to_date has correct labels" do
mock_family = mock("family")
mock_family.expects(:current_fiscal_year_start).returns(Date.current.beginning_of_year)
Current.expects(:family).at_least_once.returns(mock_family)

period = Period.from_key("fiscal_year_to_date")
assert_equal "Financial Year", period.label
assert_equal "FYTD", period.label_short
end

test "fiscal_year_to_date uses family's fiscal year start" do
mock_family = mock("family")
mock_family.expects(:current_fiscal_year_start).returns(Date.new(2026, 3, 1))
Current.expects(:family).at_least_once.returns(mock_family)

travel_to Date.new(2026, 4, 21) do
period = Period.from_key("fiscal_year_to_date")
assert_equal Date.new(2026, 3, 1), period.start_date
assert_equal Date.current, period.end_date
end
end

test "fiscal_year_to_date falls back to beginning of year when no family" do
Current.expects(:family).returns(nil)

travel_to Date.new(2026, 4, 21) do
period = Period.from_key("fiscal_year_to_date")
assert_equal Date.new(2026, 1, 1), period.start_date
assert_equal Date.current, period.end_date
end
end
end
Loading