diff --git a/app/components/button_component.html.erb b/app/components/button_component.html.erb index 75f3853e..0b69c464 100644 --- a/app/components/button_component.html.erb +++ b/app/components/button_component.html.erb @@ -1,6 +1,6 @@ <%= container do %> <% if icon && (icon_position != :right) %> - <%= helpers.icon(icon, size: size, color: icon_color) %> + <%= helpers.icon(icon, size: size, color: icon_color, class: icon_classes) %> <% end %> <% unless icon_only? %> diff --git a/app/components/buttonish_component.rb b/app/components/buttonish_component.rb index 89e3afda..4bbb2882 100644 --- a/app/components/buttonish_component.rb +++ b/app/components/buttonish_component.rb @@ -5,7 +5,7 @@ class ButtonishComponent < ViewComponent::Base icon_classes: "fg-inverse" }, secondary: { - container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", + container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", icon_classes: "fg-primary" }, destructive: { diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 9daa0ae2..445a7335 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -2,7 +2,7 @@ module AccountableResource extend ActiveSupport::Concern included do - include ScrollFocusable, Periodable + include ScrollFocusable, Periodable, StreamExtensions before_action :set_account, only: [ :show, :edit, :update, :destroy ] before_action :set_link_options, only: :new @@ -39,30 +39,32 @@ module AccountableResource @account = Current.family.accounts.create_and_sync(account_params.except(:return_to)) @account.lock_saved_attributes! - redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) + respond_to do |format| + format.html { redirect_to account_params[:return_to].presence || account_path(@account), notice: accountable_type.name.underscore.humanize + " account created" } + format.turbo_stream { stream_redirect_to account_params[:return_to].presence || account_path(@account), notice: accountable_type.name.underscore.humanize + " account created" } + end end def update - # Handle balance update if provided - if account_params[:balance].present? - result = @account.update_balance(balance: account_params[:balance], currency: account_params[:currency]) - unless result.success? - @error_message = result.error_message - render :edit, status: :unprocessable_entity - return + form = Account::OverviewForm.new( + account: @account, + name: account_params[:name], + currency: account_params[:currency], + current_balance: account_params[:balance], + current_cash_balance: @account.depository? ? account_params[:balance] : "0" + ) + + result = form.save + + if result.success? + respond_to do |format| + format.html { redirect_back_or_to account_path(@account), notice: accountable_type.name.underscore.humanize + " account updated" } + format.turbo_stream { stream_redirect_to account_path(@account), notice: accountable_type.name.underscore.humanize + " account updated" } end - end - - # Update remaining account attributes - update_params = account_params.except(:return_to, :balance, :currency) - unless @account.update(update_params) - @error_message = @account.errors.full_messages.join(", ") + else + @error_message = result.error || "Unable to update account details." render :edit, status: :unprocessable_entity - return end - - @account.lock_saved_attributes! - redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize) end def destroy @@ -90,7 +92,7 @@ module AccountableResource def account_params params.require(:account).permit( - :name, :balance, :subtype, :currency, :accountable_type, :return_to, + :name, :balance, :subtype, :currency, :accountable_type, :return_to, :tracking_start_date, accountable_attributes: self.class.permitted_accountable_attributes ) end diff --git a/app/controllers/credit_cards_controller.rb b/app/controllers/credit_cards_controller.rb index 4d0f5659..5cf8cd62 100644 --- a/app/controllers/credit_cards_controller.rb +++ b/app/controllers/credit_cards_controller.rb @@ -9,4 +9,31 @@ class CreditCardsController < ApplicationController :annual_fee, :expiration_date ) + + def update + form = Account::OverviewForm.new( + account: @account, + name: account_params[:name], + currency: account_params[:currency], + current_balance: account_params[:balance], + current_cash_balance: @account.depository? ? account_params[:balance] : "0" + ) + + result = form.save + + if result.success? + # Update credit card-specific attributes + if account_params[:accountable_attributes].present? + @account.credit_card.update!(account_params[:accountable_attributes]) + end + + respond_to do |format| + format.html { redirect_back_or_to account_path(@account), notice: "Credit card account updated" } + format.turbo_stream { stream_redirect_to account_path(@account), notice: "Credit card account updated" } + end + else + @error_message = result.error || "Unable to update account details." + render :edit, status: :unprocessable_entity + end + end end diff --git a/app/controllers/loans_controller.rb b/app/controllers/loans_controller.rb index 961c5acf..c6f80835 100644 --- a/app/controllers/loans_controller.rb +++ b/app/controllers/loans_controller.rb @@ -4,4 +4,31 @@ class LoansController < ApplicationController permitted_accountable_attributes( :id, :rate_type, :interest_rate, :term_months, :initial_balance ) + + def update + form = Account::OverviewForm.new( + account: @account, + name: account_params[:name], + currency: account_params[:currency], + current_balance: account_params[:balance], + current_cash_balance: @account.depository? ? account_params[:balance] : "0" + ) + + result = form.save + + if result.success? + # Update loan-specific attributes + if account_params[:accountable_attributes].present? + @account.loan.update!(account_params[:accountable_attributes]) + end + + respond_to do |format| + format.html { redirect_back_or_to account_path(@account), notice: "Loan account updated" } + format.turbo_stream { stream_redirect_to account_path(@account), notice: "Loan account updated" } + end + else + @error_message = result.error || "Unable to update account details." + render :edit, status: :unprocessable_entity + end + end end diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index 8b2ec062..8ccdf718 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -1,31 +1,49 @@ class PropertiesController < ApplicationController include AccountableResource, StreamExtensions - before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ] + before_action :set_property, only: [ :edit, :update, :details, :update_details, :address, :update_address ] def new @account = Current.family.accounts.build(accountable: Property.new) end def create - @account = Current.family.accounts.create!( - property_params.merge(currency: Current.family.currency, balance: 0, status: "draft") + @account = Current.family.create_property_account!( + name: property_params[:name], + current_value: property_params[:current_estimated_value].to_d, + purchase_price: property_params[:purchase_price].present? ? property_params[:purchase_price].to_d : nil, + purchase_date: property_params[:purchase_date], + currency: property_params[:currency] || Current.family.currency, + draft: true ) - redirect_to balances_property_path(@account) + redirect_to details_property_path(@account) end def update - if @account.update(property_params) + form = Account::OverviewForm.new( + account: @account, + name: property_params[:name], + currency: property_params[:currency], + opening_balance: property_params[:purchase_price], + opening_cash_balance: property_params[:purchase_price].present? ? "0" : nil, + opening_date: property_params[:purchase_date], + current_balance: property_params[:current_estimated_value], + current_cash_balance: property_params[:current_estimated_value].present? ? "0" : nil + ) + + result = form.save + + if result.success? @success_message = "Property details updated successfully." if @account.active? render :edit else - redirect_to balances_property_path(@account) + redirect_to details_property_path(@account) end else - @error_message = "Unable to update property details." + @error_message = result.error || "Unable to update property details." render :edit, status: :unprocessable_entity end end @@ -33,26 +51,25 @@ class PropertiesController < ApplicationController def edit end - def balances + def details end - def update_balances - result = @account.update_balance(balance: balance_params[:balance], currency: balance_params[:currency]) - - if result.success? - @success_message = result.updated? ? "Balance updated successfully." : "No changes made. Account is already up to date." + def update_details + if @account.update(details_params) + @success_message = "Property details updated successfully." if @account.active? - render :balances + render :details else redirect_to address_property_path(@account) end else - @error_message = result.error_message - render :balances, status: :unprocessable_entity + @error_message = "Unable to update property details." + render :details, status: :unprocessable_entity end end + def address @property = @account.property @property.address ||= Address.new @@ -78,8 +95,9 @@ class PropertiesController < ApplicationController end private - def balance_params - params.require(:account).permit(:balance, :currency) + def details_params + params.require(:account) + .permit(:subtype, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ]) end def address_params @@ -89,7 +107,9 @@ class PropertiesController < ApplicationController def property_params params.require(:account) - .permit(:name, :subtype, :accountable_type, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ]) + .permit(:name, :currency, :purchase_price, :purchase_date, :current_estimated_value, + :subtype, :accountable_type, + accountable_attributes: [ :id, :year_built, :area_unit, :area_value ]) end def set_property diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb index 90aa1da0..965d26ec 100644 --- a/app/controllers/valuations_controller.rb +++ b/app/controllers/valuations_controller.rb @@ -4,59 +4,55 @@ class ValuationsController < ApplicationController def create account = Current.family.accounts.find(params.dig(:entry, :account_id)) - result = account.update_balance( - balance: entry_params[:amount], - date: entry_params[:date], - currency: entry_params[:currency], - notes: entry_params[:notes] - ) - - if result.success? - @success_message = result.updated? ? "Balance updated" : "No changes made. Account is already up to date." - - respond_to do |format| - format.html { redirect_back_or_to account_path(account), notice: @success_message } - format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: @success_message) } - end + if entry_params[:date].to_date == Date.current + account.update_current_balance!(balance: entry_params[:amount].to_d) else - @error_message = result.error_message - render :new, status: :unprocessable_entity + account.reconcile_balance!( + balance: entry_params[:amount].to_d, + date: entry_params[:date].to_date + ) + end + + account.sync_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(account), notice: "Account value updated" } + format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: "Account value updated") } end end def update - result = @entry.account.update_balance( - date: @entry.date, - balance: entry_params[:amount], - currency: entry_params[:currency], - notes: entry_params[:notes] + # ActiveRecord::Base.transaction do + @entry.account.reconcile_balance!( + balance: entry_params[:amount].to_d, + date: entry_params[:date].to_date ) - if result.success? - @entry.reload + if entry_params[:notes].present? + @entry.update!(notes: entry_params[:notes]) + end - respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account), notice: result.updated? ? "Balance updated" : "No changes made. Account is already up to date." } - format.turbo_stream do - render turbo_stream: [ - turbo_stream.replace( - dom_id(@entry, :header), - partial: "valuations/header", - locals: { entry: @entry } - ), - turbo_stream.replace(@entry) - ] - end + @entry.account.sync_later + + @entry.reload + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: "Account value updated" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + dom_id(@entry, :header), + partial: "valuations/header", + locals: { entry: @entry } + ), + turbo_stream.replace(@entry) + ] end - else - @error_message = result.error_message - render :show, status: :unprocessable_entity end end private def entry_params - params.require(:entry) - .permit(:date, :amount, :currency, :notes) + params.require(:entry).permit(:date, :amount, :notes) end end diff --git a/app/javascript/controllers/money_field_controller.js b/app/javascript/controllers/money_field_controller.js index 2aab2d16..f41ae874 100644 --- a/app/javascript/controllers/money_field_controller.js +++ b/app/javascript/controllers/money_field_controller.js @@ -5,10 +5,15 @@ import { CurrenciesService } from "services/currencies_service"; // when currency select change, update the input value with the correct placeholder and step export default class extends Controller { static targets = ["amount", "currency", "symbol"]; + static values = { syncCurrency: Boolean }; handleCurrencyChange(e) { const selectedCurrency = e.target.value; this.updateAmount(selectedCurrency); + + if (this.syncCurrencyValue) { + this.syncOtherMoneyFields(selectedCurrency); + } } updateAmount(currency) { @@ -24,4 +29,28 @@ export default class extends Controller { this.symbolTarget.innerText = currency.symbol; }); } + + syncOtherMoneyFields(selectedCurrency) { + // Find the form this money field belongs to + const form = this.element.closest("form"); + if (!form) return; + + // Find all other money field controllers in the same form + const allMoneyFields = form.querySelectorAll('[data-controller~="money-field"]'); + + allMoneyFields.forEach(field => { + // Skip the current field + if (field === this.element) return; + + // Get the controller instance + const controller = this.application.getControllerForElementAndIdentifier(field, "money-field"); + if (!controller) return; + + // Update the currency select if it exists + if (controller.hasCurrencyTarget) { + controller.currencyTarget.value = selectedCurrency; + controller.updateAmount(selectedCurrency); + } + }); + } } diff --git a/app/models/account.rb b/app/models/account.rb index 43e02cfd..e45ff52a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,6 +1,5 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Chartable, Linkable, Enrichable - include AASM + include AASM, Syncable, Chartable, Linkable, Enrichable, Reconcileable validates :name, :balance, :currency, presence: true @@ -15,8 +14,6 @@ class Account < ApplicationRecord has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy - monetize :balance, :cash_balance - enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } scope :visible, -> { where(status: [ "draft", "active" ]) } @@ -57,30 +54,24 @@ class Account < ApplicationRecord class << self def create_and_sync(attributes) + start_date = attributes.delete(:tracking_start_date) || 2.years.ago.to_date attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty account = new(attributes.merge(cash_balance: attributes[:balance])) - initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || 0 + initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d || account.balance - transaction do - # Create 2 valuations for new accounts to establish a value history for users to see - account.entries.build( - name: "Current Balance", - date: Date.current, - amount: account.balance, - currency: account.currency, - entryable: Valuation.new - ) - account.entries.build( - name: "Initial Balance", - date: 1.day.ago.to_date, - amount: initial_balance, - currency: account.currency, - entryable: Valuation.new + account.entries.build( + name: Valuation::Name.new("opening_anchor", account.accountable_type).to_s, + date: start_date, + amount: initial_balance, + currency: account.currency, + entryable: Valuation.new( + kind: "opening_anchor", + balance: initial_balance, + cash_balance: initial_balance ) + ) - account.save! - end - + account.save! account.sync_later account end @@ -127,9 +118,13 @@ class Account < ApplicationRecord .order(amount: :desc) end + def update_currency!(new_currency) + raise "Currency cannot be changed" if linked? - def update_balance(balance:, date: Date.current, currency: nil, notes: nil) - Account::BalanceUpdater.new(self, balance:, currency:, date:, notes:).update + transaction do + update!(currency: new_currency) + entries.valuations.update_all(currency: new_currency) + end end def start_date diff --git a/app/models/account/balance_updater.rb b/app/models/account/balance_updater.rb deleted file mode 100644 index d006df3f..00000000 --- a/app/models/account/balance_updater.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 diff --git a/app/models/account/overview_form.rb b/app/models/account/overview_form.rb new file mode 100644 index 00000000..38313e41 --- /dev/null +++ b/app/models/account/overview_form.rb @@ -0,0 +1,88 @@ +class Account::OverviewForm + include ActiveModel::Model + + attr_accessor :account, :name, :currency, :opening_date + attr_reader :opening_balance, :opening_cash_balance, :current_balance, :current_cash_balance + + Result = Struct.new(:success?, :updated?, :error, keyword_init: true) + CurrencyUpdateError = Class.new(StandardError) + + def opening_balance=(value) + @opening_balance = value.nil? ? nil : value.to_d + end + + def opening_cash_balance=(value) + @opening_cash_balance = value.nil? ? nil : value.to_d + end + + def current_balance=(value) + @current_balance = value.nil? ? nil : value.to_d + end + + def current_cash_balance=(value) + @current_cash_balance = value.nil? ? nil : value.to_d + end + + def save + # Validate that balance fields are properly paired + if (!opening_balance.nil? && opening_cash_balance.nil?) || + (opening_balance.nil? && !opening_cash_balance.nil?) + raise ArgumentError, "Both opening_balance and opening_cash_balance must be provided together" + end + + if (!current_balance.nil? && current_cash_balance.nil?) || + (current_balance.nil? && !current_cash_balance.nil?) + raise ArgumentError, "Both current_balance and current_cash_balance must be provided together" + end + + updated = false + sync_required = false + + Account.transaction do + # Update name if provided + if name.present? && name != account.name + account.update!(name: name) + account.lock_attr!(:name) + updated = true + end + + # Update currency if provided + if currency.present? && currency != account.currency + account.update_currency!(currency) + updated = true + sync_required = true + end + + # Update opening balance if provided (already validated that both are present) + if !opening_balance.nil? + account.set_or_update_opening_balance!( + balance: opening_balance, + cash_balance: opening_cash_balance, + date: opening_date # optional + ) + updated = true + sync_required = true + end + + # Update current balance if provided (already validated that both are present) + if !current_balance.nil? + account.update_current_balance!( + balance: current_balance, + cash_balance: current_cash_balance + ) + updated = true + sync_required = true + end + end + + # Only sync if transaction succeeded and sync is required + account.sync_later if sync_required + + Result.new(success?: true, updated?: updated) + rescue ArgumentError => e + # Re-raise ArgumentError as it's a developer error + raise e + rescue => e + Result.new(success?: false, updated?: false, error: e.message) + end +end diff --git a/app/models/account/reconcileable.rb b/app/models/account/reconcileable.rb new file mode 100644 index 00000000..3d9f19c9 --- /dev/null +++ b/app/models/account/reconcileable.rb @@ -0,0 +1,154 @@ +# Methods for updating the historical balances of an account (opening, current, and arbitrary date reconciliations) +module Account::Reconcileable + extend ActiveSupport::Concern + + included do + include Monetizable + + monetize :balance, :cash_balance, :non_cash_balance + end + + InvalidBalanceError = Class.new(StandardError) + + # For depository accounts, this is 0 (total balance is liquid cash) + # For all other accounts, this represents "asset value" or "debt value" + # (i.e. Investment accounts would refer to this as "holdings value") + def non_cash_balance + balance - cash_balance + end + + def opening_balance + @opening_balance ||= opening_anchor_valuation&.balance + end + + def opening_cash_balance + @opening_cash_balance ||= opening_anchor_valuation&.cash_balance + end + + def opening_date + @opening_date ||= opening_anchor_valuation&.entry&.date + end + + def reconcile_balance!(balance:, cash_balance: nil, date: nil) + raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance.present? && cash_balance > balance + raise InvalidBalanceError, "Linked accounts cannot be reconciled" if linked? + + derived_cash_balance = cash_balance.present? ? cash_balance : choose_cash_balance_from_balance(balance) + + if date.nil? + update_current_balance!(balance:, cash_balance: derived_cash_balance) + return + end + + existing_valuation = valuations.joins(:entry).where(kind: "recon", entry: { date: date }).first + + transaction do + if existing_valuation.present? + existing_valuation.update!( + balance: balance, + cash_balance: derived_cash_balance + ) + existing_valuation.entry.update!(amount: balance) + else + entries.create!( + date: date, + name: Valuation::Name.new("recon", self.accountable_type), + amount: balance, + currency: self.currency, + entryable: Valuation.new( + kind: "recon", + balance: balance, + cash_balance: derived_cash_balance + ) + ) + end + + # Update cached balance fields on account when reconciling for current date + if date == Date.current + update!(balance: balance, cash_balance: derived_cash_balance) + end + end + end + + def update_current_balance!(balance:, cash_balance: nil) + raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance.present? && cash_balance > balance + + derived_cash_balance = cash_balance.present? ? cash_balance : choose_cash_balance_from_balance(balance) + + transaction do + # See test for explanation - Depository accounts are handled as a special case for current balance updates + if opening_anchor_valuation.present? && valuations.where(kind: "recon").empty? && self.depository? + adjust_opening_balance_with_delta!(balance:, cash_balance: derived_cash_balance) + else + reconcile_balance!(balance:, cash_balance: derived_cash_balance, date: Date.current) + end + + # Always update cached balance fields when updating current balance + update!(balance: balance, cash_balance: derived_cash_balance) + end + end + + def set_or_update_opening_balance!(balance:, cash_balance:, date: nil) + # A reasonable start date for most accounts to fill up adequate history for graphs + fallback_opening_date = 2.years.ago.to_date + + raise InvalidBalanceError, "Cash balance cannot exceed balance" if cash_balance > balance + + transaction do + if opening_anchor_valuation + opening_anchor_valuation.update!( + balance: balance, + cash_balance: cash_balance + ) + + opening_anchor_valuation.entry.update!(amount: balance) + opening_anchor_valuation.entry.update!(date: date) unless date.nil? + + opening_anchor_valuation + else + entry = entries.create!( + date: date || fallback_opening_date, + name: Valuation::Name.new("opening_anchor", self.accountable_type), + amount: balance, + currency: self.currency, + entryable: Valuation.new( + kind: "opening_anchor", + balance: balance, + cash_balance: cash_balance, + ) + ) + + entry.valuation + end + end + end + + private + def opening_anchor_valuation + @opening_anchor_valuation ||= valuations.opening_anchor.includes(:entry).first + end + + def current_anchor_valuation + valuations.current_anchor.first + end + + def adjust_opening_balance_with_delta!(balance:, cash_balance:) + delta = self.balance - balance + cash_delta = self.cash_balance - cash_balance + + set_or_update_opening_balance!( + balance: balance - delta, + cash_balance: cash_balance - cash_delta + ) + end + + # For depository accounts, the cash balance is the same as the balance always + # Otherwise, if not specified, we assume cash balance is 0 + def choose_cash_balance_from_balance(balance) + if self.depository? + balance + else + 0 + end + end +end diff --git a/app/models/account_import.rb b/app/models/account_import.rb index aa4c6dfe..940bbba1 100644 --- a/app/models/account_import.rb +++ b/app/models/account_import.rb @@ -20,7 +20,10 @@ class AccountImport < Import currency: row.currency, date: Date.current, name: "Imported account value", - entryable: Valuation.new + entryable: Valuation.new( + balance: row.amount.to_d, + cash_balance: row.amount.to_d + ) ) end end diff --git a/app/models/entry.rb b/app/models/entry.rb index 332cbee9..f59810b6 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -14,6 +14,11 @@ class Entry < ApplicationRecord validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } + # To ensure we can recreate balance history solely from Entries, all entries must post on or before the current anchor (i.e. "Current balance"), + # and after the opening anchor (i.e. "Opening balance"). This domain invariant should be enforced by the Account model when adding/modifying entries. + validate :date_after_opening_anchor + validate :date_on_or_before_current_anchor + scope :visible, -> { joins(:account).where(accounts: { status: [ "draft", "active" ] }) } @@ -96,4 +101,39 @@ class Entry < ApplicationRecord all.size end end + + private + def date_after_opening_anchor + return unless account && date + + # Skip validation for anchor valuations themselves + return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor]) + + opening_anchor_date = account.valuations + .joins(:entry) + .where(kind: "opening_anchor") + .pluck(Arel.sql("entries.date")) + .first + + if opening_anchor_date && date <= opening_anchor_date + errors.add(:date, "must be after the opening balance date (#{opening_anchor_date})") + end + end + + def date_on_or_before_current_anchor + return unless account && date + + # Skip validation for anchor valuations themselves + return if valuation? && entryable.kind.in?(%w[opening_anchor current_anchor]) + + current_anchor_date = account.valuations + .joins(:entry) + .where(kind: "current_anchor") + .pluck(Arel.sql("entries.date")) + .first + + if current_anchor_date && date > current_anchor_date + errors.add(:date, "must be on or before the current balance date (#{current_anchor_date})") + end + end end diff --git a/app/models/family.rb b/app/models/family.rb index 1f35488f..5ad447a8 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable + include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable, AccountCreatable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], diff --git a/app/models/family/account_creatable.rb b/app/models/family/account_creatable.rb new file mode 100644 index 00000000..9aa575d9 --- /dev/null +++ b/app/models/family/account_creatable.rb @@ -0,0 +1,160 @@ +module Family::AccountCreatable + extend ActiveSupport::Concern + + def create_property_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_value, + cash_balance: 0, + accountable_type: Property, + opening_balance: purchase_price, + opening_date: purchase_date, + currency: currency, + draft: draft + ) + end + + def create_vehicle_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_value, + cash_balance: 0, + accountable_type: Vehicle, + opening_balance: purchase_price, + opening_date: purchase_date, + currency: currency, + draft: draft + ) + end + + def create_depository_account!(name:, current_balance:, opening_date: nil, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_balance, + cash_balance: current_balance, + accountable_type: Depository, + opening_date: opening_date, + currency: currency, + draft: draft + ) + end + + # Investment account values are built up by adding holdings / trades, not by initializing a "balance" + def create_investment_account!(name:, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: 0, + cash_balance: 0, + accountable_type: Investment, + opening_balance: 0, # Investment accounts start empty + opening_cash_balance: 0, + currency: currency, + draft: draft + ) + end + + def create_other_asset_account!(name:, current_value:, purchase_price: nil, purchase_date: nil, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_value, + cash_balance: 0, + accountable_type: OtherAsset, + opening_balance: purchase_price, + opening_date: purchase_date, + currency: currency, + draft: draft + ) + end + + def create_other_liability_account!(name:, current_debt:, original_debt: nil, origination_date: nil, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_debt, + cash_balance: 0, + accountable_type: OtherLiability, + opening_balance: original_debt, + opening_date: origination_date, + currency: currency, + draft: draft + ) + end + + # For now, crypto accounts are very simple; we just track overall value + def create_crypto_account!(name:, current_value:, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_value, + cash_balance: current_value, + accountable_type: Crypto, + opening_balance: current_value, + opening_cash_balance: current_value, + currency: currency, + draft: draft + ) + end + + def create_credit_card_account!(name:, current_debt:, opening_date: nil, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_debt, + cash_balance: 0, + accountable_type: CreditCard, + opening_balance: 0, # Credit cards typically start with no debt + opening_date: opening_date, + currency: currency, + draft: draft + ) + end + + def create_loan_account!(name:, current_principal:, original_principal: nil, origination_date: nil, currency: nil, draft: false) + create_manual_account!( + name: name, + balance: current_principal, + cash_balance: 0, + accountable_type: Loan, + opening_balance: original_principal, + opening_date: origination_date, + currency: currency, + draft: draft + ) + end + + def link_depository_account + # TODO + end + + def link_investment_account + # TODO + end + + def link_credit_card_account + # TODO + end + + def link_loan_account + # TODO + end + + private + + def create_manual_account!(name:, balance:, cash_balance:, accountable_type:, opening_balance: nil, opening_cash_balance: nil, opening_date: nil, currency: nil, draft: false) + Family.transaction do + account = accounts.create!( + name: name, + balance: balance, + cash_balance: cash_balance, + currency: currency.presence || self.currency, + accountable: accountable_type.new, + status: draft ? "draft" : "active" + ) + + account.set_or_update_opening_balance!( + balance: opening_balance || balance, + cash_balance: opening_cash_balance || cash_balance, + date: opening_date + ) + + account + end + end +end diff --git a/app/models/property.rb b/app/models/property.rb index 6114a9f4..4868e42d 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -52,6 +52,7 @@ class Property < ApplicationRecord private def first_valuation_amount + return nil unless account account.entries.valuations.order(:date).first&.amount_money || account.balance_money end end diff --git a/app/models/valuation.rb b/app/models/valuation.rb index 6d1d2b4b..3f6a57b5 100644 --- a/app/models/valuation.rb +++ b/app/models/valuation.rb @@ -1,3 +1,38 @@ class Valuation < ApplicationRecord include Entryable + + enum :kind, { + recon: "recon", # A balance reconciliation that sets the Account balance from this point forward (often defined by user) + snapshot: "snapshot", # An "event-sourcing snapshot", which is purely for performance so less history is required to derive the balance + opening_anchor: "opening_anchor", # Each account has a single opening anchor, which defines the opening balance on the account + current_anchor: "current_anchor" # Each account has a single current anchor, which defines the current balance on the account + }, validate: true + + # Each account can have at most 1 opening anchor and 1 current anchor. All valuations between these anchors should + # be either "recon" or "snapshot". This ensures we can reliably construct the account balance history solely from Entries. + validate :unique_anchor_per_account, if: -> { opening_anchor? || current_anchor? } + validate :manual_accounts_cannot_have_current_anchor + + private + def unique_anchor_per_account + return unless entry&.account + + existing_anchor = entry.account.valuations + .joins(:entry) + .where(kind: kind) + .where.not(id: id) + .exists? + + if existing_anchor + errors.add(:kind, "#{kind.humanize} already exists for this account") + end + end + + def manual_accounts_cannot_have_current_anchor + return unless entry&.account + + if entry.account.unlinked? && current_anchor? + errors.add(:kind, "Manual accounts cannot have a current anchor") + end + end end diff --git a/app/models/valuation/name.rb b/app/models/valuation/name.rb new file mode 100644 index 00000000..e88da599 --- /dev/null +++ b/app/models/valuation/name.rb @@ -0,0 +1,63 @@ +# While typically a view concern, we store the `name` in the DB as a denormalized value to keep our search classes simpler. +# This is a simple class to handle the logic for generating the name. +class Valuation::Name + def initialize(valuation_kind, accountable_type) + @valuation_kind = valuation_kind + @accountable_type = accountable_type + end + + def to_s + case valuation_kind + when "opening_anchor" + opening_anchor_name + when "current_anchor" + current_anchor_name + else + recon_name + end + end + + private + attr_reader :valuation_kind, :accountable_type + + # The start value on the account + def opening_anchor_name + case accountable_type + when "Property" + "Original purchase price" + when "Loan" + "Original principal" + when "Investment" + "Opening account value" + else + "Opening balance" + end + end + + # The current value on the account + def current_anchor_name + case accountable_type + when "Property" + "Current market value" + when "Loan" + "Current loan balance" + when "Investment" + "Current account value" + else + "Current balance" + end + end + + # Any "reconciliation" in the middle of the timeline, typically an "override" by the user to account + # for missing entries that cause the balance to be incorrect. + def recon_name + case accountable_type + when "Property", "Investment" + "Manual value update" + when "Loan" + "Manual principal update" + else + "Manual balance update" + end + end +end diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index ef2e0af5..bd3df388 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -1,10 +1,12 @@ <%# locals: (account:, url:) %> <% if @error_message.present? %> - <%= render AlertComponent.new(message: @error_message, variant: :error) %> +