1
0
Fork 0
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)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* 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:
Zach Gollwitzer 2025-07-03 09:33:07 -04:00 committed by GitHub
parent ba7e8d3893
commit 662f2c04ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 1036 additions and 427 deletions

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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, -> {

View file

@ -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 })

View file

@ -100,7 +100,8 @@ class Family < ApplicationRecord
[
id,
key,
data_invalidation_key
data_invalidation_key,
accounts.maximum(:updated_at)
].compact.join("_")
end

View file

@ -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("
(

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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