Files
sure/app/models/family.rb
MkDev11 68efe71cdb feat: Customizable Budget Month Start Day (#810)
* Add customizable budget month start day (#253)

Allow users to set a custom month-to-date start date (1st-28th) for
budgeting and MTD calculations. Useful for users who want budget
periods aligned with their pay schedule (e.g., 25th to 24th).

Changes:
- Add month_start_day column to families table (default: 1)
- Add database check constraint for valid range (1-28)
- Add Family#uses_custom_month_start?, custom_month_start_for,
  custom_month_end_for, current_custom_month_period helper methods
- Add Period.current_month_for(family), last_month_for(family) methods
- Update Budget model for custom month boundaries in find_or_bootstrap,
  param_to_date, budget_date_valid?, current?, and name methods
- Add month_start_day setting to Settings > Preferences UI
- Add warning message when custom month start day is configured
- Add comprehensive tests with travel_to for date robustness

Fixes #253

* Add /api/v1/user endpoint for Flutter mobile app and PWA

Expose user preferences including month_start_day via API endpoint
following existing pattern for default_period. This allows Flutter
mobile app and PWA to read/update user preferences through a
consistent API contract.

Endpoints:
- GET /api/v1/user - Read user preferences including family settings
- PATCH /api/v1/user - Update user preferences

Response includes: id, email, first_name, last_name, default_period,
locale, and family settings (currency, timezone, date_format, country,
month_start_day).

* Update Periodable to use family-aware MTD periods

When users select 'current_month' or 'last_month' period filters on
dashboard/reports, now respects the family's custom month_start_day
setting instead of using static calendar month boundaries.

This ensures MTD filter on dashboard is consistent with how budgets
calculate their periods when custom month start day is configured.

* Fix param_to_date to correctly map budget params to custom periods

When a family uses a custom start day, the previous implementation
called custom_month_start_for on the 1st of the month, which incorrectly
shifted dates before the start day to the previous month.

Now we directly construct the date using family.month_start_day, so
'jan-2026' with month_start_day=25 correctly returns Jan 25, 2026
instead of Dec 25, 2025.

* Fix param_to_date and use Current pattern in API controller

- Fix param_to_date to directly construct date with family.month_start_day
  instead of using custom_month_start_for which incorrectly shifted dates
- Replace current_user with Current.user/Current.family in API controller
  to follow project convention used in other API v1 controllers

* Add i18n for budget name method

Use I18n.t for localizable budget period names to follow
project conventions for user-facing strings.

* Remove unused budget_end variable in budget_date_valid?

* Use Date.current for timezone consistency in Budget#current?

* Address PR review feedback

- Remove API users endpoint (mobile won't use yet)
- Remove user route from config/routes.rb
- Remove ai_summary/document_type schema bleed from pdf-import-ai branch

* Pass family to param_to_date for custom month logic

* Run migration to add month_start_day column to schema

* Schema regressions

---------

Co-authored-by: mkdev11 <jaysmth689+github@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-02-04 23:58:09 +01:00

221 lines
7.7 KiB
Ruby

class Family < ApplicationRecord
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "DD.MM.YYYY", "%d.%m.%Y" ],
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ],
[ "YYYYMMDD", "%Y%m%d" ]
].freeze
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :invitations, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :family_exports, dependent: :destroy
has_many :entries, through: :accounts
has_many :transactions, through: :accounts
has_many :rules, dependent: :destroy
has_many :trades, through: :accounts
has_many :holdings, through: :accounts
has_many :tags, dependent: :destroy
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy, class_name: "FamilyMerchant"
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
has_many :llm_usages, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
validates :month_start_day, inclusion: { in: 1..28 }
def uses_custom_month_start?
month_start_day != 1
end
def custom_month_start_for(date)
if date.day >= month_start_day
Date.new(date.year, date.month, month_start_day)
else
previous_month = date - 1.month
Date.new(previous_month.year, previous_month.month, month_start_day)
end
end
def custom_month_end_for(date)
start_date = custom_month_start_for(date)
next_month_start = start_date + 1.month
next_month_start - 1.day
end
def current_custom_month_period
start_date = custom_month_start_for(Date.current)
end_date = custom_month_end_for(Date.current)
Period.custom(start_date: start_date, end_date: end_date)
end
def assigned_merchants
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
Merchant.where(id: merchant_ids)
end
def available_merchants
assigned_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: self)
.recently_unlinked
.pluck(:merchant_id)
family_merchant_ids = merchants.pluck(:id)
Merchant.where(id: (assigned_ids + recently_unlinked_ids + family_merchant_ids).uniq)
end
def auto_categorize_transactions_later(transactions, rule_run_id: nil)
AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end
def auto_categorize_transactions(transaction_ids)
AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize
end
def auto_detect_transaction_merchants_later(transactions, rule_run_id: nil)
AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end
def auto_detect_transaction_merchants(transaction_ids)
AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect
end
def balance_sheet
@balance_sheet ||= BalanceSheet.new(self)
end
def income_statement
@income_statement ||= IncomeStatement.new(self)
end
# Returns the Investment Contributions category for this family, or nil if not found.
# This is a bootstrapped category used for auto-categorizing transfers to investment accounts.
def investment_contributions_category
categories.find_by(name: Category.investment_contributions_name)
end
# Returns account IDs for tax-advantaged accounts (401k, IRA, HSA, etc.)
# Used to exclude these accounts from budget/cashflow calculations.
# Tax-advantaged accounts are retirement savings, not daily expenses.
def tax_advantaged_account_ids
@tax_advantaged_account_ids ||= begin
# Investment accounts derive tax_treatment from subtype
tax_advantaged_subtypes = Investment::SUBTYPES.select do |_, meta|
meta[:tax_treatment].in?(%i[tax_deferred tax_exempt tax_advantaged])
end.keys
investment_ids = accounts
.joins("INNER JOIN investments ON investments.id = accounts.accountable_id AND accounts.accountable_type = 'Investment'")
.where(investments: { subtype: tax_advantaged_subtypes })
.pluck(:id)
# Crypto accounts have an explicit tax_treatment column
crypto_ids = accounts
.joins("INNER JOIN cryptos ON cryptos.id = accounts.accountable_id AND accounts.accountable_type = 'Crypto'")
.where(cryptos: { tax_treatment: %w[tax_deferred tax_exempt] })
.pluck(:id)
investment_ids + crypto_ids
end
end
def investment_statement
@investment_statement ||= InvestmentStatement.new(self)
end
def eu?
country != "US" && country != "CA"
end
def requires_securities_data_provider?
# If family has any trades, they need a provider for historical prices
trades.any?
end
def requires_exchange_rates_data_provider?
# If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates
return true if accounts.where.not(currency: self.currency).any?
# If family has any entries in different currencies, they need a provider for historical exchange rates
uniq_currencies = entries.pluck(:currency).uniq
return true if uniq_currencies.count > 1
return true if uniq_currencies.count > 0 && uniq_currencies.first != self.currency
false
end
def missing_data_provider?
(requires_securities_data_provider? && Security.provider.nil?) ||
(requires_exchange_rates_data_provider? && ExchangeRate.provider.nil?)
end
# Returns securities with plan restrictions for a specific provider
# @param provider [String] The provider name (e.g., "TwelveData")
# @return [Array<Hash>] Array of hashes with ticker, name, required_plan, provider
def securities_with_plan_restrictions(provider:)
security_ids = trades.joins(:security).pluck("securities.id").uniq
return [] if security_ids.empty?
restrictions = Security.plan_restrictions_for(security_ids, provider: provider)
return [] if restrictions.empty?
Security.where(id: restrictions.keys).map do |security|
restriction = restrictions[security.id]
{
ticker: security.ticker,
name: security.name,
required_plan: restriction[:required_plan],
provider: restriction[:provider]
}
end
end
def oldest_entry_date
entries.order(:date).first&.date || Date.current
end
# Used for invalidating family / balance sheet related aggregation queries
def build_cache_key(key, invalidate_on_data_updates: false)
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
# By including it in the cache key, we can expire caches every time family account data changes.
data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil
[
id,
key,
data_invalidation_key,
accounts.maximum(:updated_at)
].compact.join("_")
end
# Used for invalidating entry related aggregation queries
def entries_cache_version
@entries_cache_version ||= begin
ts = entries.maximum(:updated_at)
ts.present? ? ts.to_i : 0
end
end
def self_hoster?
Rails.application.config.app_mode.self_hosted?
end
end