mirror of
https://github.com/we-promise/sure.git
synced 2026-08-12 11:10:20 +00:00
* feat(statements): add account statement vault Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping. * fix(statements): return deleted account statements to inbox Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage. * fix(statements): harden vault upload review flows Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases. * fix(statements): harden vault upload and access controls * fix(statements): address vault hardening review * fix(statements): address vault review feedback Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows. Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months. * fix(statements): harden vault review follow-ups Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata. Hide statement management controls from read-only viewers while keeping server-side authorization unchanged. * fix(statements): repair settings system coverage Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment. * fix(statements): move vault beside accounts Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard. * fix(statements): address vault review cleanup Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups. * fix(statements): address vault cleanup review * fix(statements): deduplicate vault style helpers * fix(statements): close vault review follow-ups * fix(statements): refresh schema after upstream rebase * fix(statements): process vault uploads sequentially * fix(statements): close vault review follow-ups * fix(statements): scope vault index to accessible accounts * fix(statements): harden statement vault readiness Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements. Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally. * fix(statements): close vault review follow-ups Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks. Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit. * fix(statements): address vault scan follow-ups Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints. Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed. * fix(statements): defer vault tab loading --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
357 lines
12 KiB
Ruby
357 lines
12 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 :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
|
|
moniker.presence || "Family"
|
|
end
|
|
|
|
def moniker_label_plural
|
|
moniker_label == "Group" ? "Groups" : "Families"
|
|
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
|