mirror of
https://github.com/we-promise/sure.git
synced 2026-07-19 00:05:23 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/controllers/concerns/account_authorizable.rb
Normal file
29
app/controllers/concerns/account_authorizable.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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") } %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
4
db/schema.rb
generated
@@ -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
|
||||
|
||||
2
test/fixtures/indexa_capital_items.yml
vendored
2
test/fixtures/indexa_capital_items.yml
vendored
@@ -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"
|
||||
|
||||
1
test/fixtures/lunchflow_items.yml
vendored
1
test/fixtures/lunchflow_items.yml
vendored
@@ -1,5 +1,6 @@
|
||||
one:
|
||||
family: dylan_family
|
||||
|
||||
name: "Test Lunchflow Connection"
|
||||
api_key: "test_api_key_123"
|
||||
status: good
|
||||
|
||||
1
test/fixtures/mercury_items.yml
vendored
1
test/fixtures/mercury_items.yml
vendored
@@ -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"
|
||||
|
||||
1
test/fixtures/plaid_items.yml
vendored
1
test/fixtures/plaid_items.yml
vendored
@@ -1,5 +1,6 @@
|
||||
one:
|
||||
family: dylan_family
|
||||
|
||||
plaid_id: "item_mock_1"
|
||||
access_token: encrypted_token_1
|
||||
name: "Test Bank"
|
||||
|
||||
2
test/fixtures/snaptrade_items.yml
vendored
2
test/fixtures/snaptrade_items.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user