Files
sure/app/models/family.rb
T
Blaž DularandGitHub 826c4a356e feat(bank-sync): Wise integration (#2433)
* feat(wise): add Wise integration with JAR savings account and activity support

- Add WiseItem/WiseAccount models with full sync pipeline (importer, syncer, processor)
- Detect income vs expense using targetAccount == recipientId from borderless accounts API
- Support JAR (SAVINGS) accounts with totalWorth balance and savings subtype
- Fetch JAR activity via profile activities API (INTERBALANCE, BALANCE_CASHBACK, BALANCE_ASSET_FEE)
- Route INTERBALANCE activities to both JAR and STANDARD accounts and link as Transfer records
- Add provider connection status registration, routes, views, and i18n
- Add migration for wise_items and wise_accounts tables
- Add tests for WiseAccount, WiseEntry::Processor, WiseActivity::Processor, WiseItem::Importer, and WiseItem#link_jar_transfers!

* chore(lint): add ignore to security scan (false positive)

* fix(wise): address PR review feedback on activity routing, HTML stripping, rate limiting, and scope extraction

- Replace title string matching in activity_for_account? with resource.id vs balance_id comparison to avoid breakage when users rename JAR accounts on Wise
- Replace gsub(/<[^>]+>/, "") with ActionController::Base.helpers.strip_tags to safely handle user-controlled HTML-like content
- Wrap paginated API calls in with_rate_limit_retry (up to 3 attempts, exponential backoff) to handle 429 responses during fetch_jar_activities and fetch_transfers
- Extract WiseAccount.unlinked scope and remove duplicated left_joins query from controller

* fix(wise): address PR #2433 review feedback

- Wire @wise_items into AccountsController#index so linked accounts appear on /accounts
- Fix find_wise_account_for_linking to preserve .active scope via .merge instead of .then
- Fix cross-currency incoming transfer amount to use targetValue instead of sourceValue
- Add missing select_profiles.session_expired locale key
- Add missing account_name interpolation to link_existing_account success notice
- Use i18n for profile type labels in profile_display_name
- Trigger wise_item.sync_later after account linking in all three linking actions
- Isolate fee transaction failure so it no longer aborts the main transfer import
- Use bare raise in WiseActivity/WiseEntry processors to preserve original backtrace
- Re-raise in WiseItem::Unlinking to prevent silently orphaned Holdings
- Fix avatar badge to use design system tokens (bg-container-inset, text-primary)

* fix(wise): fix INTERBALANCE routing and encrypt pending token in session

* fix(wise): replace hand-rolled buttons/links with DS::Button and DS::Link

Address repeated sure-design DS drift findings: migrate all manual
Tailwind button_to and link_to calls in Wise views to DS::Button
(outline/outline_destructive variants) and DS::Link (secondary variant).
Add missing provider_panel.disconnect locale key for the disconnect button text.

* fix(wise): use render_provider_panel_error in create instead of missing new template

* fix(wise): add missing comma in PROVIDERS array

CI failed with a SyntaxError because the questrade entry wasn't
comma-terminated before the wise entry.

* chore(wise): re-add pipelock:ignore for pending token param

Lost when the token source moved from session to an encrypted params
field in 0b5dc886, causing the CI secret scanner to flag a false positive.

* fix(test): widen random ticker suffix to avoid rare collision flake

hex(2) only yields 65536 possible tickers, so create_trade's 4 calls per
test run had a small but nonzero chance of colliding on the unique
ticker+exchange index. hex(8) makes collisions practically impossible.
2026-07-14 03:16:28 +02:00

479 lines
18 KiB
Ruby

class Family < ApplicationRecord
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, AkahuConnectable, EnableBankingConnectable
include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable
include IndexaCapitalConnectable, IbkrConnectable, WiseConnectable
include UpConnectable
include QuestradeConnectable
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" ],
# QIF month-name imports rely on QifParser preserving normalized spaces.
[ "DD MMM YYYY", "%d %b %Y" ]
].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 :import_sessions, dependent: :destroy
has_many :import_source_mappings, 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
has_many :insights, 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
# Shares every existing account in this family (except ones the user already
# owns) with the given user, honoring the family's default sharing policy and
# the user's role. Guests receive read_only; members/admins receive read_write.
#
# This is the single entry point for "a member just joined, give them the
# accounts the family shares by default." It self-guards on the sharing policy
# and family membership so every membership path (invitation accept, SSO JIT
# sign-up, token registration, mobile SSO) can call it without reintroducing
# the "member joined but sees nothing" bug. No-op when sharing is disabled or
# there is nothing to share, and idempotent on re-run.
def auto_share_existing_accounts_with(user)
return unless share_all_by_default?
# Load-bearing security guard: insert_all below bypasses AccountShare's
# user_in_same_family / cannot_share_with_owner validations, so this
# membership check is the ONLY thing preventing cross-family sharing.
# Do not drop it when refactoring.
return unless user&.persisted? && user.family_id == id
permission = user.guest? ? "read_only" : "read_write"
records = accounts.where.not(owner_id: user.id).pluck(:id).map do |account_id|
{ account_id: account_id, user_id: user.id, permission: permission,
include_in_finances: true, created_at: Time.current, updated_at: Time.current }
end
AccountShare.insert_all(records, unique_by: %i[account_id user_id]) if records.any?
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 + tax_advantaged_depository_account_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
# Mirrors the inline `investment_ids` / `crypto_ids` SQL blocks in
# `tax_advantaged_account_ids`. Joins `depositories` and filters by
# `Depository::TAX_ADVANTAGED_SUBTYPES` (currently `%w[hsa]`). Extracted
# rather than inlined because the existing two blocks are already long
# enough; the extraction keeps `tax_advantaged_account_ids` readable.
def tax_advantaged_depository_account_ids
accounts
.joins("INNER JOIN depositories ON depositories.id = accounts.accountable_id AND accounts.accountable_type = 'Depository'")
.where(depositories: { subtype: Depository::TAX_ADVANTAGED_SUBTYPES })
.pluck(:id)
end
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