Providers sharing (#1273)

* third party provider scoping

* Simplify logic and allow only admins to mange providers

* Broadcast fixes

* FIX tests and build

* Fixes

* Reviews

* Scope merchants

* DRY fixes
This commit is contained in:
soky srm
2026-03-25 17:47:04 +01:00
committed by GitHub
parent 1627cf197b
commit 9410e5b38d
74 changed files with 588 additions and 583 deletions

View File

@@ -11,15 +11,15 @@ class AccountsController < ApplicationController
.listable_manual
.where(id: @accessible_account_ids)
.order(:name)
@plaid_items = family.plaid_items.ordered.includes(:syncs, :plaid_accounts)
@simplefin_items = family.simplefin_items.ordered.includes(:syncs)
@lunchflow_items = family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts)
@enable_banking_items = family.enable_banking_items.ordered.includes(:syncs)
@coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)
@mercury_items = family.mercury_items.ordered.includes(:syncs, :mercury_accounts)
@coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)
@snaptrade_items = family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts)
@indexa_capital_items = family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts)
@plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts))
@simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs))
@lunchflow_items = visible_provider_items(family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts))
@enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs))
@coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs))
@mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts))
@coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs))
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
# Build sync stats maps for all providers
build_sync_stats_maps
@@ -220,6 +220,13 @@ class AccountsController < ApplicationController
end
end
def visible_provider_items(items)
items.select do |item|
Current.user.admin? ||
(item.respond_to?(:accounts) && (item.accounts.map(&:id) & @accessible_account_ids).any?)
end
end
# Builds sync stats maps for all provider types to avoid N+1 queries in views
def build_sync_stats_maps
# SimpleFIN sync stats

View File

@@ -9,7 +9,7 @@ class Api::V1::AccountsController < Api::V1::BaseController
def index
# Test with Pagy pagination
family = current_resource_owner.family
accounts_query = family.accounts.visible.alphabetically
accounts_query = family.accounts.accessible_by(current_resource_owner).visible.alphabetically
# Handle pagination with Pagy
@pagy, @accounts = pagy(

View File

@@ -22,10 +22,15 @@ module Api
# @return [Array<Hash>] JSON array of merchant objects
def index
family = current_resource_owner.family
user = current_resource_owner
# Single query with OR conditions - more efficient than Ruby deduplication
family_merchant_ids = family.merchants.select(:id)
provider_merchant_ids = family.transactions.select(:merchant_id)
accessible_account_ids = family.accounts.accessible_by(user).select(:id)
provider_merchant_ids = Transaction.joins(:entry)
.where(entries: { account_id: accessible_account_ids })
.where.not(merchant_id: nil)
.select(:merchant_id)
@merchants = Merchant
.where(id: family_merchant_ids)
@@ -48,10 +53,11 @@ module Api
# @return [Hash] JSON merchant object or error
def show
family = current_resource_owner.family
user = current_resource_owner
@merchant = family.merchants.find_by(id: params[:id]) ||
Merchant.joins(transactions: :entry)
.where(entries: { account_id: family.accounts.select(:id) })
.where(entries: { account_id: family.accounts.accessible_by(user).select(:id) })
.distinct
.find_by(id: params[:id])

View File

@@ -10,7 +10,9 @@ class Api::V1::TransactionsController < Api::V1::BaseController
def index
family = current_resource_owner.family
accessible_account_ids = family.accounts.accessible_by(current_resource_owner).select(:id)
transactions_query = family.transactions.visible
.joins(:entry).where(entries: { account_id: accessible_account_ids })
# Apply filters
transactions_query = apply_filters(transactions_query)
@@ -76,7 +78,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
return
end
account = family.accounts.find(transaction_params[:account_id])
account = family.accounts.writable_by(current_resource_owner).find(transaction_params[:account_id])
@entry = account.entries.new(entry_params_for_create)
if @entry.save
@@ -177,7 +179,10 @@ end
def set_transaction
family = current_resource_owner.family
@transaction = family.transactions.find(params[:id])
@transaction = family.transactions
.joins(entry: :account)
.merge(Account.accessible_by(current_resource_owner))
.find(params[:id])
@entry = @transaction.entry
rescue ActiveRecord::RecordNotFound
render json: {

View File

@@ -1,7 +1,7 @@
class ApplicationController < ActionController::Base
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
FeatureGuardable, Notifiable, SafePagination
FeatureGuardable, Notifiable, SafePagination, AccountAuthorizable
include Pundit::Authorization
include Pagy::Backend
@@ -40,6 +40,17 @@ class ApplicationController < ActionController::Base
session[:pending_invitation_token] = token if invitation
end
def require_admin!
return if Current.user&.admin?
respond_to do |format|
format.html { redirect_to accounts_path, alert: t("shared.require_admin") }
format.turbo_stream { head :forbidden }
format.json { head :forbidden }
format.any { head :forbidden }
end
end
def detect_os
user_agent = request.user_agent
@os = case user_agent

View File

@@ -53,12 +53,12 @@ class BudgetsController < ApplicationController
def set_budget
start_date = Budget.param_to_date(params[:month_year], family: Current.family)
@budget = Budget.find_or_bootstrap(Current.family, start_date: start_date)
@budget = Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user)
raise ActiveRecord::RecordNotFound unless @budget
end
def redirect_to_current_month_budget
current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current)
current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current, user: Current.user)
redirect_to budget_path(current_budget)
end
end

View File

@@ -1,5 +1,6 @@
class CoinbaseItemsController < ApplicationController
before_action :set_coinbase_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def index
@coinbase_items = Current.family.coinbase_items.ordered

View File

@@ -1,5 +1,6 @@
class CoinstatsItemsController < ApplicationController
before_action :set_coinstats_item, only: [ :show, :edit, :update, :destroy, :sync ]
before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync, :link_wallet ]
def index
@coinstats_items = Current.family.coinstats_items.ordered

View File

@@ -0,0 +1,29 @@
module AccountAuthorizable
extend ActiveSupport::Concern
included do
include StreamExtensions
end
private
def require_account_permission!(account, level = :write, redirect_path: nil)
permission = account.permission_for(Current.user)
allowed = case level
when :write then permission.in?([ :owner, :full_control ])
when :annotate then permission.in?([ :owner, :full_control, :read_write ])
when :owner then permission == :owner
else false
end
return true if allowed
path = redirect_path || account_path(account)
respond_to do |format|
format.html { redirect_back_or_to path, alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(path, alert: t("accounts.not_authorized")) }
end
false
end
end

View File

@@ -46,7 +46,6 @@ module AccountableResource
opening_balance_date: opening_balance_date
)
@account.lock_saved_attributes!
@account.auto_share_with_family! if Current.family.share_all_by_default?
end
redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize)
@@ -96,14 +95,7 @@ module AccountableResource
def set_manageable_account
@account = Current.user.accessible_accounts.find(params[:id])
permission = @account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_to account_path(@account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_to(account_path(@account), alert: t("accounts.not_authorized")) }
end
nil
end
require_account_permission!(@account)
end
def account_params

View File

@@ -31,13 +31,7 @@ module EntryableResource
end
def destroy
unless can_edit_entry?
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
@entry.destroy!
@entry.sync_account_later

View File

@@ -1,6 +1,7 @@
class EnableBankingItemsController < ApplicationController
include EnableBankingItems::MapsHelper
before_action :set_enable_banking_item, only: [ :update, :destroy, :sync, :select_bank, :authorize, :reauthorize, :setup_accounts, :complete_account_setup, :new_connection ]
before_action :require_admin!, only: [ :new, :create, :link_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :select_bank, :authorize, :reauthorize, :setup_accounts, :complete_account_setup, :new_connection ]
skip_before_action :verify_authenticity_token, only: [ :callback ]
def new

View File

@@ -6,7 +6,7 @@ class FamilyMerchantsController < ApplicationController
# Show all merchants for this family
@family_merchants = Current.family.merchants.alphabetically
@provider_merchants = Current.family.assigned_merchants.where(type: "ProviderMerchant").alphabetically
@provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically
# Show recently unlinked ProviderMerchants (within last 30 days)
# Exclude merchants that are already assigned to transactions (they appear in provider_merchants)

View File

@@ -2,7 +2,7 @@ class HoldingsController < ApplicationController
include StreamExtensions
before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security sync_prices]
before_action :require_holding_write_permission!, only: %i[update destroy unlock_cost_basis remap_security reset_security]
before_action :require_holding_write_permission!, only: %i[update destroy unlock_cost_basis remap_security reset_security sync_prices]
def index
@account = accessible_accounts.find(params[:account_id])
@@ -147,13 +147,7 @@ class HoldingsController < ApplicationController
end
def require_holding_write_permission!
permission = @holding.account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@holding.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@holding.account), alert: t("accounts.not_authorized")) }
end
end
require_account_permission!(@holding.account)
end
def holding_params

View File

@@ -19,7 +19,7 @@ class Import::UploadsController < ApplicationController
elsif @import.is_a?(SureImport)
update_sure_import_upload
elsif csv_valid?(csv_str)
@import.account = accessible_accounts.find_by(id: import_account_id)
@import.account = import_account_id.present? ? accessible_accounts.find(import_account_id) : nil
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
@import.save!(validate: false)

View File

@@ -4,6 +4,7 @@ class IndexaCapitalItemsController < ApplicationController
ALLOWED_ACCOUNTABLE_TYPES = %w[Depository CreditCard Investment Loan OtherAsset OtherLiability Crypto Property Vehicle].freeze
before_action :set_indexa_capital_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def index
@indexa_capital_items = Current.family.indexa_capital_items.ordered
@@ -290,6 +291,7 @@ class IndexaCapitalItemsController < ApplicationController
accountable: accountable_class.new
)
account.auto_share_with_family! if Current.family.share_all_by_default?
indexa_capital_account.ensure_account_provider!(account)
account
end
@@ -303,12 +305,15 @@ class IndexaCapitalItemsController < ApplicationController
accountable_attrs[:subtype] = config[:subtype]
end
Current.family.accounts.create!(
account = Current.family.accounts.create!(
name: indexa_capital_account.name,
balance: config[:balance].present? ? config[:balance].to_d : (indexa_capital_account.current_balance || 0),
currency: indexa_capital_account.currency || "EUR",
accountable: accountable_class.new(accountable_attrs)
)
account.auto_share_with_family! if Current.family.share_all_by_default?
account
end
def infer_accountable_type(account_type, subtype = nil)

View File

@@ -1,5 +1,6 @@
class LunchflowItemsController < ApplicationController
before_action :set_lunchflow_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def index
@lunchflow_items = Current.family.lunchflow_items.active.ordered

View File

@@ -1,5 +1,6 @@
class MercuryItemsController < ApplicationController
before_action :set_mercury_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def index
@mercury_items = Current.family.mercury_items.active.ordered

View File

@@ -17,11 +17,7 @@ class PendingDuplicateMergesController < ApplicationController
end
def create
permission = @transaction.entry.account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
redirect_back_or_to transactions_path, alert: t("accounts.not_authorized")
return
end
return unless require_account_permission!(@transaction.entry.account, :annotate, redirect_path: transactions_path)
# Manually merge the pending transaction with the selected posted transaction
unless merge_params[:posted_entry_id].present?

View File

@@ -1,5 +1,6 @@
class PlaidItemsController < ApplicationController
before_action :set_plaid_item, only: %i[edit destroy sync]
before_action :require_admin!, only: %i[new create select_existing_account link_existing_account edit destroy sync]
def new
region = params[:region] == "eu" ? :eu : :us

View File

@@ -107,12 +107,6 @@ class PropertiesController < ApplicationController
end
def require_property_write_permission!
permission = @account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@account), alert: t("accounts.not_authorized")) }
end
end
require_account_permission!(@account)
end
end

View File

@@ -300,7 +300,7 @@ class ReportsController < ApplicationController
# Only calculate if we're looking at current month
return nil unless @period_type == :monthly && @start_date.beginning_of_month.to_date == Date.current.beginning_of_month.to_date
budget = Budget.find_or_bootstrap(Current.family, start_date: @start_date.beginning_of_month.to_date)
budget = Budget.find_or_bootstrap(Current.family, start_date: @start_date.beginning_of_month.to_date, user: Current.user)
return 0 if budget.nil? || budget.allocated_spending.zero?
(budget.actual_spending / budget.allocated_spending * 100).round(1)

View File

@@ -1,6 +1,7 @@
class SimplefinItemsController < ApplicationController
include SimplefinItems::MapsHelper
before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :new, :create, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ]
def index
@simplefin_items = Current.family.simplefin_items.active.ordered

View File

@@ -1,5 +1,6 @@
class SnaptradeItemsController < ApplicationController
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ]
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ]
def index
@snaptrade_items = Current.family.snaptrade_items.ordered

View File

@@ -87,10 +87,7 @@ class SplitsController < ApplicationController
end
def require_split_write_permission!
permission = @entry.account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
redirect_back_or_to transactions_path, alert: t("accounts.not_authorized")
end
require_account_permission!(@entry.account, redirect_path: transactions_path)
end
def resolve_to_parent!

View File

@@ -17,13 +17,7 @@ class TradesController < ApplicationController
def create
@account = accessible_accounts.find(params[:account_id])
unless @account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@account)
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
@@ -46,13 +40,7 @@ class TradesController < ApplicationController
end
def update
unless can_edit_entry?
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
if @entry.update(update_entry_params)
@entry.lock_saved_attributes!
@@ -86,13 +74,7 @@ class TradesController < ApplicationController
end
def unlock
unless @entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
@entry.unlock_for_sync!
flash[:notice] = t("entries.unlock.success")

View File

@@ -3,12 +3,7 @@ class TransactionCategoriesController < ApplicationController
def update
@entry = Current.accessible_entries.transactions.find(params[:transaction_id])
permission = @entry.account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control, :read_write ])
redirect_back_or_to transaction_path(@entry), alert: t("accounts.not_authorized")
return
end
return unless require_account_permission!(@entry.account, :annotate, redirect_path: transaction_path(@entry))
@entry.update!(entry_params)

View File

@@ -16,15 +16,7 @@ class Transactions::BulkDeletionsController < ApplicationController
params.require(:bulk_delete).permit(entry_ids: [])
end
# Accounts where the user can delete entries (owner or full_control)
def writable_accounts
Current.family.accounts
.left_joins(:account_shares)
.where(
"accounts.owner_id = :uid OR (account_shares.user_id = :uid AND account_shares.permission = :perm)",
uid: Current.user.id,
perm: "full_control"
)
.distinct
Current.family.accounts.writable_by(Current.user)
end
end

View File

@@ -93,13 +93,7 @@ class TransactionsController < ApplicationController
def create
account = Current.user.accessible_accounts.find(params.dig(:entry, :account_id))
unless account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(account)
@entry = account.entries.new(entry_params)
@@ -167,13 +161,7 @@ class TransactionsController < ApplicationController
def merge_duplicate
transaction = accessible_transactions.includes(entry: :account).find(params[:id])
unless transaction.entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(transaction.entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(transaction.entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(transaction.entry.account)
if transaction.merge_with_duplicate!
flash[:notice] = t("transactions.merge_duplicate.success")
@@ -191,13 +179,7 @@ class TransactionsController < ApplicationController
def dismiss_duplicate
transaction = accessible_transactions.includes(entry: :account).find(params[:id])
unless transaction.entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(transaction.entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(transaction.entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(transaction.entry.account)
if transaction.dismiss_duplicate_suggestion!
flash[:notice] = t("transactions.dismiss_duplicate.success")
@@ -216,13 +198,7 @@ class TransactionsController < ApplicationController
@transaction = accessible_transactions.includes(entry: :account).find(params[:id])
@entry = @transaction.entry
unless @entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
unless @entry.account.investment?
flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account")
@@ -237,13 +213,7 @@ class TransactionsController < ApplicationController
@transaction = accessible_transactions.includes(entry: :account).find(params[:id])
@entry = @transaction.entry
unless @entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
# Pre-transaction validations
unless @entry.account.investment?
@@ -319,13 +289,7 @@ class TransactionsController < ApplicationController
end
def unlock
unless @entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
@entry.unlock_for_sync!
flash[:notice] = t("entries.unlock.success")
@@ -336,13 +300,7 @@ class TransactionsController < ApplicationController
def mark_as_recurring
transaction = accessible_transactions.includes(entry: :account).find(params[:id])
unless transaction.entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(transaction.entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(transaction.entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(transaction.entry.account)
# Check if a recurring transaction already exists for this pattern
existing = Current.family.recurring_transactions.find_by(

View File

@@ -2,16 +2,15 @@ class TransferMatchesController < ApplicationController
before_action :set_entry
def new
@accounts = accessible_accounts.visible.alphabetically.where.not(id: @entry.account_id)
@accounts = Current.family.accounts.writable_by(Current.user).visible.alphabetically.where.not(id: @entry.account_id)
@transfer_match_candidates = @entry.transaction.transfer_match_candidates
end
def create
permission = @entry.account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
redirect_back_or_to transactions_path, alert: t("accounts.not_authorized")
return
end
return unless require_account_permission!(@entry.account, redirect_path: transactions_path)
target_account = resolve_target_account
return unless require_account_permission!(target_account, redirect_path: transactions_path)
@transfer = build_transfer
Transfer.transaction do
@@ -45,6 +44,14 @@ class TransferMatchesController < ApplicationController
params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)
end
def resolve_target_account
if transfer_match_params[:method] == "new"
accessible_accounts.find(transfer_match_params[:target_account_id])
else
Current.accessible_entries.find(transfer_match_params[:matched_entry_id]).account
end
end
def build_transfer
if transfer_match_params[:method] == "new"
target_account = accessible_accounts.find(transfer_match_params[:target_account_id])

View File

@@ -24,14 +24,8 @@ class TransfersController < ApplicationController
source_account = accessible_accounts.find(transfer_params[:from_account_id])
destination_account = accessible_accounts.find(transfer_params[:to_account_id])
unless source_account.permission_for(Current.user).in?([ :owner, :full_control ]) &&
destination_account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to transactions_path, alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(transactions_path, alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(source_account, redirect_path: transactions_path)
return unless require_account_permission!(destination_account, redirect_path: transactions_path)
@transfer = Transfer::Creator.new(
family: Current.family,
@@ -55,14 +49,7 @@ class TransfersController < ApplicationController
def update
outflow_account = @transfer.outflow_transaction.entry.account
permission = outflow_account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to transactions_url, alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(transactions_url, alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(outflow_account, redirect_path: transactions_url)
Transfer.transaction do
update_transfer_status
@@ -76,16 +63,8 @@ class TransfersController < ApplicationController
end
def destroy
# Require write permission on at least the outflow account
outflow_account = @transfer.outflow_transaction.entry.account
permission = outflow_account.permission_for(Current.user)
unless permission.in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to transactions_url, alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(transactions_url, alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(outflow_account, redirect_path: transactions_url)
@transfer.destroy!
redirect_back_or_to transactions_url, notice: t(".success")

View File

@@ -106,10 +106,13 @@ class UsersController < ApplicationController
end
def user_params
family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ]
family_attrs.push(:moniker, :default_account_sharing) if Current.user.admin?
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
:show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale,
family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :moniker, :default_account_sharing, :id ],
family_attributes: family_attrs,
goals: []
)
end

View File

@@ -3,14 +3,7 @@ class ValuationsController < ApplicationController
def confirm_create
@account = accessible_accounts.find(params.dig(:entry, :account_id))
unless @account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@account)
@entry = @account.entries.build(entry_params.merge(currency: @account.currency))
@@ -25,14 +18,7 @@ class ValuationsController < ApplicationController
def confirm_update
@entry = Current.accessible_entries.find(params[:id])
unless @entry.account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
@account = @entry.account
@entry.assign_attributes(entry_params.merge(currency: @account.currency))
@@ -49,14 +35,7 @@ class ValuationsController < ApplicationController
def create
account = accessible_accounts.find(params.dig(:entry, :account_id))
unless account.permission_for(Current.user).in?([ :owner, :full_control ])
respond_to do |format|
format.html { redirect_back_or_to account_path(account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(account)
result = account.create_reconciliation(
balance: entry_params[:amount],
@@ -75,13 +54,7 @@ class ValuationsController < ApplicationController
end
def update
unless can_edit_entry?
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), alert: t("accounts.not_authorized") }
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account), alert: t("accounts.not_authorized")) }
end
return
end
return unless require_account_permission!(@entry.account)
# Notes updating is independent of reconciliation, just a simple CRUD operation
@entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?

View File

@@ -33,25 +33,10 @@ class SimplefinItem::BalancesOnlyJob < ApplicationJob
target_id = ActionView::RecordIdentifier.dom_id(item)
Turbo::StreamsChannel.broadcast_replace_to(item.family, target: target_id, html: card_html)
# Also refresh Manual Accounts so the CTA state and duplicates clear without refresh
begin
manual_accounts = item.family.accounts
.visible_manual
.order(:name)
if manual_accounts.any?
manual_html = ApplicationController.render(
partial: "accounts/index/manual_accounts",
formats: [ :html ],
locals: { accounts: manual_accounts }
)
Turbo::StreamsChannel.broadcast_update_to(item.family, target: "manual-accounts", html: manual_html)
else
manual_html = ApplicationController.render(inline: '<div id="manual-accounts"></div>')
Turbo::StreamsChannel.broadcast_replace_to(item.family, target: "manual-accounts", html: manual_html)
end
rescue => inner
Rails.logger.warn("SimpleFin BalancesOnlyJob manual-accounts broadcast failed: #{inner.class} - #{inner.message}")
end
# Broadcast a refresh signal instead of rendered HTML. Each user's browser
# re-fetches via their own authenticated request, so the manual accounts
# list is correctly scoped to the current user.
item.family.broadcast_refresh
rescue => e
Rails.logger.warn("SimpleFin BalancesOnlyJob broadcast failed: #{e.class} - #{e.message}")
end

View File

@@ -1,9 +1,10 @@
class Account < ApplicationRecord
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable
before_validation :assign_default_owner, on: :create
before_validation :assign_default_owner, if: -> { owner_id.blank? }
validates :name, :balance, :currency, presence: true
validate :owner_belongs_to_family, if: -> { owner_id.present? && family_id.present? }
belongs_to :family
belongs_to :owner, class_name: "User", optional: true
@@ -48,6 +49,13 @@ class Account < ApplicationRecord
.distinct
}
# Accounts a user can write to (owned or shared with full_control)
scope :writable_by, ->(user) {
left_joins(:account_shares)
.where("accounts.owner_id = :uid OR (account_shares.user_id = :uid AND account_shares.permission = 'full_control')", uid: user.id)
.distinct
}
# Accounts that count in a user's financial calculations
scope :included_in_finances_for, ->(user) {
left_joins(:account_shares)
@@ -119,6 +127,8 @@ class Account < ApplicationRecord
date: opening_balance_date
)
raise result.error if result.error
account.auto_share_with_family! if account.family.share_all_by_default?
end
# Skip initial sync for linked accounts - the provider sync will handle balance creation
@@ -375,6 +385,8 @@ class Account < ApplicationRecord
end
def shared_with?(user)
return false if user.nil?
owned_by?(user) ||
if account_shares.loaded?
account_shares.any? { |s| s.user_id == user.id }
@@ -413,6 +425,16 @@ class Account < ApplicationRecord
def assign_default_owner
return if owner.present?
self.owner = Current.user || family&.users&.find_by(role: %w[admin super_admin]) || family&.users&.order(:created_at)&.first
if Current.user.present? && Current.user.family_id == family_id
self.owner = Current.user
else
self.owner = family&.users&.find_by(role: %w[admin super_admin]) || family&.users&.order(:created_at)&.first
end
end
def owner_belongs_to_family
return if User.where(id: owner_id, family_id: family_id).exists?
errors.add(:owner, :invalid, message: "must belong to the same family as the account")
end
end

View File

@@ -56,7 +56,7 @@ class Assistant::Function
end
def family_account_names
@family_account_names ||= family.accounts.visible.pluck(:name)
@family_account_names ||= user.accessible_accounts.visible.pluck(:name)
end
def family_category_names

View File

@@ -12,7 +12,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
def call(params = {})
{
as_of_date: Date.current,
accounts: family.accounts.includes(:balances, :account_providers).map do |account|
accounts: user.accessible_accounts.includes(:balances, :account_providers).map do |account|
{
name: account.name,
balance: account.balance,

View File

@@ -27,15 +27,15 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
oldest_account_start_date: family.oldest_entry_date,
currency: family.currency,
net_worth: {
current: family.balance_sheet.net_worth_money.format,
current: family.balance_sheet(user: user).net_worth_money.format,
monthly_history: historical_data(period)
},
assets: {
current: family.balance_sheet.assets.total_money.format,
current: family.balance_sheet(user: user).assets.total_money.format,
monthly_history: historical_data(period, classification: "asset")
},
liabilities: {
current: family.balance_sheet.liabilities.total_money.format,
current: family.balance_sheet(user: user).liabilities.total_money.format,
monthly_history: historical_data(period, classification: "liability")
},
insights: insights_data
@@ -44,7 +44,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
private
def historical_data(period, classification: nil)
scope = family.accounts.visible
scope = user.accessible_accounts.visible
scope = scope.where(classification: classification) if classification.present?
if period.start_date == Date.current
@@ -65,8 +65,8 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
end
def insights_data
assets = family.balance_sheet.assets.total
liabilities = family.balance_sheet.liabilities.total
assets = family.balance_sheet(user: user).assets.total
liabilities = family.balance_sheet(user: user).liabilities.total
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
{

View File

@@ -151,7 +151,7 @@ class Assistant::Function::GetHoldings < Assistant::Function
end
def investment_accounts
family.accounts.visible.where(accountable_type: SUPPORTED_ACCOUNT_TYPES)
user.accessible_accounts.visible.where(accountable_type: SUPPORTED_ACCOUNT_TYPES)
end
def investment_account_names

View File

@@ -27,7 +27,7 @@ class BalanceSheet::AccountTotals
def visible_accounts
@visible_accounts ||= begin
scope = family.accounts.visible.with_attached_logo
scope = family.accounts.visible.with_attached_logo.includes(:account_shares)
scope = scope.accessible_by(user) if user
scope
end

View File

@@ -3,6 +3,8 @@ class Budget < ApplicationRecord
PARAM_DATE_FORMAT = "%b-%Y"
attr_accessor :current_user
belongs_to :family
has_many :budget_categories, -> { includes(:category) }, dependent: :destroy
@@ -38,7 +40,7 @@ class Budget < ApplicationRecord
end
end
def find_or_bootstrap(family, start_date:)
def find_or_bootstrap(family, start_date:, user: nil)
return nil unless budget_date_valid?(start_date, family: family)
Budget.transaction do
@@ -58,6 +60,7 @@ class Budget < ApplicationRecord
b.currency = family.currency
end
budget.current_user = user
budget.sync_budget_categories
budget
@@ -107,7 +110,11 @@ class Budget < ApplicationRecord
end
def transactions
family.transactions.visible.in_period(period)
scope = family.transactions.visible.in_period(period)
if current_user
scope = scope.joins(:entry).where(entries: { account_id: family.accounts.accessible_by(current_user).select(:id) })
end
scope
end
def name
@@ -298,7 +305,7 @@ class Budget < ApplicationRecord
private
def income_statement
@income_statement ||= family.income_statement
@income_statement ||= family.income_statement(user: current_user)
end
def net_totals

View File

@@ -105,6 +105,29 @@ class Family < ApplicationRecord
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

View File

@@ -176,14 +176,14 @@ class IncomeStatement
def family_stats(interval: "month")
@family_stats ||= {}
@family_stats[interval] ||= Rails.cache.fetch([
"income_statement", "family_stats", family.id, user&.id, interval, family.entries_cache_version
"income_statement", "family_stats", family.id, user&.id, interval, included_account_ids_hash, family.entries_cache_version
]) { FamilyStats.new(family, interval:, account_ids: included_account_ids).call }
end
def category_stats(interval: "month")
@category_stats ||= {}
@category_stats[interval] ||= Rails.cache.fetch([
"income_statement", "category_stats", family.id, user&.id, interval, family.entries_cache_version
"income_statement", "category_stats", family.id, user&.id, interval, included_account_ids_hash, family.entries_cache_version
]) { CategoryStats.new(family, interval:, account_ids: included_account_ids).call }
end
@@ -191,12 +191,15 @@ class IncomeStatement
@included_account_ids ||= user ? user.finance_accounts.pluck(:id) : nil
end
def included_account_ids_hash
@included_account_ids_hash ||= included_account_ids ? Digest::MD5.hexdigest(included_account_ids.sort.join(",")) : nil
end
def totals_query(transactions_scope:, date_range:)
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
account_ids_hash = included_account_ids ? Digest::MD5.hexdigest(included_account_ids.sort.join(",")) : nil
Rails.cache.fetch([
"income_statement", "totals_query", "v2", family.id, user&.id, account_ids_hash, sql_hash, family.entries_cache_version
"income_statement", "totals_query", "v2", family.id, user&.id, included_account_ids_hash, sql_hash, date_range.begin, date_range.end, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range, included_account_ids: included_account_ids).call }
end

View File

@@ -6,6 +6,8 @@ class IncomeStatement::CategoryStats
end
def call
return [] if @account_ids&.empty?
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
StatRow.new(
category_id: row["category_id"],
@@ -50,7 +52,7 @@ class IncomeStatement::CategoryStats
end
def scope_to_account_ids_sql
return "" if @account_ids.blank?
return "" if @account_ids.nil?
ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ])
end

View File

@@ -6,6 +6,8 @@ class IncomeStatement::FamilyStats
end
def call
return [] if @account_ids&.empty?
ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|
StatRow.new(
classification: row["classification"],
@@ -49,7 +51,7 @@ class IncomeStatement::FamilyStats
end
def scope_to_account_ids_sql
return "" if @account_ids.blank?
return "" if @account_ids.nil?
ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ])
end

View File

@@ -74,8 +74,11 @@ class PlaidAccount::Processor
cash_balance: balance_calculator.cash_balance
)
new_account = account.new_record?
account.save!
account.auto_share_with_family! if new_account && account.family.share_all_by_default?
# Create account provider link if it doesn't exist
unless account_provider
AccountProvider.find_or_create_by!(

View File

@@ -4,7 +4,7 @@ class Rule::ConditionFilter::TransactionAccount < Rule::ConditionFilter
end
def options
family.accounts.alphabetically.pluck(:name, :id)
family.accounts.accessible_by(Current.user).alphabetically.pluck(:name, :id)
end
def apply(scope, operator, value)

View File

@@ -4,7 +4,7 @@ class Rule::ConditionFilter::TransactionMerchant < Rule::ConditionFilter
end
def options
family.available_merchants.alphabetically.pluck(:name, :id)
family.available_merchants_for(Current.user).alphabetically.pluck(:name, :id)
end
def prepare(scope)

View File

@@ -172,28 +172,10 @@ class SimplefinItem::Syncer
target_id = ActionView::RecordIdentifier.dom_id(simplefin_item)
Turbo::StreamsChannel.broadcast_replace_to(simplefin_item.family, target: target_id, html: card_html)
# Also refresh the Manual Accounts group so duplicates clear without a full page reload
begin
manual_accounts = simplefin_item.family.accounts
.visible_manual
.order(:name)
if manual_accounts.any?
manual_html = ApplicationController.render(
partial: "accounts/index/manual_accounts",
formats: [ :html ],
locals: { accounts: manual_accounts }
)
Turbo::StreamsChannel.broadcast_update_to(simplefin_item.family, target: "manual-accounts", html: manual_html)
else
manual_html = ApplicationController.render(inline: '<div id="manual-accounts"></div>')
Turbo::StreamsChannel.broadcast_replace_to(simplefin_item.family, target: "manual-accounts", html: manual_html)
end
rescue => inner
Rails.logger.warn("SimplefinItem::Syncer manual-accounts broadcast failed: #{inner.class} - #{inner.message}")
end
# Intentionally do not broadcast modal reloads here to avoid unexpected auto-pop after sync.
# Modal opening is controlled explicitly via controller redirects with actionable conditions.
# Broadcast a refresh signal instead of rendered HTML. Each user's browser
# re-fetches via their own authenticated request, so the manual accounts
# list is correctly scoped to the current user.
simplefin_item.family.broadcast_refresh
rescue => e
Rails.logger.warn("SimplefinItem::Syncer broadcast failed: #{e.class} - #{e.message}")
end

View File

@@ -24,9 +24,10 @@
<span id="member-name-<%= index %>" class="text-sm text-primary"><%= member.display_name %></span>
</div>
<div>
<% selected_permission = share&.permission || "read_only" %>
<select name="sharing[members][<%= index %>][permission]" aria-labelledby="member-name-<%= index %>" class="text-sm border border-primary rounded-lg px-3 py-1.5 bg-container text-primary w-full">
<% AccountShare::PERMISSIONS.each do |perm| %>
<option value="<%= perm %>" <%= "selected" if share&.permission == perm %>>
<option value="<%= perm %>" <%= "selected" if selected_permission == perm %>>
<%= t(".permissions.#{perm}") %>
</option>
<% end %>

View File

@@ -67,13 +67,15 @@
<% end %>
<% menu.with_item(variant: "link", text: t("accounts.account.sharing"), href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %>
<% if permission.in?([ :owner, :full_control ]) %>
<% if Current.user&.admin? %>
<% if !account.linked? && %w[Depository CreditCard Investment Crypto].include?(account.accountable_type) %>
<% menu.with_item(variant: "link", text: t("accounts.account.link_provider"), href: select_provider_account_path(account), icon: "link", data: { turbo_frame: :modal }) %>
<% elsif account.linked? %>
<% menu.with_item(variant: "link", text: t("accounts.account.unlink_provider"), href: confirm_unlink_account_path(account), icon: "unlink", data: { turbo_frame: :modal }) %>
<% end %>
<% end %>
<% if permission.in?([ :owner, :full_control ]) %>
<% menu.with_item(variant: "divider") %>
<% if account.active? %>

View File

@@ -17,7 +17,8 @@
<%= t("accounts.new.method_selector.manual_entry") %>
<% end %>
<%# Dynamic provider links %>
<%# Dynamic provider links - only admins can create provider connections %>
<% if Current.user&.admin? %>
<% provider_configs.each do |config| %>
<% link_path = config[:new_account_path].call(accountable_type, params[:return_to]) %>
<% is_lunchflow = config[:key] == "lunchflow" %>
@@ -44,6 +45,7 @@
<% end %>
<% end %>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -61,44 +61,46 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if coinbase_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_credentials"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_coinbase_item_path(coinbase_item),
disabled: coinbase_item.syncing?
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% menu.with_item(
variant: "link",
text: t(".import_wallets_menu"),
icon: "plus",
href: setup_accounts_coinbase_item_path(coinbase_item),
frame: :modal
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if coinbase_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_credentials"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_coinbase_item_path(coinbase_item),
disabled: coinbase_item.syncing?
) %>
<% end %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: coinbase_item_path(coinbase_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(coinbase_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% menu.with_item(
variant: "link",
text: t(".import_wallets_menu"),
icon: "plus",
href: setup_accounts_coinbase_item_path(coinbase_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: coinbase_item_path(coinbase_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(coinbase_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless coinbase_item.scheduled_for_deletion? %>

View File

@@ -46,34 +46,36 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if coinstats_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_api_key"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_coinstats_item_path(coinstats_item)
) %>
<% end %>
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if coinstats_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_api_key"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_coinstats_item_path(coinstats_item)
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: coinstats_item_path(coinstats_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(coinstats_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: coinstats_item_path(coinstats_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(coinstats_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless coinstats_item.scheduled_for_deletion? %>

View File

@@ -49,34 +49,36 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if enable_banking_item.requires_update? %>
<%= button_to reauthorize_enable_banking_item_path(enable_banking_item),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
<%= icon "refresh-cw", size: "sm" %>
Update
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if enable_banking_item.requires_update? %>
<%= button_to reauthorize_enable_banking_item_path(enable_banking_item),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors",
data: { turbo: false } do %>
<%= icon "refresh-cw", size: "sm" %>
Update
<% end %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_enable_banking_item_path(enable_banking_item)
) %>
<% end %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_enable_banking_item_path(enable_banking_item)
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: "Delete",
icon: "trash-2",
href: enable_banking_item_path(enable_banking_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(enable_banking_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: "Delete",
icon: "trash-2",
href: enable_banking_item_path(enable_banking_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(enable_banking_item.institution_display_name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless enable_banking_item.scheduled_for_deletion? %>

View File

@@ -49,50 +49,52 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if indexa_capital_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_credentials"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_indexa_capital_item_path(indexa_capital_item),
disabled: indexa_capital_item.syncing?
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_action"),
icon: "settings",
href: setup_accounts_indexa_capital_item_path(indexa_capital_item),
frame: :modal
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if indexa_capital_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update_credentials"),
icon: "refresh-cw",
variant: "secondary",
href: settings_providers_path,
frame: "_top"
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_indexa_capital_item_path(indexa_capital_item),
disabled: indexa_capital_item.syncing?
) %>
<% end %>
<% menu.with_item(
variant: "link",
text: t(".update_credentials"),
icon: "cable",
href: settings_providers_path(manage: "1")
) %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: indexa_capital_item_path(indexa_capital_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(indexa_capital_item.name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_action"),
icon: "settings",
href: setup_accounts_indexa_capital_item_path(indexa_capital_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "link",
text: t(".update_credentials"),
icon: "cable",
href: settings_providers_path(manage: "1")
) %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: indexa_capital_item_path(indexa_capital_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(indexa_capital_item.name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless indexa_capital_item.scheduled_for_deletion? %>

View File

@@ -54,26 +54,28 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_lunchflow_item_path(lunchflow_item)
) %>
<% end %>
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_lunchflow_item_path(lunchflow_item)
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: lunchflow_item_path(lunchflow_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(lunchflow_item.name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: lunchflow_item_path(lunchflow_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(lunchflow_item.name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless lunchflow_item.scheduled_for_deletion? %>

View File

@@ -54,26 +54,28 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_mercury_item_path(mercury_item)
) %>
<% end %>
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_mercury_item_path(mercury_item)
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: mercury_item_path(mercury_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(mercury_item.name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: mercury_item_path(mercury_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(mercury_item.name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless mercury_item.scheduled_for_deletion? %>

View File

@@ -46,34 +46,36 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if plaid_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update"),
icon: "refresh-cw",
variant: "secondary",
href: edit_plaid_item_path(plaid_item),
frame: "modal"
) %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_plaid_item_path(plaid_item)
) %>
<% end %>
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if plaid_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update"),
icon: "refresh-cw",
variant: "secondary",
href: edit_plaid_item_path(plaid_item),
frame: "modal"
) %>
<% elsif Rails.env.development? %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_plaid_item_path(plaid_item)
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: plaid_item_path(plaid_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: plaid_item_path(plaid_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless plaid_item.scheduled_for_deletion? %>

View File

@@ -143,44 +143,46 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if simplefin_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update"),
icon: "refresh-cw",
variant: "secondary",
href: edit_simplefin_item_path(simplefin_item),
frame: "modal"
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_simplefin_item_path(simplefin_item),
disabled: simplefin_item.syncing?
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_accounts_menu"),
icon: "settings",
href: setup_accounts_simplefin_item_path(simplefin_item),
frame: :modal
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if simplefin_item.requires_update? %>
<%= render DS::Link.new(
text: t(".update"),
icon: "refresh-cw",
variant: "secondary",
href: edit_simplefin_item_path(simplefin_item),
frame: "modal"
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_simplefin_item_path(simplefin_item),
disabled: simplefin_item.syncing?
) %>
<% end %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: simplefin_item_path(simplefin_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(simplefin_item.name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_accounts_menu"),
icon: "settings",
href: setup_accounts_simplefin_item_path(simplefin_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: simplefin_item_path(simplefin_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(simplefin_item.name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless simplefin_item.scheduled_for_deletion? %>

View File

@@ -57,55 +57,57 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %>
<%= render DS::Link.new(
text: t(".reconnect"),
icon: "link",
variant: "secondary",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_snaptrade_item_path(snaptrade_item),
disabled: snaptrade_item.syncing?
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "link",
text: t(".connect_brokerage"),
icon: "plus",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
<% if unlinked_count > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_accounts_menu"),
icon: "settings",
href: setup_accounts_snaptrade_item_path(snaptrade_item),
frame: :modal
<% if Current.user&.admin? %>
<div class="flex items-center gap-2">
<% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %>
<%= render DS::Link.new(
text: t(".reconnect"),
icon: "link",
variant: "secondary",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
<% else %>
<%= icon(
"refresh-cw",
as_button: true,
href: sync_snaptrade_item_path(snaptrade_item),
disabled: snaptrade_item.syncing?
) %>
<% end %>
<% menu.with_item(
variant: "link",
text: t(".manage_connections"),
icon: "cable",
href: settings_providers_path(manage: "1")
) %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: snaptrade_item_path(snaptrade_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(snaptrade_item.name, high_severity: true)
) %>
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "link",
text: t(".connect_brokerage"),
icon: "plus",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
<% if unlinked_count > 0 %>
<% menu.with_item(
variant: "link",
text: t(".setup_accounts_menu"),
icon: "settings",
href: setup_accounts_snaptrade_item_path(snaptrade_item),
frame: :modal
) %>
<% end %>
<% menu.with_item(
variant: "link",
text: t(".manage_connections"),
icon: "cable",
href: settings_providers_path(manage: "1")
) %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: snaptrade_item_path(snaptrade_item),
method: :delete,
confirm: CustomConfirm.for_resource_deletion(snaptrade_item.name, high_severity: true)
) %>
<% end %>
</div>
<% end %>
</summary>
<% unless snaptrade_item.scheduled_for_deletion? %>
@@ -124,30 +126,32 @@
activities_pending: activities_pending
) %>
<% if unlinked_count > 0 && snaptrade_item.accounts.empty? %>
<%# No accounts imported yet - show prominent setup prompt %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "settings",
variant: "primary",
href: setup_accounts_snaptrade_item_path(snaptrade_item),
frame: :modal
) %>
</div>
<% elsif snaptrade_item.snaptrade_accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
<%= render DS::Link.new(
text: t(".connect_brokerage"),
icon: "link",
variant: "primary",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
</div>
<% if Current.user&.admin? %>
<% if unlinked_count > 0 && snaptrade_item.accounts.empty? %>
<%# No accounts imported yet - show prominent setup prompt %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "settings",
variant: "primary",
href: setup_accounts_snaptrade_item_path(snaptrade_item),
frame: :modal
) %>
</div>
<% elsif snaptrade_item.snaptrade_accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
<%= render DS::Link.new(
text: t(".connect_brokerage"),
icon: "link",
variant: "primary",
href: connect_snaptrade_item_path(snaptrade_item)
) %>
</div>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -31,7 +31,7 @@
<%= render DS::Disclosure.new(title: t(".details")) do %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :merchant_id,
Current.family.available_merchants.alphabetically,
Current.family.available_merchants_for(Current.user).alphabetically,
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label") } %>

View File

@@ -11,7 +11,7 @@
<%= render DS::Disclosure.new(title: "Transactions", open: true) do %>
<div class="space-y-2">
<%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
<%= form.collection_select :merchant_id, Current.family.available_merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
<%= form.collection_select :merchant_id, Current.family.available_merchants_for(Current.user).alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
<%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", include_hidden: false } %>
<%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
</div>

View File

@@ -5,7 +5,7 @@
<%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
</div>
<div class="my-2" id="list" data-list-filter-target="list">
<% Current.family.assigned_merchants.alphabetically.each do |merchant| %>
<% Current.family.assigned_merchants_for(Current.user).alphabetically.each do |merchant| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">
<%= form.check_box :merchants,
{

View File

@@ -105,7 +105,7 @@
{ disabled: true } %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :merchant_id,
Current.family.available_merchants.alphabetically,
Current.family.available_merchants_for(Current.user).alphabetically,
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label"),

View File

@@ -10,5 +10,6 @@ en:
label: Amount
syncing_notice:
syncing: Syncing accounts data...
require_admin: "Only admins can perform this action"
trend_change:
no_change: "no change"

4
db/schema.rb generated
View File

@@ -1504,6 +1504,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_24_100003) do
t.string "kind", default: "reconciliation", null: false
end
# Could not dump table "vector_store_chunks" because of following StandardError
# Unknown type 'vector(768)' for column 'embedding'
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false

View File

@@ -2,6 +2,7 @@
configured_with_token:
family: dylan_family
name: "Indexa Capital Connection"
api_token: "test_api_token_123"
status: good
@@ -9,6 +10,7 @@ configured_with_token:
configured_with_credentials:
family: empty
name: "Indexa Capital Credentials"
username: "testuser@example.com"
document: "12345678A"

View File

@@ -1,5 +1,6 @@
one:
family: dylan_family
name: "Test Lunchflow Connection"
api_key: "test_api_key_123"
status: good

View File

@@ -1,5 +1,6 @@
one:
family: dylan_family
name: "Test Mercury Connection"
token: "test_mercury_token_123"
base_url: "https://api-sandbox.mercury.com/api/v1"

View File

@@ -1,5 +1,6 @@
one:
family: dylan_family
plaid_id: "item_mock_1"
access_token: encrypted_token_1
name: "Test Bank"

View File

@@ -3,6 +3,7 @@
configured_item:
family: dylan_family
name: "SnapTrade Connection"
client_id: "test_client_id"
consumer_key: "test_consumer_key"
@@ -17,6 +18,7 @@ configured_item:
# but before connecting to SnapTrade portal)
pending_registration_item:
family: empty
name: "Pending Registration"
client_id: "pending_client_id"
consumer_key: "pending_consumer_key"

View File

@@ -267,6 +267,8 @@ class AccountTest < ActiveSupport::TestCase
end
test "auto_share_with_family creates shares for all non-owner members" do
@family.update!(default_account_sharing: "private")
account = Account.create_and_sync({
family: @family,
owner: @admin,

View File

@@ -536,6 +536,16 @@ class IncomeStatementTest < ActiveSupport::TestCase
assert_nil net.net_expense_categories.find { |ct| ct.category.id == @food_category.id }
end
test "empty account_ids returns no results for category stats" do
results = IncomeStatement::CategoryStats.new(@family, account_ids: []).call
assert_empty results
end
test "empty account_ids returns no results for family stats" do
results = IncomeStatement::FamilyStats.new(@family, account_ids: []).call
assert_empty results
end
test "returns zero totals when family has only tax-advantaged accounts" do
# Create a fresh family with ONLY tax-advantaged accounts
family_only_retirement = Family.create!(