mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Multi-step account forms + clearer balance editing (#2427)
* Initial multi-step property form * Improve form structure, add optional tooltip help icons to form fields * Add basic inline alert component * Clean up and improve property form lifecycle * Implement Account status concept * Lint fixes * Remove whitespace * Balance editing, scope updates for account * Passing tests * Fix brakeman warning * Remove stale columns * data constraint tweaks * Redundant property
This commit is contained in:
parent
ba7e8d3893
commit
662f2c04ce
66 changed files with 1036 additions and 427 deletions
|
@ -1,5 +1,6 @@
|
|||
class Account < ApplicationRecord
|
||||
include Syncable, Monetizable, Chartable, Linkable, Enrichable
|
||||
include AASM
|
||||
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
|
@ -18,7 +19,7 @@ class Account < ApplicationRecord
|
|||
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :visible, -> { where(status: [ "draft", "active" ]) }
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
scope :liabilities, -> { where(classification: "liability") }
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
@ -30,6 +31,30 @@ class Account < ApplicationRecord
|
|||
|
||||
accepts_nested_attributes_for :accountable, update_only: true
|
||||
|
||||
# Account state machine
|
||||
aasm column: :status, timestamps: true do
|
||||
state :active, initial: true
|
||||
state :draft
|
||||
state :disabled
|
||||
state :pending_deletion
|
||||
|
||||
event :activate do
|
||||
transitions from: [ :draft, :disabled ], to: :active
|
||||
end
|
||||
|
||||
event :disable do
|
||||
transitions from: [ :draft, :active ], to: :disabled
|
||||
end
|
||||
|
||||
event :enable do
|
||||
transitions from: :disabled, to: :active
|
||||
end
|
||||
|
||||
event :mark_for_deletion do
|
||||
transitions from: [ :draft, :active, :disabled ], to: :pending_deletion
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
|
@ -77,10 +102,20 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true, is_active: false)
|
||||
mark_for_deletion!
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
# Override destroy to handle error recovery for accounts
|
||||
def destroy
|
||||
super
|
||||
rescue => e
|
||||
# If destruction fails, transition back to disabled state
|
||||
# This provides a cleaner recovery path than the generic scheduled_for_deletion flag
|
||||
disable! if may_disable?
|
||||
raise e
|
||||
end
|
||||
|
||||
def current_holdings
|
||||
holdings.where(currency: currency)
|
||||
.where.not(qty: 0)
|
||||
|
@ -92,49 +127,9 @@ class Account < ApplicationRecord
|
|||
.order(amount: :desc)
|
||||
end
|
||||
|
||||
def update_with_sync!(attributes)
|
||||
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
|
||||
|
||||
initial_balance = attributes.dig(:accountable_attributes, :initial_balance)
|
||||
should_update_initial_balance = initial_balance && initial_balance.to_d != accountable.initial_balance
|
||||
|
||||
transaction do
|
||||
update!(attributes)
|
||||
update_balance!(attributes[:balance]) if should_update_balance
|
||||
update_inital_balance!(attributes[:accountable_attributes][:initial_balance]) if should_update_initial_balance
|
||||
end
|
||||
|
||||
sync_later
|
||||
end
|
||||
|
||||
def update_balance!(balance)
|
||||
valuation = entries.valuations.find_by(date: Date.current)
|
||||
|
||||
if valuation
|
||||
valuation.update! amount: balance
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Balance update",
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def update_inital_balance!(initial_balance)
|
||||
valuation = first_valuation
|
||||
|
||||
if valuation
|
||||
valuation.update! amount: initial_balance
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
name: "Initial Balance",
|
||||
amount: initial_balance,
|
||||
currency: currency,
|
||||
entryable: Valuation.new
|
||||
end
|
||||
def update_balance(balance:, date: Date.current, currency: nil, notes: nil)
|
||||
Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update
|
||||
end
|
||||
|
||||
def start_date
|
||||
|
|
47
app/models/account/balance_updater.rb
Normal file
47
app/models/account/balance_updater.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
class Account::BalanceUpdater
|
||||
def initialize(account, balance:, currency: nil, date: Date.current, notes: nil)
|
||||
@account = account
|
||||
@balance = balance.to_d
|
||||
@currency = currency
|
||||
@date = date.to_date
|
||||
@notes = notes
|
||||
end
|
||||
|
||||
def update
|
||||
return Result.new(success?: true, updated?: false) unless requires_update?
|
||||
|
||||
Account.transaction do
|
||||
if date == Date.current
|
||||
account.balance = balance
|
||||
account.currency = currency if currency.present?
|
||||
account.save!
|
||||
end
|
||||
|
||||
valuation_entry = account.entries.valuations.find_or_initialize_by(date: date) do |entry|
|
||||
entry.entryable = Valuation.new
|
||||
end
|
||||
|
||||
valuation_entry.amount = balance
|
||||
valuation_entry.currency = currency if currency.present?
|
||||
valuation_entry.name = "Manual #{account.accountable.balance_display_name} update"
|
||||
valuation_entry.notes = notes if notes.present?
|
||||
valuation_entry.save!
|
||||
end
|
||||
|
||||
account.sync_later
|
||||
|
||||
Result.new(success?: true, updated?: true)
|
||||
rescue => e
|
||||
message = Rails.env.development? ? e.message : "Unable to update account values. Please try again."
|
||||
Result.new(success?: false, updated?: false, error_message: message)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :balance, :currency, :date, :notes
|
||||
|
||||
Result = Struct.new(:success?, :updated?, :error_message)
|
||||
|
||||
def requires_update?
|
||||
date != Date.current || account.balance != balance || account.currency != currency
|
||||
end
|
||||
end
|
|
@ -56,7 +56,7 @@ class Assistant::Function
|
|||
end
|
||||
|
||||
def family_account_names
|
||||
@family_account_names ||= family.accounts.active.pluck(:name)
|
||||
@family_account_names ||= family.accounts.visible.pluck(:name)
|
||||
end
|
||||
|
||||
def family_category_names
|
||||
|
|
|
@ -22,7 +22,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
|
|||
type: account.accountable_type,
|
||||
start_date: account.start_date,
|
||||
is_plaid_linked: account.plaid_account_id.present?,
|
||||
is_active: account.is_active,
|
||||
status: account.status,
|
||||
historical_balances: historical_balances(account)
|
||||
}
|
||||
end
|
||||
|
|
|
@ -44,7 +44,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
|
|||
|
||||
private
|
||||
def historical_data(period, classification: nil)
|
||||
scope = family.accounts.active
|
||||
scope = family.accounts.visible
|
||||
scope = scope.where(classification: classification) if classification.present?
|
||||
|
||||
if period.start_date == Date.current
|
||||
|
|
|
@ -134,7 +134,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
|
|||
def call(params = {})
|
||||
search_params = params.except("order", "page")
|
||||
|
||||
transactions_query = family.transactions.active.search(search_params)
|
||||
transactions_query = family.transactions.visible.search(search_params)
|
||||
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
|
||||
|
||||
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
|
||||
|
|
|
@ -23,8 +23,8 @@ class BalanceSheet::AccountTotals
|
|||
delegate_missing_to :account
|
||||
end
|
||||
|
||||
def active_accounts
|
||||
@active_accounts ||= family.accounts.active.with_attached_logo
|
||||
def visible_accounts
|
||||
@visible_accounts ||= family.accounts.visible.with_attached_logo
|
||||
end
|
||||
|
||||
def account_rows
|
||||
|
@ -46,7 +46,7 @@ class BalanceSheet::AccountTotals
|
|||
|
||||
def query
|
||||
@query ||= Rails.cache.fetch(cache_key) do
|
||||
active_accounts
|
||||
visible_accounts
|
||||
.joins(ActiveRecord::Base.sanitize_sql_array([
|
||||
"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?",
|
||||
Date.current,
|
||||
|
|
|
@ -6,7 +6,7 @@ class BalanceSheet::NetWorthSeriesBuilder
|
|||
def net_worth_series(period: Period.last_30_days)
|
||||
Rails.cache.fetch(cache_key(period)) do
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: active_account_ids,
|
||||
account_ids: visible_account_ids,
|
||||
currency: family.currency,
|
||||
period: period,
|
||||
favorable_direction: "up"
|
||||
|
@ -19,8 +19,8 @@ class BalanceSheet::NetWorthSeriesBuilder
|
|||
private
|
||||
attr_reader :family
|
||||
|
||||
def active_account_ids
|
||||
@active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id)
|
||||
def visible_account_ids
|
||||
@visible_account_ids ||= family.accounts.visible.with_attached_logo.pluck(:id)
|
||||
end
|
||||
|
||||
def cache_key(period)
|
||||
|
|
|
@ -17,7 +17,7 @@ class BalanceSheet::SyncStatusMonitor
|
|||
def syncing_account_ids
|
||||
Rails.cache.fetch(cache_key) do
|
||||
Sync.visible
|
||||
.where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id))
|
||||
.where(syncable_type: "Account", syncable_id: family.accounts.visible.pluck(:id))
|
||||
.pluck(:syncable_id)
|
||||
.to_set
|
||||
end
|
||||
|
|
|
@ -88,7 +88,7 @@ class Budget < ApplicationRecord
|
|||
end
|
||||
|
||||
def transactions
|
||||
family.transactions.active.in_period(period)
|
||||
family.transactions.visible.in_period(period)
|
||||
end
|
||||
|
||||
def name
|
||||
|
|
|
@ -72,6 +72,14 @@ module Accountable
|
|||
self.class.display_name
|
||||
end
|
||||
|
||||
def balance_display_name
|
||||
"account value"
|
||||
end
|
||||
|
||||
def opening_balance_display_name
|
||||
"opening balance"
|
||||
end
|
||||
|
||||
def icon
|
||||
self.class.icon
|
||||
end
|
||||
|
|
|
@ -14,8 +14,8 @@ class Entry < ApplicationRecord
|
|||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
|
||||
scope :active, -> {
|
||||
joins(:account).where(accounts: { is_active: true })
|
||||
scope :visible, -> {
|
||||
joins(:account).where(accounts: { status: [ "draft", "active" ] })
|
||||
}
|
||||
|
||||
scope :chronological, -> {
|
||||
|
|
|
@ -14,7 +14,7 @@ module Entryable
|
|||
|
||||
scope :with_entry, -> { joins(:entry) }
|
||||
|
||||
scope :active, -> { with_entry.merge(Entry.active) }
|
||||
scope :visible, -> { with_entry.merge(Entry.visible) }
|
||||
|
||||
scope :in_period, ->(period) {
|
||||
with_entry.where(entries: { date: period.start_date..period.end_date })
|
||||
|
|
|
@ -100,7 +100,8 @@ class Family < ApplicationRecord
|
|||
[
|
||||
id,
|
||||
key,
|
||||
data_invalidation_key
|
||||
data_invalidation_key,
|
||||
accounts.maximum(:updated_at)
|
||||
].compact.join("_")
|
||||
end
|
||||
|
||||
|
|
|
@ -30,8 +30,8 @@ module Family::AutoTransferMatchable
|
|||
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
|
||||
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
|
||||
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id)
|
||||
.where("inflow_accounts.is_active = true")
|
||||
.where("outflow_accounts.is_active = true")
|
||||
.where("inflow_accounts.status IN ('draft', 'active')")
|
||||
.where("outflow_accounts.status IN ('draft', 'active')")
|
||||
.where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'")
|
||||
.where("
|
||||
(
|
||||
|
|
|
@ -10,7 +10,7 @@ class IncomeStatement
|
|||
end
|
||||
|
||||
def totals(transactions_scope: nil)
|
||||
transactions_scope ||= family.transactions.active
|
||||
transactions_scope ||= family.transactions.visible
|
||||
|
||||
result = totals_query(transactions_scope: transactions_scope)
|
||||
|
||||
|
@ -62,7 +62,7 @@ class IncomeStatement
|
|||
end
|
||||
|
||||
def build_period_total(classification:, period:)
|
||||
totals = totals_query(transactions_scope: family.transactions.active.in_period(period)).select { |t| t.classification == classification }
|
||||
totals = totals_query(transactions_scope: family.transactions.visible.in_period(period)).select { |t| t.classification == classification }
|
||||
classification_total = totals.sum(&:total)
|
||||
|
||||
uncategorized_category = family.categories.uncategorized
|
||||
|
|
|
@ -42,6 +42,14 @@ class Property < ApplicationRecord
|
|||
Trend.new(current: account.balance_money, previous: first_valuation_amount)
|
||||
end
|
||||
|
||||
def balance_display_name
|
||||
"market value"
|
||||
end
|
||||
|
||||
def opening_balance_display_name
|
||||
"original purchase price"
|
||||
end
|
||||
|
||||
private
|
||||
def first_valuation_amount
|
||||
account.entries.valuations.order(:date).first&.amount_money || account.balance_money
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
class Rule::Registry::TransactionResource < Rule::Registry
|
||||
def resource_scope
|
||||
family.transactions.active.with_entry.where(entry: { date: rule.effective_date.. })
|
||||
family.transactions.visible.with_entry.where(entry: { date: rule.effective_date.. })
|
||||
end
|
||||
|
||||
def condition_filters
|
||||
|
|
|
@ -81,7 +81,7 @@ class Transaction::Search
|
|||
|
||||
def apply_active_accounts_filter(query, active_accounts_only_filter)
|
||||
if active_accounts_only_filter
|
||||
query.where(accounts: { is_active: true })
|
||||
query.where(accounts: { status: [ "draft", "active" ] })
|
||||
else
|
||||
query
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue