diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1ee9ee95b..b1605f9dd 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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: [] } diff --git a/app/models/family.rb b/app/models/family.rb index 3a3547b58..6c4cc420e 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 } @@ -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) diff --git a/app/models/period.rb b/app/models/period.rb index 2f4e8afdf..cad29b64d 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -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" + }, "last_365_days" => { date_range: -> { [ 365.days.ago.to_date, Date.current ] }, label_short: "365D", @@ -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 diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 2e8fa01b7..5ceff4ceb 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -33,6 +33,17 @@ <%= t(".month_start_day_warning") %> <% 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" } } %> +

<%= t(".fiscal_year_start_hint") %>

+ <% end %>

Please note, we are still working on translations for various languages.

<% end %> <% end %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 79e7c69d3..61d521519 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -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 diff --git a/db/migrate/20260421000000_add_fiscal_year_start_to_families.rb b/db/migrate/20260421000000_add_fiscal_year_start_to_families.rb new file mode 100644 index 000000000..1153f2f7b --- /dev/null +++ b/db/migrate/20260421000000_add_fiscal_year_start_to_families.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 1c7ecd693..7e5d8fad0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_04_21_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -595,7 +595,11 @@ t.string "assistant_type", default: "builtin", null: false t.string "default_account_sharing", default: "shared", null: false t.string "enabled_currencies", array: true + t.integer "fiscal_year_start_month", default: 1, null: false + t.integer "fiscal_year_start_day", default: 1, null: false t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" + t.check_constraint "fiscal_year_start_day >= 1 AND fiscal_year_start_day <= 28", name: "fiscal_year_start_day_range" + t.check_constraint "fiscal_year_start_month >= 1 AND fiscal_year_start_month <= 12", name: "fiscal_year_start_month_range" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end diff --git a/test/models/family/fiscal_year_test.rb b/test/models/family/fiscal_year_test.rb new file mode 100644 index 000000000..e401b12b7 --- /dev/null +++ b/test/models/family/fiscal_year_test.rb @@ -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 diff --git a/test/models/period_test.rb b/test/models/period_test.rb index fa1221eb9..1e7e4797d 100644 --- a/test/models/period_test.rb +++ b/test/models/period_test.rb @@ -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