mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 00:39:01 +00:00
Remote branch added a beta_gated_nav_item helper + 'Gating the main nav' docs section. Main concurrently renamed the beta-features gate to preview-features (concern, predicate, JSONB key, locale flash). Rename the new helper / partial local / pill marker to match preview naming and port the nav-gating docs into gating-a-preview-feature.md so the improvement survives the rename. Resolved conflicts: - db/schema.rb: take the later schema version (2026_05_19_100000). - docs/llm-guides/gating-a-beta-feature.md: accept main's deletion; port the 'Gating the main nav' section into the preview guide. Renames carried through to keep the gate wired end-to-end: - application_helper.rb: beta_gated_nav_item → preview_gated_nav_item; beta_features_enabled? → preview_features_enabled?; beta: → preview:. - _nav_item.html.erb: beta: local → preview: local; shared.beta i18n key → shared.preview. - application.html.erb: caller renamed to preview_gated_nav_item. - goals/index.html.erb: pill label uses shared.preview. - shared/en.yml: 'beta: Beta' → 'preview: Preview'. - goals_controller, goal_pledges_controller: require_beta_features! → require_preview_features!. - goals_controller_test, goal_pledges_controller_test: flip the preference key, flash matcher, and test names to 'preview'.
433 lines
15 KiB
Ruby
433 lines
15 KiB
Ruby
class Family < ApplicationRecord
|
|
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
|
|
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
|
|
include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable
|
|
include IndexaCapitalConnectable, IbkrConnectable
|
|
|
|
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
|
|
|
|
|
|
MONIKERS = [ "Family", "Group" ].freeze
|
|
ASSISTANT_TYPES = %w[builtin external].freeze
|
|
SHARING_DEFAULTS = %w[shared private].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 :account_statements, 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 :goals, dependent: :destroy
|
|
|
|
# Net inflow into every depository account linked to any primary-currency
|
|
# goal, over the given window. Transfers between linked accounts net to zero
|
|
# because both sides of an internal move land inside the same account set;
|
|
# external transfers (e.g. checking → linked savings) net positive.
|
|
#
|
|
# Scoped to the family's primary currency: mixed-currency families would
|
|
# otherwise sum raw EUR + USD numbers and surface the result as primary.
|
|
# Foreign-currency goals are excluded from this KPI until FX conversion is
|
|
# added.
|
|
#
|
|
# Entry amount convention in Sure: inflow is negative, so flip the sign.
|
|
# Result is allowed to go negative (net outflow last 30d) so the headline
|
|
# reflects reality; the controller decides how to render.
|
|
def savings_inflow_velocity(range: 30.days.ago.to_date..Date.current, account_ids: nil)
|
|
ids = account_ids || goal_linked_account_ids
|
|
return 0 if ids.empty?
|
|
|
|
net = Entry
|
|
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
|
.where(account_id: ids, date: range)
|
|
.where(excluded: false)
|
|
.merge(Transaction.excluding_pending)
|
|
.sum(:amount)
|
|
|
|
-net.to_d
|
|
end
|
|
|
|
# Two velocity windows in a single pair of sums that share one
|
|
# account-id lookup. The kpi tile on the index reads both the current
|
|
# 30d window and the prior 30d window; without this helper the
|
|
# `accounts.joins(:goal_accounts)…pluck(:id)` query runs twice per
|
|
# request even though the answer is identical.
|
|
def savings_inflow_windows(window_days: 30, now: Date.current)
|
|
ids = goal_linked_account_ids
|
|
{
|
|
current: savings_inflow_velocity(range: (now - window_days)..now, account_ids: ids),
|
|
prior: savings_inflow_velocity(range: (now - 2 * window_days)..(now - window_days - 1), account_ids: ids)
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
# Depository accounts linked to this family's goals, restricted to the
|
|
# primary currency until FX is added. Memoized for the lifetime of the
|
|
# Family instance so a single request that reads velocity twice (the
|
|
# KPI tile uses current vs prior 30d) doesn't re-run the join+pluck.
|
|
# `accounts` is already scoped by the has_many association, and the
|
|
# join restricts to this family's goals — so cross-family bleed
|
|
# remains impossible.
|
|
def goal_linked_account_ids
|
|
@goal_linked_account_ids ||= accounts
|
|
.joins(:goal_accounts)
|
|
.where(goal_accounts: { goal_id: goals.select(:id) })
|
|
.where(currency: primary_currency_code)
|
|
.distinct
|
|
.pluck(:id)
|
|
end
|
|
|
|
public
|
|
|
|
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 }
|
|
validates :moniker, inclusion: { in: MONIKERS }
|
|
validates :assistant_type, inclusion: { in: ASSISTANT_TYPES }
|
|
validates :default_account_sharing, inclusion: { in: SHARING_DEFAULTS }
|
|
|
|
before_validation :normalize_enabled_currencies!
|
|
|
|
def primary_currency_code
|
|
normalize_currency_code(currency) || "USD"
|
|
end
|
|
|
|
def custom_enabled_currencies?
|
|
enabled_currencies.present?
|
|
end
|
|
|
|
def enabled_currency_codes(extra: [])
|
|
selected_codes = if custom_enabled_currencies?
|
|
[ primary_currency_code, *Array(enabled_currencies) ]
|
|
else
|
|
Money::Currency.as_options.map(&:iso_code)
|
|
end
|
|
|
|
normalize_currency_codes([ *selected_codes, *Array(extra) ])
|
|
end
|
|
|
|
def enabled_currency_objects(extra: [])
|
|
enabled_currency_codes(extra:).map { |code| Money::Currency.new(code) }
|
|
end
|
|
|
|
def secondary_enabled_currency_objects(extra: [])
|
|
enabled_currency_objects(extra:).reject { |currency| currency.iso_code == primary_currency_code }
|
|
end
|
|
|
|
|
|
def moniker_label
|
|
case moniker.presence
|
|
when nil, "Family"
|
|
I18n.t("shared.family_moniker.singular", default: "Family")
|
|
when "Group"
|
|
I18n.t("shared.family_moniker.group_singular", default: "Group")
|
|
else
|
|
moniker
|
|
end
|
|
end
|
|
|
|
def moniker_label_plural
|
|
case moniker.presence
|
|
when nil, "Family"
|
|
I18n.t("shared.family_moniker.plural", default: "Families")
|
|
when "Group"
|
|
I18n.t("shared.family_moniker.group_plural", default: "Groups")
|
|
else
|
|
"#{moniker}s"
|
|
end
|
|
end
|
|
|
|
def share_all_by_default?
|
|
default_account_sharing == "shared"
|
|
end
|
|
|
|
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 assigned_merchants_for(user)
|
|
merchant_ids = Transaction.joins(:entry)
|
|
.where(entries: { account_id: accounts.accessible_by(user).select(:id) })
|
|
.where.not(merchant_id: nil)
|
|
.distinct
|
|
.pluck(:merchant_id)
|
|
Merchant.where(id: merchant_ids)
|
|
end
|
|
|
|
def available_merchants_for(user)
|
|
assigned_ids = Transaction.joins(:entry)
|
|
.where(entries: { account_id: accounts.accessible_by(user).select(:id) })
|
|
.where.not(merchant_id: nil)
|
|
.distinct
|
|
.pluck(:merchant_id)
|
|
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(user: Current.user)
|
|
BalanceSheet.new(self, user: user)
|
|
end
|
|
|
|
def income_statement(user: Current.user)
|
|
IncomeStatement.new(self, user: user)
|
|
end
|
|
|
|
# Returns the Investment Contributions category for this family, creating it if it doesn't exist.
|
|
# This is used for auto-categorizing transfers to investment accounts.
|
|
# Always uses the family's locale to ensure consistent category naming across all users.
|
|
def investment_contributions_category
|
|
# Find ALL legacy categories (created under old request-locale behavior)
|
|
legacy = categories.where(name: Category.all_investment_contributions_names).order(:created_at).to_a
|
|
|
|
if legacy.any?
|
|
keeper = legacy.first
|
|
duplicates = legacy[1..]
|
|
|
|
# Reassign transactions and subcategories from duplicates to keeper
|
|
if duplicates.any?
|
|
duplicate_ids = duplicates.map(&:id)
|
|
categories.where(parent_id: duplicate_ids).update_all(parent_id: keeper.id)
|
|
Transaction.where(category_id: duplicate_ids).update_all(category_id: keeper.id)
|
|
BudgetCategory.where(category_id: duplicate_ids).update_all(category_id: keeper.id)
|
|
categories.where(id: duplicate_ids).delete_all
|
|
end
|
|
|
|
# Rename keeper to family's locale name if needed
|
|
I18n.with_locale(locale) do
|
|
correct_name = Category.investment_contributions_name
|
|
keeper.update!(name: correct_name) unless keeper.name == correct_name
|
|
end
|
|
return keeper
|
|
end
|
|
|
|
# Create new category using family's locale
|
|
I18n.with_locale(locale) do
|
|
categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat|
|
|
cat.color = "#0d9488"
|
|
cat.lucide_icon = "trending-up"
|
|
end
|
|
end
|
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
# Handle race condition: another process created the category
|
|
I18n.with_locale(locale) do
|
|
categories.find_by!(name: Category.investment_contributions_name)
|
|
end
|
|
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(user: Current.user)
|
|
InvestmentStatement.new(self, user: user)
|
|
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
|
|
|
|
private
|
|
def normalize_enabled_currencies!
|
|
if enabled_currencies.blank?
|
|
self.enabled_currencies = nil
|
|
return
|
|
end
|
|
|
|
normalized_codes = normalize_currency_codes([ primary_currency_code, *Array(enabled_currencies) ])
|
|
all_codes = Money::Currency.as_options.map(&:iso_code)
|
|
all_selected = normalized_codes.size == all_codes.size && (normalized_codes - all_codes).empty?
|
|
self.enabled_currencies = all_selected ? nil : normalized_codes
|
|
end
|
|
|
|
def normalize_currency_codes(values)
|
|
Array(values).filter_map { |value| normalize_currency_code(value) }.uniq
|
|
end
|
|
|
|
def normalize_currency_code(value)
|
|
return if value.blank?
|
|
|
|
Money::Currency.new(value).iso_code
|
|
rescue Money::Currency::UnknownCurrencyError, ArgumentError
|
|
nil
|
|
end
|
|
end
|